For the complete documentation index, see llms.txt.
Skip to main content
Version: Testnet (v6.0.0-rc.1)

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() };
}
note

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:

  1. Creates a fee payment execution payload
  2. Gets merged with the user's transaction
  3. Calls the SponsoredFPC contract to pay fees
  4. 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:

  1. Executes private functions locally
  2. Generates WASM-based zero-knowledge proofs
  3. Creates the kernel proofs
  4. 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 mempool
  • proposed - Included in a proposed block
  • checkpointed - The block's checkpoint has been published on L1
  • proven - The block's checkpoint has been proven on L1
  • finalized - The L1 transaction that proved the checkpoint has reached L1 finality
  • dropped - 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.