Transaction Handling
Transaction handling is the core purpose of a wallet. This section explains how transactions flow through the extension, from dApp request to onchain settlement.
Transaction Flow Overview
1. dApp calls wallet.sendTx(payload, options)
↓
2. Content script encrypts and forwards to background
↓
3. Background stores as pending, shows in popup
↓
4. User reviews and clicks "Approve"
↓
5. Background forwards to offscreen
↓
6. Offscreen:
a. Deserializes ExecutionPayload
b. Gets fee options (SponsoredFPC)
c. Creates TxExecutionRequest
d. Generates proof (WASM, 10-60 seconds)
e. Submits to node
↓
7. Response flows back to dApp
ExecutionPayload
The dApp sends an ExecutionPayload containing:
interface ExecutionPayload {
calls: FunctionCall[]; // Contract calls to execute
authWitnesses: AuthWitness[]; // Pre-signed authorizations
capsules: any[]; // Encrypted data capsules
feePayer?: AztecAddress; // Optional explicit fee payer
}
interface FunctionCall {
name: string; // Function name
to: AztecAddress; // Target contract
selector: FunctionSelector; // Function to call
type: FunctionType; // Private, public or utility
hideMsgSender: boolean; // Hide the caller from an enqueued public call?
isStatic: boolean; // View call?
args: Fr[]; // Function arguments
}
Example from a Pod Racing dApp:
const payload = new ExecutionPayload(
[
FunctionCall.from({
name: 'boost',
to: gameContractAddress,
selector: await FunctionSelector.fromSignature('boost()'),
type: FunctionType.PRIVATE,
hideMsgSender: false,
isStatic: false,
args: [],
}),
],
[], // authWitnesses
[], // capsules
);
const { receipt } = await wallet.sendTx(payload, {
from: playerAddress,
});
Approval Flow
When a wallet message arrives, the background checks whether it needs approval:
// sendTx always requires approval — it's a state-changing operation.
// batch requires approval only if it contains a sendTx.
// Read-only calls (simulateTx, getAccounts, etc.) auto-execute.
const needsApproval =
message.type === 'sendTx' ||
(message.type === 'batch' &&
Array.isArray(message.args?.[0]) &&
message.args[0].some((m: any) => m.name === 'sendTx'));
if (needsApproval) {
const pending = {
sessionId: session.sessionId,
messageId: message.messageId,
method: message.type,
args: message.args,
from,
origin: session.origin,
timestamp: Date.now(),
};
pendingTransactions.push(pending);
updateBadge();
// User must approve in popup
}
The badge shows the pending count, alerting the user.
Approval UI
The popup displays transaction details:
function TransactionApproval({ transaction, onApprove, onReject }) {
return (
<div className="approval-card">
<div className="approval-header">
<div className="approval-origin">{new URL(transaction.origin).host}</div>
<div className="approval-type">Send Transaction</div>
</div>
<div className="approval-details">
<div className="detail-row">
<span>From</span>
<span>{truncateAddress(transaction.from)}</span>
</div>
{/* Show function calls */}
{transaction.args?.executionPayload?.calls?.map((call, i) => (
<div key={i} className="tx-call">
<div>{call.name || 'Unknown'}</div>
<div>To: {truncateAddress(call.to)}</div>
</div>
))}
</div>
<div className="btn-group">
<button onClick={onReject}>Reject</button>
<button onClick={onApprove}>Approve</button>
</div>
</div>
);
}
Processing Approved Transactions
When the user approves:
async function handleTransactionApproval(pending) {
// Forward to offscreen for execution
const result = await sendToOffscreen({
type: MessageTypes.WALLET_METHOD,
method: pending.method,
args: pending.args,
from: pending.from,
});
// Send response back to dApp
await handler.sendResponse(pending.sessionId, {
messageId: pending.messageId,
result,
walletId: WALLET_CONFIG.walletId,
});
return result;
}
Offscreen Execution
The offscreen document handles the actual transaction:
case 'sendTx': {
const { executionPayload, options } = args;
// 1. Deserialize the payload
const payload = deserializeExecutionPayload(executionPayload);
const fromAddress = AztecAddress.fromStringUnsafe(from || options.from);
// 2. Call wallet.sendTx (inherited from BaseWallet)
const result = await wallet.sendTx(payload, {
...options,
from: fromAddress,
});
// 3. Serialize the result
if ('receipt' in result) {
return {
txHash: result.receipt.txHash.toString(),
status: result.receipt.status,
blockNumber: result.receipt.blockNumber?.toString(),
};
}
return { txHash: result.txHash.toString() };
}
The actual implementation uses WalletSchema to parse arguments in a type-safe way, as described in PXE Integration. The above is a simplified view of the logic.
BaseWallet.sendTx
For details on how BaseWallet.sendTx works with completeFeeOptions, see PXE Integration.
The inherited sendTx method does the heavy lifting:
// In BaseWallet (inherited by OffscreenWallet)
async sendTx(executionPayload, opts) {
// 1. Get fee options (our override uses SponsoredFPC!)
const feeOptions = await this.completeFeeOptions({
from: opts.from,
feePayer: executionPayload.feePayer,
gasSettings: opts.fee?.gasSettings,
});
// 2. Create execution request
const txRequest = await this.createTxExecutionRequestFromPayloadAndFee(
executionPayload,
opts.from,
feeOptions
);
// 3. Generate proof (this is the slow part)
const provenTx = await this.pxe.proveTx(txRequest);
// 4. Convert to onchain transaction
const tx = await provenTx.toTx();
const txHash = tx.getTxHash();
// 5. Submit to node
await this.aztecNode.sendTx(tx);
// 6. Optionally wait for confirmation
if (opts.wait !== NO_WAIT) {
const receipt = await waitForTx(this.aztecNode, txHash, opts.wait);
return { receipt };
}
return { txHash };
}
SponsoredFPC Integration
Our completeFeeOptions override in OffscreenWallet ensures SponsoredFPC is used, as shown in PXE Integration.
The SponsoredFeePaymentMethod:
- Creates a fee payment execution payload
- Gets merged with the user's transaction
- Calls the SponsoredFPC contract to pay fees
- User transaction executes with paid fees
Proof Generation
Proof generation is the slowest part (10-60 seconds):
const provenTx = await this.pxe.proveTx(txRequest);
This:
- Executes private functions locally
- Generates WASM-based zero-knowledge proofs
- Creates the kernel proofs
- Packages everything for submission
The offscreen document handles this well because it:
- Doesn't have service worker timeouts
- Supports WASM
- Can use IndexedDB for intermediate state
Simulation
For gas estimation or validation, dApps use simulateTx:
case 'simulateTx': {
const { executionPayload, options } = args;
const payload = deserializeExecutionPayload(executionPayload);
const fromAddress = AztecAddress.fromStringUnsafe(from || options.from);
const result = await wallet.simulateTx(payload, {
...options,
from: fromAddress,
});
return serializeTxSimulationResult(result);
}
Simulation:
- Executes the transaction locally
- Estimates gas usage
- Detects revert conditions
- Doesn't generate full proofs
- Much faster than
sendTx
Error Handling
Transactions can fail at several points:
try {
const result = await wallet.sendTx(payload, options);
return { success: true, result };
} catch (error) {
// Could be:
// - Simulation failure (contract revert)
// - Proof generation failure
// - Node rejection (already settled, insufficient gas)
// - Network error
return { success: false, error: error.message };
}
Errors are serialized and returned to the dApp, which should handle them gracefully.
Authorization Witnesses
For delegated actions (like approving token spending), the wallet creates auth witnesses:
case 'createAuthWit': {
const { from: authFrom, messageHashOrIntent } = args;
const fromAddress = AztecAddress.fromStringUnsafe(authFrom);
const authWit = await wallet.createAuthWit(fromAddress, messageHashOrIntent);
return {
requestHash: authWit.requestHash.toString(),
witness: Array.from(authWit.witness),
};
}
The account signs the authorization, which can be used by other contracts to verify permission.
Transaction status
After submission, a transaction's receipt reports one of these statuses:
pending- Still in the mempoolproposed- Included in a proposed blockcheckpointed- The block's checkpoint has been published on L1proven- The block's checkpoint has been proven on L1finalized- The L1 transaction that proved the checkpoint has reached L1 finalitydropped- Dropped by the node
See Query transaction status for the receipt variants and their fields.
The wallet can wait for a transaction to be included:
const receipt = await waitForTx(this.aztecNode, txHash, {
timeout: 60, // Seconds
interval: 1, // Seconds between receipt polls
});
console.log(receipt.status); // 'checkpointed' or later
console.log(receipt.blockNumber);
By default, waitForTx resolves once the transaction is checkpointed, and throws if the transaction is dropped or its execution reverts.
Next Steps
With transactions flowing, let's build the Approval UI - the React popup that makes all this user-friendly.