Skip to content
Open
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
110 changes: 109 additions & 1 deletion yarn-project/end-to-end/src/e2e_fees/failures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 });
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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<ExecutionPayload> {
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<ExecutionPayload> {
const maxFee = this.gasSettings.getFeeLimit();
Expand Down
3 changes: 2 additions & 1 deletion yarn-project/stdlib/src/block/body.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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);
});
Expand Down
5 changes: 3 additions & 2 deletions yarn-project/stdlib/src/tx/tx_effect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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);
});
Expand Down
9 changes: 7 additions & 2 deletions yarn-project/stdlib/src/tx/tx_effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ export class TxEffect {
numPublicLogsPerCall = 1,
numContractClassLogs,
maxEffects,
revertCode,
}: {
numNoteHashes?: number;
numNullifiers?: number;
Expand All @@ -203,12 +204,13 @@ export class TxEffect {
numPublicLogsPerCall?: number;
numContractClassLogs?: number;
maxEffects?: number;
revertCode?: RevertCode;
} = {}): Promise<TxEffect> {
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),
Expand Down Expand Up @@ -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,
Expand Down
Loading