@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,21 +1,21 @@
1
- import { pick } from '@aztec-labs/foundation/collection';
1
+ import { maxBy, pick } from '@aztec-labs/foundation/collection';
2
2
  import { createLogger } from '@aztec-labs/foundation/log';
3
3
  import { RunningPromise } from '@aztec-labs/foundation/running-promise';
4
+ import { formatEther } from 'viem';
4
5
  import { Multicall3 } from './contracts/multicall.js';
5
- import { TxUtilsState } from './l1_tx_utils/index.js';
6
- // Defines the order in which we prioritise publishers based on their state (first is better)
7
- const sortOrder = [
8
- // Always prefer sending from idle publishers
9
- TxUtilsState.IDLE,
10
- // Then from publishers that have sent a tx and it got mined
11
- TxUtilsState.MINED,
12
- // Then from publishers that have sent a tx but it's in-flight
13
- TxUtilsState.SPEED_UP,
14
- TxUtilsState.SENT,
15
- // We leave cancelled and not-mined states for last, since these represent failures to mines and could be problematic
16
- TxUtilsState.CANCELLED,
17
- TxUtilsState.NOT_MINED
18
- ];
6
+ import { INSUFFICIENT_FUNDS_BACKOFF_MS, TxUtilsState, computeSendCost } from './l1_tx_utils/index.js';
7
+ /**
8
+ * Selection rank per publisher state (lower is better). IDLE and MINED rank equally: a publisher whose sends keep
9
+ * failing (e.g. for lack of funds) never leaves IDLE, so ranking IDLE above MINED would let it beat a working one.
10
+ * In-flight states come next, and cancelled or not-mined states last since they represent failures to mine.
11
+ */ const stateRank = {
12
+ [TxUtilsState.IDLE]: 0,
13
+ [TxUtilsState.MINED]: 0,
14
+ [TxUtilsState.SPEED_UP]: 1,
15
+ [TxUtilsState.SENT]: 1,
16
+ [TxUtilsState.CANCELLED]: 2,
17
+ [TxUtilsState.NOT_MINED]: 2
18
+ };
19
19
  // Which states represent a busy publisher that we should avoid if possible
