Skip to main content

Sign Transactions

Your dApp builds the transaction, KEA Wallet signs it. The user reviews and approves every signing request in the wallet modal — private keys never leave the wallet. This guide covers the full signing lifecycle: getting the signing context, signing a transaction, and submitting it to the network.

Prerequisites

How Signing Works

  1. Your app calls getSigningContext() to learn who the fee payer and signer are
  2. Your app builds the transaction using the feePayerPublicKey from the signing context
  3. Your app serializes the transaction to a base64 string and calls signTransaction(base64)
  4. The SDK opens the wallet modal — the user reviews the request and approves or rejects
  5. The wallet signs with the user's private key inside the secure iframe
  6. The signed transaction (base64) is returned to your app
  7. Your app decodes the signed bytes and submits them to the network via the RPC client
Security

Private keys remain inside the encrypted KEA Wallet iframe. Your dApp only sends a base64-encoded transaction payload and receives the signed result back. No key material is ever exposed to the parent window.

The Signing Context

Before building a transaction, call getSigningContext() to learn which keys to use. The signing context tells your app who the fee payer is and which key will sign — these may differ from the user's selected account.

interface ThruSigningContext {
mode: "managed_fee_payer";
feePayerPublicKey: string;
signerPublicKey: string;
selectedAccountPublicKey: string | null;
acceptedInputEncodings: ThruTransactionEncoding[];
outputEncoding: "raw_transaction_base64";
}
FieldTypeDescription
mode"managed_fee_payer"The wallet routes transactions through a managed fee payer
feePayerPublicKeystringUse this as the transaction's fee payer — not the selected account
signerPublicKeystringThe key that will produce the signature
selectedAccountPublicKeystring | nullThe user's currently selected account
acceptedInputEncodingsThruTransactionEncoding[]Accepted formats: "signing_payload_base64", "raw_transaction_base64"
outputEncoding"raw_transaction_base64"Format of the signed output
warning

Always use feePayerPublicKey from the signing context when building your transaction — not the selected account's public key. In the managed fee payer model, the fee payer is a system-controlled account that differs from the user's account.

import { useWallet } from '@kea-wallet/react-sdk';

function SigningContextDemo() {
const { wallet, isConnected } = useWallet();

const fetchContext = async () => {
if (!wallet || !isConnected) return;

const ctx = await wallet.getSigningContext();
console.log('Fee payer:', ctx.feePayerPublicKey);
console.log('Signer:', ctx.signerPublicKey);
console.log('Encodings:', ctx.acceptedInputEncodings);
};
}

Sign a Transaction

Once you have the signing context and have built your transaction, call signTransaction() with a base64-encoded payload. The SDK opens the wallet modal for user approval and returns the signed transaction as a base64 string.

import { useWallet } from '@kea-wallet/react-sdk';

function SignDemo() {
const { wallet, isConnected } = useWallet();
const [signing, setSigning] = useState(false);

const handleSign = async () => {
if (!wallet || !isConnected) return;

setSigning(true);
try {
const signedBase64 = await wallet.signTransaction(payloadBase64);
console.log('Signed:', signedBase64);
} catch (err) {
console.error('Signing failed:', err);
} finally {
setSigning(false);
}
};

return (
<button onClick={handleSign} disabled={signing}>
{signing ? 'Signing...' : 'Sign Transaction'}
</button>
);
}

Key points:

  • signTransaction() opens the wallet modal — always track a loading state and disable the trigger button while the modal is open.
  • The payload must be a base64-encoded string — passing invalid data throws a validation error before the modal opens.
  • Signing has a 5-minute timeout — if the user does not approve or reject within that window, a TIMEOUT error is thrown.
  • The return value is a base64-encoded signed transaction — decode it to bytes before submitting to the network.

Build, Sign, and Submit

The full transaction lifecycle: get the signing context, build the transaction, sign it via the wallet, and submit the signed bytes to the network.

import { useWallet, useKea } from '@kea-wallet/react-sdk';

