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.
- You have connected the wallet — see Connect Wallet
- You understand account handling — see Handle Accounts
- You have installed the SDK packages — see Quickstart
How Signing Works
- Your app calls
getSigningContext()to learn who the fee payer and signer are - Your app builds the transaction using the
feePayerPublicKeyfrom the signing context - Your app serializes the transaction to a base64 string and calls
signTransaction(base64) - The SDK opens the wallet modal — the user reviews the request and approves or rejects
- The wallet signs with the user's private key inside the secure iframe
- The signed transaction (base64) is returned to your app
- Your app decodes the signed bytes and submits them to the network via the RPC client
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";
}
| Field | Type | Description |
|---|---|---|
mode | "managed_fee_payer" | The wallet routes transactions through a managed fee payer |
feePayerPublicKey | string | Use this as the transaction's fee payer — not the selected account |
signerPublicKey | string | The key that will produce the signature |
selectedAccountPublicKey | string | null | The user's currently selected account |
acceptedInputEncodings | ThruTransactionEncoding[] | Accepted formats: "signing_payload_base64", "raw_transaction_base64" |
outputEncoding | "raw_transaction_base64" | Format of the signed output |
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.
- React
- Vanilla JS
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);
};
}
const ctx = await sdk.thru.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.
- React
- Vanilla JS
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>
);
}
try {
const signedBase64 = await sdk.thru.signTransaction(payloadBase64);
console.log('Signed:', signedBase64);
} catch (err) {
console.error('Signing failed:', err);
}
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
TIMEOUTerror 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.
- React
- Vanilla JS
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);
};
}
import { BrowserSDK } from '@kea-wallet/browser-sdk';
const sdk = new BrowserSDK({
rpcUrl: 'https://grpc-web.alphanet.thruput.org',
});
await sdk.initialize();
await sdk.connect({ metadata: { appName: 'My App' } });
// 1. Get signing context
const ctx = await sdk.thru.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
const payloadBase64 = btoa(String.fromCharCode(
...transaction.toWireForSigning()
));
// 4. Sign — opens wallet modal
const signedBase64 = await sdk.thru.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);
// 6. Cleanup when done
await sdk.disconnect();
sdk.destroy();
Key points:
sdk.thruis 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.feePayerPublicKeyas 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 Code | When | Recommended UX |
|---|---|---|
USER_REJECTED | User clicks "Reject" in the modal | Show "Transaction cancelled" — no retry needed |
TIMEOUT | No response within 5 minutes | Show "Request timed out" with a retry button |
WALLET_LOCKED | Wallet session expired | Prompt user to reconnect |
INVALID_TRANSACTION | Bad payload format | Developer error — check base64 encoding |
- React
- Vanilla JS
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);
}
}
};
}
import { BrowserSDK, ErrorCode } from '@kea-wallet/browser-sdk';
try {
const signed = await sdk.thru.signTransaction(payloadBase64);
console.log('Signed:', signed);
} catch (err) {
const error = err;
switch (error.code) {
case ErrorCode.USER_REJECTED:
console.log('User declined to sign');
break;
case ErrorCode.TIMEOUT:
console.log('Signing request timed out');
break;
case ErrorCode.WALLET_LOCKED:
console.log('Wallet locked, please reconnect');
break;
default:
console.error('Signing failed:', error.message);
}
}
Key points:
- Always check
isConnectedbefore calling signing methods — callingsignTransaction()when disconnected throws "Wallet not connected". USER_REJECTEDis not a real error — the user simply chose not to sign. Handle it as a cancellation, not an error.USER_REJECTEDis 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
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 signing —
signTransaction()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 errors —
USER_REJECTEDmeans the user consciously declined. Show a neutral "Transaction cancelled" message, not an error banner. - Check connection before signing — Always verify
isConnectedbefore callinggetSigningContext()orsignTransaction(). 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
- SDK Events — Subscribe to connection, signing, and account change events
- Error Handling — Comprehensive error handling patterns and error codes
- API Reference: EmbeddedThruChain — Full signing adapter API