20
20
  const busyStates = [
21
21
  TxUtilsState.SENT,
@@ -23,22 +23,75 @@ const busyStates = [
23
23
  TxUtilsState.CANCELLED,
24
24
  TxUtilsState.NOT_MINED
25
25
  ];
26
+ const LOW_BALANCE_WARNING_INTERVAL_MS = 10 * 60 * 1000;
27
+ const DEFAULT_LOW_BALANCE_WARNING_MULTIPLIER = 4;
28
+ const NO_AFFORDABLE_PUBLISHER_WARNING_KEY = 'no-affordable-publisher';
29
+ /** Thrown when publishers are available but none holds enough ETH to afford the requested L1 send. */ export class NoAffordablePublisherError extends Error {
30
+ required;
31
+ requirement;
32
+ fees;
33
+ publishers;
34
+ constructor(required, requirement, fees, publishers){
35
+ super(`No publisher can afford the L1 send: requires ${formatEther(required)} ETH ` + `(balances: ${publishers.map((p)=>`${p.address}=${formatEther(p.balance)}`).join(', ') || 'none'})`), this.required = required, this.requirement = requirement, this.fees = fees, this.publishers = publishers;
36
+ this.name = 'NoAffordablePublisherError';
37
+ }
38
+ /**
39
+ * ETH the best funded publisher that is not backed off after an insufficient funds rejection is missing to afford the
40
+ * send. Undefined if every publisher is backed off.
41
+ */ shortfall() {
42
+ const richest = this.getRichestNotBackedOff();
43
+ return richest === undefined ? undefined : this.required - richest.balance;
44
+ }
45
+ /** Tells the operator which publisher to fund and with how much ETH for the send to become affordable. */ describeFunding() {
46
+ const richest = this.getRichestNotBackedOff();
47
+ if (richest === undefined || this.required <= richest.balance) {
48
+ const minutes = INSUFFICIENT_FUNDS_BACKOFF_MS / 60_000;
49
+ return `every publisher is backed off after an insufficient funds rejection ` + `until its balance increases or ${minutes} minutes pass`;
50
+ }
51
+ const shortfall = formatEther(this.required - richest.balance);
52
+ const others = this.publishers.filter((p)=>p.address !== richest.address);
53
+ const alternative = others.length === 0 ? '' : `, or top up any of ${others.map((p)=>p.address).join(', ')} to ${formatEther(this.required)} ETH`;
54
+ return `fund ${richest.address} with ${shortfall} ETH more${alternative}`;
55
+ }
56
+ /** Structured context for logging. */ toLogContext() {
57
+ return {
58
+ required: this.required,
59
+ shortfall: this.shortfall(),
60
+ gasLimit: this.requirement.gasLimit,
61
+ blobCount: this.requirement.blobCount,
62
+ maxFeePerGas: this.fees.maxFeePerGas,
63
+ maxFeePerBlobGas: this.fees.maxFeePerBlobGas,
64
+ publishers: this.publishers
65
+ };
66
+ }
67
+ getRichestNotBackedOff() {
68
+ return maxBy(this.publishers.filter((p)=>p.insufficientFundsAtBalance === undefined), (p)=>p.balance);
69
+ }
70
+ }
26
71
  export class PublisherManager {
27
72
  publishers;
73
+ static BALANCE_CHECK_INTERVAL_MS = 60 * 1000;
28
74
  log;
29
75
  config;
30
- static FUNDING_CHECK_INTERVAL_MS = 2 * 60 * 1000;
31
76
  funder;
32
- fundingPromise;
77
+ balanceCheckPromise;
33
78
  started;
79
+ metrics;
80
+ referenceSend;
81
+ /** Whether the balance check has run since the last start, so the first one logs a summary of all balances. */ loggedBalanceSummary;
82
+ /** Last time a balance warning was logged, per publisher address or per kind of warning. */ lastLowBalanceWarningAt;
34
83
  constructor(publishers, config, opts){
35
84
  this.publishers = publishers;
36
85
  this.started = false;
86
+ this.loggedBalanceSummary = false;
87
+ this.lastLowBalanceWarningAt = new Map();
37
88
  this.funder = opts?.funder;
89
+ this.metrics = opts?.metrics;
90
+ this.referenceSend = opts?.referenceSend;
38
91
  this.log = createLogger('publisher:manager', opts?.bindings);
39
92
  this.log.info(`PublisherManager initialized with ${publishers.length} publishers.`);
40
93
  this.publishers = publishers;
41
- this.config = pick(config, 'publisherAllowInvalidStates', 'publisherFundingThreshold', 'publisherFundingAmount');
94
+ this.config = pick(config, 'publisherAllowInvalidStates', 'publisherFundingThreshold', 'publisherFundingAmount', 'publisherLowBalanceWarningMultiplier');
42
95
  const hasThreshold = this.config.publisherFundingThreshold !== undefined;
43
96
  const hasAmount = this.config.publisherFundingAmount !== undefined;
44
97
  if (hasThreshold !== hasAmount) {
@@ -51,16 +104,14 @@ export class PublisherManager {
51
104
  this.funder = undefined;
52
105
  }
53
106
  }
54
- if (this.funder && hasThreshold && hasAmount) {
55
- this.fundingPromise = new RunningPromise(()=>this.triggerFundingIfNeeded(), this.log, PublisherManager.FUNDING_CHECK_INTERVAL_MS);
56
- }
107
+ this.balanceCheckPromise = new RunningPromise(()=>this.checkBalances(), this.log, PublisherManager.BALANCE_CHECK_INTERVAL_MS);
57
108
  }
58
109
  /**
59
110
  * Clears any interrupted flag left by a previous {@link stop} so publishing works again after a restart,
60
- * loads the state of all publishers and the funder, and starts periodic funding checks. Idempotent: a
61
- * start while already started is a no-op, so it never re-runs `loadStateAndResumeMonitoring` (which
62
- * would spawn a duplicate background monitor per pending nonce). Lifecycle calls are expected to be
63
- * serialized by the caller.
111
+ * loads the state of all publishers and the funder, and starts the periodic balance check, which also tops up
112
+ * publishers if funding is configured. Idempotent: a start while already started is a no-op, so it never re-runs
113
+ * `loadStateAndResumeMonitoring` (which would spawn a duplicate background monitor per pending nonce). Lifecycle
114
+ * calls are expected to be serialized by the caller.
64
115
  */ async start() {
65
116
  if (this.started) {
66
117
  this.log.debug('PublisherManager already started, ignoring start');
@@ -74,17 +125,20 @@ export class PublisherManager {
74
125
  ...this.publishers.map((pub)=>pub.loadStateAndResumeMonitoring()),
75
126
  this.funder?.loadStateAndResumeMonitoring()
76
127
  ]);
77
- this.fundingPromise?.start();
128
+ this.loggedBalanceSummary = false;
129
+ this.lastLowBalanceWarningAt.delete(NO_AFFORDABLE_PUBLISHER_WARNING_KEY);
130
+ this.balanceCheckPromise.start();
78
131
  // Marked started only once fully up, so a start that failed to load state can be retried.
79
132
  this.started = true;
80
133
  }
81
134
  /**
82
- * Stops the funding loop, interrupts all publishers so no further L1 txs are sent, and waits (bounded)
135
+ * Stops the balance check loop, interrupts all publishers so no further L1 txs are sent, and waits (bounded)
83
136
  * for their in-flight tx monitor loops to wind down. Idempotent, and the manager may be restarted
84
137
  * afterwards via {@link start}, which clears the interrupted flag.
85
138
  */ async stop() {
86
139
  this.started = false;
87
- await this.fundingPromise?.stop();
140
+ await this.balanceCheckPromise.stop();
141
+ this.metrics?.recordBalanceSnapshot(undefined);
88
142
  this.publishers.forEach((pub)=>pub.interrupt());
89
143
  this.funder?.interrupt();
90
144
  // Wait for in-flight tx monitor loops to observe the interrupt, so no L1 requests are still
@@ -94,76 +148,264 @@ export class PublisherManager {
94
148
  this.funder?.waitMonitoringStopped()
95
149
  ]);
96
150
  }
97
- // Finds and prioritises available publishers based on
98
- // 1. Validity as per the provided filter function
99
- // 2. Validity based on the state the publisher is in
100
- // 3. Priority based on state as defined by sortOrder
101
- // 4. Then priority based on highest balance
102
- // 5. Then priority based on least recently used
103
- async getAvailablePublisher(filter = ()=>true) {
151
+ /**
152
+ * Finds and prioritises an available publisher. Candidates must pass the filter and not be busy (unless
153
+ * `publisherAllowInvalidStates` is set and no idle publisher remains), and are ranked by state (see `stateRank`), then
154
+ * highest balance, then least recently used. Publishers backed off after an insufficient funds rejection are skipped
155
+ * until their balance increases or the backoff expires.
156
+ *
157
+ * With a `requirement`, only publishers whose balance covers the worst-case cost of that send are eligible, and a
158
+ * {@link NoAffordablePublisherError} is thrown if there are none. Without one, selection is lenient: it prefers funded
159
+ * publishers but falls back to unfunded ones rather than failing, for sends that are fine to attempt and lose.
160
+ */ async getAvailablePublisher(filter = ()=>true, opts) {
104
161
  this.log.debug(`Getting available publisher`, {
105
162
  publishers: this.publishers.map((p)=>({
106
163
  address: p.getSenderAddress(),
107
164
  state: p.state,
108
165
  lastMined: p.lastMinedAtBlockNumber
109
- }))
166
+ })),
167
+ requirement: opts?.requirement
110
168
  });
