Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions yarn-project/aztec.js/src/contract/batch_call.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {

import { type MockProxy, mock } from 'jest-mock-extended';

import type { FeePaymentMethod } from '../fee/fee_payment_method.js';
import { TxSimulationResultWithAppOffset } from '../wallet/tx_simulation_result_with_app_offset.js';
import type { Wallet } from '../wallet/wallet.js';
import { BatchCall } from './batch_call.js';
Expand Down Expand Up @@ -383,6 +384,101 @@ describe('BatchCall', () => {
});
});

describe('simulate with fee payment method', () => {
it('offsets return-value indices by the calls the fee payment method prepends', async () => {
const appContract = await AztecAddress.random();
const feeContract = await AztecAddress.random();

const appPrivatePayload = createPrivateExecutionPayload('appPrivate', [Fr.random()], appContract, 1);
const appPublicPayload = createPublicExecutionPayload('appPublic', [Fr.random()], appContract);

batchCall = new BatchCall(wallet, [appPrivatePayload, appPublicPayload]);

// The fee method contributes one private and one public call, prepended ahead of the batch.
const feePrivateCall = createPrivateExecutionPayload('feePrivate', [], feeContract).calls[0];
const feePublicCall = createPublicExecutionPayload('feePublic', [], feeContract).calls[0];
const feePayload = new ExecutionPayload([feePrivateCall, feePublicCall], [], [], [], await AztecAddress.random());

const paymentMethod = mock<FeePaymentMethod>();
paymentMethod.getExecutionPayload.mockResolvedValue(feePayload);

const appPrivateReturnValues = [Fr.random()];
const appPublicReturnValues = [Fr.random()];

const txSimResult = mockTxSimResult();
// Private nested index 0 is the fee call, index 1 is the app call. Returning distinct values lets us detect
// an off-by-one that would decode the fee call's return values as the app call's.
txSimResult.getPrivateReturnValuesOfAppCall.mockImplementation(
(idx?: number) => (idx === 1 ? { values: appPrivateReturnValues } : { values: [Fr.random()] }) as any,
);
// Public index 0 is the fee call, index 1 is the app call.
txSimResult.getPublicReturnValues.mockReturnValue([
{ values: [Fr.random()] },
{ values: appPublicReturnValues },
] as any);

wallet.batch.mockResolvedValue([{ name: 'simulateTx', result: txSimResult }] as any);

const { result: results } = await batchCall.simulate({
from: await AztecAddress.random(),
fee: { paymentMethod },
});

expect(txSimResult.getPrivateReturnValuesOfAppCall).toHaveBeenCalledWith(1);
expect(results).toHaveLength(2);
expect(results[0].result).toEqual(appPrivateReturnValues[0].toBigInt());
expect(results[1].result).toEqual(appPublicReturnValues[0].toBigInt());
});

it('merges the fee payment method payload and preserves its fee payer', async () => {
const appContract = await AztecAddress.random();
const feeContract = await AztecAddress.random();
const feePayer = await AztecAddress.random();

const appPayload = createPrivateExecutionPayload('app', [Fr.random()], appContract, 1);
batchCall = new BatchCall(wallet, [appPayload]);

const feeCall = createPrivateExecutionPayload('payFee', [], feeContract).calls[0];
const feePayload = new ExecutionPayload([feeCall], [], [], [], feePayer);

const paymentMethod = mock<FeePaymentMethod>();
paymentMethod.getExecutionPayload.mockResolvedValue(feePayload);

const txSimResult = mockTxSimResult();
txSimResult.getPrivateReturnValuesOfAppCall.mockReturnValue({ values: [Fr.random()] } as any);
wallet.batch.mockResolvedValue([{ name: 'simulateTx', result: txSimResult }] as any);

await batchCall.simulate({ from: await AztecAddress.random(), fee: { paymentMethod } });

const methods = wallet.batch.mock.calls[0][0] as any[];
const { args } = methods.find(m => m.name === 'simulateTx')!;
const [executionPayload] = args;
expect(executionPayload.calls).toHaveLength(2);
expect(executionPayload.calls[0]).toEqual(feeCall);
expect(executionPayload.calls[1]).toEqual(appPayload.calls[0]);
expect(executionPayload.feePayer).toEqual(feePayer);
});

it('preserves a fee payer carried by a batched execution payload', async () => {
const appContract = await AztecAddress.random();
const feePayer = await AztecAddress.random();

const appCall = createPrivateExecutionPayload('app', [Fr.random()], appContract, 1).calls[0];
const payloadWithFeePayer = new ExecutionPayload([appCall], [], [], [], feePayer);
batchCall = new BatchCall(wallet, [payloadWithFeePayer]);

const txSimResult = mockTxSimResult();
txSimResult.getPrivateReturnValuesOfAppCall.mockReturnValue({ values: [Fr.random()] } as any);
wallet.batch.mockResolvedValue([{ name: 'simulateTx', result: txSimResult }] as any);

await batchCall.simulate({ from: await AztecAddress.random() });

const methods = wallet.batch.mock.calls[0][0] as any[];
const { args } = methods.find(m => m.name === 'simulateTx')!;
expect(args[0].feePayer).toEqual(feePayer);
});
});

