@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.
Files changed (131) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +216 -2
  4. package/artifacts/bundle.json +74250 -0
  5. package/artifacts/bundle.sha256 +1 -0
  6. package/bin/foga.js +14 -0
  7. package/build/known-interface-ids.json +18 -0
  8. package/build/profiles.json +120 -0
  9. package/dist/build/bundle.js +417 -0
  10. package/dist/build/vendor.js +74 -0
  11. package/dist/bundle/load.js +24 -0
  12. package/dist/bundle/types.js +1 -0
  13. package/dist/cli-cell.js +395 -0
  14. package/dist/cli.js +505 -0
  15. package/dist/core/acdf.js +162 -0
  16. package/dist/core/adrf.js +273 -0
  17. package/dist/core/aid.js +83 -0
  18. package/dist/core/ard.js +146 -0
  19. package/dist/core/asf.js +157 -0
  20. package/dist/core/canonical.js +79 -0
  21. package/dist/core/erc8004.js +52 -0
  22. package/dist/core/kya.js +225 -0
  23. package/dist/core/measure.js +96 -0
  24. package/dist/core/pack/cbor.js +130 -0
  25. package/dist/core/pack/skillpack.js +142 -0
  26. package/dist/core/skills.js +40 -0
  27. package/dist/core/swarm.js +206 -0
  28. package/dist/core/tenders.js +121 -0
  29. package/dist/core/units.js +122 -0
  30. package/dist/core/world.js +104 -0
  31. package/dist/demo/demander.js +76 -0
  32. package/dist/demo/evidence.js +1 -0
  33. package/dist/demo/loop-d.js +449 -0
  34. package/dist/doctor/checks.js +51 -0
  35. package/dist/doctor/doctor.js +385 -0
  36. package/dist/doctor/loop-d.js +103 -0
  37. package/dist/doctor/probe.js +97 -0
  38. package/dist/doctor/semantic.js +83 -0
  39. package/dist/genesis/extract.js +64 -0
  40. package/dist/genesis/funding.js +126 -0
  41. package/dist/genesis/operator-scope.js +90 -0
  42. package/dist/genesis/preflight.js +54 -0
  43. package/dist/genesis/probe.js +164 -0
  44. package/dist/genesis/roles.js +96 -0
  45. package/dist/genesis/run.js +71 -0
  46. package/dist/genesis/scenario.js +765 -0
  47. package/dist/genesis/skill-runner.js +103 -0
  48. package/dist/genesis/skill-worker.js +41 -0
  49. package/dist/genesis/t1-zk.js +62 -0
  50. package/dist/genesis/t3-zk.js +105 -0
  51. package/dist/genesis/t4.js +259 -0
  52. package/dist/genesis/t5.js +168 -0
  53. package/dist/genesis/t6.js +222 -0
  54. package/dist/genesis/t7.js +198 -0
  55. package/dist/genesis/t8.js +189 -0
  56. package/dist/genesis/t9.js +188 -0
  57. package/dist/genesis/zk.js +128 -0
  58. package/dist/http/server.js +276 -0
  59. package/dist/kernel/cell.js +104 -0
  60. package/dist/kernel/errors.js +78 -0
  61. package/dist/kernel/kernel.js +251 -0
  62. package/dist/kernel/provider.js +125 -0
  63. package/dist/kernel/store.js +470 -0
  64. package/dist/kernel/tx.js +385 -0
  65. package/dist/mcp/server.js +170 -0
  66. package/dist/node/config.js +195 -0
  67. package/dist/node/init.js +200 -0
  68. package/dist/node/keys.js +95 -0
  69. package/dist/node/migrate.js +173 -0
  70. package/dist/node/model/anthropic.js +97 -0
  71. package/dist/node/model/stub.js +129 -0
  72. package/dist/node/model/types.js +9 -0
  73. package/dist/node/node.js +198 -0
  74. package/dist/node/policy.js +95 -0
  75. package/dist/node/runner.js +256 -0
  76. package/dist/node/watchers.js +88 -0
  77. package/dist/tools/admin.js +184 -0
  78. package/dist/tools/catalog.js +261 -0
  79. package/dist/tools/common.js +143 -0
  80. package/dist/tools/identity.js +107 -0
  81. package/dist/tools/index.js +14 -0
  82. package/dist/tools/read.js +471 -0
  83. package/dist/tools/tenders.js +400 -0
  84. package/dist/tools/types.js +4 -0
  85. package/dist/util/bytecode.js +123 -0
  86. package/dist/util/fs.js +58 -0
  87. package/dist/util/hash.js +4 -0
  88. package/dist/util/log.js +11 -0
  89. package/dist/util/version.js +12 -0
  90. package/dist/world/deploy.js +277 -0
  91. package/dist/world/plan.js +93 -0
  92. package/dist/world/types.js +1 -0
  93. package/docs/build.md +45 -0
  94. package/docs/cell-account.md +58 -0
  95. package/docs/compatibility-matrix.md +38 -0
  96. package/docs/design-memo.md +113 -0
  97. package/docs/doctor.md +53 -0
  98. package/docs/examples/README.md +11 -0
  99. package/docs/examples/claude-code-mcp.json +13 -0
  100. package/docs/examples/claude_desktop_config.json +12 -0
  101. package/docs/examples/cursor-mcp.json +11 -0
  102. package/docs/genesis.md +312 -0
  103. package/docs/kernel.md +101 -0
  104. package/docs/loop-d.md +90 -0
  105. package/docs/mcp.md +105 -0
  106. package/docs/node.md +66 -0
  107. package/docs/version-lock.md +67 -0
  108. package/docs/world.md +50 -0
  109. package/docs/zk.md +48 -0
  110. package/package.json +82 -4
  111. package/prover/README.md +26 -0
  112. package/prover/build-predicate.mjs +77 -0
  113. package/prover/package-lock.json +1837 -0
  114. package/prover/package.json +15 -0
  115. package/prover/run.cjs +199 -0
  116. package/scenarios/fixtures/sample-filing.txt +16 -0
  117. package/scenarios/skills/finch-extract-verify/LICENSE +2 -0
  118. package/scenarios/skills/finch-extract-verify/SKILL.md +34 -0
  119. package/scenarios/skills/finch-extract-verify/fields.json +56 -0
  120. package/scenarios/skills/finch-extract-verify/manifest.json +16 -0
  121. package/scenarios/skills/foga-probe/LICENSE +2 -0
  122. package/scenarios/skills/foga-probe/SKILL.md +35 -0
  123. package/scenarios/skills/foga-probe/manifest.json +16 -0
  124. package/scenarios/skills/foga-probe/probe.schema.json +40 -0
  125. package/server.json +79 -0
  126. package/vendor/LOCK.json +107 -0
  127. package/vendor/README.md +13 -0
  128. package/worlds/local.json +34 -0
  129. package/worlds/sepolia.json +314 -0
  130. package/zk/build/verification_key.json +174 -0
  131. package/zk/circuits/kya_predicate_count_v1.circom +231 -0
