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 440f1949acf..dc7cfea8f4c 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/block/body.test.ts b/yarn-project/stdlib/src/block/body.test.ts index b082cec6566..2eb5a95ed87 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 9485945ed33..5a82e97bf04 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 6d594f93439..ce22e4adfe8 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), @@ -245,7 +247,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,