describe('request', () => {
it('should include fee payment method if provided', async () => {
const contractAddress = await AztecAddress.random();
Expand Down
22 changes: 18 additions & 4 deletions yarn-project/aztec.js/src/contract/batch_call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ export class BatchCall extends BaseContractInteraction {
* @returns The results of all the interactions that make up the batch
*/
public async simulate(options: SimulateInteractionOptions): Promise<SimulationResult> {
const feeExecutionPayload = options.fee?.paymentMethod
? await options.fee.paymentMethod.getExecutionPayload()
: undefined;
// A call-contributing fee payment method prepends its calls in front of the batch (see the merge below). Those
// calls shift the return-value indices, and the wallet-side app-call offset does not absorb them: it only counts
// the wallet's own fee payment method, not one supplied through options.fee. We offset each app call's result
// index by the number of prepended fee calls of the matching type.
const feePrivateCallCount = feeExecutionPayload?.calls.filter(c => c.type === FunctionType.PRIVATE).length ?? 0;
const feePublicCallCount = feeExecutionPayload?.calls.filter(c => c.type === FunctionType.PUBLIC).length ?? 0;

const { indexedExecutionPayloads, utility } = (await this.getExecutionPayloads()).reduce<{
/** Keep track of the number of private calls to retrieve the return values */
privateIndex: 0;
Expand Down Expand Up @@ -98,12 +108,15 @@ export class BatchCall extends BaseContractInteraction {
// Add tx simulation to batch if there are any private/public calls
if (indexedExecutionPayloads.length > 0) {
const payloads = indexedExecutionPayloads.map(([request]) => request);
const combinedPayload = mergeExecutionPayloads(payloads);
const combinedPayload = mergeExecutionPayloads(
feeExecutionPayload ? [feeExecutionPayload, ...payloads] : payloads,
);
const executionPayload = new ExecutionPayload(
combinedPayload.calls,
combinedPayload.authWitnesses.concat(options.authWitnesses ?? []),
combinedPayload.capsules.concat(options.capsules ?? []),
combinedPayload.extraHashedArgs,
combinedPayload.feePayer,
);

batchRequests.push({
Expand Down Expand Up @@ -139,11 +152,12 @@ export class BatchCall extends BaseContractInteraction {
simulatedTx = txResultWrapper.result as TxSimulationResultWithAppOffset;
indexedExecutionPayloads.forEach(([request, callIndex, resultIndex]) => {
const call = request.calls[0];
// For public functions we retrieve the values directly from the public output.
// For public functions we retrieve the values directly from the public output. Both indices are offset by
// the fee payment method's own calls, which are prepended ahead of the batch.
const rawReturnValues =
call.type == FunctionType.PRIVATE
? simulatedTx!.getPrivateReturnValuesOfAppCall(resultIndex)?.values
: simulatedTx!.getPublicReturnValues()?.[resultIndex].values;
? simulatedTx!.getPrivateReturnValuesOfAppCall(feePrivateCallCount + resultIndex)?.values
: simulatedTx!.getPublicReturnValues()?.[feePublicCallCount + resultIndex].values;

results[callIndex] = {
result: rawReturnValues ? decodeFromAbi(call.returnTypes, rawReturnValues) : [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,16 @@ export class ContractFunctionInteraction extends BaseContractInteraction {

let rawReturnValues;
if (this.functionDao.functionType == FunctionType.PRIVATE) {
rawReturnValues = simulatedTx.getPrivateReturnValuesOfAppCall(0)?.values;
// request() prepends the fee payment method's calls (if any) before this interaction's single call, so the app
// call is the last call of its type in the payload. Its position among the private return values is the number
// of private calls that precede it.
const appCallIndex = executionPayload.calls.filter(c => c.type === FunctionType.PRIVATE).length - 1;
rawReturnValues = simulatedTx.getPrivateReturnValuesOfAppCall(appCallIndex)?.values;
} else {
// For public functions we retrieve the first values directly from the public output.
rawReturnValues = simulatedTx.getPublicReturnValues()?.[0]?.values;
// For public functions we retrieve the values directly from the public output, offset by any public fee calls
// that request() prepended ahead of the app call.
const appCallIndex = executionPayload.calls.filter(c => c.type === FunctionType.PUBLIC).length - 1;
rawReturnValues = simulatedTx.getPublicReturnValues()?.[appCallIndex]?.values;
}

const returnValue = rawReturnValues ? decodeFromAbi(this.functionDao.returnTypes, rawReturnValues) : [];
Expand Down
25 changes: 11 additions & 14 deletions yarn-project/end-to-end/src/automine/phase_check.parallel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,19 +93,16 @@ describe('automine/phase_check', () => {
});

it('should fail when the fee payer is elected after the setup phase has ended', async () => {
// BatchCall.simulate ignores the fee payment method, which would make the wallet fall back to
// PREEXISTING_FEE_JUICE and end setup in the account entrypoint before any app call runs. Build the payload via
// request() instead, which merges the payment method's payload (and thus its fee payer), and simulate it directly.
const lateElection = await new BatchCall(wallet, [
contract.methods.call_function_that_ends_setup_without_phase_check(),
sponsoredFPC.methods.sponsor_unconditionally(),
]).request({
fee: {
paymentMethod: new DeferredSponsoredFeePaymentMethod(sponsoredFPC.address),
},
});
await expect(wallet.simulateTx(lateElection, { from: defaultAccountAddress })).rejects.toThrow(
'fee payer must be elected during the setup phase',
);
await expect(
new BatchCall(wallet, [
contract.methods.call_function_that_ends_setup_without_phase_check(),
sponsoredFPC.methods.sponsor_unconditionally(),
]).simulate({
from: defaultAccountAddress,
fee: {
paymentMethod: new DeferredSponsoredFeePaymentMethod(sponsoredFPC.address),
},
}),
).rejects.toThrow('fee payer must be elected during the setup phase');
});
});
12 changes: 10 additions & 2 deletions yarn-project/txe/esbuild.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,18 @@ const result = await build({
// Dump the full metafile so we can audit chunk graph / imports separately from the build.
await writeFile('dest/metafile.json', JSON.stringify(result.metafile, null, 2));

const totalBytes = Object.values(result.metafile.outputs).reduce((sum, o) => sum + o.bytes, 0);
// Report runtime JS and external sourcemaps separately: the size guard bounds the JS (what V8
// parses at cold start), while the `.map` files are loaded lazily and only tracked for growth.
const jsBytes = Object.entries(result.metafile.outputs)
.filter(([p]) => !p.endsWith('.map'))
.reduce((sum, [, o]) => sum + o.bytes, 0);
const mapBytes = Object.entries(result.metafile.outputs)
.filter(([p]) => p.endsWith('.map'))
.reduce((sum, [, o]) => sum + o.bytes, 0);
const ms = Date.now() - start;
const toMiB = b => (b / 1024 / 1024).toFixed(1);
// eslint-disable-next-line no-console
console.log(`Bundled TXE in ${ms}ms (${(totalBytes / 1024 / 1024).toFixed(1)} MiB total)`);
console.log(`Bundled TXE in ${ms}ms (${toMiB(jsBytes)} MiB JS + ${toMiB(mapBytes)} MiB sourcemaps)`);

// Surface the heaviest inputs per bundle. Pass `--inspect` on the command line to print.
if (process.argv.includes('--inspect')) {
Expand Down
50 changes: 37 additions & 13 deletions yarn-project/txe/esbuild/plugins/size_guard.mjs
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
/**
* Post-build size guard. Catches unintended bundle growth without involving CI separately.
*
* Each entry pairs a regex against the output path with a `maxKB` cap and a `description` that
* shows up in the failure message. The build fails (exit 1) if any matching file exceeds its
* cap, or if the total bundle size exceeds `totalLimitMiB`.
* Each entry in `sizeLimits` pairs a regex against the output path with a `maxKB` cap and a
* `description` that shows up in the failure message. The build fails (exit 1) if any matching
* file exceeds its cap, if the total runtime JS exceeds `totalLimitMiB`, or if the total external
* sourcemap size exceeds `sourcemapLimitMiB`.
*
* When a legitimate change pushes a chunk over its limit, raise the number AND append a one-line
* entry to the bump log so the history of size bumps stays auditable.
* The JS total and the sourcemap total are tracked separately. `sourcemap: 'external'` in
* esbuild.config.mjs keeps the `.map` files out of the runtime parse path (V8 only parses the
* `.js`; Node loads a `.map` lazily for stack traces), so folding them into the runtime-bundle
* number would measure the wrong thing — sourcemaps are ~2x the JS here. The sourcemap cap exists
* only to notice runaway map growth, so it is deliberately loose.
*
* When a legitimate change pushes a chunk or total over its limit, raise the number AND append a
* one-line entry to the bump log so the history of size bumps stays auditable.
*/

// Bump log:
// - 2026-05-27: initial limits.
// - 2026-07-08: total 14 -> 15 MiB. Merging public-v5-next into v5-next pulled in the interactive-handshake
// support (recipient- and sender-side) and the enlarged HandshakeRegistry contract chunk, pushing the TXE
// bundle to ~14.01 MiB. No individual chunk exceeded its cap.
// - 2026-07-13: bumped total to 14.5 MiB.
// bundle to ~14.01 MiB. No individual chunk exceeded its cap. (Total counted JS + sourcemaps back then.)
// - 2026-07-13: bumped total to 14.5 MiB (stopgap; total still counted JS + sourcemaps).
// - 2026-07-20: total now counts runtime JS only, excluding external `.map` sourcemaps (~66% of the old
// number). Reset to 6 MiB against ~4.74 MiB of JS today; reverts the 14.5 stopgap. Sourcemap total gets
// its own loose cap (`sourcemapLimitMiB`, 12 MiB against ~9.3 MiB today) so map growth is still watched.
export const sizeLimits = [
// Shared chunks emitted by code-splitting; carry the simulator + PXE + world-state graph.
// Spikes here usually mean a heavy dep crept into the eager import path.
Expand All @@ -26,17 +36,27 @@ export const sizeLimits = [
{ pattern: /^dest\/bin\/index\.js$/, maxKB: 8, description: 'CLI entrypoint stub' },
];

export const totalLimitMiB = 15;
// Cap on the total runtime JS (`.js` outputs). This is what V8 parses at cold start.
export const totalLimitMiB = 6;

// Loose cap on the total external sourcemap size (`.map` outputs). Not on the runtime parse path;
// exists only to flag runaway growth.
export const sourcemapLimitMiB = 12;

/**
* Validates a built esbuild `metafile` against the configured limits. Logs all violations then
* calls `process.exit(1)` if any were found.
*/
export function enforceSizeLimits(metafile) {
const violations = [];
let totalBytes = 0;
let totalJsBytes = 0;
let totalMapBytes = 0;
for (const [outPath, out] of Object.entries(metafile.outputs)) {
totalBytes += out.bytes;
if (outPath.endsWith('.map')) {
totalMapBytes += out.bytes;
continue;
}
totalJsBytes += out.bytes;
for (const limit of sizeLimits) {
if (limit.pattern.test(outPath)) {
const sizeKB = out.bytes / 1024;
Expand All @@ -46,9 +66,13 @@ export function enforceSizeLimits(metafile) {
}
}
}
const totalMiB = totalBytes / 1024 / 1024;
if (totalMiB > totalLimitMiB) {
violations.push(` total: ${totalMiB.toFixed(2)} MiB > ${totalLimitMiB} MiB`);
const totalJsMiB = totalJsBytes / 1024 / 1024;
if (totalJsMiB > totalLimitMiB) {
violations.push(` runtime JS total: ${totalJsMiB.toFixed(2)} MiB > ${totalLimitMiB} MiB`);
}
const totalMapMiB = totalMapBytes / 1024 / 1024;
if (totalMapMiB > sourcemapLimitMiB) {
violations.push(` sourcemap total: ${totalMapMiB.toFixed(2)} MiB > ${sourcemapLimitMiB} MiB`);
}
if (violations.length === 0) {
return;
Expand Down
Loading
Loading