@aztec-labs/ethereum 6.0.0-rc.3 → 6.0.0

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 (114) hide show
  1. package/dest/blob_tx.d.ts +24 -0
  2. package/dest/blob_tx.d.ts.map +1 -0
  3. package/dest/blob_tx.js +94 -0
  4. package/dest/bn254/hash_to_point.d.ts +32 -0
  5. package/dest/bn254/hash_to_point.d.ts.map +1 -0
  6. package/dest/bn254/hash_to_point.js +80 -0
  7. package/dest/bn254/index.d.ts +3 -0
  8. package/dest/bn254/index.d.ts.map +1 -0
  9. package/dest/bn254/index.js +2 -0
  10. package/dest/bn254/proof_of_possession_gas.d.ts +29 -0
  11. package/dest/bn254/proof_of_possession_gas.d.ts.map +1 -0
  12. package/dest/bn254/proof_of_possession_gas.js +24 -0
  13. package/dest/client.d.ts +1 -1
  14. package/dest/client.d.ts.map +1 -1
  15. package/dest/client.js +5 -3
  16. package/dest/contracts/errors.d.ts +10 -3
  17. package/dest/contracts/errors.d.ts.map +1 -1
  18. package/dest/contracts/errors.js +10 -4
  19. package/dest/contracts/inbox.d.ts +1 -1
  20. package/dest/contracts/inbox.d.ts.map +1 -1
  21. package/dest/contracts/inbox.js +2 -0
  22. package/dest/contracts/log.d.ts +7 -1
  23. package/dest/contracts/log.d.ts.map +1 -1
  24. package/dest/contracts/log.js +6 -0
  25. package/dest/contracts/rollup.d.ts +32 -4
  26. package/dest/contracts/rollup.d.ts.map +1 -1
  27. package/dest/contracts/rollup.js +63 -7
  28. package/dest/deploy_aztec_l1_contracts.d.ts +16 -1
  29. package/dest/deploy_aztec_l1_contracts.d.ts.map +1 -1
  30. package/dest/deploy_aztec_l1_contracts.js +201 -65
  31. package/dest/foundry_binary.d.ts +1 -1
  32. package/dest/foundry_binary.d.ts.map +1 -1
  33. package/dest/foundry_binary.js +2 -1
  34. package/dest/l1_tx_utils/amsterdam.d.ts +1 -1
  35. package/dest/l1_tx_utils/amsterdam.d.ts.map +1 -1
  36. package/dest/l1_tx_utils/amsterdam.js +4 -1
  37. package/dest/l1_tx_utils/constants.d.ts +2 -1
  38. package/dest/l1_tx_utils/constants.d.ts.map +1 -1
  39. package/dest/l1_tx_utils/constants.js +3 -0
  40. package/dest/l1_tx_utils/fee-strategies/p75_competitive.d.ts +1 -1
  41. package/dest/l1_tx_utils/fee-strategies/p75_competitive.d.ts.map +1 -1
  42. package/dest/l1_tx_utils/fee-strategies/p75_competitive.js +6 -1
  43. package/dest/l1_tx_utils/index.d.ts +2 -1
  44. package/dest/l1_tx_utils/index.d.ts.map +1 -1
  45. package/dest/l1_tx_utils/index.js +1 -0
  46. package/dest/l1_tx_utils/l1_tx_utils.d.ts +21 -1
  47. package/dest/l1_tx_utils/l1_tx_utils.d.ts.map +1 -1
  48. package/dest/l1_tx_utils/l1_tx_utils.js +71 -5
  49. package/dest/l1_tx_utils/readonly_l1_tx_utils.d.ts +8 -1
  50. package/dest/l1_tx_utils/readonly_l1_tx_utils.d.ts.map +1 -1
  51. package/dest/l1_tx_utils/readonly_l1_tx_utils.js +1 -1
  52. package/dest/l1_tx_utils/send_cost.d.ts +15 -0
  53. package/dest/l1_tx_utils/send_cost.d.ts.map +1 -0
  54. package/dest/l1_tx_utils/send_cost.js +10 -0
  55. package/dest/l1_tx_utils/signer.d.ts +2 -2
  56. package/dest/l1_tx_utils/signer.d.ts.map +1 -1
  57. package/dest/l1_tx_utils/signer.js +2 -2
  58. package/dest/l1_tx_utils/tx_delayer.d.ts +1 -1
  59. package/dest/l1_tx_utils/tx_delayer.d.ts.map +1 -1
  60. package/dest/l1_tx_utils/tx_delayer.js +8 -26
  61. package/dest/publisher_manager.d.ts +127 -13
  62. package/dest/publisher_manager.d.ts.map +1 -1
  63. package/dest/publisher_manager.js +320 -79
  64. package/dest/test/anvil_hardfork.d.ts +42 -0
  65. package/dest/test/anvil_hardfork.d.ts.map +1 -0
  66. package/dest/test/anvil_hardfork.js +107 -0
  67. package/dest/test/eth_cheat_codes.d.ts +30 -5
  68. package/dest/test/eth_cheat_codes.d.ts.map +1 -1
  69. package/dest/test/eth_cheat_codes.js +283 -101
  70. package/dest/test/index.d.ts +3 -1
  71. package/dest/test/index.d.ts.map +1 -1
  72. package/dest/test/index.js +2 -0
  73. package/dest/test/l1_hardfork.d.ts +43 -0
  74. package/dest/test/l1_hardfork.d.ts.map +1 -0
  75. package/dest/test/l1_hardfork.js +51 -0
  76. package/dest/test/recording_rpc_server.d.ts +1 -1
  77. package/dest/test/recording_rpc_server.d.ts.map +1 -1
  78. package/dest/test/recording_rpc_server.js +1 -0
  79. package/dest/test/rollup_cheat_codes.d.ts +1 -1
  80. package/dest/test/rollup_cheat_codes.d.ts.map +1 -1
  81. package/dest/test/rollup_cheat_codes.js +7 -7
  82. package/dest/test/start_anvil.d.ts +14 -2
  83. package/dest/test/start_anvil.d.ts.map +1 -1
  84. package/dest/test/start_anvil.js +28 -11
  85. package/package.json +13 -11
  86. package/src/blob_tx.ts +118 -0
  87. package/src/bn254/hash_to_point.ts +95 -0
  88. package/src/bn254/index.ts +2 -0
  89. package/src/bn254/proof_of_possession_gas.ts +54 -0
  90. package/src/client.ts +6 -3
  91. package/src/contracts/errors.ts +12 -4
  92. package/src/contracts/inbox.ts +4 -0
  93. package/src/contracts/log.ts +12 -0
  94. package/src/contracts/rollup.ts +63 -6
  95. package/src/deploy_aztec_l1_contracts.ts +238 -63
  96. package/src/foundry_binary.ts +2 -1
  97. package/src/l1_tx_utils/amsterdam.ts +4 -1
  98. package/src/l1_tx_utils/constants.ts +4 -0
  99. package/src/l1_tx_utils/fee-strategies/p75_competitive.ts +11 -1
  100. package/src/l1_tx_utils/index.ts +1 -0
  101. package/src/l1_tx_utils/l1_tx_utils.ts +72 -5
  102. package/src/l1_tx_utils/readonly_l1_tx_utils.ts +1 -1
  103. package/src/l1_tx_utils/send_cost.ts +21 -0
  104. package/src/l1_tx_utils/signer.ts +3 -2
  105. package/src/l1_tx_utils/tx_delayer.ts +8 -37
  106. package/src/publisher_manager.ts +458 -83
  107. package/src/test/anvil_hardfork.ts +95 -0
  108. package/src/test/eth_cheat_codes.ts +306 -81
  109. package/src/test/fixtures/sepolia_blob_tx.json +10 -0
  110. package/src/test/index.ts +2 -0
  111. package/src/test/l1_hardfork.ts +71 -0
  112. package/src/test/recording_rpc_server.ts +1 -0
  113. package/src/test/rollup_cheat_codes.ts +9 -9
  114. package/src/test/start_anvil.ts +41 -13
