PXE Integration
The Private eXecution Environment (PXE) is the core of any Aztec wallet. It handles private state, note management, and proof generation. This section shows how to run a PXE in the extension's offscreen document.
Why PXE in Extensions?
Every Aztec wallet needs a PXE to:
- Sync private state - Download and decrypt notes belonging to the user
- Generate proofs - Create zero-knowledge proofs for private functions
- Manage contracts - Register and track contract artifacts
- Compute witnesses - Provide private inputs for transaction execution
The PXE is stateful and long-running, which is why the extension uses an offscreen document instead of the service worker.
Initializing PXE
The offscreen document initializes PXE lazily on first use with deduplication to prevent multiple initializations:
/**
* PXE + node — lazily initialized as a pair, with dedup on the inflight promise.
*/
let pxeState: { pxe: PXE; node: AztecNode } | null = null;
let pxeInitializing: Promise<{ pxe: PXE; node: AztecNode }> | null = null;
async function ensurePXE(nodeUrl: string = NODE_URL): Promise<{ pxe: PXE; node: AztecNode }> {
if (pxeState) return pxeState;
if (pxeInitializing) return pxeInitializing;
log.debug('[offscreen] Initializing PXE with node:', nodeUrl);
pxeInitializing = (async () => {
try {
const node = createAztecNodeClient(nodeUrl);
const config = getPXEConfig();
config.l1Contracts = await node.getL1ContractAddresses();
const isLocal = nodeUrl.includes('localhost') || nodeUrl.includes('127.0.0.1');
config.proverEnabled = !isLocal;
const pxe = await createPXE(node, config, {});
log.debug('[offscreen] PXE initialized, connected to node at:', nodeUrl);
pxeState = { pxe, node };
return pxeState;
} finally {
pxeInitializing = null; // Always clear so a retry can re-attempt
}
})();
return pxeInitializing;
}
Source code: docs/examples/webapp-tutorial/test-extension/src/offscreen/offscreen.ts#L123-L155
Key configuration:
l1Contracts- Required for the PXE to verify L1 stateproverEnabled- Enables client-side proof generation
SponsoredFPC is registered lazily when the wallet's completeFeeOptions() is first called, rather than at PXE initialization time.
The Wallet Implementation
The extension has two wallet-related classes with distinct responsibilities:
-
ExtensionWalletManager(inwallet-impl.ts) - A static utility class that handles secret generation, address computation, and encrypted storage. It does not extendBaseWallet. -
OffscreenWallet(inoffscreen.ts) - ABaseWalletsubclass defined inline inside thegetWallet()function that handles actual wallet operations.
The OffscreenWallet extends BaseWallet from the wallet SDK:
/** Single wallet class used for all operations. (#18, #20) */
import type { BaseWallet } from '@aztec/wallet-sdk/base-wallet';
/**
* The wallet instance holds a BaseWallet subclass with an additional
* registerAccount method for tracking which accounts we can sign for.
* BaseWallet is dynamically imported at runtime; using `import type` gives
* us the type without a runtime dependency. (#20)
*/
type OffscreenWalletType = BaseWallet & { registerAccount(address: string, account: Account): void };
let walletInstance: OffscreenWalletType | null = null;
/**
* Creates a SponsoredFPC contract instance from its artifact and well-known salt.
* Shared between OffscreenWallet.ensureSponsoredFPC() and handleDeployAccount().
*/
async function getSponsoredFPCInstance() {
const { Fr, SponsoredFPCContract, SPONSORED_FPC_SALT, getContractInstanceFromInstantiationParams } = await getAztecDeploy();
return getContractInstanceFromInstantiationParams(
SponsoredFPCContract.artifact,
{ salt: new Fr(SPONSORED_FPC_SALT) },
);
}
async function getWallet() {
if (walletInstance) return walletInstance;
const { BaseWallet, AztecAddress, SignerlessAccount } = await getAztecWallet();
const { pxe, node } = await ensurePXE();
// AccountFeePaymentMethodOptions.EXTERNAL = 0 — fee is paid by an external FPC
const EXTERNAL_FEE_PAYMENT = 0;
class OffscreenWallet extends BaseWallet {
protected minFeePadding = 1.0; // 100% padding for fee estimation variance
private accounts: Map<string, Account> = new Map();
private sponsoredFPCAddress: any | null = null;
constructor(pxeInstance: PXE, aztecNode: AztecNode) {
super(pxeInstance, aztecNode);
}
registerAccount(address: string, account: Account) {
this.accounts.set(address, account);
}
protected async getAccountFromAddress(address: any): Promise<Account> {
if (address.equals(AztecAddress.ZERO)) {
return new SignerlessAccount();
}
const key = address.toString();
const account = this.accounts.get(key);
if (!account) {
throw new Error(`Account not found for address: ${key}`);
}
return account;
}
async getAccounts() {
return Array.from(this.accounts.entries()).map(([, acc]) => ({
alias: '',
item: acc.getAddress(),
}));
}
/** Lazily registers the SponsoredFPC contract and caches its address. */
private async ensureSponsoredFPC() {
if (this.sponsoredFPCAddress) return this.sponsoredFPCAddress;
const { SponsoredFPCContract } = await getAztecDeploy();
const sponsoredFPCInstance = await getSponsoredFPCInstance();
await this.registerContract(sponsoredFPCInstance, SponsoredFPCContract.artifact);
this.sponsoredFPCAddress = sponsoredFPCInstance.address;
return this.sponsoredFPCAddress;
}
/**
* Always uses SponsoredFPC for fee payment, mirroring the deployment flow.
* The tutorial wallet doesn't hold fee juice, so every tx is sponsor-paid.
*
* If the execution payload already has a feePayer (e.g. DeployAccountMethod
* embeds SponsoredFPC in its own payload), we skip injecting a wallet-level
* payment method to avoid calling sponsor_unconditionally() twice, which
* would trigger "Cannot enter the revertible phase twice".
*/
protected async completeFeeOptions(from: any, feePayer?: any, gasSettings?: any) {
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: EXTERNAL_FEE_PAYMENT,
};
}
const address = await this.ensureSponsoredFPC();
const { SponsoredFeePaymentMethod } = await getAztecDeploy();
return {
...base,
walletFeePaymentMethod: new SponsoredFeePaymentMethod(address),
accountFeePaymentMethodOptions: EXTERNAL_FEE_PAYMENT,
};
}
/**
* Overrides sendTx to auto-extract auth witnesses from offchain effects.
*
* dApps like gregoswap don't explicitly create auth witnesses. Instead, they
* expect the wallet to handle it: simulate with a stub account (which passes
* all auth checks), extract the authorization requests emitted by
* `#[authorize_once]` in Noir contracts, sign them, and include them in the
* real transaction.
*/
async sendTx(executionPayload: any, opts: any): Promise<any> {
if (executionPayload.authWitnesses.length === 0 && opts.from && !opts.from.equals(AztecAddress.ZERO)) {
try {
await this.extractAndInjectAuthWitnesses(executionPayload, opts.from, opts.fee?.gasSettings);
} catch (err: any) {
log.error('[offscreen] Auth witness extraction failed, proceeding without:', err.message, err.stack);
}
}
return super.sendTx(executionPayload, opts);
}
/**
* Simulates the tx with a stub account to collect offchain effects,
* parses CallAuthorizationRequest objects, and creates real auth witnesses.
*/
private async extractAndInjectAuthWitnesses(executionPayload: any, from: any, feeGasSettings?: any) {
const { Fr, getContractInstanceFromInstantiationParams } = await getAztecCore();
// Step 1: Create a stub account that passes all auth checks unconditionally
log.info('[offscreen] Step 1: Loading stub account module...');
const realAccount = await this.getAccountFromAddress(from);
const originalAddress = realAccount.getCompleteAddress();
log.info('[offscreen] Got complete address:', originalAddress.address.toString());
const { createStubAccount, getStubAccountContractArtifact } = await import('@aztec/accounts/stub/lazy');
log.info('[offscreen] Loaded @aztec/accounts/stub/lazy');
const stubArtifact = await getStubAccountContractArtifact();
log.info('[offscreen] Loaded stub artifact:', stubArtifact.name);
const stubAccount = createStubAccount(originalAddress);
const stubInstance = await getContractInstanceFromInstantiationParams(stubArtifact, { salt: Fr.random() });
log.info('[offscreen] Created stub account and instance');
// Step 2: Simulate with the stub account swapped in via PXE overrides
log.info('[offscreen] Step 2: Simulating tx with stub account...');
const feeOptions = await this.completeFeeOptions(from, executionPayload.feePayer, feeGasSettings);
const chainInfo = await this.getChainInfo();
const txRequest = await stubAccount.createTxExecutionRequest(
executionPayload,
feeOptions.gasSettings,
chainInfo,
{ txNonce: Fr.random(), cancellable: false, feePaymentMethodOptions: feeOptions.accountFeePaymentMethodOptions },
);
log.info('[offscreen] Created tx execution request, simulating...');
const simResult = await this.pxe.simulateTx(txRequest, {
simulatePublic: true,
skipTxValidation: true,
skipFeeEnforcement: true,
overrides: { contracts: { [from.toString()]: { instance: stubInstance, artifact: stubArtifact } } },
scopes: [from],
});
log.info('[offscreen] Simulation succeeded');
// Step 3: Extract auth witness requests from offchain effects
log.info('[offscreen] Step 3: Extracting offchain effects...');
const { collectOffchainEffects } = await import('@aztec/stdlib/tx');
const { CallAuthorizationRequest } = await import('@aztec/aztec.js/authorization');
if (!simResult.privateExecutionResult) {
log.warn('[offscreen] No privateExecutionResult in simulation result');
return;
}
const effects = collectOffchainEffects(simResult.privateExecutionResult);
log.info(`[offscreen] Found ${effects.length} offchain effect(s)`);
// Pre-filter by CallAuthorizationRequest selector (matching e2e test pattern)
const callAuthSelector = await CallAuthorizationRequest.getSelector();
const authEffects = effects.filter((e: any) =>
e.data.length > 0 && e.data[0].equals(callAuthSelector.toField()),
);
log.info(`[offscreen] ${authEffects.length} are CallAuthorizationRequest(s)`);
// Step 4: Create auth witnesses from parsed authorization requests
let count = 0;
for (const effect of authEffects) {
const authRequest = await CallAuthorizationRequest.fromFields(effect.data);
log.info(`[offscreen] Auth request: consumer=${effect.contractAddress.toString()}, innerHash=${authRequest.innerHash.toString()}`);
const wit = await this.createAuthWit(from, {
consumer: effect.contractAddress,
innerHash: authRequest.innerHash,
});
executionPayload.authWitnesses.push(wit);
count++;
log.info(`[offscreen] Created auth witness #${count}: messageHash=${wit.requestHash.toString()}`);
}
log.info(`[offscreen] Auth witness extraction complete: ${count} witness(es) from ${effects.length} effect(s)`);
}
}
walletInstance = new OffscreenWallet(pxe, node);
return walletInstance;
}
Source code: docs/examples/webapp-tutorial/test-extension/src/offscreen/offscreen.ts#L157-L369
By extending BaseWallet, you inherit:
sendTx()- Transaction submission with proof generationsimulateTx()- Transaction simulationcreateAuthWit()- Authorization witness creationregisterContract()- Contract registrationgetChainInfo()- Network information- And more...
You implement:
getAccountFromAddress()- Return the Account object for signinggetAccounts()- List available accountscompleteFeeOptions()- Configure fee payment (the SponsoredFPC override)sendTx()- Override with automatic auth witness extraction
SponsoredFPC Fee Payment
The key override is completeFeeOptions(), which lazily registers the SponsoredFPC contract on first use:
/**
* Always uses SponsoredFPC for fee payment, mirroring the deployment flow.
* The tutorial wallet doesn't hold fee juice, so every tx is sponsor-paid.
*
* If the execution payload already has a feePayer (e.g. DeployAccountMethod
* embeds SponsoredFPC in its own payload), we skip injecting a wallet-level
* payment method to avoid calling sponsor_unconditionally() twice, which
* would trigger "Cannot enter the revertible phase twice".
*/
protected async completeFeeOptions(from: any, feePayer?: any, gasSettings?: any) {
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: EXTERNAL_FEE_PAYMENT,
};
}
const address = await this.ensureSponsoredFPC();
const { SponsoredFeePaymentMethod } = await getAztecDeploy();
return {
...base,
walletFeePaymentMethod: new SponsoredFeePaymentMethod(address),
accountFeePaymentMethodOptions: EXTERNAL_FEE_PAYMENT,
};
}
Source code: docs/examples/webapp-tutorial/test-extension/src/offscreen/offscreen.ts#L235-L262
This ensures that by default:
- If the payload already has a
feePayer(e.g., during account deployment), the wallet respects it - Otherwise, the wallet injects
SponsoredFeePaymentMethodso users don't need fee tokens to transact
The SponsoredFeePaymentMethod creates an execution payload that:
- Calls the SponsoredFPC contract's fee payment function
- Gets merged with the user's transaction payload
- Results in SponsoredFPC paying the fee
Message Handling
The offscreen document receives messages from the background via a persistent port. When the background connects with chrome.runtime.connect({ name: 'offscreen' }), the offscreen stores the port and listens for messages. Each message includes a messageId for request/response correlation:
/**
* Handles messages from the background script via a persistent port.
* The background connects with chrome.runtime.connect({ name: 'offscreen' }).
* Each message includes a messageId for request/response correlation.
*/
chromeRuntime.runtime.onConnect.addListener((port: chrome.runtime.Port) => {
if (port.name !== 'offscreen') return;
log.debug('[offscreen] Background port connected');
backgroundPort = port;
port.onMessage.addListener((message: any) => {
log.debug('[offscreen] Received message:', message.type);
handleMessage(message)
.then((result) => {
log.debug('[offscreen] Sending response for:', message.type);
port.postMessage({ messageId: message.messageId, success: true, result });
})
.catch((error: unknown) => {
const msg = getErrorMessage(error);
log.error('[offscreen] Error:', msg, error);
port.postMessage({ messageId: message.messageId, success: false, error: msg });
});
});
port.onDisconnect.addListener(() => {
log.debug('[offscreen] Background port disconnected');
backgroundPort = null;
});
});
async function handleMessage(message: any): Promise<any> {
switch (message.type) {
case MessageTypes.GET_ACCOUNTS:
return handleGetAccounts();
case MessageTypes.MARK_DEPLOYED:
return handleMarkDeployed(message.address);
case MessageTypes.WALLET_METHOD:
return handleWalletMethod(message.method, message.args);
case MessageTypes.SETUP_PASSWORD:
return handleSetupPassword(message.password);
case MessageTypes.CREATE_ACCOUNT:
return handleCreateAccount(message.alias);
case MessageTypes.DEPLOY_ACCOUNT:
return handleDeployAccount(message.address);
case MessageTypes.UNLOCK_WALLET:
return handleUnlockWallet(message.password);
case MessageTypes.INIT_PXE:
return handleInitPXE(message.nodeUrl);
case MessageTypes.REGISTER_ACCOUNT:
return handleRegisterAccount(message.address, message.secret, message.salt);
case MessageTypes.EXPORT_WALLET:
return handleExportWallet();
case MessageTypes.IMPORT_WALLET_ACCOUNTS:
return handleImportWalletAccounts(message.accounts, message.activeAccount);
// Lock the wallet (clear cached key) — used by auto-lock (#28)
case MessageTypes.LOCK_WALLET:
cachedMasterKey = null;
walletInstance = null;
return { success: true };
default:
throw new Error(`Unknown message type: ${message.type}`);
}
}
Source code: docs/examples/webapp-tutorial/test-extension/src/offscreen/offscreen.ts#L371-L449
Each message type maps to a handler:
INIT_PXE- Ensures PXE is readyGET_ACCOUNTS- Lists stored accountsCREATE_ACCOUNT- Creates a new accountDEPLOY_ACCOUNT- Deploys an account contractWALLET_METHOD- Handles dApp wallet callsSETUP_PASSWORD- Sets master password on first useUNLOCK_WALLET- Verifies password and registers accountsEXPORT_WALLET/IMPORT_WALLET_ACCOUNTS- Wallet backup and restore
Wallet Method Dispatch
For wallet methods from dApps, the handler dispatches based on method name. The handler uses WalletSchema to parse incoming JSON arguments back into proper Aztec types (AztecAddress, Fr, ExecutionPayload, etc.), and jsonStringify to serialize results before returning through Chrome messaging:
/**
* Handles wallet method calls from the ExtensionWallet proxy via the SDK protocol.
*
* Serialization notes:
* 1. ARGS: Arrive as plain JSON. We use WalletSchema to parse them back into
* proper Aztec types (AztecAddress, Fr, ExecutionPayload, etc.).
* 2. RESULT: Contains class instances that lose prototypes through Chrome messaging.
* We serialize with jsonStringify before returning.
*/
async function handleWalletMethod(method: string, args: any[]): Promise<any> {
log.debug('[offscreen] Handling wallet method:', method);
const wallet = await getWallet();
// Dynamic dispatch: the wallet protocol sends method names as strings.
// Cast to Record for dynamic access since TypeScript can't know the method at compile time.
const walletObj = wallet as unknown as Record<string, (...args: any[]) => any>;
if (typeof walletObj[method] !== 'function') {
throw new Error(`Unknown wallet method: ${method}`);
}
const { WalletSchema, jsonStringify, schemaHasMethod } = await getAztecWallet();
// Parse args through WalletSchema to reconstruct proper Aztec types (Buffer, Fr, etc.)
// from their JSON representations. The schema's .parameters() returns a zod tuple that
// requires all positional elements even if some are optional. Pad with undefined so the
// tuple length matches and the parse succeeds.
let parsedArgs: any[] = args || [];
if (schemaHasMethod(WalletSchema, method)) {
const schema = WalletSchema[method as keyof typeof WalletSchema];
const paramSchema = schema.parameters();
const expectedLength = (paramSchema as any)?._def?.items?.length ?? 0;
const paddedArgs = [...(args || [])];
while (paddedArgs.length < expectedLength) {
paddedArgs.push(undefined);
}
try {
parsedArgs = await paramSchema.parseAsync(paddedArgs);
} catch (parseErr: any) {
log.warn('[offscreen] Args parse warning for', method, ':', parseErr.message);
parsedArgs = args || [];
}
}
// Report initial progress for long-running methods so the popup shows something
// before the PXE log matchers kick in
const longRunningMethods = ['sendTx', 'simulateTx', 'profileTx'];
if (longRunningMethods.includes(method)) {
reportProgress(`Starting ${method}...`);
}
const result = await walletObj[method](...parsedArgs);
// Serialize to JSON-safe format before returning through Chrome messaging
const jsonSafe = JSON.parse(jsonStringify(result));
log.debug('[offscreen] Wallet method completed:', method);
return jsonSafe;
}
Source code: docs/examples/webapp-tutorial/test-extension/src/offscreen/offscreen.ts#L460-L519
This generic dispatch means any method on the BaseWallet interface (e.g., getAccounts, sendTx, simulateTx, createAuthWit, getChainInfo) is automatically available to dApps through the wallet SDK protocol.
Error Handling
The port message handler wraps each operation in try/catch and posts the result back with the same messageId for correlation:
port.onMessage.addListener((message) => {
handleMessage(message)
.then((result) => {
port.postMessage({ messageId: message.messageId, success: true, result });
})
.catch((error) => {
port.postMessage({ messageId: message.messageId, success: false, error: error.message });
});
});
Errors are serialized and sent back to the background, which can:
- Show them in the popup
- Return them to the dApp as wallet errors
PXE State Persistence
The PXE uses IndexedDB for persistence (via @aztec/kv-store). This means:
- Synced notes persist across extension restarts
- Registered contracts are remembered
- Account registrations survive restarts
However, the OffscreenWallet.accounts Map is in-memory and needs reloading when the user unlocks the wallet. This is handled by handleUnlockWallet(), which verifies the password, derives a CryptoKey, initializes PXE, and registers all stored accounts. See Account Management for details.
Integration with BaseWallet
The BaseWallet base class does the heavy lifting for transactions:
// In BaseWallet (inherited)
async sendTx(executionPayload, opts) {
// 1. Complete fee options (our override uses SponsoredFPC)
const feeOptions = await this.completeFeeOptions(...);
// 2. Create execution request
const txRequest = await this.createTxExecutionRequestFromPayloadAndFee(...);
// 3. Generate proof (WASM, can take time)
const provenTx = await this.pxe.proveTx(txRequest);
// 4. Submit to node
const tx = await provenTx.toTx();
await this.aztecNode.sendTx(tx);
// 5. Optionally wait for confirmation
if (opts.wait !== NO_WAIT) {
return await waitForTx(this.aztecNode, txHash, waitOpts);
}
return txHash;
}
Our OffscreenWallet also overrides sendTx() to automatically extract authorization witnesses from offchain effects. This means dApps don't need to explicitly create auth witnesses - the wallet handles it by simulating with a stub account, collecting CallAuthorizationRequest objects, and signing them before the real transaction.
Next Steps
Now that PXE is running, let's implement Account Management - creating, storing, and managing user accounts with encrypted keys.