111
- // Extract the valid publishers
112
- let validPublishers = this.publishers.filter((pub)=>!busyStates.includes(pub.state) && filter(pub));
113
- // If none found but we allow invalid (busy) states, try again including them
114
- if (validPublishers.length === 0 && this.config.publisherAllowInvalidStates) {
115
- this.log.warn(`No valid publishers found. Trying again including invalid states.`);
116
- validPublishers = this.publishers.filter((pub)=>filter(pub));
117
- }
118
- // Error if none found
169
+ const filtered = this.publishers.filter((pub)=>filter(pub));
170
+ let validPublishers = filtered.filter((pub)=>!busyStates.includes(pub.state));
171
+ // If none found but we allow invalid (busy) states, try again including them. When the send has a requirement we
172
+ // also consider busy ones, so that an idle but unaffordable publisher does not hide a busy affordable one. Busy
173
+ // publishers still rank below idle ones.
174
+ if (this.config.publisherAllowInvalidStates && (validPublishers.length === 0 || opts?.requirement)) {
175
+ if (validPublishers.length === 0) {
176
+ this.log.warn(`No valid publishers found. Trying again including invalid states.`);
177
+ }
178
+ validPublishers = filtered;
179
+ }
119
180
  if (validPublishers.length === 0) {
120
181
  throw new Error(`Failed to find an available publisher.`);
121
182
  }
