From bd512763c4354ee8d31ae40ab6b561c91a8202b3 Mon Sep 17 00:00:00 2001 From: Phil Windle Date: Thu, 2 Apr 2026 12:52:49 +0000 Subject: [PATCH 1/2] Fix blob encoding --- .../end-to-end/src/e2e_fees/failures.test.ts | 110 +++++++++++++++++- yarn-project/stdlib/src/tx/tx_effect.ts | 5 +- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_fees/failures.test.ts b/yarn-project/end-to-end/src/e2e_fees/failures.test.ts index 440f1949acf8..dc7cfea8f4c4 100644 --- a/yarn-project/end-to-end/src/e2e_fees/failures.test.ts +++ b/yarn-project/end-to-end/src/e2e_fees/failures.test.ts @@ -2,8 +2,10 @@ import { FunctionSelector } from '@aztec/aztec.js/abi'; import type { AztecAddress } from '@aztec/aztec.js/addresses'; import { EthAddress } from '@aztec/aztec.js/addresses'; import { SetPublicAuthwitContractInteraction } from '@aztec/aztec.js/authorization'; +import { waitForProven } from '@aztec/aztec.js/contracts'; import { PrivateFeePaymentMethod, PublicFeePaymentMethod } from '@aztec/aztec.js/fee'; import { Fr } from '@aztec/aztec.js/fields'; +import type { AztecNode } from '@aztec/aztec.js/node'; import { TxExecutionResult } from '@aztec/aztec.js/tx'; import type { Wallet } from '@aztec/aztec.js/wallet'; import type { FPCContract } from '@aztec/noir-contracts.js/FPC'; @@ -23,6 +25,7 @@ describe('e2e_fees failures', () => { let bananaCoin: BananaCoin; let bananaFPC: FPCContract; let gasSettings: GasSettings; + let aztecNode: AztecNode; const coinbase = EthAddress.random(); const t = new FeesTest('failures', 3, { coinbase }); @@ -31,6 +34,7 @@ describe('e2e_fees failures', () => { await t.setup(); await t.applyFPCSetup(); ({ wallet, aliceAddress, sequencerAddress, bananaCoin, bananaFPC, gasSettings } = t); + aztecNode = t.aztecNode; // Prove up until the current state by just marking it as proven. // Then turn off the watcher to prevent it from keep proving @@ -294,7 +298,7 @@ describe('e2e_fees failures', () => { }, wait: { dontThrowOnRevert: true }, }); - expect(receipt.executionResult).toEqual(TxExecutionResult.TEARDOWN_REVERTED); + expect(receipt.executionResult).toEqual(TxExecutionResult.APP_LOGIC_REVERTED); expect(receipt.transactionFee).toBeGreaterThan(0n); await expectMapping( @@ -317,9 +321,113 @@ describe('e2e_fees failures', () => { [aliceAddress, bananaFPC.address, sequencerAddress], [initialAliceGas, initialFPCGas - receipt.transactionFee!, initialSequencerGas], ); + + // Prove the block containing the teardown-reverted tx (revert_code = 2). + await t.context.watcher.trigger(); + await t.cheatCodes.rollup.advanceToNextEpoch(); + const provenTimeout = + (t.context.config.aztecProofSubmissionEpochs + 1) * + t.context.config.aztecEpochDuration * + t.context.config.aztecSlotDuration; + await waitForProven(aztecNode, receipt, { provenTimeout }); + }); + + it('proves transaction where both app logic and teardown revert', async () => { + /** + * Regression test for a bug where the circuit encodes revert_code as 0 or 1 (boolean), + * but the TS side preserves the full RevertCode enum (BOTH_REVERTED = 3). + * This causes the tx start marker in the blob data to differ, which cascades into + * a spongeBlobHash mismatch in the block header. + * + * We trigger BOTH_REVERTED by: + * - App logic: transfer more tokens than Alice has (reverts in public app logic) + * - Teardown: use a bugged fee payment method whose teardown transfers an impossible amount + * + * See: noir-projects/noir-protocol-circuits/sponge-blob-revert-code-bug.md + */ + const outrageousPublicAmountAliceDoesNotHave = t.ALICE_INITIAL_BANANAS * 5n; + + // Send a tx that will revert in BOTH app logic and teardown. + const { receipt } = await bananaCoin.methods + .transfer_in_public(aliceAddress, sequencerAddress, outrageousPublicAmountAliceDoesNotHave, 0) + .send({ + from: aliceAddress, + fee: { + paymentMethod: new BuggedTeardownFeePaymentMethod(bananaFPC.address, aliceAddress, wallet, gasSettings), + }, + wait: { dontThrowOnRevert: true }, + }); + + expect(receipt.executionResult).toBe(TxExecutionResult.APP_LOGIC_REVERTED); + expect(receipt.transactionFee).toBeGreaterThan(0n); + + // Now prove the block containing this tx via the real prover node. + // The prover node will fail with "Block header mismatch" if the revert_code encoding + // differs between the circuit (which uses 1) and the TS (which uses 3). + await t.context.watcher.trigger(); + await t.cheatCodes.rollup.advanceToNextEpoch(); + const provenTimeout = + (t.context.config.aztecProofSubmissionEpochs + 1) * + t.context.config.aztecEpochDuration * + t.context.config.aztecSlotDuration; + await waitForProven(aztecNode, receipt, { provenTimeout }); }); }); +/** + * Fee payment method whose teardown always reverts because max_fee is set to 0. + * The FPC's _pay_refund will assert `0 >= actual_fee` which always fails since actual_fee > 0. + * The setup transfer of 0 tokens succeeds (and the authwit matches the 0 amount). + */ +class BuggedTeardownFeePaymentMethod extends PublicFeePaymentMethod { + override async getExecutionPayload(): Promise { + const zeroFee = new Fr(0n); + const authwitNonce = Fr.random(); + + const asset = await this.getAsset(); + + // Authorize the FPC to transfer 0 tokens (matches the 0 max_fee we'll pass). + const setPublicAuthWitInteraction = await SetPublicAuthwitContractInteraction.create( + this.wallet, + this.sender, + { + caller: this.paymentContract, + call: FunctionCall.from({ + name: 'transfer_in_public', + to: asset, + selector: await FunctionSelector.fromSignature('transfer_in_public((Field),(Field),u128,Field)'), + type: FunctionType.PUBLIC, + hideMsgSender: false, + isStatic: false, + args: [this.sender.toField(), this.paymentContract.toField(), zeroFee, authwitNonce], + returnTypes: [], + }), + }, + true, + ); + + return new ExecutionPayload( + [ + ...(await setPublicAuthWitInteraction.request()).calls, + FunctionCall.from({ + name: 'fee_entrypoint_public', + to: this.paymentContract, + selector: await FunctionSelector.fromSignature('fee_entrypoint_public(u128,Field)'), + type: FunctionType.PRIVATE, + hideMsgSender: false, + isStatic: false, + args: [zeroFee, authwitNonce], + returnTypes: [], + }), + ], + [], + [], + [], + this.paymentContract, + ); + } +} + class BuggedSetupFeePaymentMethod extends PublicFeePaymentMethod { override async getExecutionPayload(): Promise { const maxFee = this.gasSettings.getFeeLimit(); diff --git a/yarn-project/stdlib/src/tx/tx_effect.ts b/yarn-project/stdlib/src/tx/tx_effect.ts index 6d594f934393..cb8c4f58c9cb 100644 --- a/yarn-project/stdlib/src/tx/tx_effect.ts +++ b/yarn-project/stdlib/src/tx/tx_effect.ts @@ -245,7 +245,10 @@ export class TxEffect { getTxStartMarker(): TxStartMarker { const flatPublicLogs = FlatPublicLogs.fromLogs(this.publicLogs); const partialTxStartMarker = { - revertCode: this.revertCode.getCode(), + // The circuit encodes revert_code as a boolean (0 or 1) since the AVM only exposes + // a `reverted: bool` field. We must match that encoding here so the tx start marker + // in the blob data is identical. + revertCode: this.revertCode.isOK() ? 0 : 1, numNoteHashes: this.noteHashes.length, numNullifiers: this.nullifiers.length, numL2ToL1Msgs: this.l2ToL1Msgs.length, From a034e2e513ca04972bec878b33a6ddb0916fbb60 Mon Sep 17 00:00:00 2001 From: Phil Windle Date: Thu, 2 Apr 2026 13:18:45 +0000 Subject: [PATCH 2/2] Test fixes --- yarn-project/stdlib/src/block/body.test.ts | 3 ++- yarn-project/stdlib/src/tx/tx_effect.test.ts | 5 +++-- yarn-project/stdlib/src/tx/tx_effect.ts | 4 +++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/yarn-project/stdlib/src/block/body.test.ts b/yarn-project/stdlib/src/block/body.test.ts index b082cec6566b..2eb5a95ed876 100644 --- a/yarn-project/stdlib/src/block/body.test.ts +++ b/yarn-project/stdlib/src/block/body.test.ts @@ -1,5 +1,6 @@ import { jsonStringify } from '@aztec/foundation/json-rpc'; +import { RevertCode } from '../avm/revert_code.js'; import { Body } from './body.js'; describe('Body', () => { @@ -10,7 +11,7 @@ describe('Body', () => { }); it('converts to and from blob data', async () => { - const body = await Body.random(); + const body = await Body.random({ revertCode: RevertCode.APP_LOGIC_REVERTED }); const fields = body.toTxBlobData(); expect(Body.fromTxBlobData(fields)).toEqual(body); }); diff --git a/yarn-project/stdlib/src/tx/tx_effect.test.ts b/yarn-project/stdlib/src/tx/tx_effect.test.ts index 9485945ed33e..5a82e97bf04e 100644 --- a/yarn-project/stdlib/src/tx/tx_effect.test.ts +++ b/yarn-project/stdlib/src/tx/tx_effect.test.ts @@ -2,6 +2,7 @@ import { BlobDeserializationError } from '@aztec/blob-lib'; import { Fr } from '@aztec/foundation/curves/bn254'; import { jsonStringify } from '@aztec/foundation/json-rpc'; +import { RevertCode } from '../avm/revert_code.js'; import { TxEffect } from './tx_effect.js'; describe('TxEffect', () => { @@ -18,13 +19,13 @@ describe('TxEffect', () => { }); it('converts to and from blob data', async () => { - const txEffect = await TxEffect.random(); + const txEffect = await TxEffect.random({ revertCode: RevertCode.APP_LOGIC_REVERTED }); const data = txEffect.toTxBlobData(); expect(TxEffect.fromTxBlobData(data)).toEqual(txEffect); }); it('converts to and from blob fields', async () => { - const txEffect = await TxEffect.random(); + const txEffect = await TxEffect.random({ revertCode: RevertCode.APP_LOGIC_REVERTED }); const fields = txEffect.toBlobFields(); expect(TxEffect.fromBlobFields(fields)).toEqual(txEffect); }); diff --git a/yarn-project/stdlib/src/tx/tx_effect.ts b/yarn-project/stdlib/src/tx/tx_effect.ts index cb8c4f58c9cb..ce22e4adfe81 100644 --- a/yarn-project/stdlib/src/tx/tx_effect.ts +++ b/yarn-project/stdlib/src/tx/tx_effect.ts @@ -193,6 +193,7 @@ export class TxEffect { numPublicLogsPerCall = 1, numContractClassLogs, maxEffects, + revertCode, }: { numNoteHashes?: number; numNullifiers?: number; @@ -203,12 +204,13 @@ export class TxEffect { numPublicLogsPerCall?: number; numContractClassLogs?: number; maxEffects?: number; + revertCode?: RevertCode; } = {}): Promise { const count = (max: number, num?: number) => num ?? Math.min(maxEffects ?? randomInt(max), max); // Every tx effect must have at least 1 nullifier (the first nullifier is used for log indexing) const countNullifiers = (max: number, num?: number) => Math.max(1, count(max, num)); return new TxEffect( - RevertCode.random(), + revertCode ?? RevertCode.random(), TxHash.random(), new Fr(Math.floor(Math.random() * 100_000)), makeTuple(count(MAX_NOTE_HASHES_PER_TX, numNoteHashes), Fr.random),