@finchip.ai/foga 0.0.0-stage → 0.6.0-alpha.4
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/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +216 -2
- package/artifacts/bundle.json +74250 -0
- package/artifacts/bundle.sha256 +1 -0
- package/bin/foga.js +14 -0
- package/build/known-interface-ids.json +18 -0
- package/build/profiles.json +120 -0
- package/dist/build/bundle.js +417 -0
- package/dist/build/vendor.js +74 -0
- package/dist/bundle/load.js +24 -0
- package/dist/bundle/types.js +1 -0
- package/dist/cli-cell.js +395 -0
- package/dist/cli.js +505 -0
- package/dist/core/acdf.js +162 -0
- package/dist/core/adrf.js +273 -0
- package/dist/core/aid.js +83 -0
- package/dist/core/ard.js +146 -0
- package/dist/core/asf.js +157 -0
- package/dist/core/canonical.js +79 -0
- package/dist/core/erc8004.js +52 -0
- package/dist/core/kya.js +225 -0
- package/dist/core/measure.js +96 -0
- package/dist/core/pack/cbor.js +130 -0
- package/dist/core/pack/skillpack.js +142 -0
- package/dist/core/skills.js +40 -0
- package/dist/core/swarm.js +206 -0
- package/dist/core/tenders.js +121 -0
- package/dist/core/units.js +122 -0
- package/dist/core/world.js +104 -0
- package/dist/demo/demander.js +76 -0
- package/dist/demo/evidence.js +1 -0
- package/dist/demo/loop-d.js +449 -0
- package/dist/doctor/checks.js +51 -0
- package/dist/doctor/doctor.js +385 -0
- package/dist/doctor/loop-d.js +103 -0
- package/dist/doctor/probe.js +97 -0
- package/dist/doctor/semantic.js +83 -0
- package/dist/genesis/extract.js +64 -0
- package/dist/genesis/funding.js +126 -0
- package/dist/genesis/operator-scope.js +90 -0
- package/dist/genesis/preflight.js +54 -0
- package/dist/genesis/probe.js +164 -0
- package/dist/genesis/roles.js +96 -0
- package/dist/genesis/run.js +71 -0
- package/dist/genesis/scenario.js +765 -0
- package/dist/genesis/skill-runner.js +103 -0
- package/dist/genesis/skill-worker.js +41 -0
- package/dist/genesis/t1-zk.js +62 -0
- package/dist/genesis/t3-zk.js +105 -0
- package/dist/genesis/t4.js +259 -0
- package/dist/genesis/t5.js +168 -0
- package/dist/genesis/t6.js +222 -0
- package/dist/genesis/t7.js +198 -0
- package/dist/genesis/t8.js +189 -0
- package/dist/genesis/t9.js +188 -0
- package/dist/genesis/zk.js +128 -0
- package/dist/http/server.js +276 -0
- package/dist/kernel/cell.js +104 -0
- package/dist/kernel/errors.js +78 -0
- package/dist/kernel/kernel.js +251 -0
- package/dist/kernel/provider.js +125 -0
- package/dist/kernel/store.js +470 -0
- package/dist/kernel/tx.js +385 -0
- package/dist/mcp/server.js +170 -0
- package/dist/node/config.js +195 -0
- package/dist/node/init.js +200 -0
- package/dist/node/keys.js +95 -0
- package/dist/node/migrate.js +173 -0
- package/dist/node/model/anthropic.js +97 -0
- package/dist/node/model/stub.js +129 -0
- package/dist/node/model/types.js +9 -0
- package/dist/node/node.js +198 -0
- package/dist/node/policy.js +95 -0
- package/dist/node/runner.js +256 -0
- package/dist/node/watchers.js +88 -0
- package/dist/tools/admin.js +184 -0
- package/dist/tools/catalog.js +261 -0
- package/dist/tools/common.js +143 -0
- package/dist/tools/identity.js +107 -0
- package/dist/tools/index.js +14 -0
- package/dist/tools/read.js +471 -0
- package/dist/tools/tenders.js +400 -0
- package/dist/tools/types.js +4 -0
- package/dist/util/bytecode.js +123 -0
- package/dist/util/fs.js +58 -0
- package/dist/util/hash.js +4 -0
- package/dist/util/log.js +11 -0
- package/dist/util/version.js +12 -0
- package/dist/world/deploy.js +277 -0
- package/dist/world/plan.js +93 -0
- package/dist/world/types.js +1 -0
- package/docs/build.md +45 -0
- package/docs/cell-account.md +58 -0
- package/docs/compatibility-matrix.md +38 -0
- package/docs/design-memo.md +113 -0
- package/docs/doctor.md +53 -0
- package/docs/examples/README.md +11 -0
- package/docs/examples/claude-code-mcp.json +13 -0
- package/docs/examples/claude_desktop_config.json +12 -0
- package/docs/examples/cursor-mcp.json +11 -0
- package/docs/genesis.md +312 -0
- package/docs/kernel.md +101 -0
- package/docs/loop-d.md +90 -0
- package/docs/mcp.md +105 -0
- package/docs/node.md +66 -0
- package/docs/version-lock.md +67 -0
- package/docs/world.md +50 -0
- package/docs/zk.md +48 -0
- package/package.json +82 -4
- package/prover/README.md +26 -0
- package/prover/build-predicate.mjs +77 -0
- package/prover/package-lock.json +1837 -0
- package/prover/package.json +15 -0
- package/prover/run.cjs +199 -0
- package/scenarios/fixtures/sample-filing.txt +16 -0
- package/scenarios/skills/finch-extract-verify/LICENSE +2 -0
- package/scenarios/skills/finch-extract-verify/SKILL.md +34 -0
- package/scenarios/skills/finch-extract-verify/fields.json +56 -0
- package/scenarios/skills/finch-extract-verify/manifest.json +16 -0
- package/scenarios/skills/foga-probe/LICENSE +2 -0
- package/scenarios/skills/foga-probe/SKILL.md +35 -0
- package/scenarios/skills/foga-probe/manifest.json +16 -0
- package/scenarios/skills/foga-probe/probe.schema.json +40 -0
- package/server.json +79 -0
- package/vendor/LOCK.json +107 -0
- package/vendor/README.md +13 -0
- package/worlds/local.json +34 -0
- package/worlds/sepolia.json +314 -0
- package/zk/build/verification_key.json +174 -0
- package/zk/circuits/kya_predicate_count_v1.circom +231 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { Interface, Wallet, getCreateAddress } from "ethers";
|
|
2
|
+
import { ACDF } from "../core/acdf.js";
|
|
3
|
+
import { ADRF } from "../core/adrf.js";
|
|
4
|
+
import { AID } from "../core/aid.js";
|
|
5
|
+
import { ARD } from "../core/ard.js";
|
|
6
|
+
import { ASF } from "../core/asf.js";
|
|
7
|
+
import { ERC8004_ABI, Identity8004 } from "../core/erc8004.js";
|
|
8
|
+
import { KYA } from "../core/kya.js";
|
|
9
|
+
import { Skills } from "../core/skills.js";
|
|
10
|
+
import { Swarms } from "../core/swarm.js";
|
|
11
|
+
import { Tenders } from "../core/tenders.js";
|
|
12
|
+
import { Units } from "../core/units.js";
|
|
13
|
+
import { World } from "../core/world.js";
|
|
14
|
+
import { OperationStore } from "./store.js";
|
|
15
|
+
import { TxManager } from "./tx.js";
|
|
16
|
+
/**
|
|
17
|
+
* The kernel: one world, one operation store, the signers the node holds, and the ten protocol modules opened on
|
|
18
|
+
* that world. Everything that changes chain state goes through `tx` (idempotent by key); everything a later step
|
|
19
|
+
* or a resumed run needs is a fact in the store; everything a reviewer needs is evidence.
|
|
20
|
+
*/
|
|
21
|
+
export class Kernel {
|
|
22
|
+
world;
|
|
23
|
+
store;
|
|
24
|
+
options;
|
|
25
|
+
tx;
|
|
26
|
+
signers;
|
|
27
|
+
kya;
|
|
28
|
+
aid;
|
|
29
|
+
asf;
|
|
30
|
+
ard;
|
|
31
|
+
units;
|
|
32
|
+
skills;
|
|
33
|
+
tenders;
|
|
34
|
+
identity;
|
|
35
|
+
acdf;
|
|
36
|
+
adrf;
|
|
37
|
+
swarms;
|
|
38
|
+
evidenceList = [];
|
|
39
|
+
constructor(world, store, keys, options) {
|
|
40
|
+
this.world = world;
|
|
41
|
+
this.store = store;
|
|
42
|
+
this.options = options;
|
|
43
|
+
this.signers = Object.fromEntries(Object.entries(keys).map(([name, pk]) => [name, new Wallet(pk, world.provider)]));
|
|
44
|
+
// a public endpoint is usually a cluster: after a receipt, wait until the cluster answers from that block before reading
|
|
45
|
+
this.tx = new TxManager(world.provider, store, this.signers, { awaitVisibility: world.file.network !== "anvil", ...options });
|
|
46
|
+
// reads are pinned to the last seen block (kernel/provider.ts); a read that had to give the pin up is part of the record
|
|
47
|
+
const pinned = world.provider;
|
|
48
|
+
if ("pin" in world.provider)
|
|
49
|
+
pinned.onLag = (e) => {
|
|
50
|
+
store.journal("pinned-read", e);
|
|
51
|
+
options.log?.(` rpc: ${e.method} at block ${e.block} refused ${e.attempts} time(s) by the endpoint (${e.nodeError}); ${e.outcome === "re-pinned" ? "read at the head the endpoint reports instead" : "giving up: the run stops here and resumes from its store"}`);
|
|
52
|
+
};
|
|
53
|
+
this.kya = new KYA(world);
|
|
54
|
+
this.aid = new AID(world);
|
|
55
|
+
this.asf = new ASF(world);
|
|
56
|
+
this.ard = new ARD(world);
|
|
57
|
+
this.units = new Units(world);
|
|
58
|
+
this.skills = new Skills(world);
|
|
59
|
+
this.tenders = new Tenders(world);
|
|
60
|
+
this.identity = new Identity8004(world);
|
|
61
|
+
this.acdf = new ACDF(world);
|
|
62
|
+
this.adrf = new ADRF(world);
|
|
63
|
+
this.swarms = new Swarms(world);
|
|
64
|
+
for (const id of Object.keys(world.file.contracts)) {
|
|
65
|
+
if (id === "identity8004")
|
|
66
|
+
continue;
|
|
67
|
+
try {
|
|
68
|
+
this.tx.decoder.add(world.iface(id), id);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* no ABI for this entry */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
this.tx.decoder.add(world.ifaceOf("foga:FogaCellAccount"), "foga:FogaCellAccount");
|
|
75
|
+
for (const key of ["swarm-ai-framework:SwarmAccount", "swarm-ai-framework:SwarmDecisionAdapter", "swarm-ai-framework:CompositeEligibility", "swarm-ai-framework:AssertionLevelEligibility", "acd-framework:ACDFTaskTenderAdapter", "kya-standard:Groth16KYAVerifierAdapter", "foga:Groth16PredicateKYAVerifierAdapter", "ai-dispute-resolution:ADRFErrors", "ai-dispute-resolution:IADRFDisputeRegistry", "ai-dispute-resolution:ADRFACDFBridge"]) {
|
|
76
|
+
try {
|
|
77
|
+
this.tx.decoder.add(world.ifaceOf(key), key);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* not in this bundle */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
this.tx.decoder.add(new Interface(ERC8004_ABI), "erc8004");
|
|
84
|
+
}
|
|
85
|
+
static async open(opts) {
|
|
86
|
+
const world = World.load(opts.worldPath, opts.rpc, opts.bundlePath);
|
|
87
|
+
const store = OperationStore.open(opts.storePath);
|
|
88
|
+
const net = await world.provider.getNetwork();
|
|
89
|
+
if (Number(net.chainId) !== world.chainId)
|
|
90
|
+
throw new Error(`RPC chain ${net.chainId} is not the world's chain ${world.chainId}`);
|
|
91
|
+
// Seed the read pin with the chain's head (kernel/provider.ts). A replayed run sees only the receipts of its old
|
|
92
|
+
// operations, whose blocks must not become the pin: reads are about the chain as it is now, not as it was then.
|
|
93
|
+
await world.provider.getBlockNumber();
|
|
94
|
+
// A store belongs to one chain instance and one world: the hash of block 0 tells a restarted local chain from
|
|
95
|
+
// a resumed one; the world id and chain id tell a store opened against the wrong world file.
|
|
96
|
+
const genesis = (await world.provider.getBlock(0))?.hash ?? "unknown";
|
|
97
|
+
const known = store.getMeta("genesisBlockHash");
|
|
98
|
+
if (known && known !== genesis) {
|
|
99
|
+
throw new Error(`operation store ${opts.storePath} belongs to a chain whose block 0 is ${known}; this RPC's block 0 is ${genesis}. The chain was reset: delete the store (or pass a fresh --store) to start over.`);
|
|
100
|
+
}
|
|
101
|
+
const knownWorld = store.getMeta("worldId");
|
|
102
|
+
if (knownWorld && knownWorld !== world.file.worldId) {
|
|
103
|
+
throw new Error(`operation store ${opts.storePath} belongs to world ${knownWorld}, not to ${world.file.worldId} (${opts.worldPath}). Point it at its own world file or start a fresh store.`);
|
|
104
|
+
}
|
|
105
|
+
const knownChain = store.getMeta("chainId");
|
|
106
|
+
if (knownChain && knownChain !== String(world.chainId)) {
|
|
107
|
+
throw new Error(`operation store ${opts.storePath} belongs to chain ${knownChain}, not to chain ${world.chainId}.`);
|
|
108
|
+
}
|
|
109
|
+
if (!known)
|
|
110
|
+
store.setMeta("genesisBlockHash", genesis);
|
|
111
|
+
if (!knownChain)
|
|
112
|
+
store.setMeta("chainId", String(world.chainId));
|
|
113
|
+
if (!knownWorld)
|
|
114
|
+
store.setMeta("worldId", world.file.worldId);
|
|
115
|
+
const { worldPath: _w, rpc: _r, bundlePath: _b, storePath: _s, keys, ...txOptions } = opts;
|
|
116
|
+
return new Kernel(world, store, keys, txOptions);
|
|
117
|
+
}
|
|
118
|
+
close() {
|
|
119
|
+
this.store.close();
|
|
120
|
+
}
|
|
121
|
+
get chainId() {
|
|
122
|
+
return this.world.chainId;
|
|
123
|
+
}
|
|
124
|
+
signer(name) {
|
|
125
|
+
return this.tx.signer(name);
|
|
126
|
+
}
|
|
127
|
+
address(signerName) {
|
|
128
|
+
return this.signer(signerName).address;
|
|
129
|
+
}
|
|
130
|
+
// ---------------------------------------------------------------------------- sending
|
|
131
|
+
/**
|
|
132
|
+
* Call a function on a contract as an EOA signer. `gasLimit` overrides the estimate for calls whose outcome
|
|
133
|
+
* depends on the gas left (a registry that treats a module's `false` with little gas left as unevaluable).
|
|
134
|
+
*/
|
|
135
|
+
async call(key, signer, target, iface, fn, args, value = 0n, kind = "call", gasLimit) {
|
|
136
|
+
return this.tx.send({ key, signer, kind, build: () => ({ to: target, data: iface.encodeFunctionData(fn, args), value, ...(gasLimit ? { gasLimit } : {}) }) });
|
|
137
|
+
}
|
|
138
|
+
/** Deploy a bundle contract as an EOA signer. The predicted address is derived from the signer's nonce. */
|
|
139
|
+
async deploy(key, signer, bundleKey, args = [], onPredict) {
|
|
140
|
+
const artifact = this.world.artifact(bundleKey);
|
|
141
|
+
if (!artifact.deployable)
|
|
142
|
+
throw new Error(`${bundleKey} is not deployable`);
|
|
143
|
+
if (Object.keys(artifact.linkReferences ?? {}).length)
|
|
144
|
+
throw new Error(`${bundleKey} needs library linking; not supported by Kernel.deploy`);
|
|
145
|
+
const iface = new Interface(artifact.abi);
|
|
146
|
+
const confirmed = await this.tx.send({
|
|
147
|
+
key,
|
|
148
|
+
signer,
|
|
149
|
+
kind: "deploy",
|
|
150
|
+
build: ({ nonce, from }) => {
|
|
151
|
+
const predicted = getCreateAddress({ from, nonce });
|
|
152
|
+
const finalArgs = (onPredict && onPredict(predicted)) || args;
|
|
153
|
+
const ctor = iface.deploy;
|
|
154
|
+
const data = artifact.bytecode + (ctor.inputs.length ? iface.encodeDeploy(finalArgs).slice(2) : "");
|
|
155
|
+
return { data };
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
const address = confirmed.receipt.contractAddress;
|
|
159
|
+
if (!address)
|
|
160
|
+
throw new Error(`deployment ${key} has no contract address in its receipt`);
|
|
161
|
+
return { address, confirmed };
|
|
162
|
+
}
|
|
163
|
+
async transfer(key, signer, to, value) {
|
|
164
|
+
return this.tx.send({ key, signer, kind: "transfer", build: () => ({ to, value }) });
|
|
165
|
+
}
|
|
166
|
+
// ---------------------------------------------------------------------------- facts and evidence
|
|
167
|
+
fact(key) {
|
|
168
|
+
return this.store.getFact(key);
|
|
169
|
+
}
|
|
170
|
+
requireFact(key) {
|
|
171
|
+
return this.store.requireFact(key);
|
|
172
|
+
}
|
|
173
|
+
setFact(key, value) {
|
|
174
|
+
this.store.setFact(key, value);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* A value fixed the first time it is computed and read back on every later run. Anything that enters a
|
|
178
|
+
* transaction and depends on the clock or on chain state at run time (deadlines, block numbers) goes through
|
|
179
|
+
* here, so a resumed run rebuilds exactly the inputs the recorded transactions were made from.
|
|
180
|
+
*/
|
|
181
|
+
async memo(key, compute) {
|
|
182
|
+
const have = this.store.getFact(key);
|
|
183
|
+
if (have !== undefined)
|
|
184
|
+
return have;
|
|
185
|
+
const value = await compute();
|
|
186
|
+
this.store.setFact(key, value);
|
|
187
|
+
return value;
|
|
188
|
+
}
|
|
189
|
+
/** Record what a reviewer needs to check a step; kept in the store and in the run's evidence file. */
|
|
190
|
+
evidence(step, label, values) {
|
|
191
|
+
const rec = { step, label, values, at: new Date().toISOString() };
|
|
192
|
+
this.evidenceList.push(rec);
|
|
193
|
+
this.store.setFact(`evidence.${step}.${label}`, values);
|
|
194
|
+
}
|
|
195
|
+
evidenceRecords() {
|
|
196
|
+
return [...this.evidenceList];
|
|
197
|
+
}
|
|
198
|
+
// ---------------------------------------------------------------------------- views
|
|
199
|
+
contract(id, runner) {
|
|
200
|
+
return this.world.contract(id, runner);
|
|
201
|
+
}
|
|
202
|
+
async balance(address) {
|
|
203
|
+
return this.world.provider.getBalance(address);
|
|
204
|
+
}
|
|
205
|
+
async blockNumber() {
|
|
206
|
+
return this.world.provider.getBlockNumber();
|
|
207
|
+
}
|
|
208
|
+
async blockTimestamp() {
|
|
209
|
+
return this.world.blockTimestamp();
|
|
210
|
+
}
|
|
211
|
+
/** True for a local development chain whose clock the kernel may move. */
|
|
212
|
+
get isLocalChain() {
|
|
213
|
+
return this.world.file.network === "anvil";
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Let `seconds` of chain time pass. On anvil the clock is moved and a block mined (recorded in the journal:
|
|
217
|
+
* a skipped interval is part of the evidence); on any other chain the kernel waits for real time.
|
|
218
|
+
*/
|
|
219
|
+
async advanceTime(seconds, why) {
|
|
220
|
+
const before = await this.blockTimestamp();
|
|
221
|
+
if (this.isLocalChain) {
|
|
222
|
+
await this.world.provider.send("evm_increaseTime", [seconds]);
|
|
223
|
+
await this.world.provider.send("evm_mine", []);
|
|
224
|
+
const after = await this.blockTimestamp();
|
|
225
|
+
this.store.journal("time-skipped", { seconds, from: before, to: after, why });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const target = before + seconds;
|
|
229
|
+
this.store.journal("time-waited", { seconds, from: before, until: target, why });
|
|
230
|
+
this.options.log?.(` wait: ${seconds} s of chain time (${why}); until ${new Date(target * 1000).toISOString()}`);
|
|
231
|
+
while ((await this.blockTimestamp()) < target)
|
|
232
|
+
await new Promise((r) => setTimeout(r, 5_000));
|
|
233
|
+
this.options.log?.(` wait: over (${why})`);
|
|
234
|
+
}
|
|
235
|
+
/** Decode the first event `name` from a receipt with the ABI of world contract `id` (or any interface). */
|
|
236
|
+
event(receipt, iface, name, address) {
|
|
237
|
+
for (const log of receipt.logs) {
|
|
238
|
+
if (address && log.address.toLowerCase() !== address.toLowerCase())
|
|
239
|
+
continue;
|
|
240
|
+
try {
|
|
241
|
+
const parsed = iface.parseLog({ topics: [...log.topics], data: log.data });
|
|
242
|
+
if (parsed && parsed.name === name)
|
|
243
|
+
return parsed.args;
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
/* not ours */
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
throw new Error(`event ${name} not found in receipt ${receipt.hash}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { JsonRpcProvider, toQuantity } from "ethers";
|
|
2
|
+
/**
|
|
3
|
+
* A JSON-RPC provider that reads what it has written. A public RPC URL usually fronts a cluster whose nodes import a
|
|
4
|
+
* block at slightly different times; a read or a gas estimate made right after a receipt can reach a node that has not
|
|
5
|
+
* seen the block yet and answer from the state before it (on Sepolia the roster of a swarm read as not met one second
|
|
6
|
+
* after its third join confirmed; a call opened in one block was "unknown" to the estimate of its delivery in the next).
|
|
7
|
+
*
|
|
8
|
+
* The provider keeps a pin: the highest block it has seen in a receipt, a block header or a block number. Every state
|
|
9
|
+
* read at "latest" (eth_call, eth_estimateGas, eth_getBalance, eth_getCode, eth_getStorageAt, eth_getTransactionCount at
|
|
10
|
+
* "latest") is sent at that block instead, so a node behind the pin answers with an error rather than with stale state,
|
|
11
|
+
* and the request is retried until a node that has the block answers. Explicit block tags, "pending" and everything that
|
|
12
|
+
* is not a state read pass through unchanged. On a single local node the pin is always the node's own tip.
|
|
13
|
+
*
|
|
14
|
+
* The pin must start at the chain's head (the kernel reads the block number when it opens): the receipts a replayed
|
|
15
|
+
* run reads back are old, and a pin seeded from them would make every read a read of the past — a document read as
|
|
16
|
+
* its earlier version, a swarm read as still active — which is what the first Sepolia replays did.
|
|
17
|
+
*/
|
|
18
|
+
const BLOCK_PARAM = { eth_call: 1, eth_estimateGas: 1, eth_getBalance: 1, eth_getCode: 1, eth_getTransactionCount: 1, eth_getStorageAt: 2 };
|
|
19
|
+
// what the clients of a public cluster say when asked about a block they do not (yet) have the state of: geth ("header
|
|
20
|
+
// not found", "missing trie node"), erigon and reth ("block not found"), nethermind ("historical state ... is not
|
|
21
|
+
// available", which it also says for a block one behind its own head), besu ("Block not found"), and a few gateways
|
|
22
|
+
const BEHIND = /header not found|unknown block|block not found|missing trie node|cannot query|beyond|out of range|unknown header|no state|historical state|state (is )?not available|not available|pruned|too old|invalid block|resource not found/i;
|
|
23
|
+
export class PinnedProvider extends JsonRpcProvider {
|
|
24
|
+
/** The highest block seen; reads are made no earlier than this. */
|
|
25
|
+
pin = 0;
|
|
26
|
+
/** Milliseconds to keep retrying a read that a lagging node refused before re-sampling the head and, failing that, raising. */
|
|
27
|
+
settleMs = 60_000;
|
|
28
|
+
/** Told when a pinned read ran out of retries (the kernel journals it). */
|
|
29
|
+
onLag;
|
|
30
|
+
constructor(url, network, options) {
|
|
31
|
+
super(url, network, options);
|
|
32
|
+
}
|
|
33
|
+
async send(method, params) {
|
|
34
|
+
const idx = BLOCK_PARAM[method];
|
|
35
|
+
const tag = idx === undefined ? undefined : params[idx];
|
|
36
|
+
const pinnable = idx !== undefined && this.pin > 0 && (tag === undefined || tag === null || tag === "latest");
|
|
37
|
+
if (!pinnable) {
|
|
38
|
+
const result = await super.send(method, params);
|
|
39
|
+
this.observe(method, params, result);
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
const pinned = [...params];
|
|
43
|
+
while (pinned.length <= idx)
|
|
44
|
+
pinned.push("latest");
|
|
45
|
+
const block = this.pin;
|
|
46
|
+
pinned[idx] = toQuantity(block);
|
|
47
|
+
const deadline = Date.now() + this.settleMs;
|
|
48
|
+
for (let attempt = 1;; attempt++) {
|
|
49
|
+
try {
|
|
50
|
+
return await super.send(method, pinned);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
// ethers wraps a node's error (a call's as CALL_EXCEPTION, "missing revert data"); the node's own words are in info.error
|
|
54
|
+
const text = [e?.info?.error?.message, e?.error?.message, e?.shortMessage, e?.message].filter(Boolean).join(" | ");
|
|
55
|
+
// a contract's revert is an answer, not a lag: it is never retried
|
|
56
|
+
const reverted = /execution reverted/i.test(text) || Boolean(e?.data) || Boolean(e?.revert);
|
|
57
|
+
if (reverted)
|
|
58
|
+
throw e;
|
|
59
|
+
const behind = BEHIND.test(text);
|
|
60
|
+
if (behind && Date.now() < deadline) {
|
|
61
|
+
await new Promise((r) => setTimeout(r, Math.min(2_000, 250 * attempt)));
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
// The lag outlasted the retries, or the refusal is one we do not recognize. A read at "latest" is not an
|
|
65
|
+
// option: the node that refuses an explicit block may be the one that is hours behind, and a stale answer is
|
|
66
|
+
// worse than no answer (it fails checks that hold on chain). Instead the head is sampled again: when the pin
|
|
67
|
+
// came from a node ahead of the rest, it is lowered to what the endpoint reports now and the read made there,
|
|
68
|
+
// once; otherwise the error is raised with the node's own words, and the run resumes from its store.
|
|
69
|
+
const head = await this.sampleHead();
|
|
70
|
+
if (head > 0 && head < block && pinned[idx] !== toQuantity(head)) {
|
|
71
|
+
this.pin = head;
|
|
72
|
+
pinned[idx] = toQuantity(head);
|
|
73
|
+
try {
|
|
74
|
+
const result = await super.send(method, pinned);
|
|
75
|
+
this.onLag?.({ method, block, attempts: attempt, nodeError: text, outcome: "re-pinned" });
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
catch (e2) {
|
|
79
|
+
this.raise(e2, method, block, attempt, text);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
this.raise(e, method, block, attempt, text);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** The highest block number three consecutive samples of the endpoint report (an outlier ahead is not followed twice). */
|
|
87
|
+
async sampleHead() {
|
|
88
|
+
const seen = [];
|
|
89
|
+
for (let i = 0; i < 3; i++) {
|
|
90
|
+
try {
|
|
91
|
+
seen.push(parseInt(await super.send("eth_blockNumber", []), 16));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
/* a sample that fails is left out */
|
|
95
|
+
}
|
|
96
|
+
if (i < 2)
|
|
97
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
98
|
+
}
|
|
99
|
+
seen.sort((a, b) => a - b);
|
|
100
|
+
return seen.length ? seen[Math.floor(seen.length / 2)] : 0;
|
|
101
|
+
}
|
|
102
|
+
raise(e, method, block, attempts, text) {
|
|
103
|
+
this.onLag?.({ method, block, attempts, nodeError: text, outcome: "raised" });
|
|
104
|
+
if (e && typeof e === "object" && typeof e.message === "string")
|
|
105
|
+
e.message += ` [read at block ${block} refused ${attempts} time${attempts === 1 ? "" : "s"} by the endpoint: ${text}]`;
|
|
106
|
+
throw e;
|
|
107
|
+
}
|
|
108
|
+
/** Move the pin forward from what the chain tells us. */
|
|
109
|
+
observe(method, params, result) {
|
|
110
|
+
let n = null;
|
|
111
|
+
if (method === "eth_blockNumber" && typeof result === "string")
|
|
112
|
+
n = parseInt(result, 16);
|
|
113
|
+
else if (method === "eth_getTransactionReceipt" && result?.blockNumber)
|
|
114
|
+
n = parseInt(result.blockNumber, 16);
|
|
115
|
+
else if (method === "eth_getBlockByNumber" && result?.number && (params[0] === "latest" || params[0] === "pending"))
|
|
116
|
+
n = parseInt(result.number, 16);
|
|
117
|
+
if (n !== null && Number.isFinite(n) && n > this.pin)
|
|
118
|
+
this.pin = n;
|
|
119
|
+
}
|
|
120
|
+
/** The kernel confirms a receipt: nothing is read before its block from now on. */
|
|
121
|
+
advanceTo(block) {
|
|
122
|
+
if (block > this.pin)
|
|
123
|
+
this.pin = block;
|
|
124
|
+
}
|
|
125
|
+
}
|