122
- // Get the balances
123
- const publishersWithBalance = await Promise.all(validPublishers.map(async (pub)=>{
124
- return {
125
- balance: await pub.getSenderBalance(),
126
- publisher: pub
127
- };
128
- }));
129
- // Discount unfunded publishers: a publisher with no ETH cannot send a tx, so it must never win
130
- // selection regardless of how favourable its state or last-used time is. Fall back to the full
131
- // set only if every candidate is unfunded, so behaviour is no worse than before.
132
- let fundedPublishers = publishersWithBalance.filter((p)=>p.balance > 0n);
133
- if (fundedPublishers.length === 0) {
134
- this.log.warn(`All candidate publishers have zero balance; selecting from unfunded publishers.`);
135
- fundedPublishers = publishersWithBalance;
136
- }
137
- // Sort based on state, then balance, then time since last use
138
- const sortedPublishers = fundedPublishers.sort((a, b)=>{
139
- const stateComparison = sortOrder.indexOf(a.publisher.state) - sortOrder.indexOf(b.publisher.state);
183
+ const withBalance = await this.getBalances(validPublishers);
184
+ let candidates;
185
+ if (opts?.requirement) {
186
+ candidates = await this.getAffordable(withBalance, opts.requirement);
187
+ } else {
188
+ candidates = this.getLenientCandidates(withBalance);
189
+ }
190
+ const sortedPublishers = candidates.sort((a, b)=>{
191
+ const stateComparison = stateRank[a.publisher.state] - stateRank[b.publisher.state];
140
192
  if (stateComparison !== 0) {
141
193
  return stateComparison;
142
194
  }
143
- const balanceComparison = Number(b.balance - a.balance);
144
- if (balanceComparison !== 0) {
145
- return balanceComparison;
195
+ if (a.balance !== b.balance) {
196
+ return b.balance > a.balance ? 1 : -1;
146
197
  }
147
198
  const lastUsedComparison = Number((a.publisher.lastMinedAtBlockNumber ?? 0n) - (b.publisher.lastMinedAtBlockNumber ?? 0n));
148
199
  return lastUsedComparison;
149
200
  });
150
201
  return sortedPublishers[0].publisher;
151
202
  }
152
- /** Check all publisher balances and fund those below threshold. */ async triggerFundingIfNeeded() {
203
+ /**
204
+ * Reads the balance of every publisher and the funder, prices the reference send at current fees, records metrics,
205
+ * logs balances that are low or cannot afford the reference send, and tops up publishers below the funding threshold
206
+ * if funding is configured. Runs once a minute while started.
207
+ */ async checkBalances() {
208
+ const funder = this.funder;
209
+ const [publisherBalances, funderBalance] = await Promise.all([
210
+ Promise.all(this.publishers.map((publisher)=>this.tryGetBalance(publisher))),
211
+ funder ? this.tryGetBalance(funder) : undefined
212
+ ]);
213
+ const withBalance = this.publishers.flatMap((publisher, i)=>{
214
+ const balance = publisherBalances[i];
215
+ return balance === undefined ? [] : [
216
+ {
217
+ publisher,
218
+ balance
219
+ }
220
+ ];
221
+ });
222
+ const cost = await this.tryGetReferenceCost();
223
+ const required = cost !== undefined && cost.required > 0n ? cost.required : undefined;
224
+ const snapshot = {
225
+ publishers: withBalance.map(({ publisher, balance })=>({
226
+ address: publisher.getSenderAddress().toString(),
227
+ balance,
228
+ sendsAffordable: required === undefined ? undefined : publisher.hasActiveInsufficientFundsBackoff(balance) ? 0 : Number(balance / required)
229
+ })),
230
+ funder: funder && funderBalance !== undefined ? {
231
+ address: funder.getSenderAddress().toString(),
232
+ balance: funderBalance
233
+ } : undefined,
234
+ required
235
+ };
236
+ this.metrics?.recordBalanceSnapshot(snapshot);
237
+ this.logBalances(withBalance, snapshot, cost?.fees);
238
+ if (required !== undefined) {
239
+ this.warnOnLowBalances(withBalance, required);
240
+ }
241
+ await this.fundIfNeeded(withBalance, funderBalance);
242
+ }
243
+ getBalances(publishers) {
244
+ return Promise.all(publishers.map(async (publisher)=>({
245
+ publisher,
246
+ balance: await publisher.getSenderBalance()
247
+ })));
248
+ }
249
+ /** Returns the publishers that can afford the requirement, or throws NoAffordablePublisherError if none. */ async getAffordable(publishers, requirement) {
250
+ if (publishers.length === 0) {
251
+ throw new Error(`Failed to find an available publisher.`);
252
+ }
253
+ // All publishers share the L1 network, so fees are fetched once rather than per publisher.
254
+ const fees = await publishers[0].publisher.getFeesPerGas(undefined, requirement.blobCount > 0, 0);
255
+ const required = computeSendCost(fees, requirement);
256
+ const affordable = publishers.filter((p)=>p.balance >= required && !p.publisher.isBackedOffForInsufficientFunds(p.balance));
257
+ if (affordable.length === 0) {
258
+ throw new NoAffordablePublisherError(required, requirement, fees, this.toBalanceInfo(publishers));
259
+ }
260
+ return affordable;
261
+ }
262
+ toBalanceInfo(publishers) {
263
+ return publishers.map(({ publisher, balance })=>({
264
+ address: publisher.getSenderAddress().toString(),
265
+ balance,
266
+ state: TxUtilsState[publisher.state],
267
+ insufficientFundsAtBalance: publisher.hasActiveInsufficientFundsBackoff(balance) ? publisher.getInsufficientFundsAtBalance() : undefined
268
+ }));
269
+ }
270
+ /**
271
+ * Prefers publishers that are funded and not backed off after an insufficient funds rejection, but never returns an
272
+ * empty list: if every candidate is excluded, falls back to the full set so lenient sends still have something to try.
273
+ */ getLenientCandidates(publishers) {
274
+ const usable = publishers.filter((p)=>p.balance > 0n && !p.publisher.isBackedOffForInsufficientFunds(p.balance));
275
+ if (usable.length > 0) {
276
+ return usable;
277
+ }
278
+ const funded = publishers.filter((p)=>p.balance > 0n);
279
+ if (funded.length > 0) {
280
+ return funded;
281
+ }
282
+ this.log.warn(`All candidate publishers have zero balance; selecting from unfunded publishers.`);
283
+ return publishers;
284
+ }
285
+ async tryGetBalance(utils) {
286
+ try {
287
+ return await utils.getSenderBalance();
288
+ } catch (err) {
289
+ const address = utils.getSenderAddress().toString();
290
+ if (this.shouldWarn(`balance-read:${address}`)) {
291
+ this.log.warn(`Failed to read L1 balance of account`, {
292
+ address,
293
+ err
294
+ });
295
+ }
296
+ return undefined;
297
+ }
298
+ }
299
+ async tryGetReferenceCost() {
300
+ const { referenceSend } = this;
301
+ if (referenceSend === undefined || this.publishers.length === 0) {
302
+ return undefined;
303
+ }
304
+ try {
305
+ // All publishers share the L1 network, so fees are fetched once rather than per publisher.
306
+ const fees = await this.publishers[0].getFeesPerGas(undefined, referenceSend.requirement.blobCount > 0, 0);
307
+ return {
308
+ fees,
309
+ required: computeSendCost(fees, referenceSend.requirement)
310
+ };
311
+ } catch (err) {
312
+ if (this.shouldWarn('fee-read')) {
313
+ this.log.warn(`Failed to read L1 fees to check whether publishers can afford a ${referenceSend.name}`, {
314
+ err
315
+ });
316
+ }
317
+ return undefined;
318
+ }
319
+ }
320
+ /**
321
+ * Logs a summary of all balances on the first check after a start, and an error (at most once per interval) while no
322
+ * publisher can afford the reference send.
323
+ */ logBalances(publishers, snapshot, fees) {
324
+ const firstCheck = !this.loggedBalanceSummary;
325
+ this.loggedBalanceSummary = true;
326
+ const { referenceSend } = this;
327
+ const { required } = snapshot;
328
+ const context = {
329
+ publishers: snapshot.publishers.map((p)=>({
330
+ address: p.address,
331
+ balance: formatEther(p.balance),
332
+ sendsAffordable: p.sendsAffordable
333
+ })),
334
+ referenceSend: referenceSend?.name,
335
+ required: required === undefined ? undefined : formatEther(required),
336
+ funder: snapshot.funder && {
337
+ address: snapshot.funder.address,
338
+ balance: formatEther(snapshot.funder.balance)
339
+ }
340
+ };
341
+ const allRead = publishers.length === this.publishers.length;
342
+ const noneAffordable = referenceSend !== undefined && required !== undefined && fees !== undefined && allRead && snapshot.publishers.length > 0 && snapshot.publishers.every((p)=>(p.sendsAffordable ?? 0) < 1);
343
+ if (!noneAffordable) {
344
+ // Only a publisher seen affording the send ends an episode. A check that could not tell (failed reads) must not
345
+ // reset the rate limit, or alternating failed and empty checks would log the error on every empty one.
346
+ if (snapshot.publishers.some((p)=>(p.sendsAffordable ?? 0) >= 1)) {
347
+ this.lastLowBalanceWarningAt.delete(NO_AFFORDABLE_PUBLISHER_WARNING_KEY);
348
+ }
349
+ if (firstCheck) {
350
+ this.log.info(`Publisher balances`, context);
351
+ }
352
+ return;
353
+ }
354
+ if (this.shouldWarn(NO_AFFORDABLE_PUBLISHER_WARNING_KEY)) {
355
+ const err = new NoAffordablePublisherError(required, referenceSend.requirement, fees, this.toBalanceInfo(publishers));
356
+ this.log.error(`No publisher can afford a ${referenceSend.name} at current L1 fees: ${err.describeFunding()}`, {
357
+ ...context,
358
+ shortfall: err.shortfall()
359
+ });
360
+ }
361
+ }
362
+ /** Warns at most once per interval for every publisher below the low-balance level. */ warnOnLowBalances(publishers, required) {
363
+ const multiplier = this.config.publisherLowBalanceWarningMultiplier ?? DEFAULT_LOW_BALANCE_WARNING_MULTIPLIER;
364
+ const warningLevel = required * BigInt(Math.round(multiplier * 1000)) / 1000n;
365
+ for (const { publisher, balance } of publishers){
366
+ if (balance >= warningLevel) {
367
+ continue;
368
+ }
369
+ const address = publisher.getSenderAddress().toString();
370
+ if (this.shouldWarn(address)) {
371
+ this.log.warn(`Publisher balance is low relative to the ETH required for an L1 send`, {
372
+ address,
373
+ balance,
374
+ required,
375
+ multiplier,
376
+ warningLevel
377
+ });
378
+ }
379
+ }
380
+ const { publisherFundingThreshold } = this.config;
381
+ if (this.funder && publisherFundingThreshold !== undefined && publisherFundingThreshold < warningLevel && this.shouldWarn('funding-threshold')) {
382
+ this.log.warn(`Publisher funding threshold is below the low-balance warning level`, {
383
+ publisherFundingThreshold,
384
+ warningLevel,
385
+ required,
386
+ multiplier
387
+ });
388
+ }
389
+ }
390
+ shouldWarn(key) {
391
+ const now = Date.now();
392
+ const last = this.lastLowBalanceWarningAt.get(key);
393
+ if (last !== undefined && now - last < LOW_BALANCE_WARNING_INTERVAL_MS) {
394
+ return false;
395
+ }
396
+ this.lastLowBalanceWarningAt.set(key, now);
397
+ return true;
398
+ }
399
+ /** Funds the publishers below the funding threshold, using the balances read in the same balance check. */ async fundIfNeeded(publishers, funderBalance) {
153
400
  const { funder, config } = this;
154
- if (!funder || config.publisherFundingThreshold === undefined || config.publisherFundingAmount === undefined) {
401
+ const { publisherFundingThreshold, publisherFundingAmount: fundingAmount } = config;
402
+ if (!funder || publisherFundingThreshold === undefined || fundingAmount === undefined) {
155
403
  return;
156
404
  }
157
- const allBalances = await Promise.all(this.publishers.map(async (pub)=>({
158
- balance: await pub.getSenderBalance(),
159
- publisher: pub
160
- })));
161
- const lowBalance = allBalances.filter((p)=>p.balance < config.publisherFundingThreshold);
162
- if (lowBalance.length === 0) {
405
+ const lowBalance = publishers.filter((p)=>p.balance < publisherFundingThreshold);
406
+ if (lowBalance.length === 0 || funderBalance === undefined) {
163
407
  return;
164
408
  }
165
- const fundingAmount = config.publisherFundingAmount;
166
- const funderBalance = await funder.getSenderBalance();
167
409
  if (funderBalance < 10n * fundingAmount) {
168
410
  this.log.warn(`Funding account balance is low`, {
169
411
  funderBalance,
@@ -185,15 +427,14 @@ export class PublisherManager {
185
427
  });
186
428
  }
187
429
  const toFund = lowBalance.slice(0, affordableCount).map((p)=>p.publisher);
188
- await this.fundPublishers(toFund);
430
+ await this.fundPublishers(funder, toFund, fundingAmount);
189
431
  }
190
- /** Fund publishers via a single Multicall3 aggregate3Value transaction. */ async fundPublishers(publishers) {
191
- const fundingAmount = this.config.publisherFundingAmount;
432
+ /** Fund publishers via a single Multicall3 aggregate3Value transaction. */ async fundPublishers(funder, publishers, fundingAmount) {
192
433
  const calls = publishers.map((pub)=>({
193
434
  to: pub.getSenderAddress().toString(),
194
435
  value: fundingAmount
195
436
  }));
196
- await Multicall3.forwardValue(calls, this.funder, this.log);
437
+ await Multicall3.forwardValue(calls, funder, this.log);
197
438
  this.log.info(`Funded ${publishers.length} publishers`);
198
439
  }
199
440
  }
@@ -0,0 +1,42 @@
1
+ import { type EIP1193RequestFn } from 'viem';
2
+ import type { EthCheatCodes } from './eth_cheat_codes.js';
3
+ import { type Anvil, startAnvil } from './start_anvil.js';
4
+ /**
5
+ * Starts a throwaway anvil on `hardfork` and checks that it mines blocks of that fork, so a test that needs a fork
6
+ * the installed foundry cannot run fails within seconds instead of after its whole setup. Never skips.
7
+ */
8
+ export declare function assertAnvilSupportsHardfork(hardfork: string): Promise<void>;
9
+ /**
10
+ * Writes anvil's state, including the historical states it still keeps, as the plain JSON file `--load-state` reads.
11
+ * Without history, reads at blocks before a restart fail on the reloaded anvil. A dump with history easily exceeds
12
+ * viem's default response size limit, so `client` must be one built without it, such as `createExtendedL1Client`'s.
13
+ */
14
+ export declare function dumpAnvilStateWithHistory(client: {
15
+ request: EIP1193RequestFn;
16
+ }, path: string): Promise<void>;
17
+ /**
18
+ * Moves an interval-mining anvil to another hardfork, continuing its chain. Anvil cannot change hardfork at runtime,
19
+ * so this stops mining, dumps the state with history to `stateDir` using `dumpClient`, stops `anvil`, and starts a new
20
+ * anvil on the same port from the dump with `startOpts`. Mining stays stopped until the old anvil is gone, so no block
21
+ * mined after the dump is lost. Pending txs are lost, since the mempool is not part of the dump.
22
+ *
23
+ * The reloaded anvil would stamp its next block as if no time had passed since the dump, so the first block on the new
24
+ * hardfork is mined at the first L1 slot of the old chain's grid that is not in the past, and interval mining resumes
25
+ * from there.
26
+ *
27
+ * @returns The new anvil, which replaces `anvil`, and the first block mined on `hardfork`.
28
+ */
29
+ export declare function restartAnvilOnHardfork(anvil: Anvil, cheat: EthCheatCodes, dumpClient: {
30
+ request: EIP1193RequestFn;
31
+ }, opts: {
32
+ hardfork: string;
33
+ stateDir: string;
34
+ startOpts?: Parameters<typeof startAnvil>[0];
35
+ }): Promise<{
36
+ anvil: Anvil;
37
+ forkBlock: {
38
+ number: bigint;
39
+ timestamp: bigint;
40
+ };
41
+ }>;
42
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYW52aWxfaGFyZGZvcmsuZC50cyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3NyYy90ZXN0L2FudmlsX2hhcmRmb3JrLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUlBLE9BQU8sRUFBRSxLQUFLLGdCQUFnQixFQUFzQyxNQUFNLE1BQU0sQ0FBQztBQUlqRixPQUFPLEtBQUssRUFBRSxhQUFhLEVBQUUsTUFBTSxzQkFBc0IsQ0FBQztBQUMxRCxPQUFPLEVBQUUsS0FBSyxLQUFLLEVBQUUsVUFBVSxFQUFFLE1BQU0sa0JBQWtCLENBQUM7QUFFMUQ7OztHQUdHO0FBQ0gsd0JBQXNCLDJCQUEyQixDQUFDLFFBQVEsRUFBRSxNQUFNLEdBQUcsT0FBTyxDQUFDLElBQUksQ0FBQyxDQWtCakY7QUFFRDs7OztHQUlHO0FBQ0gsd0JBQXNCLHlCQUF5QixDQUFDLE1BQU0sRUFBRTtJQUFFLE9BQU8sRUFBRSxnQkFBZ0IsQ0FBQTtDQUFFLEVBQUUsSUFBSSxFQUFFLE1BQU0sR0FBRyxPQUFPLENBQUMsSUFBSSxDQUFDLENBU2xIO0FBRUQ7Ozs7Ozs7Ozs7O0dBV0c7QUFDSCx3QkFBc0Isc0JBQXNCLENBQzFDLEtBQUssRUFBRSxLQUFLLEVBQ1osS0FBSyxFQUFFLGFBQWEsRUFDcEIsVUFBVSxFQUFFO0lBQUUsT0FBTyxFQUFFLGdCQUFnQixDQUFBO0NBQUUsRUFDekMsSUFBSSxFQUFFO0lBQUUsUUFBUSxFQUFFLE1BQU0sQ0FBQztJQUFDLFFBQVEsRUFBRSxNQUFNLENBQUM7SUFBQyxTQUFTLENBQUMsRUFBRSxVQUFVLENBQUMsT0FBTyxVQUFVLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQTtDQUFFLEdBQ3pGLE9BQU8sQ0FBQztJQUFFLEtBQUssRUFBRSxLQUFLLENBQUM7SUFBQyxTQUFTLEVBQUU7UUFBRSxNQUFNLEVBQUUsTUFBTSxDQUFDO1FBQUMsU0FBUyxFQUFFLE1BQU0sQ0FBQTtLQUFFLENBQUE7Q0FBRSxDQUFDLENBMEI3RSJ9
@@ -0,0 +1 @@
1
+ {"version":3,"file":"anvil_hardfork.d.ts","sourceRoot":"","sources":["../../src/test/anvil_hardfork.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,gBAAgB,EAAsC,MAAM,MAAM,CAAC;AAIjF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAC1D,OAAO,EAAE,KAAK,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAE1D;;;GAGG;AACH,wBAAsB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAkBjF;AAED;;;;GAIG;AACH,wBAAsB,yBAAyB,CAAC,MAAM,EAAE;IAAE,OAAO,EAAE,gBAAgB,CAAA;CAAE,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CASlH;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,sBAAsB,CAC1C,KAAK,EAAE,KAAK,EACZ,KAAK,EAAE,aAAa,EACpB,UAAU,EAAE;IAAE,OAAO,EAAE,gBAAgB,CAAA;CAAE,EACzC,IAAI,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC,CAAC,CAAC,CAAA;CAAE,GACzF,OAAO,CAAC;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,SAAS,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC,CA0B7E"}
@@ -0,0 +1,107 @@
1
+ import { Buffer } from 'buffer';
2
+ import { writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { gunzipSync } from 'node:zlib';
5
+ import { createPublicClient, http } from 'viem';
6
+ import { resolveFoundryBinary } from '../foundry_binary.js';
7
+ import { isAmsterdamBlock } from '../l1_tx_utils/amsterdam.js';
8
+ import { startAnvil } from './start_anvil.js';
9
+ /**
10
+ * Starts a throwaway anvil on `hardfork` and checks that it mines blocks of that fork, so a test that needs a fork
11
+ * the installed foundry cannot run fails within seconds instead of after its whole setup. Never skips.
12
+ */ export async function assertAnvilSupportsHardfork(hardfork) {
13
+ let res;
14
+ try {
15
+ res = await startAnvil({
16
+ port: 0,
17
+ hardfork
18
+ });
19
+ const client = createPublicClient({
20
+ transport: http(res.rpcUrl, {
21
+ batch: false
22
+ })
23
+ });
24
+ await client.request({
25
+ method: 'evm_mine',
26
+ params: []
27
+ });
28
+ const block = await client.getBlock({
29
+ blockTag: 'latest'
30
+ });
31
+ if (isAmsterdamBlock(block) !== (hardfork === 'amsterdam')) {
32
+ throw new Error(`anvil --hardfork ${hardfork} mined block ${block.number} with the wrong header shape`);
33
+ }
34
+ } catch (err) {
35
+ throw new Error(`Anvil at ${resolveFoundryBinary('anvil')} cannot run --hardfork ${hardfork}; foundry >= 1.8.5 is required`, {
36
+ cause: err
37
+ });
38
+ } finally{
39
+ await res?.stop();
40
+ }
41
+ }
42
+ /**
43
+ * Writes anvil's state, including the historical states it still keeps, as the plain JSON file `--load-state` reads.
44
+ * Without history, reads at blocks before a restart fail on the reloaded anvil. A dump with history easily exceeds
45
+ * viem's default response size limit, so `client` must be one built without it, such as `createExtendedL1Client`'s.
46
+ */ export async function dumpAnvilStateWithHistory(client, path) {
47
+ const hex = await client.request({
48
+ method: 'anvil_dumpState',
49
+ params: [
50
+ true
51
+ ]
52
+ });
53
+ const dump = Buffer.from(hex.slice(2), 'hex');
54
+ const isGzip = dump[0] === 0x1f && dump[1] === 0x8b;
55
+ // Kept as a Buffer: a dump with history can exceed the maximum length of a V8 string.
56
+ await writeFile(path, isGzip ? gunzipSync(dump) : dump);
57
+ }
58
+ /**
59
+ * Moves an interval-mining anvil to another hardfork, continuing its chain. Anvil cannot change hardfork at runtime,
60
+ * so this stops mining, dumps the state with history to `stateDir` using `dumpClient`, stops `anvil`, and starts a new
61
+ * anvil on the same port from the dump with `startOpts`. Mining stays stopped until the old anvil is gone, so no block
62
+ * mined after the dump is lost. Pending txs are lost, since the mempool is not part of the dump.
63
+ *
64
+ * The reloaded anvil would stamp its next block as if no time had passed since the dump, so the first block on the new
65
+ * hardfork is mined at the first L1 slot of the old chain's grid that is not in the past, and interval mining resumes
66
+ * from there.
67
+ *
68
+ * @returns The new anvil, which replaces `anvil`, and the first block mined on `hardfork`.
69
+ */ export async function restartAnvilOnHardfork(anvil, cheat, dumpClient, opts) {
70
+ const blockTimeS = await cheat.getIntervalMining();
71
+ if (!blockTimeS) {
72
+ throw new Error('restartAnvilOnHardfork needs an anvil in interval mining');
73
+ }
74
+ await cheat.setIntervalMining(0, {
75
+ silent: true
76
+ });
77
+ const tip = await cheat.publicClient.getBlock({
78
+ blockTag: 'latest'
79
+ });
80
+ const statePath = join(opts.stateDir, `anvil-state-${tip.number}.json`);
81
+ await dumpAnvilStateWithHistory(dumpClient, statePath);
82
+ await anvil.stop();
83
+ const restarted = await startAnvil({
84
+ ...opts.startOpts,
85
+ port: anvil.port,
86
+ hardfork: opts.hardfork,
87
+ loadState: statePath,
88
+ l1BlockTime: blockTimeS
89
+ });
90
+ const blockTime = BigInt(blockTimeS);
91
+ const nowS = BigInt(Math.ceil(cheat.dateProvider.now() / 1000));
92
+ const slotsSinceTip = nowS > tip.timestamp ? (nowS - tip.timestamp + blockTime - 1n) / blockTime : 1n;
93
+ await cheat.warp(tip.timestamp + blockTime * slotsSinceTip, {
94
+ silent: true,
95
+ resetBlockInterval: true
96
+ });
97
+ const forkBlock = await cheat.publicClient.getBlock({
98
+ blockNumber: tip.number + 1n
99
+ });
100
+ return {
101
+ anvil: restarted.anvil,
102
+ forkBlock: {
103
+ number: forkBlock.number,
104
+ timestamp: forkBlock.timestamp
105
+ }
106
+ };
107
+ }