@@ -1,23 +1,31 @@
1
- import { pick } from '@aztec-labs/foundation/collection';
1
+ import { maxBy, pick } from '@aztec-labs/foundation/collection';
2
2
  import { type Logger, type LoggerBindings, createLogger } from '@aztec-labs/foundation/log';
3
3
  import { RunningPromise } from '@aztec-labs/foundation/running-promise';
4
+ import { formatEther } from 'viem';
4
5
 
5
6
  import { Multicall3 } from './contracts/multicall.js';
6
- import { L1TxUtils, TxUtilsState } from './l1_tx_utils/index.js';
7
-
8
- // Defines the order in which we prioritise publishers based on their state (first is better)
9
- const sortOrder = [
10
- // Always prefer sending from idle publishers
11
- TxUtilsState.IDLE,
12
- // Then from publishers that have sent a tx and it got mined
13
- TxUtilsState.MINED,
14
- // Then from publishers that have sent a tx but it's in-flight
15
- TxUtilsState.SPEED_UP,
16
- TxUtilsState.SENT,
17
- // We leave cancelled and not-mined states for last, since these represent failures to mines and could be problematic
18
- TxUtilsState.CANCELLED,
19
- TxUtilsState.NOT_MINED,
20
- ];
7
+ import {
8
+ type FeesPerGas,
9
+ INSUFFICIENT_FUNDS_BACKOFF_MS,
10
+ L1TxUtils,
11
+ type SendCostRequirement,
12
+ TxUtilsState,
13
+ computeSendCost,
14
+ } from './l1_tx_utils/index.js';
15
+
16
+ /**
17
+ * Selection rank per publisher state (lower is better). IDLE and MINED rank equally: a publisher whose sends keep
18
+ * failing (e.g. for lack of funds) never leaves IDLE, so ranking IDLE above MINED would let it beat a working one.
19
+ * In-flight states come next, and cancelled or not-mined states last since they represent failures to mine.
20
+ */
21
+ const stateRank: Record<TxUtilsState, number> = {
22
+ [TxUtilsState.IDLE]: 0,
23
+ [TxUtilsState.MINED]: 0,
24
+ [TxUtilsState.SPEED_UP]: 1,
25
+ [TxUtilsState.SENT]: 1,
26
+ [TxUtilsState.CANCELLED]: 2,
27
+ [TxUtilsState.NOT_MINED]: 2,
28
+ };
21
29
 
