Send a transaction
One sendTx(tx, { account, chain }) per wallet, typed per platform, with fixed chain-routing rules. Build the transaction with your chain library.
butr is deliberately not a transaction builder. You build the transaction with
your chain library (viem, @solana/kit, @mysten/sui, bitcoinjs-lib) and
either hand it to the adapter's sendTx, or take the signer from getSigner()
and submit through that library.
sendTx
sendTx?: (tx: Tx, options?: { account?: Account; chain?: ChainBase }) => Promise<string>;It asks the wallet to sign and broadcast, and resolves the transaction hash, signature, digest, or txid. It exists only when the wallet can do that, so check for it first:
import type { ConnectedWallet } from "@usebutr/core";
import { EVM_CHAINS } from "@usebutr/core";
const pay = async (wallet: ConnectedWallet<"evm">, to: string) => {
if (!wallet.connector.sendTx) {
throw new Error(`${wallet.connector.name} cannot send transactions`);
}
return wallet.connector.sendTx(
{ to, value: 10_000_000_000_000_000n },
{ account: wallet.account, chain: EVM_CHAINS.sepolia },
);
};accountmust be one the wallet exposes; omit it to send from the active account. An unknown account rejects instead of sending from another one.chaintargets this one transaction; omit it to use the wallet's current chain. See chain routing.
Transaction types
Tx is different on every platform:
| Platform | Tx | Resolves |
|---|---|---|
| EVM | EvmTransactionRequest: an eth_sendTransaction object; bigint quantities are hex-encoded for you, and from is always the resolved account | transaction hash |
| SVM | Uint8Array: the serialized transaction | base58 signature |
| Sui | SuiTransactionInput: a @mysten/sui Transaction, its JSON string, or BCS bytes | transaction digest |
| Bitcoin | BitcoinTransfer: { amount: bigint; recipient: string }, amount in satoshis | txid |
| Polkadot | no sendTx: build and submit extrinsics with polkadot-api through getSigner() | — |
A ConnectedWallet from useWallet() or useConnectedWallets() could be any
platform, so narrow it before calling sendTx:
import { isPlatformWallet } from "@usebutr/core";
if (isPlatformWallet(wallet, "svm") && wallet.connector.sendTx) {
const signature = await wallet.connector.sendTx(serializedTx, { account: wallet.account });
}wallet.connector.chainPlatform === "svm" narrows the same way, and
useSelectedWallet("svm") hands you a wallet that is already narrowed.
Chain routing
What chain does depends on the transport, and no adapter ignores it:
| Transport | With chain |
|---|---|
| Wallet Standard (Solana, Sui, Bitcoin) | routes this one call; the wallet stays where it is. Rejects a chain the wallet doesn't advertise. |
| EVM (injected, and WalletConnect's EVM namespace) | an EVM wallet has one global network, so it switches first (wallet_switchEthereumChain, which may prompt), then sends. The wallet stays switched. |
| WalletConnect Solana, Sui, Bitcoin | routes this one call. Rejects a chain the session didn't approve. |
| Injected Bitcoin | Unisat and Xverse switch the wallet's network first; OKX and window.btc reject with ChainMismatch. |
| Ledger | signs for the chain it was built for; any other chain rejects with ChainMismatch. |
The same rules apply to signTransaction's chain, which is the only one a
Ledger has. A chain from another platform's namespace always rejects.
Sign without sending
When you broadcast yourself, use signTransaction. It takes the same options
and exists only when the wallet supports it:
| Platform | signTransaction input | Resolves |
|---|---|---|
| SVM | serialized transaction | the signed transaction, ready for your RPC |
| Sui | SuiTransactionInput | { bytes, signature } for executeTransactionBlock |
| Bitcoin | PSBT bytes (psbt.toBuffer()) | the signed PSBT, to finalise and broadcast |
EVM adapters have none: EVM wallets sign and send in one
eth_sendTransaction. On EVM, getTransactionReceipt(hash) reports
"Pending", "Success", or "Error" through the wallet's provider; other
platforms read receipts with your own client.
Through your chain library
getSigner() hands back the object the adapter drives, tagged by transport.
Narrow on kind and give it to your library. With viem:
import { createWalletClient, custom, getAddress, parseEther } from "viem";
import { sepolia } from "viem/chains";
const signer = await wallet.connector.getSigner();
if (signer.kind !== "eip1193") {
throw new Error(`${wallet.connector.name} has no EIP-1193 provider`);
}
const account = getAddress(wallet.account.walletAddress);
const walletClient = createWalletClient({
account,
chain: sepolia,
transport: custom(signer.provider),
});
const hash = await walletClient.sendTransaction({
account,
chain: sepolia,
to: BURN_ADDRESS,
value: parseEther("0"),
});In a component, useSigner(wallet) resolves the same signer as
{ status, data, error } and refetches when the wallet changes. Pass it the
entry from useWallet() or useSelectedWallet("evm"); with no wallet it stays
"idle".
The kind follows the transport, not the platform. An EVM wallet behind a
Ledger hands back ledger-evm, so code that only handles eip1193 should say
so rather than cast. Solana, Sui, and Bitcoin Wallet Standard wallets hand back
{ kind: "wallet-standard", wallet }; read their features with getFeature
(see signing).
The viem, wagmi,
gill, @solana/kit,
framework-kit, Sui,
and Bitcoin integrations show the full build-and-send
flow for each library.
Source: packages/core/src/types/wallet.ts; apps/demo-with-viem, apps/demo-with-wagmi,
apps/demo-with-gill, apps/demo-with-solana-kit, apps/demo-with-sui, and
apps/demo-with-bitcoin in the butr
repository. These target testnets.