Skip to content

Commit 5570957

Browse files
authored
fix(bot): check L1-to-L2 message readiness against PXE sync tip (#24004)
## Motivation The bot waits for its Fee Juice bridge claim with `waitForL1ToL2MessageReady` and then immediately simulates the account deployment that consumes the claim. Readiness was always evaluated against the `latest` block, but the bot's embedded PXE can be configured to sync to a slower tip (e.g. `syncChainTip=checkpointed`). When the tips diverge, readiness passes while the PXE simulation anchors to an older block whose message tree does not contain the message yet, and simulation fails with `No L1 to L2 message found for message hash ...`, sending the bot into a crash loop where it repeatedly validates a claim it cannot consume. ## Approach Make readiness answer the question the consumer actually needs: is the message present at the same chain tip the consuming PXE will anchor its simulation to? - `isL1ToL2MessageReady` / `waitForL1ToL2MessageReady` accept an optional chain tip (`BlockTag`), defaulting to `latest` so existing callers are unaffected. The helper compares the message checkpoint against the block at the requested tip. - The bot does not get a new config knob and no wallet APIs change: `addBot` extracts `syncChainTip` from the same PXE options its callers use to build the embedded wallet, and threads it through `BotRunner` → bot `create` → `BotFactory`. This keeps the readiness tip from drifting from the PXE's actual config. Polling the node at the PXE's configured tip (rather than exposing the PXE anchor) is required for the wait to make progress, since the PXE synchronizer is pull-on-demand and its anchor only advances on `pxe.sync()`. - All bot readiness checks now pass the tip: the stored-claim revalidation and the new-claim wait in `BotFactory`, the cross-chain setup wait, and the steady-state message selection in `CrossChainBot`. ## API changes `isL1ToL2MessageReady(node, msgHash, chainTip?)` and `waitForL1ToL2MessageReady(node, msgHash, { timeoutSeconds, chainTip? })` in `@aztec/aztec.js/messaging` accept an optional `BlockTag` (default `'latest'`, preserving previous behavior). Their node dependency narrowed from `getBlock` to the cheaper `getBlockData`. ## Changes - **aztec.js**: tip-aware readiness helpers in `utils/cross_chain.ts`; new unit tests covering the latest fallback and the tip-aware path. - **bot**: `BotRunner`, `Bot`/`AmmBot`/`CrossChainBot.create`, and `BotFactory` accept the PXE sync tip and use it at every L1-to-L2 readiness check. - **aztec**: `addBot` extracts `syncChainTip` from the PXE options and passes it to `BotRunner`. Fixes A-1155
1 parent 227a74e commit 5570957

8 files changed

Lines changed: 148 additions & 11 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { CheckpointNumber } from '@aztec/foundation/branded-types';
2+
import { Fr } from '@aztec/foundation/curves/bn254';
3+
import type { BlockData } from '@aztec/stdlib/block';
4+
import type { AztecNode } from '@aztec/stdlib/interfaces/client';
5+
6+
import { type MockProxy, mock } from 'jest-mock-extended';
7+
8+
import { isL1ToL2MessageReady } from './cross_chain.js';
9+
10+
describe('isL1ToL2MessageReady', () => {
11+
let node: MockProxy<Pick<AztecNode, 'getBlockData' | 'getL1ToL2MessageCheckpoint'>>;
12+
let messageHash: Fr;
13+
14+
const blockAtCheckpoint = (checkpointNumber: number) =>
15+
({ checkpointNumber: CheckpointNumber(checkpointNumber) }) as BlockData;
16+
17+
beforeEach(() => {
18+
node = mock();
19+
messageHash = Fr.random();
20+
});
21+
22+
it('returns false when the message is not yet in any checkpoint', async () => {
23+
node.getL1ToL2MessageCheckpoint.mockResolvedValue(undefined);
24+
25+
expect(await isL1ToL2MessageReady(node, messageHash)).toBe(false);
26+
expect(node.getBlockData).not.toHaveBeenCalled();
27+
});
28+
29+
describe('latest fallback (no chain tip)', () => {
30+
beforeEach(() => {
31+
node.getL1ToL2MessageCheckpoint.mockResolvedValue(CheckpointNumber(5));
32+
});
33+
34+
it('checks readiness against the latest block', async () => {
35+
node.getBlockData.mockResolvedValue(blockAtCheckpoint(5));
36+
37+
expect(await isL1ToL2MessageReady(node, messageHash)).toBe(true);
38+
expect(node.getBlockData).toHaveBeenCalledWith('latest');
39+
});
40+
41+
it('returns true once the latest block reaches the message checkpoint', async () => {
42+
node.getBlockData.mockResolvedValue(blockAtCheckpoint(6));
43+
44+
expect(await isL1ToL2MessageReady(node, messageHash)).toBe(true);
45+
});
46+
47+
it('returns false when the latest block is behind the message checkpoint', async () => {
48+
node.getBlockData.mockResolvedValue(blockAtCheckpoint(4));
49+
50+
expect(await isL1ToL2MessageReady(node, messageHash)).toBe(false);
51+
});
52+
53+
it('returns false when there is no block', async () => {
54+
node.getBlockData.mockResolvedValue(undefined);
55+
56+
expect(await isL1ToL2MessageReady(node, messageHash)).toBe(false);
57+
});
58+
});
59+
60+
describe('with an explicit chain tip', () => {
61+
beforeEach(() => {
62+
node.getL1ToL2MessageCheckpoint.mockResolvedValue(CheckpointNumber(5));
63+
});
64+
65+
it('compares against the requested tip instead of latest', async () => {
66+
// The proven tip lags behind latest: the message is in checkpoint 5 but proven is only at 4.
67+
node.getBlockData.mockImplementation(param =>
68+
Promise.resolve(param === 'proven' ? blockAtCheckpoint(4) : blockAtCheckpoint(6)),
69+
);
70+
71+
expect(await isL1ToL2MessageReady(node, messageHash, 'latest')).toBe(true);
72+
expect(await isL1ToL2MessageReady(node, messageHash, 'proven')).toBe(false);
73+
expect(node.getBlockData).toHaveBeenLastCalledWith('proven');
74+
});
75+
76+
it('returns true once the requested tip reaches the message checkpoint', async () => {
77+
node.getBlockData.mockImplementation(param =>
78+
Promise.resolve(param === 'proven' ? blockAtCheckpoint(5) : blockAtCheckpoint(7)),
79+
);
80+
81+
expect(await isL1ToL2MessageReady(node, messageHash, 'proven')).toBe(true);
82+
});
83+
});
84+
});
Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Fr } from '@aztec/foundation/curves/bn254';
22
import { retryUntil } from '@aztec/foundation/retry';
3+
import type { BlockTag } from '@aztec/stdlib/block';
34
import type { AztecNode } from '@aztec/stdlib/interfaces/client';
45