22
30
  // Which states represent a busy publisher that we should avoid if possible
23
31
  const busyStates: TxUtilsState[] = [
@@ -27,6 +35,10 @@ const busyStates: TxUtilsState[] = [
27
35
  TxUtilsState.NOT_MINED,
28
36
  ];
29
37
 
38
+ const LOW_BALANCE_WARNING_INTERVAL_MS = 10 * 60 * 1000;
39
+ const DEFAULT_LOW_BALANCE_WARNING_MULTIPLIER = 4;
40
+ const NO_AFFORDABLE_PUBLISHER_WARNING_KEY = 'no-affordable-publisher';
41
+
30
42
  export type PublisherFilter<UtilsType extends L1TxUtils> = (utils: UtilsType) => boolean;
31
43
 
32
44
  /** Config accepted by PublisherManager. */
@@ -34,26 +46,156 @@ type PublisherManagerConfig = {
34
46
  publisherAllowInvalidStates?: boolean;
35
47
  publisherFundingThreshold?: bigint;
36
48
  publisherFundingAmount?: bigint;
49
+ /** Warn when a publisher balance is below this multiple of the ETH required for the reference send. */
50
+ publisherLowBalanceWarningMultiplier?: number;
51
+ };
52
+
53
+ /** A send the periodic balance check prices at current fees, to report whether publishers can afford it. */
54
+ export type ReferenceSend = {
55
+ /** Short name of the send for logs, such as "proposal" or "epoch proof". */
56
+ name: string;
57
+ requirement: SendCostRequirement;
58
+ };
59
+
60
+ /** Balances read by a periodic balance check, and how many reference sends each publisher can afford. */
61
+ export type PublisherBalanceSnapshot = {
62
+ /** Publishers whose balance could be read. */
63
+ publishers: {
64
+ address: string;
65
+ balance: bigint;
66
+ /**
67
+ * Number of reference sends the balance covers at current fees, 0 while backed off after an insufficient funds
68
+ * rejection. Unset without a reference send or if fees could not be read.
69
+ */
70
+ sendsAffordable?: number;
71
+ }[];
72
+ funder?: { address: string; balance: bigint };
73
+ /** ETH needed for the reference send at current fees. Unset without a reference send or if fees could not be read. */
74
+ required?: bigint;
75
+ };
76
+
77
+ /** Metrics emitted by the PublisherManager from its periodic balance check. */
78
+ export interface IPublisherManagerMetrics {
79
+ /** Records the latest balance snapshot, replacing the previous one. Undefined clears it once the manager stops. */
80
+ recordBalanceSnapshot(snapshot: PublisherBalanceSnapshot | undefined): void;
81
+ }
82
+
83
+ /** Balance snapshot of a publisher considered for an L1 send. */
84
+ export type PublisherBalanceInfo = {
85
+ address: string;
86
+ balance: bigint;
87
+ state: string;
88
+ /** Balance at which a previous send was rejected for insufficient funds, if the publisher is backed off. */
89
+ insufficientFundsAtBalance?: bigint;
37
90
  };
38
91
 
92
+ /** Thrown when publishers are available but none holds enough ETH to afford the requested L1 send. */
93
+ export class NoAffordablePublisherError extends Error {
94
+ constructor(
95
+ public readonly required: bigint,
96
+ public readonly requirement: SendCostRequirement,
97
+ public readonly fees: FeesPerGas,
98
+ public readonly publishers: PublisherBalanceInfo[],
99
+ ) {
100
+ super(
101
+ `No publisher can afford the L1 send: requires ${formatEther(required)} ETH ` +
102
+ `(balances: ${publishers.map(p => `${p.address}=${formatEther(p.balance)}`).join(', ') || 'none'})`,
103
+ );
104
+ this.name = 'NoAffordablePublisherError';
105
+ }
106
+
107
+ /**
108
+ * ETH the best funded publisher that is not backed off after an insufficient funds rejection is missing to afford the
109
+ * send. Undefined if every publisher is backed off.
110
+ */
111
+ public shortfall(): bigint | undefined {
112
+ const richest = this.getRichestNotBackedOff();
113
+ return richest === undefined ? undefined : this.required - richest.balance;
114
+ }
115
+
116
+ /** Tells the operator which publisher to fund and with how much ETH for the send to become affordable. */
117
+ public describeFunding(): string {
118
+ const richest = this.getRichestNotBackedOff();
119
+ if (richest === undefined || this.required <= richest.balance) {
120
+ const minutes = INSUFFICIENT_FUNDS_BACKOFF_MS / 60_000;
121
+ return (
122
+ `every publisher is backed off after an insufficient funds rejection ` +
123
+ `until its balance increases or ${minutes} minutes pass`
124
+ );
125
+ }
126
+ const shortfall = formatEther(this.required - richest.balance);
127
+ const others = this.publishers.filter(p => p.address !== richest.address);
128
+ const alternative =
129
+ others.length === 0
130
+ ? ''
131
+ : `, or top up any of ${others.map(p => p.address).join(', ')} to ${formatEther(this.required)} ETH`;
132
+ return `fund ${richest.address} with ${shortfall} ETH more${alternative}`;
133
+ }
134
+
135
+ /** Structured context for logging. */
136
+ public toLogContext() {
137
+ return {
138
+ required: this.required,
139
+ shortfall: this.shortfall(),
140
+ gasLimit: this.requirement.gasLimit,
141
+ blobCount: this.requirement.blobCount,
142
+ maxFeePerGas: this.fees.maxFeePerGas,
143
+ maxFeePerBlobGas: this.fees.maxFeePerBlobGas,
144
+ publishers: this.publishers,
145
+ };
146
+ }
147
+
148
+ private getRichestNotBackedOff(): PublisherBalanceInfo | undefined {
149
+ return maxBy(
150
+ this.publishers.filter(p => p.insufficientFundsAtBalance === undefined),
151
+ p => p.balance,
152
+ );
153
+ }
154
+ }
155
+
156
+ type PublisherWithBalance<UtilsType> = { publisher: UtilsType; balance: bigint };
157
+
39
158
  export class PublisherManager<UtilsType extends L1TxUtils = L1TxUtils> {
40
- private log: Logger;
159
+ private static readonly BALANCE_CHECK_INTERVAL_MS = 60 * 1000;
160
+ protected log: Logger;
41
161
  private config: PublisherManagerConfig;
42
- private static readonly FUNDING_CHECK_INTERVAL_MS = 2 * 60 * 1000;
43
162
  protected funder?: UtilsType;
44
- protected readonly fundingPromise?: RunningPromise;
163
+ protected readonly balanceCheckPromise: RunningPromise;
45
164
  private started = false;
165
+ private readonly metrics?: IPublisherManagerMetrics;
166
+ private readonly referenceSend?: ReferenceSend;
167
+ /** Whether the balance check has run since the last start, so the first one logs a summary of all balances. */
168
+ private loggedBalanceSummary = false;
169
+ /** Last time a balance warning was logged, per publisher address or per kind of warning. */
170
+ private readonly lastLowBalanceWarningAt = new Map<string, number>();
46
171
 
47
172
  constructor(
48
173
  protected publishers: UtilsType[],
49
174
  config: PublisherManagerConfig,
50
- opts?: { bindings?: LoggerBindings; funder?: UtilsType },
175
+ opts?: {
176
+ bindings?: LoggerBindings;
177
+ funder?: UtilsType;
178
+ metrics?: IPublisherManagerMetrics;
179
+ /**
180
+ * Send the balance check prices to report whether publishers can afford it. Without one, balances are still
181
+ * recorded, but affordability is neither recorded nor warned about.
182
+ */
183
+ referenceSend?: ReferenceSend;
184
+ },
51
185
  ) {
52
186
  this.funder = opts?.funder;
187
+ this.metrics = opts?.metrics;
188
+ this.referenceSend = opts?.referenceSend;
53
189
  this.log = createLogger('publisher:manager', opts?.bindings);
54
190
  this.log.info(`PublisherManager initialized with ${publishers.length} publishers.`);
55
191
  this.publishers = publishers;
56
- this.config = pick(config, 'publisherAllowInvalidStates', 'publisherFundingThreshold', 'publisherFundingAmount');
192
+ this.config = pick(
193
+ config,
194
+ 'publisherAllowInvalidStates',
195
+ 'publisherFundingThreshold',
196
+ 'publisherFundingAmount',
197
+ 'publisherLowBalanceWarningMultiplier',
198
+ );
57
199
 
58
200
  const hasThreshold = this.config.publisherFundingThreshold !== undefined;
59
201
  const hasAmount = this.config.publisherFundingAmount !== undefined;
@@ -69,21 +211,19 @@ export class PublisherManager<UtilsType extends L1TxUtils = L1TxUtils> {
69
211
  }
70
212
  }
71
213
 
72
- if (this.funder && hasThreshold && hasAmount) {
73
- this.fundingPromise = new RunningPromise(
74
- () => this.triggerFundingIfNeeded(),
75
- this.log,
76
- PublisherManager.FUNDING_CHECK_INTERVAL_MS,
77
- );
78
- }
214
+ this.balanceCheckPromise = new RunningPromise(
215
+ () => this.checkBalances(),
216
+ this.log,
217
+ PublisherManager.BALANCE_CHECK_INTERVAL_MS,
218
+ );
79
219
  }
80
220
 
81
221
  /**
82
222
  * Clears any interrupted flag left by a previous {@link stop} so publishing works again after a restart,
83
- * loads the state of all publishers and the funder, and starts periodic funding checks. Idempotent: a
84
- * start while already started is a no-op, so it never re-runs `loadStateAndResumeMonitoring` (which
85
- * would spawn a duplicate background monitor per pending nonce). Lifecycle calls are expected to be
86
- * serialized by the caller.
223
+ * loads the state of all publishers and the funder, and starts the periodic balance check, which also tops up
224
+ * publishers if funding is configured. Idempotent: a start while already started is a no-op, so it never re-runs
225
+ * `loadStateAndResumeMonitoring` (which would spawn a duplicate background monitor per pending nonce). Lifecycle
226
+ * calls are expected to be serialized by the caller.
87
227
  */
88
228
  public async start(): Promise<void> {
89
229
  if (this.started) {
@@ -101,19 +241,22 @@ export class PublisherManager<UtilsType extends L1TxUtils = L1TxUtils> {
101
241
  this.funder?.loadStateAndResumeMonitoring(),
102
242
  ]);
103
243
 
104
- this.fundingPromise?.start();
244
+ this.loggedBalanceSummary = false;
245
+ this.lastLowBalanceWarningAt.delete(NO_AFFORDABLE_PUBLISHER_WARNING_KEY);
246
+ this.balanceCheckPromise.start();
105
247
  // Marked started only once fully up, so a start that failed to load state can be retried.
106
248
  this.started = true;
107
249
  }
108
250
 
109
251
  /**
110
- * Stops the funding loop, interrupts all publishers so no further L1 txs are sent, and waits (bounded)
252
+ * Stops the balance check loop, interrupts all publishers so no further L1 txs are sent, and waits (bounded)
111
253
  * for their in-flight tx monitor loops to wind down. Idempotent, and the manager may be restarted
112
254
  * afterwards via {@link start}, which clears the interrupted flag.
113
255
  */
114
256
  public async stop(): Promise<void> {
115
257
  this.started = false;
116
- await this.fundingPromise?.stop();
258
+ await this.balanceCheckPromise.stop();
259
+ this.metrics?.recordBalanceSnapshot(undefined);
117
260
  this.publishers.forEach(pub => pub.interrupt());
118
261
  this.funder?.interrupt();
119
262
  // Wait for in-flight tx monitor loops to observe the interrupt, so no L1 requests are still
@@ -124,60 +267,62 @@ export class PublisherManager<UtilsType extends L1TxUtils = L1TxUtils> {
124
267
  ]);
125
268
  }
