@sm-lab/recipes 0.2.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/README.md +164 -0
- package/dist/cl-activate-CPqr6v_D.mjs +653 -0
- package/dist/cl-activate-CPqr6v_D.mjs.map +1 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +1353 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/cm.d.mts +87 -0
- package/dist/cm.d.mts.map +1 -0
- package/dist/cm.mjs +305 -0
- package/dist/cm.mjs.map +1 -0
- package/dist/context-BiYdAnKn.d.mts +8397 -0
- package/dist/context-BiYdAnKn.d.mts.map +1 -0
- package/dist/index.d.mts +393 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +3 -0
- package/dist/topup-B2NzJV7_.mjs +437 -0
- package/dist/topup-B2NzJV7_.mjs.map +1 -0
- package/package.json +65 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
import { _ as contract, h as roleMember, i as getPubkey, l as SETTLE_GENERAL_DELAYED_PENALTY_ROLE, m as actAs, o as DEFAULT_ADMIN_ROLE, p as randomSeed, r as getKeyBalance, s as REPORT_GENERAL_DELAYED_PENALTY_ROLE, u as SET_TREE_ROLE, v as resolveGate } from "./topup-B2NzJV7_.mjs";
|
|
2
|
+
import { encodeAbiParameters, keccak256, maxUint256, parseAbiParameters, parseEther, toBytes, toHex } from "viem";
|
|
3
|
+
import { curatedGateAbi, feeDistributorAbi, feeOracleAbi, hashConsensusAbi, lidoAbi, vettedGateAbi } from "@sm-lab/receipts";
|
|
4
|
+
import { buildAddressesTree, ipfsOptionsFromEnv, makeRewards, pinJsonToIpfs, shouldAttemptPin } from "@sm-lab/merkle";
|
|
5
|
+
//#region src/recipes/operator-info.ts
|
|
6
|
+
/** Typed read of a node operator's on-chain record. (Port of `NodeOperators.operatorInfo`.) */
|
|
7
|
+
async function operatorInfo(ctx, opts) {
|
|
8
|
+
const m = contract(ctx, "module");
|
|
9
|
+
return await ctx.client.readContract({
|
|
10
|
+
...m,
|
|
11
|
+
functionName: "getNodeOperator",
|
|
12
|
+
args: [opts.noId]
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region src/recipes/chain.ts
|
|
17
|
+
/** Advance fork time by `seconds` and mine a block (evm_increaseTime + evm_mine). */
|
|
18
|
+
async function warpBy(ctx, seconds) {
|
|
19
|
+
await ctx.client.increaseTime({ seconds: Number(seconds) });
|
|
20
|
+
await ctx.client.mine({ blocks: 1 });
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Warp fork time to an absolute unix timestamp and mine a block
|
|
24
|
+
* (evm_setNextBlockTimestamp + evm_mine). The absolute counterpart of `warpBy` — used by
|
|
25
|
+
* `submitRewards`'s consensus-frame wait. Over RPC, `setNextBlockTimestamp` + `mine` reproduces
|
|
26
|
+
* Foundry's `vm.warp(ts)` (the post-warp `block.timestamp` settles automatically after mining).
|
|
27
|
+
*/
|
|
28
|
+
async function warpTo(ctx, timestamp) {
|
|
29
|
+
await ctx.client.setNextBlockTimestamp({ timestamp: BigInt(timestamp) });
|
|
30
|
+
await ctx.client.mine({ blocks: 1 });
|
|
31
|
+
}
|
|
32
|
+
/** Take an anvil state snapshot; returns the snapshot id (evm_snapshot). */
|
|
33
|
+
function snapshot(ctx) {
|
|
34
|
+
return ctx.client.snapshot();
|
|
35
|
+
}
|
|
36
|
+
/** Revert the fork to a snapshot id (evm_revert). */
|
|
37
|
+
async function revert(ctx, id) {
|
|
38
|
+
await ctx.client.revert({ id });
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/recipes/address-changes.ts
|
|
42
|
+
/** Propose a new manager address for an operator (signed by the current manager). */
|
|
43
|
+
async function proposeManager(ctx, opts) {
|
|
44
|
+
const m = contract(ctx, "module");
|
|
45
|
+
await actAs(ctx, (await ctx.client.readContract({
|
|
46
|
+
...m,
|
|
47
|
+
functionName: "getNodeOperator",
|
|
48
|
+
args: [opts.noId]
|
|
49
|
+
})).managerAddress, (from) => ctx.client.writeContract({
|
|
50
|
+
...m,
|
|
51
|
+
functionName: "proposeNodeOperatorManagerAddressChange",
|
|
52
|
+
args: [opts.noId, opts.proposed],
|
|
53
|
+
account: from,
|
|
54
|
+
chain: null
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
/** Confirm the proposed manager address (signed by the proposed manager). */
|
|
58
|
+
async function confirmManager(ctx, opts) {
|
|
59
|
+
const m = contract(ctx, "module");
|
|
60
|
+
await actAs(ctx, (await ctx.client.readContract({
|
|
61
|
+
...m,
|
|
62
|
+
functionName: "getNodeOperator",
|
|
63
|
+
args: [opts.noId]
|
|
64
|
+
})).proposedManagerAddress, (from) => ctx.client.writeContract({
|
|
65
|
+
...m,
|
|
66
|
+
functionName: "confirmNodeOperatorManagerAddressChange",
|
|
67
|
+
args: [opts.noId],
|
|
68
|
+
account: from,
|
|
69
|
+
chain: null
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
/** Propose a new reward address (signed by the MANAGER, per the on-chain access rule). */
|
|
73
|
+
async function proposeReward(ctx, opts) {
|
|
74
|
+
const m = contract(ctx, "module");
|
|
75
|
+
await actAs(ctx, (await ctx.client.readContract({
|
|
76
|
+
...m,
|
|
77
|
+
functionName: "getNodeOperator",
|
|
78
|
+
args: [opts.noId]
|
|
79
|
+
})).managerAddress, (from) => ctx.client.writeContract({
|
|
80
|
+
...m,
|
|
81
|
+
functionName: "proposeNodeOperatorRewardAddressChange",
|
|
82
|
+
args: [opts.noId, opts.proposed],
|
|
83
|
+
account: from,
|
|
84
|
+
chain: null
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
/** Confirm the proposed reward address (signed by the proposed reward address). */
|
|
88
|
+
async function confirmReward(ctx, opts) {
|
|
89
|
+
const m = contract(ctx, "module");
|
|
90
|
+
await actAs(ctx, (await ctx.client.readContract({
|
|
91
|
+
...m,
|
|
92
|
+
functionName: "getNodeOperator",
|
|
93
|
+
args: [opts.noId]
|
|
94
|
+
})).proposedRewardAddress, (from) => ctx.client.writeContract({
|
|
95
|
+
...m,
|
|
96
|
+
functionName: "confirmNodeOperatorRewardAddressChange",
|
|
97
|
+
args: [opts.noId],
|
|
98
|
+
account: from,
|
|
99
|
+
chain: null
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
//#endregion
|
|
103
|
+
//#region src/encode.ts
|
|
104
|
+
/** A node-operator id packed as the on-chain `bytes8(uint64)` (big-endian). */
|
|
105
|
+
function nodeOperatorIdBytes(noId) {
|
|
106
|
+
return toHex(noId, { size: 8 });
|
|
107
|
+
}
|
|
108
|
+
/** A key count packed as the on-chain `bytes16(uint128)` (big-endian). */
|
|
109
|
+
function keyCountBytes(count) {
|
|
110
|
+
return toHex(count, { size: 16 });
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
113
|
+
//#region src/recipes/vetting.ts
|
|
114
|
+
/** Set an operator's vetted-keys count (StakingRouter-gated). `vettedKeys` is the new absolute count. */
|
|
115
|
+
async function unvet(ctx, opts) {
|
|
116
|
+
const m = contract(ctx, "module");
|
|
117
|
+
await actAs(ctx, ctx.addresses.stakingRouter, (from) => ctx.client.writeContract({
|
|
118
|
+
...m,
|
|
119
|
+
functionName: "decreaseVettedSigningKeysCount",
|
|
120
|
+
args: [nodeOperatorIdBytes(opts.noId), keyCountBytes(opts.vettedKeys)],
|
|
121
|
+
account: from,
|
|
122
|
+
chain: null
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
/** Set an operator's exited-keys count (StakingRouter-gated). `exitedKeys` is the new absolute total. */
|
|
126
|
+
async function exit(ctx, opts) {
|
|
127
|
+
const m = contract(ctx, "module");
|
|
128
|
+
await actAs(ctx, ctx.addresses.stakingRouter, (from) => ctx.client.writeContract({
|
|
129
|
+
...m,
|
|
130
|
+
functionName: "updateExitedValidatorsCount",
|
|
131
|
+
args: [nodeOperatorIdBytes(opts.noId), keyCountBytes(opts.exitedKeys)],
|
|
132
|
+
account: from,
|
|
133
|
+
chain: null
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/recipes/validators.ts
|
|
138
|
+
/** Report a validator slashing for an operator's key (Verifier-gated). `keyIndex` is the storage index. */
|
|
139
|
+
async function slash(ctx, opts) {
|
|
140
|
+
const m = contract(ctx, "module");
|
|
141
|
+
await actAs(ctx, ctx.addresses.Verifier, (from) => ctx.client.writeContract({
|
|
142
|
+
...m,
|
|
143
|
+
functionName: "reportValidatorSlashing",
|
|
144
|
+
args: [opts.noId, opts.keyIndex],
|
|
145
|
+
account: from,
|
|
146
|
+
chain: null
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
/** Report a regular validator withdrawal for one key (Verifier-gated). isSlashed = slashingPenalty > 0. */
|
|
150
|
+
async function withdraw(ctx, opts) {
|
|
151
|
+
const m = contract(ctx, "module");
|
|
152
|
+
const slashingPenalty = opts.slashingPenalty ?? 0n;
|
|
153
|
+
const info = {
|
|
154
|
+
nodeOperatorId: opts.noId,
|
|
155
|
+
keyIndex: opts.keyIndex,
|
|
156
|
+
exitBalance: opts.exitBalance,
|
|
157
|
+
slashingPenalty,
|
|
158
|
+
isSlashed: slashingPenalty > 0n
|
|
159
|
+
};
|
|
160
|
+
await actAs(ctx, ctx.addresses.Verifier, (from) => ctx.client.writeContract({
|
|
161
|
+
...m,
|
|
162
|
+
functionName: "reportRegularWithdrawnValidators",
|
|
163
|
+
args: [[info]],
|
|
164
|
+
account: from,
|
|
165
|
+
chain: null
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
//#endregion
|
|
169
|
+
//#region src/recipes/penalties.ts
|
|
170
|
+
/** Report a general delayed penalty against an operator (penalty-reporter role). */
|
|
171
|
+
async function reportPenalty(ctx, opts) {
|
|
172
|
+
const m = contract(ctx, "module");
|
|
173
|
+
const reporter = await roleMember(ctx, m, REPORT_GENERAL_DELAYED_PENALTY_ROLE);
|
|
174
|
+
const penaltyType = opts.penaltyType ?? toHex(1n, { size: 32 });
|
|
175
|
+
const details = opts.details ?? "fork-penalty";
|
|
176
|
+
await actAs(ctx, reporter, (from) => ctx.client.writeContract({
|
|
177
|
+
...m,
|
|
178
|
+
functionName: "reportGeneralDelayedPenalty",
|
|
179
|
+
args: [
|
|
180
|
+
opts.noId,
|
|
181
|
+
penaltyType,
|
|
182
|
+
opts.amount,
|
|
183
|
+
details
|
|
184
|
+
],
|
|
185
|
+
account: from,
|
|
186
|
+
chain: null
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
/** Cancel a previously-reported general delayed penalty (penalty-reporter role). */
|
|
190
|
+
async function cancelPenalty(ctx, opts) {
|
|
191
|
+
const m = contract(ctx, "module");
|
|
192
|
+
await actAs(ctx, await roleMember(ctx, m, REPORT_GENERAL_DELAYED_PENALTY_ROLE), (from) => ctx.client.writeContract({
|
|
193
|
+
...m,
|
|
194
|
+
functionName: "cancelGeneralDelayedPenalty",
|
|
195
|
+
args: [opts.noId, opts.amount],
|
|
196
|
+
account: from,
|
|
197
|
+
chain: null
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Settle (process) an operator's general delayed penalty (penalty-settler role).
|
|
202
|
+
* The `maxAmount` option is the per-operator settlement cap passed as the contract's 2nd array arg (confusingly named `bondLockNonces` in the ABI); the default `maxUint256` settles the penalty fully.
|
|
203
|
+
*/
|
|
204
|
+
async function settlePenalty(ctx, opts) {
|
|
205
|
+
const m = contract(ctx, "module");
|
|
206
|
+
await actAs(ctx, await roleMember(ctx, m, SETTLE_GENERAL_DELAYED_PENALTY_ROLE), (from) => ctx.client.writeContract({
|
|
207
|
+
...m,
|
|
208
|
+
functionName: "settleGeneralDelayedPenalty",
|
|
209
|
+
args: [[opts.noId], [opts.maxAmount ?? maxUint256]],
|
|
210
|
+
account: from,
|
|
211
|
+
chain: null
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
/** Compensate (pay off) an operator's general delayed penalty — signed by the operator manager. */
|
|
215
|
+
async function compensatePenalty(ctx, opts) {
|
|
216
|
+
const m = contract(ctx, "module");
|
|
217
|
+
await actAs(ctx, (await ctx.client.readContract({
|
|
218
|
+
...m,
|
|
219
|
+
functionName: "getNodeOperator",
|
|
220
|
+
args: [opts.noId]
|
|
221
|
+
})).managerAddress, (from) => ctx.client.writeContract({
|
|
222
|
+
...m,
|
|
223
|
+
functionName: "compensateGeneralDelayedPenalty",
|
|
224
|
+
args: [opts.noId],
|
|
225
|
+
account: from,
|
|
226
|
+
chain: null
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
//#endregion
|
|
230
|
+
//#region src/recipes/bond.ts
|
|
231
|
+
/** Top up an operator's bond with ETH (permissionless `depositETH`), signed by the manager. */
|
|
232
|
+
async function addBond(ctx, opts) {
|
|
233
|
+
const m = contract(ctx, "module");
|
|
234
|
+
const acc = contract(ctx, "Accounting");
|
|
235
|
+
await actAs(ctx, (await ctx.client.readContract({
|
|
236
|
+
...m,
|
|
237
|
+
functionName: "getNodeOperator",
|
|
238
|
+
args: [opts.noId]
|
|
239
|
+
})).managerAddress, (from) => ctx.client.writeContract({
|
|
240
|
+
...acc,
|
|
241
|
+
functionName: "depositETH",
|
|
242
|
+
args: [opts.noId],
|
|
243
|
+
account: from,
|
|
244
|
+
value: opts.amount,
|
|
245
|
+
chain: null
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Charge a penalty against an operator's bond (creating bond debt if uncovered). `Accounting.penalize`
|
|
250
|
+
* is module-only, so we impersonate the module address. Returns whether the penalty was fully covered.
|
|
251
|
+
*/
|
|
252
|
+
async function createBondDebt(ctx, opts) {
|
|
253
|
+
const moduleAddress = contract(ctx, "module").address;
|
|
254
|
+
const acc = contract(ctx, "Accounting");
|
|
255
|
+
return actAs(ctx, moduleAddress, async (from) => {
|
|
256
|
+
const { result, request } = await ctx.client.simulateContract({
|
|
257
|
+
...acc,
|
|
258
|
+
functionName: "penalize",
|
|
259
|
+
args: [opts.noId, opts.amount],
|
|
260
|
+
account: from
|
|
261
|
+
});
|
|
262
|
+
await ctx.client.writeContract({
|
|
263
|
+
...request,
|
|
264
|
+
chain: null
|
|
265
|
+
});
|
|
266
|
+
return { penaltyCovered: result };
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
//#endregion
|
|
270
|
+
//#region src/recipes/set-gate.ts
|
|
271
|
+
/** Default gate selector per module: cm → 'po' (CuratedGates[0]); csm → 'ics' (VettedGate). */
|
|
272
|
+
function defaultSelector(ctx) {
|
|
273
|
+
return ctx.module === "cm" ? "po" : "ics";
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Build a gate addresses tree (OZ ['address'], via @sm-lab/merkle) and install it on the gate
|
|
277
|
+
* (`setTreeParams(root, cid)`), impersonating the gate admin. The cid is pinned to IPFS
|
|
278
|
+
* (env-configured: a local @sm-lab/ipfs or Pinata) unless `cid` is supplied.
|
|
279
|
+
*
|
|
280
|
+
* Module-agnostic (port of fork.just `set-gate-addrs` + `update-gate-tree`, both selector-driven):
|
|
281
|
+
* the csm VettedGate and cm CuratedGate share a byte-identical grantRole/setTreeParams/getRoleMember
|
|
282
|
+
* surface, so the only per-module differences are the resolved address, the default selector, and
|
|
283
|
+
* which gate ABI carries the fragments.
|
|
284
|
+
*/
|
|
285
|
+
async function setGateAddrs(ctx, opts) {
|
|
286
|
+
const selector = opts.selector ?? defaultSelector(ctx);
|
|
287
|
+
const abi = ctx.module === "cm" ? curatedGateAbi : vettedGateAbi;
|
|
288
|
+
const gate = {
|
|
289
|
+
address: resolveGate(ctx, selector),
|
|
290
|
+
abi
|
|
291
|
+
};
|
|
292
|
+
const tree = buildAddressesTree(opts.addresses);
|
|
293
|
+
const treeRoot = tree.root;
|
|
294
|
+
const treeCid = opts.cid ?? await pinTree(tree.dump(), selector);
|
|
295
|
+
const admin = await roleMember(ctx, gate, DEFAULT_ADMIN_ROLE);
|
|
296
|
+
await actAs(ctx, admin, async (from) => {
|
|
297
|
+
await ctx.client.writeContract({
|
|
298
|
+
...gate,
|
|
299
|
+
functionName: "grantRole",
|
|
300
|
+
args: [SET_TREE_ROLE, admin],
|
|
301
|
+
account: from,
|
|
302
|
+
chain: null
|
|
303
|
+
});
|
|
304
|
+
await ctx.client.writeContract({
|
|
305
|
+
...gate,
|
|
306
|
+
functionName: "setTreeParams",
|
|
307
|
+
args: [treeRoot, treeCid],
|
|
308
|
+
account: from,
|
|
309
|
+
chain: null
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
return {
|
|
313
|
+
treeRoot,
|
|
314
|
+
treeCid
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
async function pinTree(dump, selector) {
|
|
318
|
+
if (!shouldAttemptPin()) throw new Error("@sm-lab/recipes: could not pin the gate tree — set IPFS_API_URL (a local @sm-lab/ipfs) or PINATA_* credentials, or pass opts.cid");
|
|
319
|
+
return pinJsonToIpfs(dump, `gate-${selector}`, ipfsOptionsFromEnv());
|
|
320
|
+
}
|
|
321
|
+
//#endregion
|
|
322
|
+
//#region src/recipes/rewards.ts
|
|
323
|
+
/** Reward draw bounds (mirrors `mock-rewards.mjs`): per active key, in wei. */
|
|
324
|
+
const REWARD_MIN_WEI = 100000000000000000n;
|
|
325
|
+
const REWARD_SPAN = 200000000000000000n - REWARD_MIN_WEI;
|
|
326
|
+
/** type(uint64).max — padding id for a lone real operator (FeeDistributor non-empty-proof rule). */
|
|
327
|
+
const PAD_NO_ID = (1n << 64n) - 1n;
|
|
328
|
+
/** Empty-tree sentinel root: 32 zero bytes. */
|
|
329
|
+
const ZERO_ROOT = `0x${"00".repeat(32)}`;
|
|
330
|
+
/**
|
|
331
|
+
* Build the cumulative FeeDistributor rewards tree off on-chain operator state and a seeded mock
|
|
332
|
+
* reward per active key, pin the tree + report log to IPFS (guarded), and return a typed in-memory
|
|
333
|
+
* `RewardsReport`. Port of `mock-rewards.mjs` — but the OUTPUT path stays bigint-typed (no bare
|
|
334
|
+
* `JSON.stringify` of bigints), and the per-key draw is fully seeded (no `Math.random`) so tests can
|
|
335
|
+
* pin `treeRoot`/`distributed`.
|
|
336
|
+
*
|
|
337
|
+
* Carry-forward is via `opts.previousCumulatives` only (option (a) of the design). The pure
|
|
338
|
+
* carry-forward logic — carry every prior leaf forward except the pad, then add this frame's deltas —
|
|
339
|
+
* is identical no matter where the prior Map came from, so the fork path chains calls:
|
|
340
|
+
* `makeRewards(ctx, { previousCumulatives: prev.cumulatives })`.
|
|
341
|
+
* TODO(6f?): on-chain `treeRoot()` → `treeCid()` → IPFS fetch of the prior tree as a default source.
|
|
342
|
+
*/
|
|
343
|
+
async function makeRewards$1(ctx, opts = {}) {
|
|
344
|
+
const seed = opts.seed ?? randomSeed();
|
|
345
|
+
const m = contract(ctx, "module");
|
|
346
|
+
const count = await ctx.client.readContract({
|
|
347
|
+
...m,
|
|
348
|
+
functionName: "getNodeOperatorsCount"
|
|
349
|
+
});
|
|
350
|
+
const ids = Array.from({ length: Number(count) }, (_, i) => BigInt(i));
|
|
351
|
+
const infos = await Promise.all(ids.map((noId) => operatorInfo(ctx, { noId })));
|
|
352
|
+
const reportOperators = {};
|
|
353
|
+
const frameDeltas = /* @__PURE__ */ new Map();
|
|
354
|
+
let distributed = 0n;
|
|
355
|
+
for (const [i, noId] of ids.entries()) {
|
|
356
|
+
const info = infos[i];
|
|
357
|
+
const activeKeys = info.totalDepositedKeys - info.totalWithdrawnKeys;
|
|
358
|
+
if (activeKeys <= 0) continue;
|
|
359
|
+
let opDistributed = 0n;
|
|
360
|
+
for (let k = 0; k < activeKeys; k++) opDistributed += REWARD_MIN_WEI + seededUint(seed, `reward:${noId}:${k}`, REWARD_SPAN);
|
|
361
|
+
reportOperators[noId.toString()] = { distributed_rewards: opDistributed };
|
|
362
|
+
frameDeltas.set(noId, opDistributed);
|
|
363
|
+
distributed += opDistributed;
|
|
364
|
+
}
|
|
365
|
+
const rebate = 0n;
|
|
366
|
+
const cumulatives = /* @__PURE__ */ new Map();
|
|
367
|
+
for (const [noId, shares] of normalizePrevious(opts.previousCumulatives)) if (noId !== PAD_NO_ID) cumulatives.set(noId, shares);
|
|
368
|
+
for (const [noId, delta] of frameDeltas) cumulatives.set(noId, (cumulatives.get(noId) ?? 0n) + delta);
|
|
369
|
+
const leaves = [...cumulatives.entries()].map(([noId, shares]) => [noId, shares]);
|
|
370
|
+
if (leaves.length === 1) leaves.push([PAD_NO_ID, 0n]);
|
|
371
|
+
if (leaves.length === 0) return {
|
|
372
|
+
treeRoot: ZERO_ROOT,
|
|
373
|
+
treeCid: "",
|
|
374
|
+
logCid: "",
|
|
375
|
+
distributed,
|
|
376
|
+
rebate,
|
|
377
|
+
cumulatives
|
|
378
|
+
};
|
|
379
|
+
if ((opts.treeCid === void 0 || opts.logCid === void 0) && !shouldAttemptPin()) throw new Error("@sm-lab/recipes: could not pin the rewards tree/log — set IPFS_API_URL (a local @sm-lab/ipfs) or PINATA_* credentials, or pass opts.treeCid + opts.logCid");
|
|
380
|
+
const log = {
|
|
381
|
+
blockstamp: { block_timestamp: opts.now ?? Math.floor(Date.now() / 1e3) },
|
|
382
|
+
distributable: distributed,
|
|
383
|
+
distributed_rewards: distributed,
|
|
384
|
+
rebate_to_protocol: rebate,
|
|
385
|
+
operators: reportOperators
|
|
386
|
+
};
|
|
387
|
+
let treeRoot;
|
|
388
|
+
let treeCid;
|
|
389
|
+
let logCid;
|
|
390
|
+
let treeDump;
|
|
391
|
+
if (opts.treeCid !== void 0 && opts.logCid !== void 0) {
|
|
392
|
+
const result = await makeRewards(leaves, { noUpload: true });
|
|
393
|
+
treeRoot = result.treeRoot;
|
|
394
|
+
treeDump = result.treeDump;
|
|
395
|
+
treeCid = opts.treeCid;
|
|
396
|
+
logCid = opts.logCid;
|
|
397
|
+
} else if (opts.treeCid !== void 0) {
|
|
398
|
+
const result = await makeRewards(leaves, {
|
|
399
|
+
log,
|
|
400
|
+
noUpload: false
|
|
401
|
+
});
|
|
402
|
+
treeRoot = result.treeRoot;
|
|
403
|
+
treeDump = result.treeDump;
|
|
404
|
+
treeCid = opts.treeCid;
|
|
405
|
+
logCid = result.logCid ?? "";
|
|
406
|
+
} else if (opts.logCid !== void 0) {
|
|
407
|
+
const result = await makeRewards(leaves, { noUpload: false });
|
|
408
|
+
treeRoot = result.treeRoot;
|
|
409
|
+
treeDump = result.treeDump;
|
|
410
|
+
treeCid = result.treeCid ?? "";
|
|
411
|
+
logCid = opts.logCid;
|
|
412
|
+
} else {
|
|
413
|
+
const result = await makeRewards(leaves, { log });
|
|
414
|
+
treeRoot = result.treeRoot;
|
|
415
|
+
treeDump = result.treeDump;
|
|
416
|
+
treeCid = result.treeCid ?? "";
|
|
417
|
+
logCid = result.logCid ?? "";
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
treeRoot,
|
|
421
|
+
treeCid,
|
|
422
|
+
logCid,
|
|
423
|
+
distributed,
|
|
424
|
+
rebate,
|
|
425
|
+
treeDump,
|
|
426
|
+
cumulatives
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
/** Deterministic uint in `[0, span)` from `keccak256(toBytes(\`${seed}:${label}\`)) % span`. */
|
|
430
|
+
function seededUint(seed, label, span) {
|
|
431
|
+
return BigInt(keccak256(toBytes(`${seed}:${label}`))) % span;
|
|
432
|
+
}
|
|
433
|
+
/** Normalize the carry-forward input (Map or entries) to an iterable of [noId, shares]. */
|
|
434
|
+
function normalizePrevious(prev) {
|
|
435
|
+
if (!prev) return [];
|
|
436
|
+
return prev instanceof Map ? prev.entries() : prev;
|
|
437
|
+
}
|
|
438
|
+
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
|
439
|
+
/**
|
|
440
|
+
* The `IFeeOracle.ReportData` struct as ONE tuple parameter — components in declaration order
|
|
441
|
+
* (verified against `fixtures/receipts/src/abi/FeeOracle.ts`). `abi.encode(data)` for a single
|
|
442
|
+
* struct == ABI-encoding one tuple parameter (head/tail offsets for the dynamic `string` fields);
|
|
443
|
+
* passing the tuple type as a single param to `encodeAbiParameters` reproduces it. Do NOT flatten
|
|
444
|
+
* into 9 top-level params — that drops the tuple offset and changes the hash. This is the exact
|
|
445
|
+
* reportHash transcription trap.
|
|
446
|
+
*/
|
|
447
|
+
const REPORT_DATA_PARAMS = parseAbiParameters("(uint256 consensusVersion, uint256 refSlot, bytes32 treeRoot, string treeCid, string logCid, uint256 distributed, uint256 rebate, bytes32 strikesTreeRoot, string strikesTreeCid)");
|
|
448
|
+
/**
|
|
449
|
+
* Submit a `RewardsReport` (from `makeRewards`) on-chain: fund the FeeDistributor, warp to the next
|
|
450
|
+
* consensus frame, build the `ReportData` tuple, reach consensus across the fast-lane members (with
|
|
451
|
+
* a `getMembers` fallback), and submit the report data. Port of `OracleReport.s.sol:submitRewards`.
|
|
452
|
+
*
|
|
453
|
+
* Fork-only in practice (it WRITES + warps); the hermetic tests assert the call sequence, the funding
|
|
454
|
+
* literals, the warp arguments, and the reportHash, not real frame advancement.
|
|
455
|
+
*
|
|
456
|
+
* A zero-root report (no active keys this frame) is a graceful no-op: returns `{ submitted: false }`
|
|
457
|
+
* with no reads/writes, so `submitRewards(ctx, await makeRewards(ctx))` composes on an empty fork.
|
|
458
|
+
*/
|
|
459
|
+
async function submitRewards(ctx, report) {
|
|
460
|
+
if (report.treeRoot === ZERO_ROOT) return { submitted: false };
|
|
461
|
+
const feeDistributor = {
|
|
462
|
+
address: ctx.addresses.FeeDistributor,
|
|
463
|
+
abi: feeDistributorAbi
|
|
464
|
+
};
|
|
465
|
+
const oracle = {
|
|
466
|
+
address: ctx.addresses.FeeOracle,
|
|
467
|
+
abi: feeOracleAbi
|
|
468
|
+
};
|
|
469
|
+
const hashConsensus = {
|
|
470
|
+
address: ctx.addresses.HashConsensus,
|
|
471
|
+
abi: hashConsensusAbi
|
|
472
|
+
};
|
|
473
|
+
const lido = {
|
|
474
|
+
address: ctx.addresses.lido,
|
|
475
|
+
abi: lidoAbi
|
|
476
|
+
};
|
|
477
|
+
const pending = await ctx.client.readContract({
|
|
478
|
+
...feeDistributor,
|
|
479
|
+
functionName: "pendingSharesToDistribute"
|
|
480
|
+
});
|
|
481
|
+
if (pending < report.distributed + report.rebate) {
|
|
482
|
+
const neededShares = report.distributed + report.rebate - pending;
|
|
483
|
+
const x = await ctx.client.readContract({
|
|
484
|
+
...lido,
|
|
485
|
+
functionName: "getPooledEthByShares",
|
|
486
|
+
args: [neededShares]
|
|
487
|
+
});
|
|
488
|
+
const submitValue = x + parseEther("1");
|
|
489
|
+
const fd = feeDistributor.address;
|
|
490
|
+
await ctx.client.setBalance({
|
|
491
|
+
address: fd,
|
|
492
|
+
value: x + parseEther("2")
|
|
493
|
+
});
|
|
494
|
+
await ctx.client.impersonateAccount({ address: fd });
|
|
495
|
+
try {
|
|
496
|
+
await ctx.client.writeContract({
|
|
497
|
+
...lido,
|
|
498
|
+
functionName: "submit",
|
|
499
|
+
args: [ZERO_ADDRESS],
|
|
500
|
+
value: submitValue,
|
|
501
|
+
account: fd,
|
|
502
|
+
chain: null
|
|
503
|
+
});
|
|
504
|
+
} finally {
|
|
505
|
+
await ctx.client.stopImpersonatingAccount({ address: fd });
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const [slotsPerEpoch, secondsPerSlot, genesisTime] = await ctx.client.readContract({
|
|
509
|
+
...hashConsensus,
|
|
510
|
+
functionName: "getChainConfig"
|
|
511
|
+
});
|
|
512
|
+
const [initialEpoch, epochsPerFrame] = await ctx.client.readContract({
|
|
513
|
+
...hashConsensus,
|
|
514
|
+
functionName: "getFrameConfig"
|
|
515
|
+
});
|
|
516
|
+
if (((await ctx.client.getBlock()).timestamp - genesisTime) / secondsPerSlot / slotsPerEpoch < initialEpoch) await warpTo(ctx, genesisTime + 1n + initialEpoch * slotsPerEpoch * secondsPerSlot);
|
|
517
|
+
const [frameRefSlot] = await ctx.client.readContract({
|
|
518
|
+
...hashConsensus,
|
|
519
|
+
functionName: "getCurrentFrame"
|
|
520
|
+
});
|
|
521
|
+
const frameStart = genesisTime + (frameRefSlot + slotsPerEpoch * epochsPerFrame + 1n) * secondsPerSlot;
|
|
522
|
+
if (frameStart > (await ctx.client.getBlock()).timestamp) await warpTo(ctx, frameStart);
|
|
523
|
+
const [refSlot] = await ctx.client.readContract({
|
|
524
|
+
...hashConsensus,
|
|
525
|
+
functionName: "getCurrentFrame"
|
|
526
|
+
});
|
|
527
|
+
const consensusVersion = await ctx.client.readContract({
|
|
528
|
+
...oracle,
|
|
529
|
+
functionName: "getConsensusVersion"
|
|
530
|
+
});
|
|
531
|
+
const data = {
|
|
532
|
+
consensusVersion,
|
|
533
|
+
refSlot,
|
|
534
|
+
treeRoot: report.treeRoot,
|
|
535
|
+
treeCid: report.treeCid,
|
|
536
|
+
logCid: report.logCid,
|
|
537
|
+
distributed: report.distributed,
|
|
538
|
+
rebate: report.rebate,
|
|
539
|
+
strikesTreeRoot: strikesTreeRoot(refSlot),
|
|
540
|
+
strikesTreeCid: `mock-strikes-${refSlot}`
|
|
541
|
+
};
|
|
542
|
+
const hash = reportHash(data);
|
|
543
|
+
let [members] = await ctx.client.readContract({
|
|
544
|
+
...hashConsensus,
|
|
545
|
+
functionName: "getFastLaneMembers"
|
|
546
|
+
});
|
|
547
|
+
if (members.length === 0) [members] = await ctx.client.readContract({
|
|
548
|
+
...hashConsensus,
|
|
549
|
+
functionName: "getMembers"
|
|
550
|
+
});
|
|
551
|
+
if (members.length === 0) throw new Error("@sm-lab/recipes: no consensus members to reach quorum");
|
|
552
|
+
for (const member of members) await actAs(ctx, member, (from) => ctx.client.writeContract({
|
|
553
|
+
...hashConsensus,
|
|
554
|
+
functionName: "submitReport",
|
|
555
|
+
args: [
|
|
556
|
+
refSlot,
|
|
557
|
+
hash,
|
|
558
|
+
consensusVersion
|
|
559
|
+
],
|
|
560
|
+
account: from,
|
|
561
|
+
chain: null
|
|
562
|
+
}));
|
|
563
|
+
const contractVersion = await ctx.client.readContract({
|
|
564
|
+
...oracle,
|
|
565
|
+
functionName: "getContractVersion"
|
|
566
|
+
});
|
|
567
|
+
await actAs(ctx, members[0], (from) => ctx.client.writeContract({
|
|
568
|
+
...oracle,
|
|
569
|
+
functionName: "submitReportData",
|
|
570
|
+
args: [data, contractVersion],
|
|
571
|
+
account: from,
|
|
572
|
+
chain: null
|
|
573
|
+
}));
|
|
574
|
+
return {
|
|
575
|
+
submitted: true,
|
|
576
|
+
refSlot,
|
|
577
|
+
treeRoot: report.treeRoot,
|
|
578
|
+
reportHash: hash,
|
|
579
|
+
members
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
/** keccak256(abi.encode(data)) — encode the 9-field struct as ONE tuple param (see REPORT_DATA_PARAMS). */
|
|
583
|
+
function reportHash(data) {
|
|
584
|
+
return keccak256(encodeAbiParameters(REPORT_DATA_PARAMS, [data]));
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Mock strikes root: `keccak256(abi.encode("mock-strikes", refSlot))` — TWO top-level params
|
|
588
|
+
* (`string`, `uint256`), NOT a tuple. Matches the source's `abi.encode(a, b)`.
|
|
589
|
+
*/
|
|
590
|
+
function strikesTreeRoot(refSlot) {
|
|
591
|
+
return keccak256(encodeAbiParameters(parseAbiParameters("string, uint256"), ["mock-strikes", refSlot]));
|
|
592
|
+
}
|
|
593
|
+
//#endregion
|
|
594
|
+
//#region src/cl-mock.ts
|
|
595
|
+
/**
|
|
596
|
+
* POST one validator to a running `@sm-lab/cl` `/admin/validators`. Surfaces the mock's
|
|
597
|
+
* `errors[]` and throws unless the single item was accepted.
|
|
598
|
+
*
|
|
599
|
+
* cl-mock returns 200 (clean), 207 (partial — only when `errors.length > 0` AND `accepted > 0`,
|
|
600
|
+
* which is impossible for a single item), or 400 (all rejected, no `accepted` field). For one
|
|
601
|
+
* item the realistic failure is 400, caught by the `!res.ok` clause. The `(accepted ?? 0) < 1`
|
|
602
|
+
* guard is defensive belt-and-braces for a synthetic accepted:0 / missing-`accepted` body the mock
|
|
603
|
+
* never actually emits — the `?? 0` is load-bearing, since a bare `accepted < 1` is `false` for
|
|
604
|
+
* `undefined` and would let such a body through as a silent success.
|
|
605
|
+
*/
|
|
606
|
+
async function setClValidator(clMockUrl, input) {
|
|
607
|
+
const url = `${clMockUrl.replace(/\/+$/, "")}/admin/validators`;
|
|
608
|
+
const body = JSON.stringify({
|
|
609
|
+
pubkey: input.pubkey,
|
|
610
|
+
status: input.status,
|
|
611
|
+
...input.effectiveBalanceGwei !== void 0 ? { effective_balance: input.effectiveBalanceGwei.toString() } : {}
|
|
612
|
+
});
|
|
613
|
+
const res = await fetch(url, {
|
|
614
|
+
method: "POST",
|
|
615
|
+
headers: { "Content-Type": "application/json" },
|
|
616
|
+
body
|
|
617
|
+
});
|
|
618
|
+
const json = await res.json().catch(() => void 0);
|
|
619
|
+
if (!res.ok || !json || (json.accepted ?? 0) < 1) {
|
|
620
|
+
const errs = json?.errors?.length ? ` — ${json.errors.join("; ")}` : "";
|
|
621
|
+
throw new Error(`@sm-lab/recipes: cl-mock rejected validator (${res.status} ${res.statusText})${errs}`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
//#endregion
|
|
625
|
+
//#region src/recipes/cl-activate.ts
|
|
626
|
+
const GWEI_PER_ETH = 1000000000n;
|
|
627
|
+
const BASE_ETH_GWEI = 32n * GWEI_PER_ETH;
|
|
628
|
+
/**
|
|
629
|
+
* Read a key's pubkey + allocated balance on-chain, then mark it `active_ongoing` on a running
|
|
630
|
+
* cl-mock with effective balance = 32 ETH + allocated balance, in gwei. Port of
|
|
631
|
+
* `cl-mock.just:cl-activate`. Diverges from the Solidity helper's integer-ETH truncation
|
|
632
|
+
* (`balances[0] / 1 ether`) by keeping full gwei precision (cl-mock stores `effective_balance`
|
|
633
|
+
* in gwei) — the spec-blessed divergence.
|
|
634
|
+
*/
|
|
635
|
+
async function clActivate(ctx, opts) {
|
|
636
|
+
if (!ctx.clMockUrl) throw new Error("@sm-lab/recipes: clActivate needs ctx.clMockUrl (a running cl-mock)");
|
|
637
|
+
const pubkey = await getPubkey(ctx, opts);
|
|
638
|
+
const effectiveBalanceGwei = BASE_ETH_GWEI + await getKeyBalance(ctx, opts) / GWEI_PER_ETH;
|
|
639
|
+
await setClValidator(ctx.clMockUrl, {
|
|
640
|
+
pubkey,
|
|
641
|
+
status: "active_ongoing",
|
|
642
|
+
effectiveBalanceGwei
|
|
643
|
+
});
|
|
644
|
+
return {
|
|
645
|
+
pubkey,
|
|
646
|
+
status: "active_ongoing",
|
|
647
|
+
effectiveBalanceGwei
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
//#endregion
|
|
651
|
+
export { snapshot as C, operatorInfo as E, revert as S, warpTo as T, nodeOperatorIdBytes as _, setGateAddrs as a, proposeManager as b, cancelPenalty as c, settlePenalty as d, slash as f, keyCountBytes as g, unvet as h, submitRewards as i, compensatePenalty as l, exit as m, setClValidator as n, addBond as o, withdraw as p, makeRewards$1 as r, createBondDebt as s, clActivate as t, reportPenalty as u, confirmManager as v, warpBy as w, proposeReward as x, confirmReward as y };
|
|
652
|
+
|
|
653
|
+
//# sourceMappingURL=cl-activate-CPqr6v_D.mjs.map
|