56
/**
@@ -9,14 +10,20 @@ import type { AztecNode } from '@aztec/stdlib/interfaces/client';
910
* @param opts - Options
1011
*/
1112
export function waitForL1ToL2MessageReady(
12-
node: Pick<AztecNode, 'getBlock' | 'getL1ToL2MessageCheckpoint'>,
13+
node: Pick<AztecNode, 'getBlockData' | 'getL1ToL2MessageCheckpoint'>,
1314
l1ToL2MessageHash: Fr,
1415
opts: {
1516
/** Timeout for the operation in seconds */ timeoutSeconds: number;
17+
/**
18+
* Chain tip to evaluate readiness against. Defaults to `'latest'`. Set this to the tip the consuming PXE syncs to
19+
* (e.g. `'proven'`) so readiness answers whether the message is present at the same block the transaction
20+
* simulation will anchor to, not at a newer tip.
21+
*/
22+
chainTip?: BlockTag;
1623
},
1724
) {
1825
return retryUntil(
19-
() => isL1ToL2MessageReady(node, l1ToL2MessageHash),
26+
() => isL1ToL2MessageReady(node, l1ToL2MessageHash, opts.chainTip),
2027
`L1 to L2 message ${l1ToL2MessageHash.toString()} ready`,
2128
opts.timeoutSeconds,
2229
1,
@@ -27,18 +34,21 @@ export function waitForL1ToL2MessageReady(
2734
* Returns whether the L1 to L2 message is ready to be consumed.
2835
* @param node - Aztec node instance used to obtain the information about the message
2936
* @param l1ToL2MessageHash - Hash of the L1 to L2 message
37+
* @param chainTip - Chain tip to evaluate readiness against. Defaults to `'latest'`. Pass the tip the consuming PXE
38+
* syncs to (e.g. `'proven'`) so readiness is checked at the block the transaction simulation will anchor to.
3039
* @returns True if the message is ready to be consumed, false otherwise
3140
*/
3241
export async function isL1ToL2MessageReady(
33-
node: Pick<AztecNode, 'getBlock' | 'getL1ToL2MessageCheckpoint'>,
42+
node: Pick<AztecNode, 'getBlockData' | 'getL1ToL2MessageCheckpoint'>,
3443
l1ToL2MessageHash: Fr,
44+
chainTip: BlockTag = 'latest',
3545
): Promise<boolean> {
3646
const messageCheckpointNumber = await node.getL1ToL2MessageCheckpoint(l1ToL2MessageHash);
3747
if (messageCheckpointNumber === undefined) {
3848
return false;
3949
}
4050

4151
// L1 to L2 messages are included in the first block of a checkpoint
42-
const latestBlock = await node.getBlock('latest');
43-
return latestBlock !== undefined && latestBlock.checkpointNumber >= messageCheckpointNumber;
52+
const block = await node.getBlockData(chainTip);
53+
return block !== undefined && block.checkpointNumber >= messageCheckpointNumber;
4454
}

yarn-project/aztec/src/cli/cmds/start_bot.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,19 @@ export async function addBot(
5656
const config = extractRelevantOptions<BotConfig>(options, botConfigMappings, 'bot');
5757
userLog?.(`Starting bot with config ${stringifyConfig(config)}`);
5858

59+
// The bot wallet's embedded PXE syncs to this tip (see start_bot.ts/start_node.ts which build the wallet from the
60+
// same options). L1-to-L2 readiness checks must be evaluated at this tip rather than at 'latest', or the bot can
61+
// consider a message ready while the PXE simulation anchors to an older block that cannot prove its membership yet.
62+
const { syncChainTip } = extractRelevantOptions<PXEConfig & CliPXEOptions>(options, allPxeConfigMappings, 'pxe');
63+
5964
const db = await (config.dataDirectory
6065
? createStore('bot', BotStore.SCHEMA_VERSION, config)
6166
: openTmpStore('bot', true, config.dataStoreMapSizeKb));
6267

6368
const store = new BotStore(db);
6469
await store.cleanupOldClaims();
6570

66-
const botRunner = new BotRunner(config, wallet, aztecNode, telemetry, aztecNodeAdmin, store);
71+
const botRunner = new BotRunner(config, wallet, aztecNode, telemetry, aztecNodeAdmin, store, syncChainTip);
6772
if (!config.noStart) {
6873
void botRunner.start(); // Do not block since bot setup takes time
6974
}

yarn-project/bot/src/amm_bot.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { TxHash, TxReceipt } from '@aztec/aztec.js/tx';
55
import { jsonStringify } from '@aztec/foundation/json-rpc';
66
import type { AMMContract } from '@aztec/noir-contracts.js/AMM';
77
import type { TokenContract } from '@aztec/noir-contracts.js/Token';
8+
import type { BlockTag } from '@aztec/stdlib/block';
89
import type { AztecNode, AztecNodeAdmin } from '@aztec/stdlib/interfaces/client';
910
import type { EmbeddedWallet } from '@aztec/wallets/embedded';
1011

@@ -37,13 +38,15 @@ export class AmmBot extends BaseBot {
3738
aztecNode: AztecNode,
3839
aztecNodeAdmin: AztecNodeAdmin | undefined,
3940
store: BotStore,
41+
syncChainTip?: BlockTag,
4042
): Promise<AmmBot> {
4143
const { defaultAccountAddress, token0, token1, amm } = await new BotFactory(
4244
config,
4345
wallet,
4446
store,
4547
aztecNode,
4648
aztecNodeAdmin,
49+
syncChainTip,
4750
).setupAmm();
4851
return new AmmBot(aztecNode, wallet, defaultAccountAddress, amm, token0, token1, config);
4952
}

yarn-project/bot/src/bot.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { TxHash } from '@aztec/aztec.js/tx';
44
import { times } from '@aztec/foundation/collection';
55
import type { PrivateTokenContract } from '@aztec/noir-contracts.js/PrivateToken';
66
import type { TokenContract } from '@aztec/noir-contracts.js/Token';
7+
import type { BlockTag } from '@aztec/stdlib/block';
78
import type { AztecNode, AztecNodeAdmin } from '@aztec/stdlib/interfaces/client';
89
import type { EmbeddedWallet } from '@aztec/wallets/embedded';
910

@@ -33,13 +34,15 @@ export class Bot extends BaseBot {
3334
aztecNode: AztecNode,
3435
aztecNodeAdmin: AztecNodeAdmin | undefined,
3536
store: BotStore,
37+
syncChainTip?: BlockTag,
3638
): Promise<Bot> {
3739
const { defaultAccountAddress, token, recipient } = await new BotFactory(
3840
config,
3941
wallet,
4042
store,
4143
aztecNode,
4244
aztecNodeAdmin,
45+
syncChainTip,
4346
).setup();
4447
return new Bot(aztecNode, wallet, defaultAccountAddress, token, recipient, config);
4548
}

yarn-project/bot/src/cross_chain_bot.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import type { ExtendedViemWalletClient } from '@aztec/ethereum/types';
3131
import { Fr } from '@aztec/foundation/curves/bn254';
3232
import { EthAddress } from '@aztec/foundation/eth-address';
3333
import type { TestContract } from '@aztec/noir-test-contracts.js/Test';
34+
import type { BlockTag } from '@aztec/stdlib/block';
3435
import type { AztecNode, AztecNodeAdmin } from '@aztec/stdlib/interfaces/client';
3536
import type { EmbeddedWallet } from '@aztec/wallets/embedded';
3637

@@ -60,6 +61,7 @@ export class CrossChainBot extends BaseBot {
6061
private readonly rollupVersion: bigint,
6162
private readonly store: BotStore,
6263
config: BotConfig,
64+
private readonly syncChainTip?: BlockTag,
6365
) {
6466
super(node, wallet, defaultAccountAddress, config);
6567
}
@@ -70,11 +72,12 @@ export class CrossChainBot extends BaseBot {
7072
aztecNode: AztecNode,
7173
aztecNodeAdmin: AztecNodeAdmin | undefined,
7274
store: BotStore,
75+
syncChainTip?: BlockTag,
7376
): Promise<CrossChainBot> {
7477
if (config.followChain === 'NONE') {
7578
throw new Error(`CrossChainBot requires followChain to be set (got NONE)`);
7679
}
77-
const factory = new BotFactory(config, wallet, store, aztecNode, aztecNodeAdmin);
80+
const factory = new BotFactory(config, wallet, store, aztecNode, aztecNodeAdmin, syncChainTip);
7881
const { defaultAccountAddress, contract, l1Client, rollupVersion } = await factory.setupCrossChain();
7982
const l1Recipient = EthAddress.fromString(l1Client.account!.address);
8083
const { l1ContractAddresses } = await aztecNode.getNodeInfo();
@@ -90,6 +93,7 @@ export class CrossChainBot extends BaseBot {
9093
rollupVersion,
9194
store,
9295
config,
96+
syncChainTip,
9397
);
9498
}
9599

@@ -176,7 +180,7 @@ export class CrossChainBot extends BaseBot {
176180
): Promise<PendingL1ToL2Message | undefined> {
177181
const now = Date.now();
178182
for (const msg of pendingMessages) {
179-
const ready = await isL1ToL2MessageReady(this.node, Fr.fromHexString(msg.msgHash));
183+
const ready = await isL1ToL2MessageReady(this.node, Fr.fromHexString(msg.msgHash), this.syncChainTip);
180184
if (ready) {
181185
return msg;
182186
}

yarn-project/bot/src/factory.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { AMMContract } from '@aztec/noir-contracts.js/AMM';
2929
import { PrivateTokenContract } from '@aztec/noir-contracts.js/PrivateToken';
3030
import { TokenContract } from '@aztec/noir-contracts.js/Token';
3131
import { TestContract } from '@aztec/noir-test-contracts.js/Test';
32+
import type { BlockTag } from '@aztec/stdlib/block';
3233
import type { ContractInstanceWithAddress } from '@aztec/stdlib/contract';
3334
import { GasFees, ManaUsageEstimate } from '@aztec/stdlib/gas';
3435
import type { AztecNode, AztecNodeAdmin } from '@aztec/stdlib/interfaces/client';
@@ -54,6 +55,7 @@ export class BotFactory {
5455
private readonly store: BotStore,
5556
private readonly aztecNode: AztecNode,
5657
private readonly aztecNodeAdmin?: AztecNodeAdmin,
58+
private readonly syncChainTip?: BlockTag,
5759
) {
5860
// Set fee padding on the wallet so that all transactions during setup
5961
// (token deploy, minting, etc.) use the configured padding, not the default.
@@ -174,6 +176,7 @@ export class BotFactory {
174176
const firstMsg = allMessages[0];
175177
await waitForL1ToL2MessageReady(this.aztecNode, Fr.fromHexString(firstMsg.msgHash), {
176178
timeoutSeconds: this.config.l1ToL2MessageTimeoutSeconds,
179+
chainTip: this.syncChainTip,
177180
});
178181
this.log.info(`First L1→L2 message is ready`);
179182
}
@@ -675,6 +678,7 @@ export class BotFactory {
675678
await this.withNoMinTxsPerBlock(() =>
676679
waitForL1ToL2MessageReady(this.aztecNode, messageHash, {
677680
timeoutSeconds: this.config.l1ToL2MessageTimeoutSeconds,
681+
chainTip: this.syncChainTip,
678682
}),
679683
);
680684
return existingClaim.claim;
@@ -713,6 +717,7 @@ export class BotFactory {
713717
await this.withNoMinTxsPerBlock(() =>
714718
waitForL1ToL2MessageReady(this.aztecNode, Fr.fromHexString(claim.messageHash), {
715719
timeoutSeconds: this.config.l1ToL2MessageTimeoutSeconds,
720+
chainTip: this.syncChainTip,
716721
}),
717722
);
718723

yarn-project/bot/src/runner.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createLogger } from '@aztec/aztec.js/log';
22
import type { AztecNode } from '@aztec/aztec.js/node';
33
import { omit } from '@aztec/foundation/collection';
44
import { RunningPromise } from '@aztec/foundation/running-promise';
5+
import type { BlockTag } from '@aztec/stdlib/block';
56
import type { AztecNodeAdmin } from '@aztec/stdlib/interfaces/client';
67
import { type TelemetryClient, type Traceable, type Tracer, trackSpan } from '@aztec/telemetry-client';
78
import type { EmbeddedWallet } from '@aztec/wallets/embedded';
@@ -30,6 +31,7 @@ export class BotRunner implements BotRunnerApi, Traceable {
3031
private readonly telemetry: TelemetryClient,
3132
private readonly aztecNodeAdmin: AztecNodeAdmin | undefined,
3233
private readonly store: BotStore,
34+
private readonly syncChainTip?: BlockTag,
3335
) {
3436
this.tracer = telemetry.getTracer('Bot');
3537

@@ -149,13 +151,34 @@ export class BotRunner implements BotRunnerApi, Traceable {
149151
try {
150152
switch (this.config.botMode) {
151153
case 'crosschain':
152-
this.bot = CrossChainBot.create(this.config, this.wallet, this.aztecNode, this.aztecNodeAdmin, this.store);
154+
this.bot = CrossChainBot.create(
155+
this.config,
156+
this.wallet,
157+
this.aztecNode,
158+
this.aztecNodeAdmin,
159+
this.store,
160+
this.syncChainTip,
161+
);
153162
break;
154163
case 'amm':
155-
this.bot = AmmBot.create(this.config, this.wallet, this.aztecNode, this.aztecNodeAdmin, this.store);
164+
this.bot = AmmBot.create(
165+
this.config,
166+
this.wallet,
167+
this.aztecNode,
168+
this.aztecNodeAdmin,
169+
this.store,
170+
this.syncChainTip,
171+
);
156172
break;
157173
case 'transfer':
158-
this.bot = Bot.create(this.config, this.wallet, this.aztecNode, this.aztecNodeAdmin, this.store);
174+
this.bot = Bot.create(
175+
this.config,
176+
this.wallet,
177+
this.aztecNode,
178+
this.aztecNodeAdmin,
179+
this.store,
180+
this.syncChainTip,
181+
);
159182
break;
160183
default: {
161184
const _exhaustive: never = this.config.botMode;

0 commit comments

Comments
 (0)