@@ -0,0 +1,385 @@
1
+ import { keccak256 } from "ethers";
2
+ import { RevertDecoder } from "./errors.js";
3
+ export class OperationAmbiguous extends Error {
4
+ op;
5
+ constructor(op, message) {
6
+ super(message);
7
+ this.op = op;
8
+ this.name = "OperationAmbiguous";
9
+ }
10
+ }
11
+ export class OperationReverted extends Error {
12
+ key;
13
+ data;
14
+ constructor(key, message, data) {
15
+ super(message);
16
+ this.key = key;
17
+ this.data = data;
18
+ this.name = "OperationReverted";
19
+ }
20
+ }
21
+ /** Exit code of an injected crash (EX_TEMPFAIL): the run is incomplete and must be resumed. */
22
+ export const CRASH_EXIT_CODE = 75;
23
+ /**
24
+ * Sends transactions exactly once per key. The sequence for a new operation is: fix the nonce, build, sign,
25
+ * write the hash to the store, broadcast, wait, write the receipt. A resumed run finds the row and asks the chain
26
+ * before deciding: a receipt means done; a pending transaction is waited for; an unknown hash with the nonce
27
+ * still unconsumed is re-sent with that same nonce; an unknown hash with the nonce consumed is ambiguous and is
28
+ * never retried automatically.
29
+ *
30
+ * A transaction that is still pending when the receipt timeout passes (a public chain whose base fee rose after
31
+ * the quote) is replaced: the same payload and nonce, signed again with fees raised by a third, up to
32
+ * `maxReplacements` times. Every hash the operation ever had is in the journal, and inclusion of any of them
33
+ * completes the operation — the nonce makes them mutually exclusive.
34
+ */
35
+ export class TxManager {
36
+ provider;
37
+ store;
38
+ signers;
39
+ options;
40
+ decoder = new RevertDecoder();
41
+ log;
42
+ constructor(provider, store, signers, options = {}) {
43
+ this.provider = provider;
44
+ this.store = store;
45
+ this.signers = signers;
46
+ this.options = options;
47
+ this.log = options.log ?? (() => { });
48
+ }
49
+ signer(name) {
50
+ const w = this.signers[name];
51
+ if (!w)
52
+ throw new Error(`unknown signer ${name}`);
53
+ return w;
54
+ }
55
+ /** One transaction at a time per signer: nonces are taken and used in order, whoever asks concurrently. */
56
+ queues = new Map();
57
+ async send(req) {
58
+ const wallet = this.signer(req.signer);
59
+ const previous = this.queues.get(wallet.address) ?? Promise.resolve();
60
+ const turn = previous.catch(() => undefined).then(() => this.sendInTurn(req, wallet));
61
+ this.queues.set(wallet.address, turn);
62
+ try {
63
+ return await turn;
64
+ }
65
+ finally {
66
+ if (this.queues.get(wallet.address) === turn)
67
+ this.queues.delete(wallet.address);
68
+ }
69
+ }
70
+ async sendInTurn(req, wallet) {
71
+ let rec = this.store.prepare(req.key, req.kind ?? "tx", req.signer, req.binding);
72
+ if (rec.status === "confirmed" && rec.txHash) {
73
+ const receipt = await this.provider.getTransactionReceipt(rec.txHash);
74
+ if (!receipt) {
75
+ throw new OperationAmbiguous(rec, `operation ${req.key} is confirmed in the store (tx ${rec.txHash}) but the chain has no receipt for it: the store and the chain have diverged (was the chain reset?). Start from a fresh store or point at the right world.`);
76
+ }
77
+ this.store.journal("replayed", { txHash: rec.txHash }, req.key);
78
+ return { key: req.key, txHash: rec.txHash, receipt, replayed: true, recovered: false };
79
+ }
80
+ if (rec.status === "failed") {
81
+ // The failure is the operation's answer. A new attempt is a decision the caller makes explicitly (and re-checks
82
+ // its budget for); nothing is sent anew because a key was asked for again.
83
+ if (!(req.retryFailed ?? this.options.retryFailed)) {
84
+ this.store.journal("replayed-failure", { error: rec.error }, req.key);
85
+ throw new OperationReverted(req.key, `operation ${req.key} failed before (${rec.error}); the same key answers with that failure. Retry explicitly to send it again.`, null);
86
+ }
87
+ this.store.markRetry(req.key);
88
+ this.store.journal("retry", { previousError: rec.error, attempts: rec.attempts }, req.key);
89
+ rec = this.store.get(req.key);
90
+ }
91
+ let nonce;
92
+ let pendingBefore = null;
93
+ if (rec.status === "sent" && rec.txHash && rec.nonce !== null) {
94
+ const recovered = await this.recover(rec, wallet.address);
95
+ if (recovered.confirmed)
96
+ return recovered.confirmed;
97
+ nonce = rec.nonce; // the nonce is still the operation's: either free (re-send) or held by its pending transaction (replace)
98
+ pendingBefore = recovered.pending;
99
+ }
100
+ else {
101
+ nonce = await this.provider.getTransactionCount(wallet.address, "pending");
102
+ }
103
+ const populated = await this.populate(req, wallet, nonce);
104
+ if (pendingBefore) {
105
+ // the recorded transaction is still in the pool: raise its fees and take its place rather than wait again
106
+ const next = await this.replace(req, wallet, populated, pendingBefore);
107
+ // no replacement possible (limit or fee cap): the pending transaction is waited for as it is
108
+ const receipt = next ? await this.awaitInclusion(req, wallet, next.populated, next.hash) : await this.awaitOnly(req, pendingBefore.hash);
109
+ await this.ensureVisible(receipt.blockNumber);
110
+ return this.finish(req.key, receipt, true);
111
+ }
112
+ const signed = await wallet.signTransaction(populated);
113
+ const txHash = keccak256(signed);
114
+ // the value the operation moves: what the binding says the cell moves (an execute carries it inside), else the transaction's own
115
+ this.store.markSent(req.key, txHash, nonce, req.binding?.valueWei ?? BigInt(populated.value ?? 0));
116
+ this.store.journal("sent", { txHash, nonce, to: populated.to ?? null, value: (populated.value ?? 0n).toString(), kind: req.kind ?? "tx" }, req.key);
117
+ if (this.options.crashBeforeBroadcast === req.key)
118
+ this.crash(req.key, "before-broadcast");
119
+ await this.provider.broadcastTransaction(signed);
120
+ this.log(`${req.key}: sent ${txHash} (nonce ${nonce}, ${req.signer})`);
121
+ if (this.options.crashAfterBroadcast === req.key)
122
+ this.crash(req.key, "after-broadcast");
123
+ const receipt = await this.awaitInclusion(req, wallet, populated, txHash);
124
+ await this.ensureVisible(receipt.blockNumber);
125
+ return this.finish(req.key, receipt, false);
126
+ }
127
+ /** With `awaitVisibility`: wait (up to 60 s) until two consecutive block-number reads are at or past `block`. */
128
+ async ensureVisible(block) {
129
+ if (!this.options.awaitVisibility)
130
+ return;
131
+ const deadline = Date.now() + 60_000;
132
+ let agreeing = 0;
133
+ let lagged = false;
134
+ while (agreeing < 2 && Date.now() < deadline) {
135
+ const n = await this.provider.getBlockNumber();
136
+ if (n >= block)
137
+ agreeing++;
138
+ else {
139
+ agreeing = 0;
140
+ lagged = true;
141
+ }
142
+ if (agreeing < 2)
143
+ await new Promise((r) => setTimeout(r, 750));
144
+ }
145
+ if (lagged)
146
+ this.store.journal("visibility-waited", { block, caughtUp: agreeing >= 2 });
147
+ }
148
+ /** Build, estimate and populate the request for a nonce; a failing estimate marks the operation failed. */
149
+ async populate(req, wallet, nonce) {
150
+ const base = await req.build({ nonce, from: wallet.address });
151
+ try {
152
+ // The estimate is taken on the pending state; the inclusion state differs by at least the block timestamp
153
+ // (a heartbeat-style SSTORE that was a no-op during the estimate costs 2900 more when the second ticks),
154
+ // so a margin is added to every estimate rather than trusting it to the unit.
155
+ let gasLimit = base.gasLimit;
156
+ if (gasLimit === undefined || gasLimit === null) {
157
+ const estimate = await this.provider.estimateGas({ ...base, from: wallet.address, nonce });
158
+ gasLimit = estimate + estimate / 4n + 20000n;
159
+ }
160
+ const populated = await wallet.populateTransaction({ ...base, from: wallet.address, nonce, gasLimit });
161
+ const cap = req.maxFeePerGasCap ?? this.options.maxFeePerGasCap;
162
+ if (cap !== undefined && populated.maxFeePerGas !== null && populated.maxFeePerGas !== undefined && BigInt(populated.maxFeePerGas) > cap) {
163
+ populated.maxFeePerGas = cap;
164
+ if (populated.maxPriorityFeePerGas !== null && populated.maxPriorityFeePerGas !== undefined && BigInt(populated.maxPriorityFeePerGas) > cap)
165
+ populated.maxPriorityFeePerGas = cap;
166
+ this.store.journal("fee-capped", { maxFeePerGas: cap.toString() }, req.key);
167
+ }
168
+ return populated;
169
+ }
170
+ catch (e) {
171
+ const data = RevertDecoder.revertData(e);
172
+ const explained = this.decoder.explain(e);
173
+ this.store.markFailed(req.key, explained);
174
+ this.store.journal("estimate-failed", { error: explained }, req.key);
175
+ throw new OperationReverted(req.key, `operation ${req.key} would revert: ${explained}`, data);
176
+ }
177
+ }
178
+ /**
179
+ * Every hash this operation's current attempt was broadcast under, oldest first (the store row holds only the
180
+ * latest). An explicit retry starts a new attempt: the hashes before it belong to the failure that was retried and
181
+ * must not be waited for again (their receipt is the old revert).
182
+ */
183
+ hashesOf(key, latest) {
184
+ let out = [];
185
+ for (const j of this.store.journalEntries()) {
186
+ if (j.opKey !== key)
187
+ continue;
188
+ if (j.event === "retry") {
189
+ out = [];
190
+ continue;
191
+ }
192
+ if (!j.detail)
193
+ continue;
194
+ const h = j.event === "sent" || j.event === "replaced" ? j.detail.txHash : undefined;
195
+ if (h && !out.includes(h))
196
+ out.push(h);
197
+ }
198
+ if (!out.includes(latest))
199
+ out.push(latest);
200
+ return out;
201
+ }
202
+ /**
203
+ * Wait for the broadcast transaction's receipt. When the timeout passes and the transaction is still pending, it is
204
+ * replaced with higher fees (same nonce) until the operation's cumulative count reaches `maxReplacements` (the
205
+ * count is in the store, so a resumed run continues it rather than starting over) or the fee cap would be passed;
206
+ * when it has vanished from the pool and the nonce is free, it is broadcast again as it was; when any earlier hash
207
+ * of the operation was mined instead, that receipt completes it. After the last attempt the row stays `sent` and
208
+ * the next run resolves it from the chain (docs/kernel.md). A timeout on a local chain usually means the node is
209
+ * starved (another heavy process on the machine) rather than a lost transaction.
210
+ */
211
+ async awaitInclusion(req, wallet, populated, txHash) {
212
+ const timeoutMs = this.options.receiptTimeoutMs ?? 120_000;
213
+ const max = this.options.maxReplacements ?? 3;
214
+ let current = populated;
215
+ let hash = txHash;
216
+ for (;;) {
217
+ const receipt = await this.waitAny(req.key, hash, timeoutMs);
218
+ if (receipt)
219
+ return receipt;
220
+ const made = this.store.get(req.key)?.replacements ?? 0;
221
+ this.store.journal("receipt-timeout", { txHash: hash, timeoutMs, replacements: made }, req.key);
222
+ const pending = await this.provider.getTransaction(hash);
223
+ if (pending) {
224
+ if (made >= max) {
225
+ throw new Error(`operation ${req.key}: no receipt for ${hash} within ${timeoutMs / 1000} s after ${made} fee replacement${made === 1 ? "" : "s"} (still pending; the limit is ${max}). The row stays 'sent' and is recovered on the next run (the receipt is read if a transaction of this operation was mined, otherwise it is replaced or sent again). If the node is local, check that nothing else is saturating the machine (a proving-key build, for instance) and run again.`);
226
+ }
227
+ const next = await this.replace(req, wallet, current, pending);
228
+ if (!next) {
229
+ throw new Error(`operation ${req.key}: ${hash} is still pending after ${timeoutMs / 1000} s and raising its fees would pass the fee cap; the row stays 'sent' and is recovered on the next run.`);
230
+ }
231
+ current = next.populated;
232
+ hash = next.hash;
233
+ continue;
234
+ }
235
+ // not pending, not mined: either the nonce moved on (ambiguous) or the pool dropped it (send again as it was)
236
+ const consumed = await this.provider.getTransactionCount(wallet.address, "latest");
237
+ if (consumed > Number(current.nonce)) {
238
+ const rec = this.store.get(req.key);
239
+ this.store.journal("ambiguous", { txHash: hash, nonce: current.nonce, accountNonce: consumed }, req.key);
240
+ throw new OperationAmbiguous(rec, `operation ${req.key}: tx ${hash} is unknown to the node but nonce ${current.nonce} of ${wallet.address} has been consumed by another transaction. The outcome cannot be decided automatically; inspect the chain and mark the operation by hand.`);
241
+ }
242
+ const signed = await wallet.signTransaction(current);
243
+ this.store.journal("resend", { txHash: hash, nonce: current.nonce }, req.key);
244
+ this.log(`${req.key}: ${hash} left the pool unmined and nonce ${current.nonce} is free; sending again`);
245
+ await this.provider.broadcastTransaction(signed);
246
+ hash = keccak256(signed);
247
+ }
248
+ }
249
+ /** Wait for the operation's hashes without replacing (a resumed operation whose replacement is capped or at its limit). */
250
+ async awaitOnly(req, hash) {
251
+ const timeoutMs = this.options.receiptTimeoutMs ?? 120_000;
252
+ const receipt = await this.waitAny(req.key, hash, timeoutMs);
253
+ if (!receipt)
254
+ throw new Error(`operation ${req.key}: ${hash} is still pending and cannot be replaced (replacement limit or fee cap); the row stays 'sent' and is recovered on the next run.`);
255
+ return receipt;
256
+ }
257
+ /**
258
+ * Replace a pending transaction of the operation: the same payload and nonce with fees a third above the pending
259
+ * ones (and above the current quote), recorded as the operation's new hash and counted in the store. Returns
260
+ * null, without sending, when the raised fee would pass the cap or the cumulative limit is reached.
261
+ */
262
+ async replace(req, wallet, populated, pending) {
263
+ const made = this.store.get(req.key)?.replacements ?? 0;
264
+ const max = this.options.maxReplacements ?? 3;
265
+ if (made >= max) {
266
+ this.store.journal("replace-limit", { replacements: made, max }, req.key);
267
+ return null;
268
+ }
269
+ const bump = (v, floor) => {
270
+ const a = BigInt(v ?? 0n);
271
+ const b = BigInt(floor ?? 0n);
272
+ const base = a > b ? a : b;
273
+ return base + base / 3n + 1n;
274
+ };
275
+ const next = { ...populated };
276
+ const cap = req.maxFeePerGasCap ?? this.options.maxFeePerGasCap;
277
+ if (pending.maxFeePerGas !== null && pending.maxFeePerGas !== undefined) {
278
+ next.maxFeePerGas = bump(pending.maxFeePerGas, populated.maxFeePerGas);
279
+ next.maxPriorityFeePerGas = bump(pending.maxPriorityFeePerGas, populated.maxPriorityFeePerGas);
280
+ if (next.maxPriorityFeePerGas > next.maxFeePerGas)
281
+ next.maxFeePerGas = next.maxPriorityFeePerGas;
282
+ if (cap !== undefined && next.maxFeePerGas > cap) {
283
+ this.store.journal("replace-capped", { wanted: next.maxFeePerGas.toString(), cap: cap.toString() }, req.key);
284
+ return null;
285
+ }
286
+ }
287
+ else {
288
+ next.gasPrice = bump(pending.gasPrice, populated.gasPrice);
289
+ delete next.maxFeePerGas;
290
+ delete next.maxPriorityFeePerGas;
291
+ if (cap !== undefined && next.gasPrice > cap) {
292
+ this.store.journal("replace-capped", { wanted: next.gasPrice.toString(), cap: cap.toString() }, req.key);
293
+ return null;
294
+ }
295
+ }
296
+ const signed = await wallet.signTransaction(next);
297
+ const hash = keccak256(signed);
298
+ this.store.markReplaced(req.key, hash, Number(next.nonce));
299
+ const attempt = made + 1;
300
+ this.store.journal("replaced", { txHash: hash, replaces: pending.hash, nonce: next.nonce, attempt, maxFeePerGas: next.maxFeePerGas?.toString() ?? null, maxPriorityFeePerGas: next.maxPriorityFeePerGas?.toString() ?? null, gasPrice: next.gasPrice?.toString() ?? null }, req.key);
301
+ this.log(`${req.key}: ${pending.hash} still pending after the timeout; replaced by ${hash} with fees raised by a third (replacement ${attempt} of ${max})`);
302
+ try {
303
+ await this.provider.broadcastTransaction(signed);
304
+ }
305
+ catch (e) {
306
+ // the original was mined between the check and the broadcast, or the pool refuses the replacement: the wait decides
307
+ this.store.journal("replace-rejected", { txHash: hash, error: String(e?.shortMessage ?? e?.message ?? e) }, req.key);
308
+ this.log(`${req.key}: the node refused the replacement (${e?.shortMessage ?? e?.message}); waiting for whichever transaction is included`);
309
+ }
310
+ return { populated: next, hash };
311
+ }
312
+ /** The receipt of any hash the operation ever had, or null when none is mined within the timeout. */
313
+ async waitAny(key, latest, timeoutMs) {
314
+ const hashes = this.hashesOf(key, latest);
315
+ const deadline = Date.now() + timeoutMs;
316
+ const poll = this.provider.pollingInterval ?? 4_000;
317
+ for (;;) {
318
+ for (const h of hashes) {
319
+ const r = await this.provider.getTransactionReceipt(h);
320
+ if (r)
321
+ return r;
322
+ }
323
+ if (Date.now() >= deadline)
324
+ return null;
325
+ await new Promise((r) => setTimeout(r, Math.min(poll, Math.max(0, deadline - Date.now()))));
326
+ }
327
+ }
328
+ /**
329
+ * Decide what a `sent` row means by asking the chain about every hash the operation had: a receipt completes it;
330
+ * a pending transaction is returned for replacement; an unknown hash with the nonce still free means re-send; an
331
+ * unknown hash with the nonce consumed is ambiguous and is never retried automatically.
332
+ */
333
+ async recover(rec, from) {
334
+ for (const h of this.hashesOf(rec.key, rec.txHash)) {
335
+ const receipt = await this.provider.getTransactionReceipt(h);
336
+ if (receipt) {
337
+ this.store.journal("recovered-receipt", { txHash: h, block: receipt.blockNumber }, rec.key);
338
+ this.log(`${rec.key}: recovered receipt for ${h} from block ${receipt.blockNumber}; not re-sent`);
339
+ await this.ensureVisible(receipt.blockNumber);
340
+ return { confirmed: this.finish(rec.key, receipt, true), pending: null };
341
+ }
342
+ }
343
+ const pending = await this.provider.getTransaction(rec.txHash);
344
+ if (pending) {
345
+ this.store.journal("recovered-pending", { txHash: rec.txHash }, rec.key);
346
+ this.log(`${rec.key}: ${rec.txHash} is still pending; replacing it with higher fees`);
347
+ return { confirmed: null, pending };
348
+ }
349
+ const consumed = await this.provider.getTransactionCount(from, "latest");
350
+ if (consumed > rec.nonce) {
351
+ this.store.journal("ambiguous", { txHash: rec.txHash, nonce: rec.nonce, accountNonce: consumed }, rec.key);
352
+ throw new OperationAmbiguous(rec, `operation ${rec.key}: tx ${rec.txHash} is unknown to the node but nonce ${rec.nonce} of ${from} has been consumed by another transaction. The outcome cannot be decided automatically; inspect the chain and mark the operation by hand.`);
353
+ }
354
+ this.store.journal("resend", { txHash: rec.txHash, nonce: rec.nonce }, rec.key);
355
+ this.log(`${rec.key}: ${rec.txHash} never reached the chain and nonce ${rec.nonce} is free; sending again`);
356
+ return { confirmed: null, pending: null };
357
+ }
358
+ finish(key, receipt, recovered) {
359
+ const price = receipt.gasPrice ?? 0n;
360
+ const gasCost = receipt.gasUsed * price;
361
+ if (receipt.status === 0) {
362
+ this.store.markFailed(key, `reverted on chain in tx ${receipt.hash}`, gasCost);
363
+ this.store.journal("reverted", { txHash: receipt.hash, block: receipt.blockNumber, gasUsed: receipt.gasUsed.toString(), effectiveGasPrice: price.toString() }, key);
364
+ throw new OperationReverted(key, `operation ${key} reverted on chain (tx ${receipt.hash})`, null);
365
+ }
366
+ this.store.markConfirmed(key, receipt.hash, receipt.blockNumber, {
367
+ txHash: receipt.hash,
368
+ blockNumber: receipt.blockNumber,
369
+ blockHash: receipt.blockHash,
370
+ gasUsed: receipt.gasUsed.toString(),
371
+ effectiveGasPrice: price.toString(),
372
+ from: receipt.from,
373
+ to: receipt.to,
374
+ contractAddress: receipt.contractAddress,
375
+ logs: receipt.logs.length,
376
+ }, gasCost);
377
+ this.store.journal("confirmed", { txHash: receipt.hash, block: receipt.blockNumber, gasUsed: receipt.gasUsed.toString(), effectiveGasPrice: price.toString() }, key);
378
+ return { key, txHash: receipt.hash, receipt, replayed: false, recovered };
379
+ }
380
+ crash(key, where) {
381
+ this.store.journal("crash-injected", { where }, key);
382
+ this.log(`${key}: injected crash (${where}); exiting with code ${CRASH_EXIT_CODE}`);
383
+ process.exit(CRASH_EXIT_CODE);
384
+ }
385
+ }
@@ -0,0 +1,170 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { McpServer } from "@modelcontextprotocol/server";
4
+ import { z } from "zod";
5
+ import { fogaCellSpecification } from "../core/asf.js";
6
+ import { ACTION_RANK } from "../tools/index.js";
7
+ import { repoRoot } from "../util/fs.js";
8
+ const READ_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
9
+ const ACT_ANNOTATIONS = { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true };
10
+ const GENTLE_ACT_ANNOTATIONS = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true };
11
+ const SIMULATE_ANNOTATIONS = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true };
12
+ const BUILD_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
13
+ /** Acts that commit nothing and move nothing: their act tool is not marked destructive. */
14
+ const GENTLE_ACTS = new Set(["tender_precheck", "aid_heartbeat"]);
15
+ const text = (value) => JSON.stringify(value, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 2);
16
+ function success(structured) {
17
+ return { content: [{ type: "text", text: text(structured) }], structuredContent: structured };
18
+ }
19
+ function refusal(r) {
20
+ const structured = { ok: false, gate: r.gate, reason: r.reason, decisionId: r.decisionId ?? null, jobId: r.jobId ?? null };
21
+ return { isError: true, content: [{ type: "text", text: `refused at ${r.gate}: ${r.reason}` }], structuredContent: structured };
22
+ }
23
+ /** The output schema of an act tool: the capability's output under `output`, or a refusal's fields. */
24
+ function actOutputSchema(cap) {
25
+ return z.object({
26
+ ok: z.boolean(),
27
+ gate: z.string().optional(),
28
+ reason: z.string().optional(),
29
+ decisionId: z.number().nullable().optional(),
30
+ jobId: z.string().nullable().optional(),
31
+ operations: z.array(z.string()).optional(),
32
+ output: cap.output.optional(),
33
+ });
34
+ }
35
+ const buildOutputSchema = z.object({
36
+ ok: z.boolean(),
37
+ as: z.string().optional(),
38
+ calls: z.array(z.object({ to: z.string(), value: z.string(), data: z.string(), label: z.string() })).optional(),
39
+ gate: z.string().optional(),
40
+ reason: z.string().optional(),
41
+ decisionId: z.number().nullable().optional(),
42
+ });
43
+ const precheckOutputSchema = (cap) => z.object({
44
+ eligible: z.boolean(),
45
+ gate: z.string().optional(),
46
+ reason: z.string().optional(),
47
+ decisionId: z.number().nullable().optional(),
48
+ jobId: z.string().nullable().optional(),
49
+ result: cap.output.optional(),
50
+ });
51
+ /** A read output that is not an object is carried under `result` (structuredContent must be an object). */
52
+ function readOutputSchema(cap) {
53
+ const json = z.toJSONSchema(cap.output, { target: "draft-2020-12", io: "output" });
54
+ if (json.type === "object")
55
+ return { schema: cap.output, wrap: false };
56
+ return { schema: z.object({ result: cap.output }), wrap: true };
57
+ }
58
+ export function buildMcpServer(o) {
59
+ const { node, catalog, caller } = o;
60
+ const cell = node.cell;
61
+ const server = new McpServer({ name: "foga", title: `FOGA node ${node.config.name}`, version: o.version }, {
62
+ instructions: [
63
+ `This is the Finch OG Agent node of cell ${node.config.name}${cell ? ` (account ${cell.address})` : ""} on world ${node.world.file.worldId} (chain ${node.chainId}).`,
64
+ "Reads need nothing. A write capability is three tools: the act itself, `_simulate` (every check and a dry run, a decision record, nothing sent) and `_build` (the calls for your own account, pass `as`).",
65
+ "To take a tender: tender_inspect, then tender_precheck (eligible: false names the gate), skill_run with the task's input, tender_submit with the run's resultHash, tender_settle with the answer.",
66
+ "A refused act returns isError with gate, reason and decisionId; the decision record is the node's account of why. Nothing you say changes a gate: policy is code.",
67
+ ].join(" "),
68
+ });
69
+ for (const summary of catalog.list(caller)) {
70
+ const cap = catalog.get(summary.name);
71
+ if (cap.action === "read") {
72
+ const { schema, wrap } = readOutputSchema(cap);
73
+ server.registerTool(cap.name, { title: cap.title, description: cap.description, inputSchema: cap.input, outputSchema: schema, annotations: READ_ANNOTATIONS }, async (args) => {
74
+ const r = await catalog.invoke(node, cap.name, args ?? {}, caller);
75
+ if (!r.ok)
76
+ return refusal(r);
77
+ return success(wrap ? { result: r.output } : r.output);
78
+ });
79
+ continue;
80
+ }
81
+ if (cap.action === "admin")
82
+ continue;
83
+ const act = cap;
84
+ const modes = summary.modes;
85
+ if (modes.includes("act")) {
86
+ if (act.name === "tender_precheck") {
87
+ server.registerTool(act.name, { title: act.title, description: act.description, inputSchema: act.input, outputSchema: precheckOutputSchema(act), annotations: GENTLE_ACT_ANNOTATIONS }, async (args) => {
88
+ const r = await catalog.invoke(node, act.name, args ?? {}, caller, { mode: "act" });
89
+ if (!r.ok)
90
+ return success({ eligible: false, gate: r.gate, reason: r.reason, decisionId: r.decisionId ?? null, jobId: r.jobId ?? null });
91
+ return success({ eligible: true, decisionId: r.decisionId ?? null, jobId: r.jobId ?? null, result: r.output });
92
+ });
93
+ }
94
+ else {
95
+ server.registerTool(act.name, { title: act.title, description: act.description, inputSchema: act.input, outputSchema: actOutputSchema(act), annotations: GENTLE_ACTS.has(act.name) ? GENTLE_ACT_ANNOTATIONS : ACT_ANNOTATIONS }, async (args) => {
96
+ const r = await catalog.invoke(node, act.name, args ?? {}, caller, { mode: "act" });
97
+ if (!r.ok)
98
+ return refusal(r);
99
+ return success({ ok: true, decisionId: r.decisionId ?? null, jobId: r.jobId ?? null, operations: r.operations ?? [], output: r.output });
100
+ });
101
+ }
102
+ }
103
+ if (modes.includes("simulate")) {
104
+ const name = `${act.name}_simulate`;
105
+ const schema = act.name === "tender_precheck" ? precheckOutputSchema(act) : actOutputSchema(act);
106
+ server.registerTool(name, { title: `${act.title} (dry run)`, description: `${act.description} Dry run: every check and a simulation of the first call, a decision record, nothing sent.`, inputSchema: act.input, outputSchema: schema, annotations: SIMULATE_ANNOTATIONS }, async (args) => {
107
+ const r = await catalog.invoke(node, act.name, args ?? {}, caller, { mode: "simulate" });
108
+ if (act.name === "tender_precheck") {
109
+ if (!r.ok)
110
+ return success({ eligible: false, gate: r.gate, reason: r.reason, decisionId: r.decisionId ?? null, jobId: r.jobId ?? null });
111
+ return success({ eligible: true, decisionId: r.decisionId ?? null, jobId: r.jobId ?? null, result: r.output });
112
+ }
113
+ if (!r.ok)
114
+ return refusal(r);
115
+ return success({ ok: true, decisionId: r.decisionId ?? null, jobId: r.jobId ?? null, output: r.output });
116
+ });
117
+ }
118
+ if (modes.includes("build") && act.name !== "tender_precheck" && act.name !== "skill_run") {
119
+ const name = `${act.name}_build`;
120
+ server.registerTool(name, { title: `${act.title} (calldata)`, description: `${act.description} Build: the unsigned calls for the account named in \`as\` (your own), to sign and send yourself.`, inputSchema: act.input, outputSchema: buildOutputSchema, annotations: BUILD_ANNOTATIONS }, async (args) => {
121
+ const r = await catalog.invoke(node, act.name, args ?? {}, caller, { mode: "build" });
122
+ if (!r.ok)
123
+ return refusal(r);
124
+ return success({ ok: true, ...r.output, decisionId: r.decisionId ?? null });
125
+ });
126
+ }
127
+ }
128
+ // resources: what a model reads rules and state from
129
+ server.registerResource("world", "foga://world", { title: "The world", description: "World id, chain, contracts, bundle hash.", mimeType: "application/json" }, async (uri) => {
130
+ const r = await catalog.invoke(node, "world_describe", {}, caller);
131
+ return { contents: [{ uri: uri.href, mimeType: "application/json", text: text(r.ok ? r.output : r) }] };
132
+ });
133
+ if (cell) {
134
+ server.registerResource("cell-status", "foga://cell/status", { title: "The cell's status", description: "Anchor, binding, skills, policy summary (and for the operator the budget and open work).", mimeType: "application/json" }, async (uri) => {
135
+ const r = await catalog.invoke(node, "cell_status", {}, caller);
136
+ return { contents: [{ uri: uri.href, mimeType: "application/json", text: text(r.ok ? r.output : r) }] };
137
+ });
138
+ }
139
+ server.registerResource("policy", "foga://policy", { title: "The cell's policy", description: "Budget and tender rules the policy engine applies to every act.", mimeType: "application/json" }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "application/json", text: text(node.config.policy) }] }));
140
+ server.registerResource("spec-foga-cell-v1", "foga://spec/foga-cell-v1", { title: "FOGA-Cell-v1", description: "The ERC-8468 safety specification every FOGA cell adopts.", mimeType: "application/json" }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "application/json", text: text(fogaCellSpecification()) }] }));
141
+ const matrix = join(repoRoot(), "docs", "compatibility-matrix.md");
142
+ if (existsSync(matrix)) {
143
+ server.registerResource("compatibility-matrix", "foga://docs/compatibility-matrix", { title: "Compatibility matrix", description: "The ten standards, what the kernel exercises of each, and the evidence.", mimeType: "text/markdown" }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: readFileSync(matrix, "utf8") }] }));
144
+ }
145
+ // the one prompt: take a tender as the node's operator would
146
+ server.registerPrompt("take-tender", { title: "Take a tender", description: "Work one ERC-8414 tender the way the node's operator would: inspect, pre-check, run the skill, submit, settle.", argsSchema: z.object({ tokenId: z.string().describe("the tender's token id") }) }, ({ tokenId }) => ({
147
+ messages: [
148
+ {
149
+ role: "user",
150
+ content: {
151
+ type: "text",
152
+ text: [
153
+ `Take tender ${tokenId} for cell ${node.config.name} if the cell's rules allow it.`,
154
+ `1. tender_inspect {"tokenId":"${tokenId}"}: read the task document (skill, packageHash, input, acceptance).`,
155
+ `2. tender_precheck {"tokenId":"${tokenId}"}: if eligible is false, stop and report the gate and reason — do not look for a way around it.`,
156
+ `3. skill_run {"skill":<the task's skill>,"input":<the task's input>,"tokenId":"${tokenId}"}: the run returns answer, answerHash and resultHash.`,
157
+ `4. tender_submit {"tokenId":"${tokenId}","resultHash":<resultHash>,"runReceipt":<runReceipt>}.`,
158
+ `5. tender_settle {"tokenId":"${tokenId}","submissionId":<from the submit>,"answer":<answer>}; then credit_withdraw if creditWei is above zero.`,
159
+ "Report every decisionId you were given. A refusal is the node's verdict, not an obstacle.",
160
+ ].join("\n"),
161
+ },
162
+ },
163
+ ],
164
+ }));
165
+ return server;
166
+ }
167
+ /** The highest mode a caller gets over MCP, for the server's own description of itself. */
168
+ export function callerSummary(caller) {
169
+ return `${caller.id} (${caller.entrance}; level ${caller.level}, ${caller.visibility} data${ACTION_RANK[caller.level] >= ACTION_RANK.act ? "; may act" : ""})`;
170
+ }