126
269
 
127
- // Finds and prioritises available publishers based on
128
- // 1. Validity as per the provided filter function
129
- // 2. Validity based on the state the publisher is in
130
- // 3. Priority based on state as defined by sortOrder
131
- // 4. Then priority based on highest balance
132
- // 5. Then priority based on least recently used
133
- public async getAvailablePublisher(filter: PublisherFilter<UtilsType> = () => true): Promise<UtilsType> {
270
+ /**
271
+ * Finds and prioritises an available publisher. Candidates must pass the filter and not be busy (unless
272
+ * `publisherAllowInvalidStates` is set and no idle publisher remains), and are ranked by state (see `stateRank`), then
273
+ * highest balance, then least recently used. Publishers backed off after an insufficient funds rejection are skipped
274
+ * until their balance increases or the backoff expires.
275
+ *
276
+ * With a `requirement`, only publishers whose balance covers the worst-case cost of that send are eligible, and a
277
+ * {@link NoAffordablePublisherError} is thrown if there are none. Without one, selection is lenient: it prefers funded
278
+ * publishers but falls back to unfunded ones rather than failing, for sends that are fine to attempt and lose.
279
+ */
280
+ public async getAvailablePublisher(
281
+ filter: PublisherFilter<UtilsType> = () => true,
282
+ opts?: { requirement?: SendCostRequirement },
283
+ ): Promise<UtilsType> {
134
284
  this.log.debug(`Getting available publisher`, {
135
285
  publishers: this.publishers.map(p => ({
136
286
  address: p.getSenderAddress(),
137
287
  state: p.state,
138
288
  lastMined: p.lastMinedAtBlockNumber,
139
289
  })),
290
+ requirement: opts?.requirement,
140
291
  });
141
292
 
142
- // Extract the valid publishers
143
- let validPublishers = this.publishers.filter((pub: UtilsType) => !busyStates.includes(pub.state) && filter(pub));
293
+ const filtered = this.publishers.filter(pub => filter(pub));
294
+ let validPublishers = filtered.filter(pub => !busyStates.includes(pub.state));
144
295
 
145
- // If none found but we allow invalid (busy) states, try again including them
146
- if (validPublishers.length === 0 && this.config.publisherAllowInvalidStates) {
147
- this.log.warn(`No valid publishers found. Trying again including invalid states.`);
148
- validPublishers = this.publishers.filter(pub => filter(pub));
296
+ // If none found but we allow invalid (busy) states, try again including them. When the send has a requirement we
297
+ // also consider busy ones, so that an idle but unaffordable publisher does not hide a busy affordable one. Busy
298
+ // publishers still rank below idle ones.
299
+ if (this.config.publisherAllowInvalidStates && (validPublishers.length === 0 || opts?.requirement)) {
300
+ if (validPublishers.length === 0) {
301
+ this.log.warn(`No valid publishers found. Trying again including invalid states.`);
302
+ }
303
+ validPublishers = filtered;
149
304
  }
150
305
 
151
- // Error if none found
152
306
  if (validPublishers.length === 0) {
153
307
  throw new Error(`Failed to find an available publisher.`);
154
308
  }
155
309
 
156
- // Get the balances
157
- const publishersWithBalance = await Promise.all(
158
- validPublishers.map(async pub => {
159
- return { balance: await pub.getSenderBalance(), publisher: pub };
160
- }),
161
- );
310
+ const withBalance = await this.getBalances(validPublishers);
162
311
 
163
- // Discount unfunded publishers: a publisher with no ETH cannot send a tx, so it must never win
164
- // selection regardless of how favourable its state or last-used time is. Fall back to the full
165
- // set only if every candidate is unfunded, so behaviour is no worse than before.
166
- let fundedPublishers = publishersWithBalance.filter(p => p.balance > 0n);
167
- if (fundedPublishers.length === 0) {
168
- this.log.warn(`All candidate publishers have zero balance; selecting from unfunded publishers.`);
169
- fundedPublishers = publishersWithBalance;
312
+ let candidates: PublisherWithBalance<UtilsType>[];
313
+ if (opts?.requirement) {
314
+ candidates = await this.getAffordable(withBalance, opts.requirement);
315
+ } else {
316
+ candidates = this.getLenientCandidates(withBalance);
170
317
  }
171
318
 
172
- // Sort based on state, then balance, then time since last use
173
- const sortedPublishers = fundedPublishers.sort((a, b) => {
174
- const stateComparison = sortOrder.indexOf(a.publisher.state) - sortOrder.indexOf(b.publisher.state);
319
+ const sortedPublishers = candidates.sort((a, b) => {
320
+ const stateComparison = stateRank[a.publisher.state] - stateRank[b.publisher.state];
175
321
  if (stateComparison !== 0) {
176
322
  return stateComparison;
177
323
  }
178
- const balanceComparison = Number(b.balance - a.balance);
179
- if (balanceComparison !== 0) {
180
- return balanceComparison;
324
+ if (a.balance !== b.balance) {
325
+ return b.balance > a.balance ? 1 : -1;
181
326
  }
182
327
  const lastUsedComparison = Number(
183
328
  (a.publisher.lastMinedAtBlockNumber ?? 0n) - (b.publisher.lastMinedAtBlockNumber ?? 0n),
@@ -188,23 +333,254 @@ export class PublisherManager<UtilsType extends L1TxUtils = L1TxUtils> {
188
333
  return sortedPublishers[0].publisher;
189
334
  }
190
335
 
191
- /** Check all publisher balances and fund those below threshold. */
192
- private async triggerFundingIfNeeded(): Promise<void> {
193
- const { funder, config } = this;
194
- if (!funder || config.publisherFundingThreshold === undefined || config.publisherFundingAmount === undefined) {
195
- return;
336
+ /**
337
+ * Reads the balance of every publisher and the funder, prices the reference send at current fees, records metrics,
338
+ * logs balances that are low or cannot afford the reference send, and tops up publishers below the funding threshold
339
+ * if funding is configured. Runs once a minute while started.
340
+ */
341
+ protected async checkBalances(): Promise<void> {
342
+ const funder = this.funder;
343
+ const [publisherBalances, funderBalance] = await Promise.all([
344
+ Promise.all(this.publishers.map(publisher => this.tryGetBalance(publisher))),
345
+ funder ? this.tryGetBalance(funder) : undefined,
346
+ ]);
347
+ const withBalance = this.publishers.flatMap((publisher, i) => {
348
+ const balance = publisherBalances[i];
349
+ return balance === undefined ? [] : [{ publisher, balance }];
350
+ });
351
+ const cost = await this.tryGetReferenceCost();
352
+ const required = cost !== undefined && cost.required > 0n ? cost.required : undefined;
353
+
354
+ const snapshot: PublisherBalanceSnapshot = {
355
+ publishers: withBalance.map(({ publisher, balance }) => ({
356
+ address: publisher.getSenderAddress().toString(),
357
+ balance,
358
+ sendsAffordable:
359
+ required === undefined
360
+ ? undefined
361
+ : publisher.hasActiveInsufficientFundsBackoff(balance)
362
+ ? 0
363
+ : Number(balance / required),
364
+ })),
365
+ funder:
366
+ funder && funderBalance !== undefined
367
+ ? { address: funder.getSenderAddress().toString(), balance: funderBalance }
368
+ : undefined,
369
+ required,
370
+ };
371
+ this.metrics?.recordBalanceSnapshot(snapshot);
372
+
373
+ this.logBalances(withBalance, snapshot, cost?.fees);
374
+ if (required !== undefined) {
375
+ this.warnOnLowBalances(withBalance, required);
196
376
  }
197
377
 
198
- const allBalances = await Promise.all(
199
- this.publishers.map(async pub => ({ balance: await pub.getSenderBalance(), publisher: pub })),
378
+ await this.fundIfNeeded(withBalance, funderBalance);
379
+ }
380
+
381
+ private getBalances(publishers: UtilsType[]): Promise<PublisherWithBalance<UtilsType>[]> {
382
+ return Promise.all(publishers.map(async publisher => ({ publisher, balance: await publisher.getSenderBalance() })));
383
+ }
384
+
385
+ /** Returns the publishers that can afford the requirement, or throws NoAffordablePublisherError if none. */
386
+ private async getAffordable(
387
+ publishers: PublisherWithBalance<UtilsType>[],
388
+ requirement: SendCostRequirement,
389
+ ): Promise<PublisherWithBalance<UtilsType>[]> {
390
+ if (publishers.length === 0) {
391
+ throw new Error(`Failed to find an available publisher.`);
392
+ }
393
+ // All publishers share the L1 network, so fees are fetched once rather than per publisher.
394
+ const fees = await publishers[0].publisher.getFeesPerGas(undefined, requirement.blobCount > 0, 0);
395
+ const required = computeSendCost(fees, requirement);
396
+
397
+ const affordable = publishers.filter(
398
+ p => p.balance >= required && !p.publisher.isBackedOffForInsufficientFunds(p.balance),
200
399
  );
201
- const lowBalance = allBalances.filter(p => p.balance < config.publisherFundingThreshold!);
202
- if (lowBalance.length === 0) {
400
+ if (affordable.length === 0) {
401
+ throw new NoAffordablePublisherError(required, requirement, fees, this.toBalanceInfo(publishers));
402
+ }
403
+ return affordable;
404
+ }
405
+
406
+ private toBalanceInfo(publishers: PublisherWithBalance<UtilsType>[]): PublisherBalanceInfo[] {
407
+ return publishers.map(({ publisher, balance }) => ({
408
+ address: publisher.getSenderAddress().toString(),
409
+ balance,
410
+ state: TxUtilsState[publisher.state],
411
+ insufficientFundsAtBalance: publisher.hasActiveInsufficientFundsBackoff(balance)
412
+ ? publisher.getInsufficientFundsAtBalance()
413
+ : undefined,
414
+ }));
415
+ }
416
+
417
+ /**
418
+ * Prefers publishers that are funded and not backed off after an insufficient funds rejection, but never returns an
419
+ * empty list: if every candidate is excluded, falls back to the full set so lenient sends still have something to try.
420
+ */
421
+ private getLenientCandidates(publishers: PublisherWithBalance<UtilsType>[]): PublisherWithBalance<UtilsType>[] {
422
+ const usable = publishers.filter(p => p.balance > 0n && !p.publisher.isBackedOffForInsufficientFunds(p.balance));
423
+ if (usable.length > 0) {
424
+ return usable;
425
+ }
426
+ const funded = publishers.filter(p => p.balance > 0n);
427
+ if (funded.length > 0) {
428
+ return funded;
429
+ }
430
+ this.log.warn(`All candidate publishers have zero balance; selecting from unfunded publishers.`);
431
+ return publishers;
432
+ }
433
+
434
+ private async tryGetBalance(utils: UtilsType): Promise<bigint | undefined> {
435
+ try {
436
+ return await utils.getSenderBalance();
437
+ } catch (err) {
438
+ const address = utils.getSenderAddress().toString();
439
+ if (this.shouldWarn(`balance-read:${address}`)) {
440
+ this.log.warn(`Failed to read L1 balance of account`, { address, err });
441
+ }
442
+ return undefined;
443
+ }
444
+ }
445
+
446
+ private async tryGetReferenceCost(): Promise<{ fees: FeesPerGas; required: bigint } | undefined> {
447
+ const { referenceSend } = this;
448
+ if (referenceSend === undefined || this.publishers.length === 0) {
449
+ return undefined;
450
+ }
451
+ try {
452
+ // All publishers share the L1 network, so fees are fetched once rather than per publisher.
453
+ const fees = await this.publishers[0].getFeesPerGas(undefined, referenceSend.requirement.blobCount > 0, 0);
454
+ return { fees, required: computeSendCost(fees, referenceSend.requirement) };
455
+ } catch (err) {
456
+ if (this.shouldWarn('fee-read')) {
457
+ this.log.warn(`Failed to read L1 fees to check whether publishers can afford a ${referenceSend.name}`, { err });
458
+ }
459
+ return undefined;
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Logs a summary of all balances on the first check after a start, and an error (at most once per interval) while no
465
+ * publisher can afford the reference send.
466
+ */
467
+ private logBalances(
468
+ publishers: PublisherWithBalance<UtilsType>[],
469
+ snapshot: PublisherBalanceSnapshot,
470
+ fees: FeesPerGas | undefined,
471
+ ): void {
472
+ const firstCheck = !this.loggedBalanceSummary;
473
+ this.loggedBalanceSummary = true;
474
+ const { referenceSend } = this;
475
+ const { required } = snapshot;
476
+ const context = {
477
+ publishers: snapshot.publishers.map(p => ({
478
+ address: p.address,
479
+ balance: formatEther(p.balance),
480
+ sendsAffordable: p.sendsAffordable,
481
+ })),
482
+ referenceSend: referenceSend?.name,
483
+ required: required === undefined ? undefined : formatEther(required),
484
+ funder: snapshot.funder && { address: snapshot.funder.address, balance: formatEther(snapshot.funder.balance) },
485
+ };
486
+
487
+ const allRead = publishers.length === this.publishers.length;
488
+ const noneAffordable =
489
+ referenceSend !== undefined &&
490
+ required !== undefined &&
491
+ fees !== undefined &&
492
+ allRead &&
493
+ snapshot.publishers.length > 0 &&
494
+ snapshot.publishers.every(p => (p.sendsAffordable ?? 0) < 1);
495
+
496
+ if (!noneAffordable) {
497
+ // Only a publisher seen affording the send ends an episode. A check that could not tell (failed reads) must not
498
+ // reset the rate limit, or alternating failed and empty checks would log the error on every empty one.
499
+ if (snapshot.publishers.some(p => (p.sendsAffordable ?? 0) >= 1)) {
500
+ this.lastLowBalanceWarningAt.delete(NO_AFFORDABLE_PUBLISHER_WARNING_KEY);
501
+ }
502
+ if (firstCheck) {
503
+ this.log.info(`Publisher balances`, context);
504
+ }
203
505
  return;
204
506
  }
205
507
 
206
- const fundingAmount = config.publisherFundingAmount!;
207
- const funderBalance = await funder.getSenderBalance();
508
+ if (this.shouldWarn(NO_AFFORDABLE_PUBLISHER_WARNING_KEY)) {
509
+ const err = new NoAffordablePublisherError(
510
+ required,
511
+ referenceSend.requirement,
512
+ fees,
513
+ this.toBalanceInfo(publishers),
514
+ );
515
+ this.log.error(`No publisher can afford a ${referenceSend.name} at current L1 fees: ${err.describeFunding()}`, {
516
+ ...context,
517
+ shortfall: err.shortfall(),
518
+ });
519
+ }
520
+ }
521
+
522
+ /** Warns at most once per interval for every publisher below the low-balance level. */
523
+ private warnOnLowBalances(publishers: PublisherWithBalance<UtilsType>[], required: bigint): void {
524
+ const multiplier = this.config.publisherLowBalanceWarningMultiplier ?? DEFAULT_LOW_BALANCE_WARNING_MULTIPLIER;
525
+ const warningLevel = (required * BigInt(Math.round(multiplier * 1000))) / 1000n;
526
+
527
+ for (const { publisher, balance } of publishers) {
528
+ if (balance >= warningLevel) {
529
+ continue;
530
+ }
531
+ const address = publisher.getSenderAddress().toString();
532
+ if (this.shouldWarn(address)) {
533
+ this.log.warn(`Publisher balance is low relative to the ETH required for an L1 send`, {
534
+ address,
535
+ balance,
536
+ required,
537
+ multiplier,
538
+ warningLevel,
539
+ });
540
+ }
541
+ }
542
+
543
+ const { publisherFundingThreshold } = this.config;
544
+ if (
545
+ this.funder &&
546
+ publisherFundingThreshold !== undefined &&
547
+ publisherFundingThreshold < warningLevel &&
548
+ this.shouldWarn('funding-threshold')
549
+ ) {
550
+ this.log.warn(`Publisher funding threshold is below the low-balance warning level`, {
551
+ publisherFundingThreshold,
552
+ warningLevel,
553
+ required,
554
+ multiplier,
555
+ });
556
+ }
557
+ }
558
+
559
+ private shouldWarn(key: string): boolean {
560
+ const now = Date.now();
561
+ const last = this.lastLowBalanceWarningAt.get(key);
562
+ if (last !== undefined && now - last < LOW_BALANCE_WARNING_INTERVAL_MS) {
563
+ return false;
564
+ }
565
+ this.lastLowBalanceWarningAt.set(key, now);
566
+ return true;
567
+ }
568
+
569
+ /** Funds the publishers below the funding threshold, using the balances read in the same balance check. */
570
+ private async fundIfNeeded(
571
+ publishers: PublisherWithBalance<UtilsType>[],
572
+ funderBalance: bigint | undefined,
573
+ ): Promise<void> {
574
+ const { funder, config } = this;
575
+ const { publisherFundingThreshold, publisherFundingAmount: fundingAmount } = config;
576
+ if (!funder || publisherFundingThreshold === undefined || fundingAmount === undefined) {
577
+ return;
578
+ }
579
+
580
+ const lowBalance = publishers.filter(p => p.balance < publisherFundingThreshold);
581
+ if (lowBalance.length === 0 || funderBalance === undefined) {
582
+ return;
583
+ }
208
584
 
209
585
  if (funderBalance < 10n * fundingAmount) {
210
586
  this.log.warn(`Funding account balance is low`, { funderBalance, threshold: 10n * fundingAmount });
@@ -222,18 +598,17 @@ export class PublisherManager<UtilsType extends L1TxUtils = L1TxUtils> {
222
598
  }
223
599
 
224
600
  const toFund = lowBalance.slice(0, affordableCount).map(p => p.publisher);
225
- await this.fundPublishers(toFund);
601
+ await this.fundPublishers(funder, toFund, fundingAmount);
226
602
  }
227
603
 
228
604
  /** Fund publishers via a single Multicall3 aggregate3Value transaction. */
229
- private async fundPublishers(publishers: UtilsType[]): Promise<void> {
230
- const fundingAmount = this.config.publisherFundingAmount!;
605
+ private async fundPublishers(funder: UtilsType, publishers: UtilsType[], fundingAmount: bigint): Promise<void> {
231
606
  const calls = publishers.map(pub => ({
232
607
  to: pub.getSenderAddress().toString(),
233
608
  value: fundingAmount,
234
609
  }));
235
610
 
236
- await Multicall3.forwardValue(calls, this.funder!, this.log);
611
+ await Multicall3.forwardValue(calls, funder, this.log);
237
612
  this.log.info(`Funded ${publishers.length} publishers`);
238
613
  }
239
614
  }