@aztec/ethereum 0.0.1-commit.9117c5f5a → 0.0.1-commit.936cb2cae

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dest/client.d.ts +10 -2
  2. package/dest/client.d.ts.map +1 -1
  3. package/dest/client.js +13 -7
  4. package/dest/config.d.ts +3 -1
  5. package/dest/config.d.ts.map +1 -1
  6. package/dest/config.js +6 -0
  7. package/dest/contracts/multicall.d.ts +51 -2
  8. package/dest/contracts/multicall.d.ts.map +1 -1
  9. package/dest/contracts/multicall.js +85 -0
  10. package/dest/contracts/registry.d.ts +3 -1
  11. package/dest/contracts/registry.d.ts.map +1 -1
  12. package/dest/contracts/registry.js +30 -1
  13. package/dest/contracts/rollup.d.ts +18 -4
  14. package/dest/contracts/rollup.d.ts.map +1 -1
  15. package/dest/contracts/rollup.js +58 -12
  16. package/dest/l1_artifacts.d.ts +69 -69
  17. package/dest/l1_reader.d.ts +3 -1
  18. package/dest/l1_reader.d.ts.map +1 -1
  19. package/dest/l1_reader.js +6 -1
  20. package/dest/l1_tx_utils/l1_tx_utils.d.ts +5 -1
  21. package/dest/l1_tx_utils/l1_tx_utils.d.ts.map +1 -1
  22. package/dest/l1_tx_utils/l1_tx_utils.js +39 -20
  23. package/dest/l1_tx_utils/readonly_l1_tx_utils.d.ts +1 -1
  24. package/dest/l1_tx_utils/readonly_l1_tx_utils.d.ts.map +1 -1
  25. package/dest/l1_tx_utils/readonly_l1_tx_utils.js +8 -4
  26. package/dest/publisher_manager.d.ts +21 -7
  27. package/dest/publisher_manager.d.ts.map +1 -1
  28. package/dest/publisher_manager.js +81 -7
  29. package/dest/test/chain_monitor.d.ts +22 -3
  30. package/dest/test/chain_monitor.d.ts.map +1 -1
  31. package/dest/test/chain_monitor.js +33 -2
  32. package/dest/test/eth_cheat_codes.d.ts +6 -4
  33. package/dest/test/eth_cheat_codes.d.ts.map +1 -1
  34. package/dest/test/eth_cheat_codes.js +6 -4
  35. package/dest/test/start_anvil.d.ts +15 -1
  36. package/dest/test/start_anvil.d.ts.map +1 -1
  37. package/dest/test/start_anvil.js +17 -2
  38. package/dest/utils.d.ts +1 -1
  39. package/dest/utils.d.ts.map +1 -1
  40. package/dest/utils.js +16 -12
  41. package/package.json +5 -5
  42. package/src/client.ts +10 -2
  43. package/src/config.ts +12 -0
  44. package/src/contracts/multicall.ts +65 -1
  45. package/src/contracts/registry.ts +31 -1
  46. package/src/contracts/rollup.ts +66 -19
  47. package/src/l1_reader.ts +13 -1
  48. package/src/l1_tx_utils/l1_tx_utils.ts +30 -7
  49. package/src/l1_tx_utils/readonly_l1_tx_utils.ts +8 -4
  50. package/src/publisher_manager.ts +105 -10
  51. package/src/test/chain_monitor.ts +60 -3
  52. package/src/test/eth_cheat_codes.ts +6 -4
  53. package/src/test/start_anvil.ts +33 -2
  54. package/src/utils.ts +17 -14
package/src/client.ts CHANGED
@@ -25,10 +25,17 @@ type Config = {
25
25
  l1ChainId: number;
26
26
  /** The polling interval viem uses in ms */
27
27
  viemPollingIntervalMS?: number;
28
+ /** Timeout for HTTP requests to the L1 RPC node in ms. */
29
+ l1HttpTimeoutMS?: number;
28
30
  };
29
31
 
30
32
  export type { Config as EthereumClientConfig };
31
33
 
34
+ /** Creates a viem fallback HTTP transport for the given L1 RPC URLs. */
35
+ export function makeL1HttpTransport(rpcUrls: string[], opts?: { timeout?: number }) {
36
+ return fallback(rpcUrls.map(url => http(url, { batch: false, timeout: opts?.timeout })));
37
+ }
38
+
32
39
  // TODO: Use these methods to abstract the creation of viem clients.
33
40
 
34
41
  /** Returns a viem public client given the L1 config. */
