Skip to main content
Version: dev

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 {
to: AztecAddress; // Target contract
functionSelector: FunctionSelector;
args: Fr[]; // Function arguments
isStatic: boolean; // View call?
}

Example from a Pod Racing dApp:

const payload = new ExecutionPayload([
{
to: gameContractAddress,
functionSelector: FunctionSelector.fromSignature('boost()'),
args: [],
isStatic: false,
},
]);

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.functionSelector?.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.fromString(from || options.from);

// 2. Call wallet.sendTx (inherited from BaseWallet)
const result = await wallet.sendTx(payload, {
...options,
from: fromAddress,
});

// 3. Serialize the result
if (typeof result === 'object' && 'txHash' in result) {
return {
txHash: result.txHash.toString(),
status: result.status,
blockNumber: result.blockNumber?.toString(),
};
}
return { txHash: result.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(
opts.from,
executionPayload.feePayer,
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) {
return await waitForTx(this.aztecNode, txHash, opts.wait);
}
return txHash;
}

SponsoredFPC Integration​

Our completeFeeOptions override in OffscreenWallet ensures SponsoredFPC is used:

protected async completeFeeOptions(from, feePayer, gasSettings) {
const base = await super.completeFeeOptions(from, feePayer, gasSettings);

// If the payload already includes a fee payer, don't inject another one
if (feePayer) {
return {
...base,
accountFeePaymentMethodOptions: 0, // EXTERNAL
};
}

// Otherwise, lazily register and use SponsoredFPC
const address = await this.ensureSponsoredFPC();
return {
...base,
walletFeePaymentMethod: new SponsoredFeePaymentMethod(address),
accountFeePaymentMethodOptions: 0, // EXTERNAL: sponsored FPC pays
};
}

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.fromString(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.fromString(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, transactions go through states:

  1. Pending - Submitted, waiting for sequencer
  2. Included - In a block, but not proven
  3. Proven - Epoch proof submitted
  4. Finalized - L1 finality achieved

The wallet can track status:

const receipt = await waitForTx(this.aztecNode, txHash, {
timeout: 60_000, // 60 seconds
interval: 1_000, // Check every second
});

console.log(receipt.status); // 'success' | 'reverted'
console.log(receipt.blockNumber);

Next Steps​

With transactions flowing, let's build the Approval UI - the React popup that makes all this user-friendly.