@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.
- package/dest/blob_tx.d.ts +24 -0
- package/dest/blob_tx.d.ts.map +1 -0
- package/dest/blob_tx.js +94 -0
- package/dest/bn254/hash_to_point.d.ts +32 -0
- package/dest/bn254/hash_to_point.d.ts.map +1 -0
- package/dest/bn254/hash_to_point.js +80 -0
- package/dest/bn254/index.d.ts +3 -0
- package/dest/bn254/index.d.ts.map +1 -0
- package/dest/bn254/index.js +2 -0
- package/dest/bn254/proof_of_possession_gas.d.ts +29 -0
- package/dest/bn254/proof_of_possession_gas.d.ts.map +1 -0
- package/dest/bn254/proof_of_possession_gas.js +24 -0
- package/dest/client.d.ts +1 -1
- package/dest/client.d.ts.map +1 -1
- package/dest/client.js +5 -3
- package/dest/contracts/errors.d.ts +10 -3
- package/dest/contracts/errors.d.ts.map +1 -1
- package/dest/contracts/errors.js +10 -4
- package/dest/contracts/inbox.d.ts +1 -1
- package/dest/contracts/inbox.d.ts.map +1 -1
- package/dest/contracts/inbox.js +2 -0
- package/dest/contracts/log.d.ts +7 -1
- package/dest/contracts/log.d.ts.map +1 -1
- package/dest/contracts/log.js +6 -0
- package/dest/contracts/rollup.d.ts +32 -4
- package/dest/contracts/rollup.d.ts.map +1 -1
- package/dest/contracts/rollup.js +63 -7
- package/dest/deploy_aztec_l1_contracts.d.ts +16 -1
- package/dest/deploy_aztec_l1_contracts.d.ts.map +1 -1
- package/dest/deploy_aztec_l1_contracts.js +201 -65
- package/dest/foundry_binary.d.ts +1 -1
- package/dest/foundry_binary.d.ts.map +1 -1
- package/dest/foundry_binary.js +2 -1
- package/dest/l1_tx_utils/amsterdam.d.ts +1 -1
- package/dest/l1_tx_utils/amsterdam.d.ts.map +1 -1
- package/dest/l1_tx_utils/amsterdam.js +4 -1
- package/dest/l1_tx_utils/constants.d.ts +2 -1
- package/dest/l1_tx_utils/constants.d.ts.map +1 -1
- package/dest/l1_tx_utils/constants.js +3 -0
- package/dest/l1_tx_utils/fee-strategies/p75_competitive.d.ts +1 -1
- package/dest/l1_tx_utils/fee-strategies/p75_competitive.d.ts.map +1 -1
- package/dest/l1_tx_utils/fee-strategies/p75_competitive.js +6 -1
- package/dest/l1_tx_utils/index.d.ts +2 -1
- package/dest/l1_tx_utils/index.d.ts.map +1 -1
- package/dest/l1_tx_utils/index.js +1 -0
- package/dest/l1_tx_utils/l1_tx_utils.d.ts +21 -1
- package/dest/l1_tx_utils/l1_tx_utils.d.ts.map +1 -1
- package/dest/l1_tx_utils/l1_tx_utils.js +71 -5
- package/dest/l1_tx_utils/readonly_l1_tx_utils.d.ts +8 -1
- package/dest/l1_tx_utils/readonly_l1_tx_utils.d.ts.map +1 -1
- package/dest/l1_tx_utils/readonly_l1_tx_utils.js +1 -1
- package/dest/l1_tx_utils/send_cost.d.ts +15 -0
- package/dest/l1_tx_utils/send_cost.d.ts.map +1 -0
- package/dest/l1_tx_utils/send_cost.js +10 -0
- package/dest/l1_tx_utils/signer.d.ts +2 -2
- package/dest/l1_tx_utils/signer.d.ts.map +1 -1
- package/dest/l1_tx_utils/signer.js +2 -2
- package/dest/l1_tx_utils/tx_delayer.d.ts +1 -1
- package/dest/l1_tx_utils/tx_delayer.d.ts.map +1 -1
- package/dest/l1_tx_utils/tx_delayer.js +8 -26
- package/dest/publisher_manager.d.ts +127 -13
- package/dest/publisher_manager.d.ts.map +1 -1
- package/dest/publisher_manager.js +320 -79
- package/dest/test/anvil_hardfork.d.ts +42 -0
- package/dest/test/anvil_hardfork.d.ts.map +1 -0
- package/dest/test/anvil_hardfork.js +107 -0
- package/dest/test/eth_cheat_codes.d.ts +30 -5
- package/dest/test/eth_cheat_codes.d.ts.map +1 -1
- package/dest/test/eth_cheat_codes.js +283 -101
- package/dest/test/index.d.ts +3 -1
- package/dest/test/index.d.ts.map +1 -1
- package/dest/test/index.js +2 -0
- package/dest/test/l1_hardfork.d.ts +43 -0
- package/dest/test/l1_hardfork.d.ts.map +1 -0
- package/dest/test/l1_hardfork.js +51 -0
- package/dest/test/recording_rpc_server.d.ts +1 -1
- package/dest/test/recording_rpc_server.d.ts.map +1 -1
- package/dest/test/recording_rpc_server.js +1 -0
- package/dest/test/rollup_cheat_codes.d.ts +1 -1
- package/dest/test/rollup_cheat_codes.d.ts.map +1 -1
- package/dest/test/rollup_cheat_codes.js +7 -7
- package/dest/test/start_anvil.d.ts +14 -2
- package/dest/test/start_anvil.d.ts.map +1 -1
- package/dest/test/start_anvil.js +28 -11
- package/package.json +13 -11
- package/src/blob_tx.ts +118 -0
- package/src/bn254/hash_to_point.ts +95 -0
- package/src/bn254/index.ts +2 -0
- package/src/bn254/proof_of_possession_gas.ts +54 -0
- package/src/client.ts +6 -3
- package/src/contracts/errors.ts +12 -4
- package/src/contracts/inbox.ts +4 -0
- package/src/contracts/log.ts +12 -0
- package/src/contracts/rollup.ts +63 -6
- package/src/deploy_aztec_l1_contracts.ts +238 -63
- package/src/foundry_binary.ts +2 -1
- package/src/l1_tx_utils/amsterdam.ts +4 -1
- package/src/l1_tx_utils/constants.ts +4 -0
- package/src/l1_tx_utils/fee-strategies/p75_competitive.ts +11 -1
- package/src/l1_tx_utils/index.ts +1 -0
- package/src/l1_tx_utils/l1_tx_utils.ts +72 -5
- package/src/l1_tx_utils/readonly_l1_tx_utils.ts +1 -1
- package/src/l1_tx_utils/send_cost.ts +21 -0
- package/src/l1_tx_utils/signer.ts +3 -2
- package/src/l1_tx_utils/tx_delayer.ts +8 -37
- package/src/publisher_manager.ts +458 -83
- package/src/test/anvil_hardfork.ts +95 -0
- package/src/test/eth_cheat_codes.ts +306 -81
- package/src/test/fixtures/sepolia_blob_tx.json +10 -0
- package/src/test/index.ts +2 -0
- package/src/test/l1_hardfork.ts +71 -0
- package/src/test/recording_rpc_server.ts +1 -0
- package/src/test/rollup_cheat_codes.ts +9 -9
- package/src/test/start_anvil.ts +41 -13
package/src/publisher_manager.ts
CHANGED
|
@@ -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 {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
TxUtilsState
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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
|
|
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?: {
|
|
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(
|
|
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
|
-
|
|
73
|
-
this.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
84
|
-
* start while already started is a no-op, so it never re-runs
|
|
85
|
-
* would spawn a duplicate background monitor per pending nonce). Lifecycle
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
143
|
-
let validPublishers =
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
/**
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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,
|
|
611
|
+
await Multicall3.forwardValue(calls, funder, this.log);
|
|
237
612
|
this.log.info(`Funded ${publishers.length} publishers`);
|
|
238
613
|
}
|
|
239
614
|
}
|