@@ -36,7 +43,7 @@ export function getPublicClient(config: Config): ViemPublicClient {
36
43
  const chain = createEthereumChain(config.l1RpcUrls, config.l1ChainId);
37
44
  return createPublicClient({
38
45
  chain: chain.chainInfo,
39
- transport: fallback(config.l1RpcUrls.map(url => http(url, { batch: false }))),
46
+ transport: makeL1HttpTransport(config.l1RpcUrls, { timeout: config.l1HttpTimeoutMS }),
40
47
  pollingInterval: config.viemPollingIntervalMS,
41
48
  });
42
49
  }
@@ -77,6 +84,7 @@ export function createExtendedL1Client(
77
84
  chain: Chain = foundry,
78
85
  pollingIntervalMS?: number,
79
86
  addressIndex?: number,
87
+ opts?: { httpTimeoutMS?: number },
80
88
  ): ExtendedViemWalletClient {
81
89
  const hdAccount =
82
90
  typeof mnemonicOrPrivateKeyOrHdAccount === 'string'
@@ -88,7 +96,7 @@ export function createExtendedL1Client(
88
96
  const extendedClient = createWalletClient({
89
97
  account: hdAccount,
90
98
  chain,
91
- transport: fallback(rpcUrls.map(url => http(url, { batch: false }))),
99
+ transport: makeL1HttpTransport(rpcUrls, { timeout: opts?.httpTimeoutMS }),
92
100
  pollingInterval: pollingIntervalMS,
93
101
  }).extend(publicActions);
94
102
 
package/src/config.ts CHANGED
@@ -19,6 +19,8 @@ export type GenesisStateConfig = {
19
19
  testAccounts: boolean;
20
20
  /** Whether to populate the genesis state with initial fee juice for the sponsored FPC */
21
21
  sponsoredFPC: boolean;
22
+ /** Additional addresses to prefund with fee juice at genesis */
23
+ prefundAddresses: string[];
22
24
  };
23
25
 
24
26
  export type L1ContractsConfig = {
@@ -259,6 +261,16 @@ export const genesisStateConfigMappings: ConfigMappingsType<GenesisStateConfig>
259
261
  description: 'Whether to populate the genesis state with initial fee juice for the sponsored FPC.',
260
262
  ...booleanConfigHelper(false),
261
263
  },
264
+ prefundAddresses: {
265
+ env: 'PREFUND_ADDRESSES',
266
+ description: 'Comma-separated list of Aztec addresses to prefund with fee juice at genesis (local network only).',
267
+ parseEnv: (val: string) =>
268
+ val
269
+ .split(',')
270
+ .map(a => a.trim())
271
+ .filter(a => a.length > 0),
272
+ defaultValue: [],
273
+ },
262
274
  };
263
275
 
264
276
  export function getL1ContractsConfigEnvVars(): L1ContractsConfig {
@@ -2,7 +2,7 @@ import { toHex as toPaddedHex } from '@aztec/foundation/bigint-buffer';
2
2
  import { TimeoutError } from '@aztec/foundation/error';
3
3
  import type { Logger } from '@aztec/foundation/log';
4
4
 
5
- import { type EncodeFunctionDataParameters, type Hex, encodeFunctionData, multicall3Abi } from 'viem';
5
+ import { type Address, type EncodeFunctionDataParameters, type Hex, encodeFunctionData, multicall3Abi } from 'viem';
6
6
 
7
7
  import type { L1BlobInputs, L1TxConfig, L1TxRequest, L1TxUtils } from '../l1_tx_utils/index.js';
8
8
  import type { ExtendedViemWalletClient } from '../types.js';
@@ -11,6 +11,39 @@ import { RollupContract } from './rollup.js';
11
11
 
12
12
  export const MULTI_CALL_3_ADDRESS = '0xcA11bde05977b3631167028862bE2a173976CA11' as const;
13
13
 
14
+ /** ABI fragment for aggregate3Value — not included in viem's multicall3Abi. */
15
+ export const aggregate3ValueAbi = [
16
+ {
17
+ inputs: [
18
+ {
19
+ components: [
20
+ { internalType: 'address', name: 'target', type: 'address' },
21
+ { internalType: 'bool', name: 'allowFailure', type: 'bool' },
22
+ { internalType: 'uint256', name: 'value', type: 'uint256' },
23
+ { internalType: 'bytes', name: 'callData', type: 'bytes' },
24
+ ],
25
+ internalType: 'struct Multicall3.Call3Value[]',
26
+ name: 'calls',
27
+ type: 'tuple[]',
28
+ },
29
+ ],
30
+ name: 'aggregate3Value',
31
+ outputs: [
32
+ {
33
+ components: [
34
+ { internalType: 'bool', name: 'success', type: 'bool' },
35
+ { internalType: 'bytes', name: 'returnData', type: 'bytes' },
36
+ ],
37
+ internalType: 'struct Multicall3.Result[]',
38
+ name: 'returnData',
39
+ type: 'tuple[]',
40
+ },
41
+ ],
42
+ stateMutability: 'payable',
43
+ type: 'function',
44
+ },
45
+ ] as const;
46
+
14
47
  export class Multicall3 {
15
48
  static async forward(
16
49
  requests: L1TxRequest[],
@@ -122,6 +155,37 @@ export class Multicall3 {
122
155
  throw err;
123
156
  }
124
157
  }
158
+
159
+ /** Batch multiple value transfers into a single aggregate3Value call on Multicall3. */
160
+ static async forwardValue(calls: { to: Address; value: bigint }[], l1TxUtils: L1TxUtils, logger: Logger) {
161
+ const args = calls.map(c => ({
162
+ target: c.to,
163
+ allowFailure: false,
164
+ value: c.value,
165
+ callData: '0x' as Hex,
166
+ }));
167
+
168
+ const data = encodeFunctionData({
169
+ abi: aggregate3ValueAbi,
170
+ functionName: 'aggregate3Value',
171
+ args: [args],
172
+ });
173
+
174
+ const totalValue = calls.reduce((sum, c) => sum + c.value, 0n);
175
+
176
+ logger.info(`Sending aggregate3Value with ${calls.length} calls`, { totalValue });
177
+ const { receipt } = await l1TxUtils.sendAndMonitorTransaction({
178
+ to: MULTI_CALL_3_ADDRESS,
179
+ data,
180
+ value: totalValue,
181
+ });
182
+
183
+ if (receipt.status !== 'success') {
184
+ throw new Error(`aggregate3Value transaction reverted: ${receipt.transactionHash}`);
185
+ }
186
+
187
+ return { receipt };
188
+ }
125
189
  }
126
190
 
127
191
  export async function deployMulticall3(l1Client: ExtendedViemWalletClient, logger: Logger) {
@@ -3,7 +3,7 @@ import { createLogger } from '@aztec/foundation/log';
3
3
  import { RegistryAbi } from '@aztec/l1-artifacts/RegistryAbi';
4
4
  import { TestERC20Abi } from '@aztec/l1-artifacts/TestERC20Abi';
5
5
 
6
- import { type GetContractReturnType, type Hex, getContract } from 'viem';
6
+ import { type GetContractReturnType, type Hex, getAbiItem, getContract } from 'viem';
7
7
 
8
8
  import type { L1ContractAddresses } from '../l1_contract_addresses.js';
9
9
  import type { ViemClient } from '../types.js';
@@ -128,4 +128,34 @@ export class RegistryContract {
128
128
  public async getRewardDistributor(): Promise<EthAddress> {
129
129
  return EthAddress.fromString(await this.registry.read.getRewardDistributor());
130
130
  }
131
+
132
+ /** Returns the L1 timestamp at which the given rollup was registered via addRollup(). */
133
+ public async getCanonicalRollupRegistrationTimestamp(
134
+ rollupAddress: EthAddress,
135
+ fromBlock?: bigint,
136
+ ): Promise<bigint | undefined> {
137
+ const event = getAbiItem({ abi: RegistryAbi, name: 'CanonicalRollupUpdated' });
138
+ const start = fromBlock ?? 0n;
139
+ const latestBlock = await this.client.getBlockNumber();
140
+ const chunkSize = 1_000n;
141
+
142
+ for (let from = start; from <= latestBlock; from += chunkSize) {
143
+ const to = from + chunkSize - 1n > latestBlock ? latestBlock : from + chunkSize - 1n;
144
+ const logs = await this.client.getLogs({
145
+ address: this.address.toString(),
146
+ fromBlock: from,
147
+ toBlock: to,
148
+ strict: true,
149
+ event,
150
+ args: { instance: rollupAddress.toString() },
151
+ });
152
+
153
+ if (logs.length > 0) {
154
+ const block = await this.client.getBlock({ blockNumber: logs[0].blockNumber });
155
+ return block.timestamp;
156
+ }
157
+ }
158
+
159
+ return undefined;
160
+ }
131
161
  }
@@ -4,6 +4,7 @@ import { Fr } from '@aztec/foundation/curves/bn254';
4
4
  import { memoize } from '@aztec/foundation/decorators';
5
5
  import { EthAddress } from '@aztec/foundation/eth-address';
6
6
  import type { ViemSignature } from '@aztec/foundation/eth-signature';
7
+ import { createLogger } from '@aztec/foundation/log';
7
8
  import { makeBackoff, retry } from '@aztec/foundation/retry';
8
9
  import { EscapeHatchAbi } from '@aztec/l1-artifacts/EscapeHatchAbi';
9
10
  import { RollupAbi } from '@aztec/l1-artifacts/RollupAbi';
@@ -134,6 +135,14 @@ export type L1FeeData = {
134
135
  blobFee: bigint;
135
136
  };
136
137
 
138
+ /** Components of the minimum fee per mana, as returned by the L1 rollup contract. */
139
+ export type ManaMinFeeComponents = {
140
+ sequencerCost: bigint;
141
+ proverCost: bigint;
142
+ congestionCost: bigint;
143
+ congestionMultiplier: bigint;
144
+ };
145
+
137
146
  /**
138
147
  * Reward configuration for the rollup
139
148
  */
@@ -204,6 +213,7 @@ export type CheckpointProposedLog = L1EventLog<CheckpointProposedArgs>;
204
213
 
205
214
  export class RollupContract {
206
215
  private readonly rollup: GetContractReturnType<typeof RollupAbi, ViemClient>;
216
+ private readonly logger = createLogger('ethereum:rollup');
207
217
 
208
218
  private static cachedStfStorageSlot: Hex | undefined;
209
219
  private cachedEscapeHatch?: {
@@ -379,6 +389,20 @@ export class RollupContract {
379
389
  return Fr.fromString(await this.rollup.read.archiveAt([0n]));
380
390
  }
381
391
 
392
+ @memoize
393
+ async getVkTreeRoot(): Promise<Fr> {
394
+ const slot = BigInt(RollupContract.stfStorageSlot) + 3n;
395
+ const value = await this.client.getStorageAt({ address: this.address, slot: `0x${slot.toString(16)}` });
396
+ return Fr.fromString(value ?? '0x0');
397
+ }
398
+
399
+ @memoize
400
+ async getProtocolContractsHash(): Promise<Fr> {
401
+ const slot = BigInt(RollupContract.stfStorageSlot) + 4n;
402
+ const value = await this.client.getStorageAt({ address: this.address, slot: `0x${slot.toString(16)}` });
403
+ return Fr.fromString(value ?? '0x0');
404
+ }
405
+
382
406
  /**
383
407
  * Returns rollup constants used for epoch queries.
384
408
  * Return type is `L1RollupConstants` which is defined in stdlib,
@@ -392,16 +416,25 @@ export class RollupContract {
392
416
  epochDuration: number;
393
417
  proofSubmissionEpochs: number;
394
418
  targetCommitteeSize: number;
419
+ rollupManaLimit: number;
395
420
  }> {
396
- const [l1StartBlock, l1GenesisTime, slotDuration, epochDuration, proofSubmissionEpochs, targetCommitteeSize] =
397
- await Promise.all([
398
- this.getL1StartBlock(),
399
- this.getL1GenesisTime(),
400
- this.getSlotDuration(),
401
- this.getEpochDuration(),
402
- this.getProofSubmissionEpochs(),
403
- this.getTargetCommitteeSize(),
404
- ]);
421
+ const [
422
+ l1StartBlock,
423
+ l1GenesisTime,
424
+ slotDuration,
425
+ epochDuration,
426
+ proofSubmissionEpochs,
427
+ targetCommitteeSize,
428
+ rollupManaLimit,
429
+ ] = await Promise.all([
430
+ this.getL1StartBlock(),
431
+ this.getL1GenesisTime(),
432
+ this.getSlotDuration(),
433
+ this.getEpochDuration(),
434
+ this.getProofSubmissionEpochs(),
435
+ this.getTargetCommitteeSize(),
436
+ this.getManaLimit(),
437
+ ]);
405
438
  return {
406
439
  l1StartBlock,
407
440
  l1GenesisTime,
@@ -409,6 +442,7 @@ export class RollupContract {
409
442
  epochDuration: Number(epochDuration),
410
443
  proofSubmissionEpochs: Number(proofSubmissionEpochs),
411
444
  targetCommitteeSize,
445
+ rollupManaLimit: Number(rollupManaLimit),
412
446
  };
413
447
  }
414
448
 
@@ -463,7 +497,11 @@ export class RollupContract {
463
497
 
464
498
  const [isOpen] = await escapeHatch.read.isHatchOpen([BigInt(epoch)]);
465
499
  return isOpen;
466
- } catch {
500
+ } catch (err) {
501
+ this.logger.warn('isEscapeHatchOpen failed (treating as closed); RPC or contract error may cause liveness risk', {
502
+ epoch: Number(epoch),
503
+ error: err,
504
+ });
467
505
  return false;
468
506
  }
469
507
  }
@@ -503,8 +541,9 @@ export class RollupContract {
503
541
  return CheckpointNumber.fromBigInt(await this.rollup.read.getPendingCheckpointNumber());
504
542
  }
505
543
 
506
- async getProvenCheckpointNumber(): Promise<CheckpointNumber> {
507
- return CheckpointNumber.fromBigInt(await this.rollup.read.getProvenCheckpointNumber());
544
+ async getProvenCheckpointNumber(options?: { blockNumber?: bigint }): Promise<CheckpointNumber> {
545
+ await checkBlockTag(options?.blockNumber, this.client);
546
+ return CheckpointNumber.fromBigInt(await this.rollup.read.getProvenCheckpointNumber(options));
508
547
  }
509
548
 
510
549
  async getSlotNumber(): Promise<SlotNumber> {
@@ -745,14 +784,13 @@ export class RollupContract {
745
784
  * timestamp of the next L1 block
746
785
  * @throws otherwise
747
786
  */
748
- public async canProposeAtNextEthBlock(
787
+ public async canProposeAt(
749
788
  archive: Buffer,
750
789
  account: `0x${string}` | Account,
751
- slotDuration: number,
790
+ timestamp: bigint,
752
791
  opts: { forcePendingCheckpointNumber?: CheckpointNumber } = {},
753
792
  ): Promise<{ slot: SlotNumber; checkpointNumber: CheckpointNumber; timeOfNextL1Slot: bigint }> {
754
- const latestBlock = await this.client.getBlock();
755
- const timeOfNextL1Slot = latestBlock.timestamp + BigInt(slotDuration);
793
+ const timeOfNextL1Slot = timestamp;
756
794
  const who = typeof account === 'string' ? account : account.address;
757
795
 
758
796
  try {
@@ -852,6 +890,16 @@ export class RollupContract {
852
890
  return this.rollup.read.getManaMinFeeAt([timestamp, inFeeAsset]);
853
891
  }
854
892
 
893
+ async getManaMinFeeComponentsAt(timestamp: bigint, inFeeAsset: boolean): Promise<ManaMinFeeComponents> {
894
+ const result = await this.rollup.read.getManaMinFeeComponentsAt([timestamp, inFeeAsset]);
895
+ return {
896
+ sequencerCost: result.sequencerCost,
897
+ proverCost: result.proverCost,
898
+ congestionCost: result.congestionCost,
899
+ congestionMultiplier: result.congestionMultiplier,
900
+ };
901
+ }
902
+
855
903
  async getSlotAt(timestamp: bigint): Promise<SlotNumber> {
856
904
  return SlotNumber.fromBigInt(await this.rollup.read.getSlotAt([timestamp]));
857
905
  }
@@ -895,11 +943,10 @@ export class RollupContract {
895
943
  return this.rollup.read.getSpecificProverRewardsForEpoch([epoch, prover]);
896
944
  }
897
945
 
898
- async getAttesters(): Promise<EthAddress[]> {
946
+ async getAttesters(timestamp?: bigint): Promise<EthAddress[]> {
899
947
  const attesterSize = await this.getActiveAttesterCount();
900
948
  const gse = new GSEContract(this.client, await this.getGSE());
901
- const ts = (await this.client.getBlock()).timestamp;
902
-
949
+ const ts = timestamp ?? (await this.client.getBlock()).timestamp;
903
950
  const indices = Array.from({ length: attesterSize }, (_, i) => BigInt(i));
904
951
  const chunks = chunk(indices, 1000);
905
952
 
package/src/l1_reader.ts CHANGED
@@ -1,4 +1,9 @@
1
- import { type ConfigMappingsType, getConfigFromMappings, numberConfigHelper } from '@aztec/foundation/config';
1
+ import {
2
+ type ConfigMappingsType,
3
+ getConfigFromMappings,
4
+ numberConfigHelper,
5
+ optionalNumberConfigHelper,
6
+ } from '@aztec/foundation/config';
2
7
 
3
8
  import { type L1ContractAddresses, l1ContractAddressesMapping } from './l1_contract_addresses.js';
4
9
 
@@ -14,6 +19,8 @@ export interface L1ReaderConfig {
14
19
  l1Contracts: L1ContractAddresses;
15
20
  /** The polling interval viem uses in ms */
16
21
  viemPollingIntervalMS: number;
22
+ /** Timeout for HTTP requests to the L1 RPC node in ms. */
23
+ l1HttpTimeoutMS?: number;
17
24
  }
18
25
 
19
26
  export const l1ReaderConfigMappings: ConfigMappingsType<L1ReaderConfig> = {
@@ -43,6 +50,11 @@ export const l1ReaderConfigMappings: ConfigMappingsType<L1ReaderConfig> = {
43
50
  description: 'The polling interval viem uses in ms',
44
51
  ...numberConfigHelper(1_000),
45
52
  },
53
+ l1HttpTimeoutMS: {
54
+ env: 'ETHEREUM_HTTP_TIMEOUT_MS',
55
+ description: 'Timeout for HTTP requests to the L1 RPC node in ms.',
56
+ ...optionalNumberConfigHelper(),
57
+ },
46
58
  };
47
59
 
48
60
  export function getL1ReaderConfigFromEnv(): L1ReaderConfig {
@@ -4,6 +4,7 @@ import { merge, pick } from '@aztec/foundation/collection';
4
4
  import { InterruptError, TimeoutError } from '@aztec/foundation/error';
5
5
  import { EthAddress } from '@aztec/foundation/eth-address';
6
6
  import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log';
7
+ import { Semaphore } from '@aztec/foundation/queue';
7
8
  import { retryUntil } from '@aztec/foundation/retry';
8
9
  import { sleep } from '@aztec/foundation/sleep';
9
10
  import { DateProvider } from '@aztec/foundation/timer';
@@ -45,6 +46,10 @@ const MAX_L1_TX_STATES = 32;
45
46
 
46
47
  export class L1TxUtils extends ReadOnlyL1TxUtils {
47
48
  protected txs: L1TxState[] = [];
49
+ /** Last nonce successfully sent to the chain. Used as a lower bound when a fallback RPC node returns a stale count. */
50
+ private lastSentNonce: number | undefined;
51
+ /** Mutex to prevent concurrent sendTransaction calls from racing on the same nonce. */
52
+ private readonly sendMutex = new Semaphore(1);
48
53
  /** Tx delayer for testing. Only set when enableDelayer config is true. */
49
54
  public delayer?: Delayer;
50
55
  /** KZG instance for blob operations. */
@@ -105,6 +110,11 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
105
110
  this.metrics?.recordMinedTx(l1TxState, new Date(l1Timestamp));
106
111
  } else if (newState === TxUtilsState.NOT_MINED) {
107
112
  this.metrics?.recordDroppedTx(l1TxState);
113
+ // The tx was dropped: the chain nonce reverted to l1TxState.nonce, so our lower bound is
114
+ // no longer valid. Clear it so the next send fetches the real nonce from the chain.
115
+ if (this.lastSentNonce === l1TxState.nonce) {
116
+ this.lastSentNonce = undefined;
117
+ }
108
118
  }
109
119
 
110
120
  // Update state in the store
@@ -246,14 +256,27 @@ export class L1TxUtils extends ReadOnlyL1TxUtils {
246
256
  );
247
257
  }
248
258
 
249
- const nonce = await this.client.getTransactionCount({ address: account, blockTag: 'pending' });
250
-
251
- const baseState = { request, gasLimit, blobInputs, gasPrice, nonce };
252
- const txData = this.makeTxData(baseState, { isCancelTx: false });
259
+ let txHash: Hex;
260
+ let nonce: number;
261
+ let baseState: Pick<L1TxState, 'request' | 'gasLimit' | 'blobInputs' | 'gasPrice' | 'nonce'>;
253
262
 
254
- // Send the new tx
255
- const signedRequest = await this.prepareSignedTransaction(txData);
256
- const txHash = await this.client.sendRawTransaction({ serializedTransaction: signedRequest });
263
+ await this.sendMutex.acquire();
264
+ try {
265
+ const chainNonce = await this.client.getTransactionCount({ address: account, blockTag: 'pending' });
266
+ // If a fallback RPC node returns a stale count (lower than what we last sent), use our
267
+ // local lower bound to avoid sending a duplicate of an already-pending transaction.
268
+ nonce =
269
+ this.lastSentNonce !== undefined && chainNonce <= this.lastSentNonce ? this.lastSentNonce + 1 : chainNonce;
270
+
271
+ baseState = { request, gasLimit, blobInputs, gasPrice, nonce };
272
+ const txData = this.makeTxData(baseState, { isCancelTx: false });
273
+
274
+ const signedRequest = await this.prepareSignedTransaction(txData);
275
+ txHash = await this.client.sendRawTransaction({ serializedTransaction: signedRequest });
276
+ this.lastSentNonce = nonce;
277
+ } finally {
278
+ this.sendMutex.release();
279
+ }
257
280
 
258
281
  // Create the new state for monitoring
259
282
  const l1TxState: L1TxState = {
@@ -130,9 +130,10 @@ export class ReadOnlyL1TxUtils {
130
130
  const numBlocks = Math.ceil(gasConfig.stallTimeMs! / BLOCK_TIME_MS);
131
131
  for (let i = 0; i < numBlocks; i++) {
132
132
  // each block can go up 12.5% from previous baseFee
133
- maxFeePerGas = (maxFeePerGas * (1_000n + 125n)) / 1_000n;
133
+ // ceil, (a+b-1)/b, to avoid truncation at small values (e.g. 1 wei blob base fee)
134
+ maxFeePerGas = (maxFeePerGas * (1_000n + 125n) + 999n) / 1_000n;
134
135
  // same for blob gas fee
135
- maxFeePerBlobGas = (maxFeePerBlobGas * (1_000n + 125n)) / 1_000n;
136
+ maxFeePerBlobGas = (maxFeePerBlobGas * (1_000n + 125n) + 999n) / 1_000n;
136
137
  }
137
138
 
138
139
  if (attempt > 0) {
@@ -242,13 +243,16 @@ export class ReadOnlyL1TxUtils {
242
243
  const gasConfig = { ...this.config, ..._gasConfig };
243
244
  let initialEstimate = 0n;
244
245
  if (_blobInputs) {
245
- // @note requests with blobs also require maxFeePerBlobGas to be set
246
+ // @note requests with blobs also require maxFeePerBlobGas to be set.
247
+ // Use 2x buffer for maxFeePerBlobGas to avoid stale fees and to pass EIP-4844 validation (even if it is a gas estimation call).
248
+ // 1. maxFeePerBlobGas >= blobBaseFee
249
+ // 2. account balance >= gas * maxFeePerGas + maxFeePerBlobGas * blobCount + value
246
250
  const gasPrice = await this.getGasPrice(gasConfig, true, 0);
247
251
  initialEstimate = await this.client.estimateGas({
248
252
  account,
249
253
  ...request,
250
254
  ..._blobInputs,
251
- maxFeePerBlobGas: gasPrice.maxFeePerBlobGas!,
255
+ maxFeePerBlobGas: gasPrice.maxFeePerBlobGas! * 2n,
252
256
  gas: MAX_L1_TX_LIMIT,
253
257
  blockTag: 'latest',
254
258
  });
@@ -1,6 +1,8 @@
1
1
  import { pick } from '@aztec/foundation/collection';
2
2
  import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log';
3
+ import { RunningPromise } from '@aztec/foundation/running-promise';
3
4
 
5
+ import { Multicall3 } from './contracts/multicall.js';
4
6
  import { L1TxUtils, TxUtilsState } from './l1_tx_utils/index.js';
5
7
 
6
8
  // Defines the order in which we prioritise publishers based on their state (first is better)
@@ -27,24 +29,72 @@ const busyStates: TxUtilsState[] = [
27
29
 
28
30
  export type PublisherFilter<UtilsType extends L1TxUtils> = (utils: UtilsType) => boolean;
29
31
 
32
+ /** Config accepted by PublisherManager. */
33
+ type PublisherManagerConfig = {
34
+ publisherAllowInvalidStates?: boolean;
35
+ publisherFundingThreshold?: bigint;
36
+ publisherFundingAmount?: bigint;
37
+ };
38
+
30
39
  export class PublisherManager<UtilsType extends L1TxUtils = L1TxUtils> {
31
40
  private log: Logger;
32
- private config: { publisherAllowInvalidStates?: boolean };
41
+ private config: PublisherManagerConfig;
42
+ private static readonly FUNDING_CHECK_INTERVAL_MS = 2 * 60 * 1000;
43
+ private funder?: UtilsType;
44
+ private fundingPromise?: RunningPromise;
33
45
 
34
46
  constructor(
35
47
  private publishers: UtilsType[],
36
- config: { publisherAllowInvalidStates?: boolean },
37
- bindings?: LoggerBindings,
48
+ config: PublisherManagerConfig,
49
+ opts?: { bindings?: LoggerBindings; funder?: UtilsType },
38
50
  ) {
39
- this.log = createLogger('publisher:manager', bindings);
51
+ this.funder = opts?.funder;
52
+ this.log = createLogger('publisher:manager', opts?.bindings);
40
53
  this.log.info(`PublisherManager initialized with ${publishers.length} publishers.`);
41
54
  this.publishers = publishers;
42
- this.config = pick(config, 'publisherAllowInvalidStates');
55
+ this.config = pick(config, 'publisherAllowInvalidStates', 'publisherFundingThreshold', 'publisherFundingAmount');
56
+
57
+ const hasThreshold = this.config.publisherFundingThreshold !== undefined;
58
+ const hasAmount = this.config.publisherFundingAmount !== undefined;
59
+ if (hasThreshold !== hasAmount) {
60
+ this.log.warn(`Incomplete funding config: both publisherFundingThreshold and publisherFundingAmount must be set`);
61
+ }
62
+
63
+ if (this.funder) {
64
+ const funderAddress = this.funder.getSenderAddress();
65
+ if (publishers.some(p => p.getSenderAddress().equals(funderAddress))) {
66
+ this.log.error(`Funding account ${funderAddress} is also a publisher, disabling funding to avoid self-funding`);
67
+ this.funder = undefined;
68
+ }
69
+ }
43
70
  }
44
71
 
45
- /** Loads the state of all publishers and resumes monitoring any pending txs */
46
- public async loadState(): Promise<void> {
47
- await Promise.all(this.publishers.map(pub => pub.loadStateAndResumeMonitoring()));
72
+ /** Loads the state of all publishers and the funder, and starts periodic funding checks. */
73
+ public async start(): Promise<void> {
74
+ await Promise.all([
75
+ ...this.publishers.map(pub => pub.loadStateAndResumeMonitoring()),
76
+ this.funder?.loadStateAndResumeMonitoring(),
77
+ ]);
78
+
79
+ if (
80
+ this.funder &&
81
+ this.config.publisherFundingThreshold !== undefined &&
82
+ this.config.publisherFundingAmount !== undefined
83
+ ) {
84
+ this.fundingPromise = new RunningPromise(
85
+ () => this.triggerFundingIfNeeded(),
86
+ this.log,
87
+ PublisherManager.FUNDING_CHECK_INTERVAL_MS,
88
+ );
89
+ this.fundingPromise.start();
90
+ }
91
+ }
92
+
93
+ /** Stops the funding loop and interrupts all publishers. */
94
+ public async stop(): Promise<void> {
95
+ await this.fundingPromise?.stop();
96
+ this.publishers.forEach(pub => pub.interrupt());
97
+ this.funder?.interrupt();
48
98
  }
49
99
 
50
100
  // Finds and prioritises available publishers based on
@@ -102,7 +152,52 @@ export class PublisherManager<UtilsType extends L1TxUtils = L1TxUtils> {
102
152
  return sortedPublishers[0].publisher;
103
153
  }
104
154
 
105
- public interrupt() {
106
- this.publishers.forEach(pub => pub.interrupt());
155
+ /** Check all publisher balances and fund those below threshold. */
156
+ private async triggerFundingIfNeeded(): Promise<void> {
157
+ const { funder, config } = this;
158
+ if (!funder || config.publisherFundingThreshold === undefined || config.publisherFundingAmount === undefined) {
159
+ return;
160
+ }
161
+
162
+ const allBalances = await Promise.all(
163
+ this.publishers.map(async pub => ({ balance: await pub.getSenderBalance(), publisher: pub })),
164
+ );
165
+ const lowBalance = allBalances.filter(p => p.balance < config.publisherFundingThreshold!);
166
+ if (lowBalance.length === 0) {
167
+ return;
168
+ }
169
+
170
+ const fundingAmount = config.publisherFundingAmount!;
171
+ const funderBalance = await funder.getSenderBalance();
172
+
173
+ if (funderBalance < 10n * fundingAmount) {
174
+ this.log.warn(`Funding account balance is low`, { funderBalance, threshold: 10n * fundingAmount });
175
+ }
176
+ const affordableCount = Number(funderBalance / fundingAmount);
177
+ if (affordableCount === 0) {
178
+ this.log.error(`Funding account balance too low to fund any publisher`, { funderBalance, fundingAmount });
179
+ return;
180
+ }
181
+ if (affordableCount < lowBalance.length) {
182
+ this.log.warn(`Funder can only afford ${affordableCount}/${lowBalance.length} publishers`, {
183
+ funderBalance,
184
+ fundingAmount,
185
+ });
186
+ }
187
+
188
+ const toFund = lowBalance.slice(0, affordableCount).map(p => p.publisher);
189
+ await this.fundPublishers(toFund);
190
+ }
191
+
192
+ /** Fund publishers via a single Multicall3 aggregate3Value transaction. */
193
+ private async fundPublishers(publishers: UtilsType[]): Promise<void> {
194
+ const fundingAmount = this.config.publisherFundingAmount!;
195
+ const calls = publishers.map(pub => ({
196
+ to: pub.getSenderAddress().toString(),
197
+ value: fundingAmount,
198
+ }));
199
+
200
+ await Multicall3.forwardValue(calls, this.funder!, this.log);
201
+ this.log.info(`Funded ${publishers.length} publishers`);
107
202
  }
108
203
  }