function SendTransaction() {
const { wallet, isConnected } = useWallet();
const { sdk } = useKea();

const handleSend = async () => {
if (!wallet || !sdk || !isConnected) return;

// 1. Get signing context (fee payer + signer keys)
const ctx = await wallet.getSigningContext();

// 2. Get RPC client for chain queries
const thru = sdk.getThru();
const chainInfo = await thru.chain.getChainInfo();

// 3. Build your transaction using ctx.feePayerPublicKey
// (transaction building depends on your program)
const payloadBase64 = btoa(String.fromCharCode(
...transaction.toWireForSigning()
));

// 4. Sign — opens wallet modal for user approval
const signedBase64 = await wallet.signTransaction(payloadBase64);

// 5. Submit to network
const bytes = Uint8Array.from(
atob(signedBase64), c => c.charCodeAt(0)
);
const txHash = await thru.transactions.send(bytes);
console.log('Submitted! TX hash:', txHash);
};
}

Key points:

  • sdk.thru is the signing adapter — it requires a connected wallet and delegates signing to the wallet iframe.
  • sdk.getThru() returns a Thru RPC client — use it for read-only chain queries and transaction submission. It does not require a connected wallet.
  • Always use ctx.feePayerPublicKey as the fee payer when building your transaction — never hardcode it or use the selected account.
  • Transaction payload must be base64-encoded — use btoa() on the serialized bytes.
  • The RPC client provides transactions.send(), transactions.build(), chain.getChainInfo(), accounts.get(), blocks.getBlockHeight(), and more.

Error Handling

Signing can fail for several reasons — the user may reject the request, the modal may time out, or the wallet session may have expired. Import ErrorCode from @kea-wallet/browser-sdk to handle each case:

Error CodeWhenRecommended UX
USER_REJECTEDUser clicks "Reject" in the modalShow "Transaction cancelled" — no retry needed
TIMEOUTNo response within 5 minutesShow "Request timed out" with a retry button
WALLET_LOCKEDWallet session expiredPrompt user to reconnect
INVALID_TRANSACTIONBad payload formatDeveloper error — check base64 encoding
import { useWallet, useKea } from '@kea-wallet/react-sdk';
import { ErrorCode } from '@kea-wallet/browser-sdk';

function SignWithErrorHandling() {
const { wallet, isConnected } = useWallet();

const handleSign = async (payloadBase64: string) => {
if (!wallet || !isConnected) {
console.error('Wallet not connected');
return;
}

try {
const signed = await wallet.signTransaction(payloadBase64);
console.log('Signed:', signed);
} catch (err) {
const error = err as Error & { code?: string };

switch (error.code) {
case ErrorCode.USER_REJECTED:
// User chose not to sign — not a real error
console.log('User declined to sign');
break;
case ErrorCode.TIMEOUT:
// Modal was open for 5 minutes with no response
console.log('Signing request timed out');
break;
case ErrorCode.WALLET_LOCKED:
// Session expired — need to reconnect
console.log('Wallet locked, please reconnect');
break;
default:
console.error('Signing failed:', error.message);
}
}
};
}

Key points:

  • Always check isConnected before calling signing methods — calling signTransaction() when disconnected throws "Wallet not connected".
  • USER_REJECTED is not a real error — the user simply chose not to sign. Handle it as a cancellation, not an error.
  • USER_REJECTED is the same error code used for connection rejection — handle it consistently across your app.
  • Transaction payloads must be valid base64 — passing invalid data throws a validation error before the modal even opens.

For the complete error reference, see the Error Handling guide.

A Note on Message Signing

info

KEA Wallet currently supports signTransaction() only. The signMessage() method is not available. For authentication flows that typically rely on message signing, consider signing a minimal transaction or using an alternative verification method.

UX Best Practices

  • Show loading state during signingsignTransaction() is async and the modal stays open until the user decides. Always disable the trigger button and show "Signing..." while waiting.
  • Handle the 5-minute timeout — If your app expects long deliberation, consider showing a countdown or a gentle reminder that the signing request will expire.
  • Distinguish rejection from errorsUSER_REJECTED means the user consciously declined. Show a neutral "Transaction cancelled" message, not an error banner.
  • Check connection before signing — Always verify isConnected before calling getSigningContext() or signTransaction(). If the session expired, prompt the user to reconnect.
  • Always use the signing context — Call getSigningContext() before building every transaction. Never hardcode the fee payer or assume the selected account pays fees.
  • Provide transaction context — Before triggering a signature, show users a human-readable summary of what they are signing (amount, recipient, action) so they can make an informed decision in the modal.

What's Next