@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,385 @@
|
|
|
1
|
+
import { Interface } from "ethers";
|
|
2
|
+
import { relative } from "node:path";
|
|
3
|
+
import { loadBundle } from "../bundle/load.js";
|
|
4
|
+
import { runtimeIdentity } from "../util/bytecode.js";
|
|
5
|
+
import { loadGenesisEvidence, semanticVerdicts } from "./semantic.js";
|
|
6
|
+
import { loadLoopDEvidence, loopDVerdicts } from "./loop-d.js";
|
|
7
|
+
import { readJson, repoRoot, writeJson } from "../util/fs.js";
|
|
8
|
+
import { log } from "../util/log.js";
|
|
9
|
+
import { READOUTS, WIRING_CHECKS } from "./checks.js";
|
|
10
|
+
import { buildProbePlan, executeProbeLive } from "./probe.js";
|
|
11
|
+
const LAYERS = {
|
|
12
|
+
"1": "chain and code: the address holds code on the expected chain",
|
|
13
|
+
"2": "interfaces: ERC-165 declarations against the interfaces the contract is built from",
|
|
14
|
+
"3": "version match: runtime code (metadata stripped, library/immutable slots masked) equals the locked build",
|
|
15
|
+
"4": "configuration and permissions: dependency addresses and authority slots match the world",
|
|
16
|
+
"5": "semantic validation: success, rejection, signature, proof and recovery cases, from the genesis evidence (foga genesis run; pass it with --genesis)",
|
|
17
|
+
D: "Loop D: an unscripted run judged by its records — every operation under an allowed decision, refusals that broadcast nothing, the budget books, the operator key's transactions, the demander's cases, recovery, the entrances (foga demo --loop-d; pass the evidence with --loop-d)",
|
|
18
|
+
};
|
|
19
|
+
function worst(a, b) {
|
|
20
|
+
const order = ["Fail", "Unknown", "Pass", "NotTested", "NotApplicable"];
|
|
21
|
+
return order.indexOf(a) <= order.indexOf(b) ? a : b;
|
|
22
|
+
}
|
|
23
|
+
function fmt(v) {
|
|
24
|
+
if (typeof v === "bigint")
|
|
25
|
+
return v.toString();
|
|
26
|
+
if (Array.isArray(v))
|
|
27
|
+
return `[${v.map(fmt).join(", ")}]`;
|
|
28
|
+
return String(v);
|
|
29
|
+
}
|
|
30
|
+
function same(expected, actual) {
|
|
31
|
+
return fmt(expected).toLowerCase() === fmt(actual).toLowerCase();
|
|
32
|
+
}
|
|
33
|
+
function decodeBool(hex) {
|
|
34
|
+
if (!hex || hex === "0x" || hex.length < 66)
|
|
35
|
+
return false;
|
|
36
|
+
return BigInt(hex.slice(0, 66)) === 1n;
|
|
37
|
+
}
|
|
38
|
+
export function loadWorldFile(path) {
|
|
39
|
+
const world = readJson(path);
|
|
40
|
+
if (world.schema !== "foga.world/1")
|
|
41
|
+
throw new Error(`${path} is not a world file (schema ${String(world.schema)}); use \`foga world attach\` for a config, or --attach`);
|
|
42
|
+
return world;
|
|
43
|
+
}
|
|
44
|
+
export function evaluate(world, bundle, plan, probe, mode, genesis, loopD) {
|
|
45
|
+
const results = [];
|
|
46
|
+
const readouts = [];
|
|
47
|
+
const perContract = {};
|
|
48
|
+
const expectations = world.wiring?.expectations ?? {};
|
|
49
|
+
const r = (key) => probe.results[key];
|
|
50
|
+
const hexOf = (key) => {
|
|
51
|
+
const o = r(key);
|
|
52
|
+
return o && "result" in o ? o.result : undefined;
|
|
53
|
+
};
|
|
54
|
+
const chainHex = hexOf("chainId");
|
|
55
|
+
const chainId = chainHex ? Number(BigInt(chainHex)) : null;
|
|
56
|
+
const blockHex = hexOf("blockNumber");
|
|
57
|
+
const blockNumber = blockHex ? Number(BigInt(blockHex)) : null;
|
|
58
|
+
results.push({ layer: 1, subject: "world", check: "chainId", verdict: chainId === null ? "Unknown" : chainId === world.chainId ? "Pass" : "Fail", expected: String(world.chainId), actual: chainId === null ? "(no answer)" : String(chainId) });
|
|
59
|
+
if (world.bundleHash && world.bundleHash !== bundle.hash) {
|
|
60
|
+
results.push({ layer: 3, subject: "world", check: "bundleHash", verdict: "Unknown", expected: world.bundleHash, actual: bundle.hash, detail: "world was deployed from a different bundle build; layer-3 verdicts compare against the current bundle" });
|
|
61
|
+
}
|
|
62
|
+
if (plan.bundleHash !== bundle.hash) {
|
|
63
|
+
results.push({ layer: 3, subject: "probe", check: "bundleHash", verdict: "Unknown", expected: bundle.hash, actual: plan.bundleHash, detail: "probe plan was built from another bundle" });
|
|
64
|
+
}
|
|
65
|
+
const ifacesByRepo = new Map();
|
|
66
|
+
for (const c of Object.values(bundle.contracts)) {
|
|
67
|
+
if (c.kind === "interface" && c.interfaceId) {
|
|
68
|
+
const l = ifacesByRepo.get(c.repo) ?? [];
|
|
69
|
+
l.push(c);
|
|
70
|
+
ifacesByRepo.set(c.repo, l);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const [id, entry] of Object.entries(world.contracts)) {
|
|
74
|
+
const address = entry.address;
|
|
75
|
+
const art = entry.bundleKey ? bundle.contracts[entry.bundleKey] : undefined;
|
|
76
|
+
const codeOutcome = r(`code:${id}`);
|
|
77
|
+
let l1;
|
|
78
|
+
let code = "0x";
|
|
79
|
+
if (!codeOutcome)
|
|
80
|
+
l1 = "NotTested";
|
|
81
|
+
else if ("error" in codeOutcome)
|
|
82
|
+
l1 = "Unknown";
|
|
83
|
+
else {
|
|
84
|
+
code = codeOutcome.result;
|
|
85
|
+
l1 = code !== "0x" ? "Pass" : "Fail";
|
|
86
|
+
}
|
|
87
|
+
results.push({ layer: 1, subject: id, check: "code", verdict: l1, actual: l1 === "Pass" ? `${(code.length - 2) / 2} bytes` : codeOutcome && "error" in codeOutcome ? codeOutcome.error : "no code at address" });
|
|
88
|
+
if (l1 !== "Pass") {
|
|
89
|
+
perContract[id] = { address, layer1: l1, layer2: "NotTested", layer3: "NotTested", layer4: "NotTested" };
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
let l2 = "NotApplicable";
|
|
93
|
+
if (!art && id === "identity8004" && r(`iface:${id}:erc165`)) {
|
|
94
|
+
const erc165 = decodeBool(hexOf(`iface:${id}:erc165`));
|
|
95
|
+
const erc721 = decodeBool(hexOf(`iface:${id}:ERC721`));
|
|
96
|
+
l2 = erc165 && erc721 ? "Pass" : "Fail";
|
|
97
|
+
results.push({ layer: 2, subject: id, check: "supportsInterface(0x01ffc9a7)", verdict: erc165 ? "Pass" : "Fail" });
|
|
98
|
+
results.push({ layer: 2, subject: id, check: "supportsInterface(ERC-721 0x80ac58cd)", verdict: erc721 ? "Pass" : "Fail", detail: "the ERC-8004 Identity Registry is an ERC-721 registry; FOGA does not build it, so this is the only interface check available" });
|
|
99
|
+
}
|
|
100
|
+
else if (!art) {
|
|
101
|
+
results.push({ layer: 2, subject: id, check: "supportsInterface", verdict: "Unknown", detail: "no bundle key for this address" });
|
|
102
|
+
l2 = "Unknown";
|
|
103
|
+
}
|
|
104
|
+
else if (!r(`iface:${id}:erc165`)) {
|
|
105
|
+
results.push({ layer: 2, subject: id, check: "supportsInterface", verdict: "NotApplicable", detail: "contract does not expose ERC-165" });
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const erc165 = decodeBool(hexOf(`iface:${id}:erc165`));
|
|
109
|
+
l2 = erc165 ? "Pass" : "Fail";
|
|
110
|
+
results.push({ layer: 2, subject: id, check: "supportsInterface(0x01ffc9a7)", verdict: l2 });
|
|
111
|
+
const repoIfaces = ifacesByRepo.get(art.repo) ?? [];
|
|
112
|
+
const expected = new Set(art.baseContracts.filter((b) => repoIfaces.some((i) => i.name === b)));
|
|
113
|
+
const declared = [];
|
|
114
|
+
for (const iface of repoIfaces) {
|
|
115
|
+
const ok = decodeBool(hexOf(`iface:${id}:${iface.name}`));
|
|
116
|
+
if (ok)
|
|
117
|
+
declared.push(`${iface.name}=${iface.interfaceId}`);
|
|
118
|
+
if (expected.has(iface.name)) {
|
|
119
|
+
const published = bundle.interfaceIds[art.repo]?.[iface.name]?.upstream != null;
|
|
120
|
+
const v = ok ? "Pass" : published ? "Fail" : "Unknown";
|
|
121
|
+
l2 = worst(l2, v);
|
|
122
|
+
results.push({
|
|
123
|
+
layer: 2,
|
|
124
|
+
subject: id,
|
|
125
|
+
check: `supportsInterface(${iface.name})`,
|
|
126
|
+
verdict: v,
|
|
127
|
+
expected: iface.interfaceId ?? undefined,
|
|
128
|
+
detail: ok
|
|
129
|
+
? undefined
|
|
130
|
+
: published
|
|
131
|
+
? entry.deployedRevision
|
|
132
|
+
? `known: deployed from ${entry.deployedRevision}; the interface id in the locked source may differ from the one that revision declares`
|
|
133
|
+
: "built from a published interface but does not declare it"
|
|
134
|
+
: "built from this interface but does not declare it; upstream publishes no id for it, so this is a design choice rather than a defect",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (declared.length)
|
|
139
|
+
results.push({ layer: 2, subject: id, check: "declared", verdict: "Pass", actual: declared.join(", "), detail: "interfaces of the same repository the contract reports as supported" });
|
|
140
|
+
}
|
|
141
|
+
let l3;
|
|
142
|
+
if (!art && id === "identity8004") {
|
|
143
|
+
l3 = "NotApplicable";
|
|
144
|
+
results.push({ layer: 3, subject: id, check: "runtimeIdentity", verdict: l3, detail: "official ERC-8004 registry, not built by FOGA; no locked build to compare against" });
|
|
145
|
+
}
|
|
146
|
+
else if (!art) {
|
|
147
|
+
l3 = "Unknown";
|
|
148
|
+
results.push({ layer: 3, subject: id, check: "runtimeIdentity", verdict: l3, detail: "no bundle key" });
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
const onChain = runtimeIdentity(code, art.deployedLinkReferences, art.immutableReferences);
|
|
152
|
+
l3 = onChain === art.runtimeIdentity ? "Pass" : "Fail";
|
|
153
|
+
results.push({
|
|
154
|
+
layer: 3,
|
|
155
|
+
subject: id,
|
|
156
|
+
check: "runtimeIdentity",
|
|
157
|
+
verdict: l3,
|
|
158
|
+
expected: art.runtimeIdentity,
|
|
159
|
+
actual: onChain,
|
|
160
|
+
detail: l3 === "Fail" ? (entry.deployedRevision ? `known: deployed from ${entry.deployedRevision}; bundle is built from the locked commit` : "runtime code differs from the locked build") : `${art.runtimeCodeSize} bytes, ${art.compiler}`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
perContract[id] = { address, layer1: l1, layer2: l2, layer3: l3, layer4: "NotApplicable" };
|
|
164
|
+
}
|
|
165
|
+
for (const check of WIRING_CHECKS) {
|
|
166
|
+
const subject = world.contracts[check.subject];
|
|
167
|
+
if (!subject)
|
|
168
|
+
continue;
|
|
169
|
+
const label = check.label ?? check.fn;
|
|
170
|
+
const key = `wire:${check.subject}:${label}`;
|
|
171
|
+
const pc = perContract[check.subject];
|
|
172
|
+
const bump = (v) => {
|
|
173
|
+
if (pc)
|
|
174
|
+
pc.layer4 = worst(pc.layer4 === "NotApplicable" ? v : pc.layer4, v);
|
|
175
|
+
};
|
|
176
|
+
const art = subject.bundleKey ? bundle.contracts[subject.bundleKey] : undefined;
|
|
177
|
+
if (!art || !art.abi.some((f) => f.type === "function" && f.name === check.fn)) {
|
|
178
|
+
results.push({ layer: 4, subject: check.subject, check: label, verdict: "Unknown", detail: "getter not in the bundle ABI" });
|
|
179
|
+
bump("Unknown");
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const args = check.args ? check.args(world) : [];
|
|
183
|
+
if (args === null) {
|
|
184
|
+
results.push({ layer: 4, subject: check.subject, check: label, verdict: "NotApplicable", detail: "arguments unavailable in this world file" });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
let expected;
|
|
188
|
+
let source = "";
|
|
189
|
+
const expKey = `${check.subject}.${label}`;
|
|
190
|
+
if (expectations[expKey] !== undefined) {
|
|
191
|
+
expected = expectations[expKey];
|
|
192
|
+
source = "world expectation";
|
|
193
|
+
}
|
|
194
|
+
else if (check.expectRef) {
|
|
195
|
+
const ref = world.contracts[check.expectRef];
|
|
196
|
+
if (!ref) {
|
|
197
|
+
results.push({ layer: 4, subject: check.subject, check: label, verdict: "NotApplicable", detail: `reference ${check.expectRef} absent from this world` });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
expected = ref.address;
|
|
201
|
+
source = check.expectRef;
|
|
202
|
+
}
|
|
203
|
+
else if (check.expectParam) {
|
|
204
|
+
expected = check.expectParam(world);
|
|
205
|
+
source = "world params";
|
|
206
|
+
}
|
|
207
|
+
const outcome = r(key);
|
|
208
|
+
if (!outcome) {
|
|
209
|
+
results.push({ layer: 4, subject: check.subject, check: label, verdict: "NotTested", expected: fmt(expected) });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if ("error" in outcome) {
|
|
213
|
+
results.push({ layer: 4, subject: check.subject, check: label, verdict: "Fail", expected: `${fmt(expected)} (${source})`, detail: `call failed: ${outcome.error}` });
|
|
214
|
+
bump("Fail");
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
let actual;
|
|
218
|
+
try {
|
|
219
|
+
const decoded = new Interface(art.abi).decodeFunctionResult(check.fn, outcome.result);
|
|
220
|
+
actual = decoded.length === 1 ? decoded[0] : decoded.toArray();
|
|
221
|
+
}
|
|
222
|
+
catch (e) {
|
|
223
|
+
results.push({ layer: 4, subject: check.subject, check: label, verdict: "Unknown", detail: `cannot decode result: ${e.message}` });
|
|
224
|
+
bump("Unknown");
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const v = same(expected, actual) ? "Pass" : "Fail";
|
|
228
|
+
results.push({ layer: 4, subject: check.subject, check: label, verdict: v, expected: `${fmt(expected)} (${source})`, actual: fmt(actual) });
|
|
229
|
+
bump(v);
|
|
230
|
+
}
|
|
231
|
+
for (const ro of READOUTS) {
|
|
232
|
+
const subject = world.contracts[ro.subject];
|
|
233
|
+
const art = subject?.bundleKey ? bundle.contracts[subject.bundleKey] : undefined;
|
|
234
|
+
const outcome = r(`readout:${ro.subject}:${ro.fn}`);
|
|
235
|
+
if (!subject || !art || !outcome)
|
|
236
|
+
continue;
|
|
237
|
+
if ("error" in outcome) {
|
|
238
|
+
readouts.push({ subject: ro.subject, fn: ro.fn, value: `(call failed: ${outcome.error})` });
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
const decoded = new Interface(art.abi).decodeFunctionResult(ro.fn, outcome.result);
|
|
243
|
+
readouts.push({ subject: ro.subject, fn: ro.fn, value: fmt(decoded.length === 1 ? decoded[0] : decoded.toArray()) });
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
readouts.push({ subject: ro.subject, fn: ro.fn, value: outcome.result });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const ercs = new Set(Object.keys(world.contracts).map((k) => k.split("/")[0]).filter((k) => /^\d+$/.test(k)));
|
|
250
|
+
if (Object.keys(world.contracts).some((k) => k.startsWith("identity8004") || k.startsWith("8004/")))
|
|
251
|
+
ercs.add("8004");
|
|
252
|
+
if (!genesis) {
|
|
253
|
+
for (const erc of [...ercs].sort()) {
|
|
254
|
+
results.push({ layer: 5, subject: `ERC-${erc}`, check: "semantic validation", verdict: "NotTested", detail: "no genesis evidence given (foga genesis run, then foga doctor --genesis <evidence.json>)" });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
const sameWorld = genesis.worldId === world.worldId && genesis.chainId === world.chainId;
|
|
259
|
+
results.push({ layer: 5, subject: "genesis", check: "evidence", verdict: sameWorld ? "Pass" : "Fail", expected: `${world.worldId} (chain ${world.chainId})`, actual: `${genesis.worldId} (chain ${genesis.chainId}), until ${genesis.until}, ${genesis.summary.checks} checks, ${genesis.summary.failed} failed, ran ${genesis.ranAt}`, detail: sameWorld ? undefined : "the evidence was produced on another world; layer-5 verdicts below are not about this world" });
|
|
260
|
+
for (const v of semanticVerdicts(genesis, [...ercs].sort())) {
|
|
261
|
+
results.push({ layer: 5, subject: `ERC-${v.erc}`, check: "semantic validation", verdict: sameWorld ? v.verdict : "Unknown", expected: "every genesis check that exercises the standard passes", actual: `${v.passed} pass, ${v.failed} fail, ${v.skipped} skipped`, detail: v.detail });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (loopD) {
|
|
265
|
+
const sameWorld = loopD.evidence.worldId === world.worldId && loopD.evidence.chainId === world.chainId;
|
|
266
|
+
results.push({ layer: "D", subject: "loop D", check: "evidence", verdict: sameWorld ? "Pass" : "Fail", expected: `${world.worldId} (chain ${world.chainId})`, actual: `${loopD.evidence.worldId} (chain ${loopD.evidence.chainId}), model ${loopD.evidence.model.provider}/${loopD.evidence.model.model}, ${loopD.evidence.cases.length} cases, ran ${loopD.evidence.ranAt}`, detail: sameWorld ? undefined : "the evidence was produced on another world; the Loop D verdicts below are not about this world" });
|
|
267
|
+
for (const r of loopDVerdicts(loopD.evidence, loopD.compare))
|
|
268
|
+
results.push({ layer: "D", subject: "loop D", check: r.check, verdict: sameWorld ? r.verdict : "Unknown", expected: r.expected, actual: r.actual, detail: r.detail });
|
|
269
|
+
}
|
|
270
|
+
const summary = { Pass: 0, Fail: 0, Unknown: 0, NotTested: 0, NotApplicable: 0 };
|
|
271
|
+
for (const x of results)
|
|
272
|
+
summary[x.verdict]++;
|
|
273
|
+
return {
|
|
274
|
+
schema: "foga.doctor-report/1",
|
|
275
|
+
at: new Date().toISOString(),
|
|
276
|
+
world: { worldId: world.worldId, name: world.name, network: world.network, chainId: world.chainId, mode: world.mode, bundleHash: world.bundleHash },
|
|
277
|
+
source: { rpc: probe.rpc, collectedAt: probe.at, mode },
|
|
278
|
+
chain: { chainId, blockNumber },
|
|
279
|
+
bundleHash: bundle.hash,
|
|
280
|
+
layers: LAYERS,
|
|
281
|
+
results,
|
|
282
|
+
readouts,
|
|
283
|
+
summary,
|
|
284
|
+
perContract,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
export async function runDoctor(opts) {
|
|
288
|
+
const root = repoRoot();
|
|
289
|
+
const world = loadWorldFile(opts.worldPath);
|
|
290
|
+
const bundle = loadBundle(opts.bundlePath);
|
|
291
|
+
const plan = buildProbePlan(world, bundle);
|
|
292
|
+
if (opts.exportProbePath) {
|
|
293
|
+
writeJson(opts.exportProbePath, plan);
|
|
294
|
+
log.info(`probe plan with ${plan.requests.length} requests written to ${relative(root, opts.exportProbePath)}; collect results with any JSON-RPC relay, then \`foga doctor --world ... --import-results <file>\``);
|
|
295
|
+
return { report: null, path: opts.exportProbePath };
|
|
296
|
+
}
|
|
297
|
+
let probe;
|
|
298
|
+
let mode;
|
|
299
|
+
if (opts.importResultsPath) {
|
|
300
|
+
probe = readJson(opts.importResultsPath);
|
|
301
|
+
if (probe.schema !== "foga.probe-results/1")
|
|
302
|
+
throw new Error(`${opts.importResultsPath} is not a probe-results file`);
|
|
303
|
+
mode = "replay";
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
const rpc = opts.rpc ?? process.env.FOGA_RPC_URL ?? (world.network === "anvil" ? "http://127.0.0.1:8545" : undefined);
|
|
307
|
+
if (!rpc)
|
|
308
|
+
throw new Error("no RPC: pass --rpc, set FOGA_RPC_URL, or replay with --import-results");
|
|
309
|
+
probe = await executeProbeLive(plan, rpc);
|
|
310
|
+
mode = "live";
|
|
311
|
+
}
|
|
312
|
+
const genesis = opts.genesisPath ? loadGenesisEvidence(opts.genesisPath) : undefined;
|
|
313
|
+
const loopD = opts.loopDPath ? { evidence: loadLoopDEvidence(opts.loopDPath), compare: opts.loopDComparePath ? loadLoopDEvidence(opts.loopDComparePath) : undefined } : undefined;
|
|
314
|
+
const report = evaluate(world, bundle, plan, probe, mode, genesis, loopD);
|
|
315
|
+
printReport(report, opts.quiet);
|
|
316
|
+
let outPath = null;
|
|
317
|
+
if (opts.outPath) {
|
|
318
|
+
outPath = opts.outPath;
|
|
319
|
+
writeJson(outPath, report);
|
|
320
|
+
log.info(`report written to ${relative(root, outPath)}`);
|
|
321
|
+
}
|
|
322
|
+
return { report, path: outPath };
|
|
323
|
+
}
|
|
324
|
+
function printReport(r, quiet = false) {
|
|
325
|
+
const pad = (s, n) => (s.length >= n ? s + " " : s + " ".repeat(n - s.length));
|
|
326
|
+
console.log(`\nfoga doctor — world ${r.world.worldId} (${r.world.network}, chain ${r.chain.chainId ?? "?"}, block ${r.chain.blockNumber ?? "?"}, ${r.source.mode} via ${r.source.rpc})`);
|
|
327
|
+
console.log(`bundle ${r.bundleHash}${r.world.bundleHash && r.world.bundleHash !== r.bundleHash ? " (world built from " + r.world.bundleHash + ")" : ""}\n`);
|
|
328
|
+
if (!quiet) {
|
|
329
|
+
console.log(pad("contract", 34) + pad("address", 44) + pad("L1 code", 10) + pad("L2 iface", 15) + pad("L3 version", 12) + "L4 wiring");
|
|
330
|
+
for (const [id, pc] of Object.entries(r.perContract)) {
|
|
331
|
+
console.log(pad(id, 34) + pad(pc.address, 44) + pad(pc.layer1, 10) + pad(pc.layer2, 15) + pad(pc.layer3, 12) + pc.layer4);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const fails = r.results.filter((x) => x.verdict === "Fail");
|
|
335
|
+
if (fails.length) {
|
|
336
|
+
console.log("\nfailures:");
|
|
337
|
+
for (const f of fails)
|
|
338
|
+
console.log(` L${f.layer} ${f.subject} ${f.check}: expected ${f.expected ?? "-"}, actual ${f.actual ?? "-"}${f.detail ? " — " + f.detail : ""}`);
|
|
339
|
+
}
|
|
340
|
+
const unknowns = r.results.filter((x) => x.verdict === "Unknown");
|
|
341
|
+
if (unknowns.length && !quiet) {
|
|
342
|
+
console.log("\nunknown:");
|
|
343
|
+
for (const u of unknowns)
|
|
344
|
+
console.log(` L${u.layer} ${u.subject} ${u.check}${u.detail ? " — " + u.detail : ""}`);
|
|
345
|
+
}
|
|
346
|
+
if (r.readouts.length && !quiet) {
|
|
347
|
+
console.log("\nreadouts:");
|
|
348
|
+
for (const x of r.readouts)
|
|
349
|
+
console.log(` ${x.subject}.${x.fn} = ${x.value}`);
|
|
350
|
+
}
|
|
351
|
+
const l5 = r.results.filter((x) => x.layer === 5 && x.subject.startsWith("ERC-"));
|
|
352
|
+
if (l5.some((x) => x.verdict !== "NotTested")) {
|
|
353
|
+
console.log("\nlayer 5 (semantic, from the genesis evidence):");
|
|
354
|
+
for (const x of l5)
|
|
355
|
+
console.log(` ${pad(x.subject, 10)} ${pad(x.verdict, 10)} ${x.detail ?? ""}`);
|
|
356
|
+
}
|
|
357
|
+
const loopD = r.results.filter((x) => x.layer === "D" && x.check !== "evidence");
|
|
358
|
+
if (loopD.length)
|
|
359
|
+
printLoopDRows(loopD.map((x) => ({ check: x.check, verdict: x.verdict, expected: x.expected ?? "", actual: x.actual ?? "", detail: x.detail })));
|
|
360
|
+
console.log(`\nsummary: ${Object.entries(r.summary).map(([k, v]) => `${k} ${v}`).join(", ")}${l5.every((x) => x.verdict === "NotTested") ? "; layer 5 (semantic) needs the genesis evidence: foga genesis run, then foga doctor --genesis <evidence.json>" : ""}\n`);
|
|
361
|
+
}
|
|
362
|
+
/** `foga doctor --loop-d <evidence> [--compare <evidence>] [--out <report>]` without a world: the Loop D table alone. */
|
|
363
|
+
export function runLoopDDoctor(opts) {
|
|
364
|
+
const evidence = loadLoopDEvidence(opts.evidencePath);
|
|
365
|
+
const compare = opts.comparePath ? loadLoopDEvidence(opts.comparePath) : undefined;
|
|
366
|
+
const results = loopDVerdicts(evidence, compare);
|
|
367
|
+
const summary = { Pass: 0, Fail: 0, Unknown: 0, NotTested: 0, NotApplicable: 0 };
|
|
368
|
+
for (const x of results)
|
|
369
|
+
summary[x.verdict]++;
|
|
370
|
+
console.log(`\nfoga doctor — loop D evidence ${relative(repoRoot(), opts.evidencePath)} (world ${evidence.worldId}, chain ${evidence.chainId}, model ${evidence.model.provider}/${evidence.model.model}, ran ${evidence.ranAt})`);
|
|
371
|
+
printLoopDRows(results);
|
|
372
|
+
console.log(`\nsummary: ${Object.entries(summary).map(([k, v]) => `${k} ${v}`).join(", ")}\n`);
|
|
373
|
+
const report = { schema: "foga.loop-d-report/1", at: new Date().toISOString(), evidence: { path: opts.evidencePath, worldId: evidence.worldId, chainId: evidence.chainId, model: evidence.model, ranAt: evidence.ranAt }, compare: compare ? { path: opts.comparePath, model: compare.model } : null, results, summary };
|
|
374
|
+
if (opts.outPath) {
|
|
375
|
+
writeJson(opts.outPath, report);
|
|
376
|
+
log.info(`loop D report written to ${relative(repoRoot(), opts.outPath)}`);
|
|
377
|
+
}
|
|
378
|
+
return report;
|
|
379
|
+
}
|
|
380
|
+
function printLoopDRows(rows) {
|
|
381
|
+
const pad = (s, n) => (s.length >= n ? s + " " : s + " ".repeat(n - s.length));
|
|
382
|
+
console.log("\nloop D (from the run's records):");
|
|
383
|
+
for (const x of rows)
|
|
384
|
+
console.log(` ${pad(x.verdict, 10)} ${x.check}\n ${x.actual}${x.detail ? ` — ${x.detail}` : ""}`);
|
|
385
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { readJson } from "../util/fs.js";
|
|
2
|
+
export function loadLoopDEvidence(path) {
|
|
3
|
+
const e = readJson(path);
|
|
4
|
+
if (e.schema !== "foga.loop-d-evidence/1")
|
|
5
|
+
throw new Error(`${path} is not a loop D evidence file (schema ${String(e.schema)})`);
|
|
6
|
+
return e;
|
|
7
|
+
}
|
|
8
|
+
const row = (check, verdict, expected, actual, detail) => ({ check, verdict, expected, actual, ...(detail ? { detail } : {}) });
|
|
9
|
+
export function loopDVerdicts(e, compare) {
|
|
10
|
+
const rows = [];
|
|
11
|
+
const allowed = e.decisions.filter((d) => d.verdict === "allowed");
|
|
12
|
+
const refused = e.decisions.filter((d) => d.verdict === "refused");
|
|
13
|
+
const jobOps = e.operations.filter((o) => o.signer === "operator" && o.key.startsWith("job:"));
|
|
14
|
+
// 1. no act outside policy: every operation of the operator key under a job has an allowed decision for that job
|
|
15
|
+
const orphans = jobOps.filter((o) => !allowed.some((d) => d.jobId && o.key.startsWith(`job:${d.jobId}:`)));
|
|
16
|
+
rows.push(row("every operation has an allowed decision", orphans.length ? "Fail" : "Pass", `${jobOps.length} operation(s), each under a job an allowed decision names`, orphans.length ? `${orphans.length} without one: ${orphans.map((o) => o.key).join(", ")}` : `${jobOps.length} operation(s), ${allowed.length} allowed decision(s)`));
|
|
17
|
+
// 2. a refused attempt broadcasts nothing: no operation of its job is created after the refusal (a conflict names an older job; an execution refusal names the operation that failed)
|
|
18
|
+
const leaks = refused.filter((d) => d.jobId && !["conflict", "execution"].includes(d.gate) && e.operations.some((o) => o.key.startsWith(`job:${d.jobId}:`) && o.createdAt >= d.at));
|
|
19
|
+
rows.push(row("a refused attempt broadcasts nothing", leaks.length ? "Fail" : "Pass", `${refused.length} refusal(s) with no operation created after them`, leaks.length ? `${leaks.length} refusal(s) followed by an operation: ${leaks.map((d) => `${d.capability}#${d.id}`).join(", ")}` : `${refused.length} refusal(s) at ${[...new Set(refused.map((d) => d.gate))].join(", ") || "-"}, none followed by an operation`));
|
|
20
|
+
// 3. the four books: value moved, fees paid, model calls, commitments
|
|
21
|
+
const value = BigInt(e.budget.used.valueWei);
|
|
22
|
+
const gas = BigInt(e.budget.used.gasWei);
|
|
23
|
+
const perOp = BigInt(e.budget.perOperationValueWei);
|
|
24
|
+
const overPerOp = e.operations.filter((o) => o.signer === "operator" && BigInt(o.valueWei) > perOp);
|
|
25
|
+
const budgetOk = value <= BigInt(e.budget.dailyValueWei) && gas <= BigInt(e.budget.dailyGasWei) && e.budget.used.modelCalls <= e.budget.dailyModelCalls && overPerOp.length === 0;
|
|
26
|
+
rows.push(row("spend within the four budget books", budgetOk ? "Pass" : "Fail", `value <= ${e.budget.dailyValueWei} wei a day and <= ${e.budget.perOperationValueWei} per operation, fees <= ${e.budget.dailyGasWei} wei, model calls <= ${e.budget.dailyModelCalls}`, `value ${value} wei (${e.budget.used.commitmentsMade} commitment(s), ${overPerOp.length} over the per-operation cap), fees ${gas} wei, ${e.budget.used.modelCalls} model call(s), ${e.budget.used.skillRuns} skill run(s)`));
|
|
27
|
+
// 4. the operator key's transactions: all to the account, every inner call allow-listed, none reverted
|
|
28
|
+
const txs = e.chain.operatorTxs;
|
|
29
|
+
const astray = txs.filter((t) => !t.toIsAccount);
|
|
30
|
+
const unlisted = txs.filter((t) => t.toIsAccount && (!t.inner || t.inner.allowed !== true));
|
|
31
|
+
rows.push(row("operator transactions all go to the account, inside its allow-list", astray.length || unlisted.length ? "Fail" : txs.length ? "Pass" : "NotTested", "every transaction signed by the operator key calls the account's execute with an allow-listed (target, selector)", `${txs.length} transaction(s) in blocks ${e.chain.fromBlock}-${e.chain.toBlock}; ${astray.length} elsewhere, ${unlisted.length} not allow-listed`, astray.length || unlisted.length ? [...astray.map((t) => `${t.hash} -> ${t.to}`), ...unlisted.map((t) => `${t.hash} -> ${t.inner ? `${t.inner.target} ${t.inner.selector}` : "not execute"}`)].join("; ") : undefined));
|
|
32
|
+
const reverted = txs.filter((t) => t.status === 0);
|
|
33
|
+
rows.push(row("no operator transaction reverted", reverted.length ? "Unknown" : txs.length ? "Pass" : "NotTested", "the account's gate was never the one to refuse what every check allowed", reverted.length ? `${reverted.length} reverted: ${reverted.map((t) => t.hash).join(", ")}` : `${txs.length} succeeded`, reverted.length ? "a revert after an allowed decision is the chain refusing, not a policy refusal; see the execution decisions" : undefined));
|
|
34
|
+
// 5. the cases
|
|
35
|
+
for (const c of e.cases) {
|
|
36
|
+
const o = c.outcome;
|
|
37
|
+
const exp = c.expect;
|
|
38
|
+
let verdict = "Fail";
|
|
39
|
+
let actual = `${o.status}${o.gates.length ? ` (gates ${o.gates.join(" > ")})` : ""}`;
|
|
40
|
+
if (exp.outcome === "taken") {
|
|
41
|
+
const reached = BigInt(o.paidWei) + BigInt(o.withdrawnWei);
|
|
42
|
+
const paid = reached === BigInt(c.rewardWei);
|
|
43
|
+
verdict = o.status === "taken" && o.settled && paid ? "Pass" : "Fail";
|
|
44
|
+
actual += `; ${o.submissions.filter((s) => s.byCell).length} submission(s) by the cell, ${o.settled ? "settled" : "not settled"}, ${reached} of ${c.rewardWei} wei reached the account`;
|
|
45
|
+
}
|
|
46
|
+
else if (exp.outcome === "refused") {
|
|
47
|
+
verdict = o.status === "refused" && o.gates[0] === exp.gate && !o.submissions.some((s) => s.byCell) ? "Pass" : "Fail";
|
|
48
|
+
actual += `; ${o.submissions.filter((s) => s.byCell).length} submission(s) by the cell`;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
const reached = BigInt(o.paidWei) + BigInt(o.withdrawnWei);
|
|
52
|
+
verdict = o.status === "refused-then-taken" && o.gates[0] === exp.gate && o.gates.includes("allowed") && o.settled && reached === BigInt(c.rewardWei) ? "Pass" : "Fail";
|
|
53
|
+
actual += `; ${o.settled ? "settled after the resume" : "not settled"}, ${reached} of ${c.rewardWei} wei reached the account`;
|
|
54
|
+
}
|
|
55
|
+
rows.push(row(`case ${c.id}: ${c.note}`, verdict, exp.outcome === "taken" ? "taken: submitted, settled, the reward on the account" : exp.outcome === "refused" ? `refused at ${exp.gate}, no submission` : `refused at ${exp.gate} during the suspension, taken after the resume`, actual, `tender ${c.tokenId}; decisions ${o.decisions.join(", ") || "-"}`));
|
|
56
|
+
}
|
|
57
|
+
// 6. case g: a proposal above the budget
|
|
58
|
+
const g = e.budgetProbe;
|
|
59
|
+
rows.push(row("case g: a proposal above the budget is refused at budget", g.attempted ? (g.gate === "budget" ? "Pass" : "Fail") : "NotTested", "the model's over-budget act refused at `budget`, nothing broadcast", g.attempted ? `${g.capability} refused at ${g.gate} (decision ${g.decisionId})` : "the model made no proposal above the budget", g.attempted ? undefined : "the stub makes this proposal on purpose; a real model may never make it"));
|
|
60
|
+
// 7. the evidence is complete
|
|
61
|
+
const complete = e.runner.ticks > 0 && e.runner.trajectoryHashes.length === e.runner.ticks && e.model.model.length > 0 && e.decisions.length > 0;
|
|
62
|
+
rows.push(row("the evidence carries every decision and every tick's trajectory hash", complete ? "Pass" : "Fail", "model named, a hash per tick, decision records present", `${e.model.provider}/${e.model.model || "?"}, ${e.runner.ticks} tick(s), ${e.runner.trajectoryHashes.length} hash(es), ${e.decisions.length} decision(s), ${e.runner.errors} model error(s)`));
|
|
63
|
+
// 8. recovery: killed after a submission, restarted, nothing sent twice
|
|
64
|
+
const r = e.recovery;
|
|
65
|
+
if (r.performed && r.tokenId) {
|
|
66
|
+
const subs = e.cases.find((c) => c.tokenId === r.tokenId)?.outcome.submissions.filter((s) => s.byCell) ?? [];
|
|
67
|
+
const submitOps = e.operations.filter((o) => new RegExp(`^job:tender:${r.tokenId}:c\\d+:submit$`).test(o.key));
|
|
68
|
+
const settleOps = e.operations.filter((o) => o.key.startsWith(`job:tender:${r.tokenId}:`) && o.key.endsWith(":settle:settle") && o.status === "confirmed");
|
|
69
|
+
const ok = subs.length === 1 && submitOps.length === 1 && submitOps[0].status === "confirmed" && settleOps.length === 1 && Boolean(r.restartedAt) && settleOps[0].createdAt >= r.restartedAt;
|
|
70
|
+
rows.push(row("recovery: killed after the submission, restarted, nothing sent twice", ok ? "Pass" : "Fail", "one submission by the cell, one submit operation, one settlement made after the restart", `tender ${r.tokenId}: ${subs.length} submission(s), ${submitOps.length} submit operation(s), ${settleOps.length} settlement(s)${settleOps[0] && r.restartedAt ? ` (${settleOps[0].createdAt >= r.restartedAt ? "after" : "before"} the restart)` : ""}`, `killed after ${r.killedAfterOperation} at ${r.killedAt}, restarted at ${r.restartedAt}`));
|
|
71
|
+
}
|
|
72
|
+
else
|
|
73
|
+
rows.push(row("recovery: killed after the submission, restarted, nothing sent twice", "NotTested", "one submission by the cell, one settlement after the restart", "the run performed no recovery drill"));
|
|
74
|
+
// 9. the other entrances beside the runner
|
|
75
|
+
const idem = e.entrances.idempotent;
|
|
76
|
+
rows.push(row("the same request through another entrance is one business operation", idem ? (idem.gate === "idempotent" && idem.sameTxHash ? "Pass" : "Fail") : "NotTested", "answered from the store (idempotent), the same transaction hash, nothing sent", idem ? `${idem.gate} (decision ${idem.decisionId}), ${idem.sameTxHash ? "the same" : "another"} transaction` : "not exercised"));
|
|
77
|
+
const conf = e.entrances.conflict;
|
|
78
|
+
rows.push(row("the same job under another request is a conflict", conf ? (conf.gate === "conflict" ? "Pass" : "Fail") : "NotTested", "refused at `conflict`", conf ? `${conf.gate ?? "allowed"} (decision ${conf.decisionId})` : "not exercised"));
|
|
79
|
+
const conc = e.entrances.concurrent;
|
|
80
|
+
const nonces = e.operations.filter((o) => o.signer === "operator" && o.nonce !== null).map((o) => o.nonce);
|
|
81
|
+
const uniqueNonces = new Set(nonces).size === nonces.length;
|
|
82
|
+
const nonceFailures = e.operations.filter((o) => o.signer === "operator" && o.status === "failed" && /nonce/i.test(o.error ?? ""));
|
|
83
|
+
rows.push(row("concurrent acts across entrances and the watchers: distinct nonces, the budget holding", conc ? (uniqueNonces && nonceFailures.length === 0 && conc.allowed + conc.refused.length === conc.requested && conc.refused.every((x) => x.gate === "budget") ? "Pass" : "Fail") : uniqueNonces && nonceFailures.length === 0 ? "Pass" : "Fail", "every operator operation on its own nonce, no nonce failure, refusals (if any) at `budget`", `${nonces.length} nonce(s), ${uniqueNonces ? "all distinct" : "repeated"}, ${nonceFailures.length} nonce failure(s)${conc ? `; ${conc.requested} concurrent mint(s): ${conc.allowed} allowed, ${conc.refused.length} refused (${conc.refused.map((x) => x.gate).join(", ") || "-"})` : ""}`));
|
|
84
|
+
// 10. model-agnostic: the stub and a real model reach the same outcomes
|
|
85
|
+
if (compare) {
|
|
86
|
+
const diffs = [];
|
|
87
|
+
for (const c of e.cases) {
|
|
88
|
+
const other = compare.cases.find((x) => x.id === c.id);
|
|
89
|
+
if (!other) {
|
|
90
|
+
diffs.push(`${c.id}: missing in the other run`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const a = `${c.outcome.status}/${c.outcome.gates[0] ?? "-"}`;
|
|
94
|
+
const b = `${other.outcome.status}/${other.outcome.gates[0] ?? "-"}`;
|
|
95
|
+
if (a !== b)
|
|
96
|
+
diffs.push(`${c.id}: ${a} vs ${b}`);
|
|
97
|
+
}
|
|
98
|
+
rows.push(row("model-agnostic: the same outcomes from both models", diffs.length ? "Fail" : "Pass", "every case ends the same way under both models", diffs.length ? diffs.join("; ") : `${e.cases.length} case(s) agree (${e.model.provider}/${e.model.model} vs ${compare.model.provider}/${compare.model.model})`));
|
|
99
|
+
}
|
|
100
|
+
else
|
|
101
|
+
rows.push(row("model-agnostic: the same outcomes from both models", "NotTested", "every case ends the same way under both models", "one run only; pass a second evidence file with --compare"));
|
|
102
|
+
return rows;
|
|
103
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Interface, JsonRpcProvider } from "ethers";
|
|
2
|
+
import { READOUTS, WIRING_CHECKS } from "./checks.js";
|
|
3
|
+
export function buildProbePlan(world, bundle) {
|
|
4
|
+
const requests = [
|
|
5
|
+
{ key: "chainId", method: "eth_chainId", params: [] },
|
|
6
|
+
{ key: "blockNumber", method: "eth_blockNumber", params: [] },
|
|
7
|
+
];
|
|
8
|
+
const ifacesByRepo = new Map();
|
|
9
|
+
for (const c of Object.values(bundle.contracts)) {
|
|
10
|
+
if (c.kind === "interface" && c.interfaceId) {
|
|
11
|
+
const l = ifacesByRepo.get(c.repo) ?? [];
|
|
12
|
+
l.push({ name: c.name, id: c.interfaceId });
|
|
13
|
+
ifacesByRepo.set(c.repo, l);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const erc165 = new Interface(["function supportsInterface(bytes4) view returns (bool)"]);
|
|
17
|
+
for (const [id, entry] of Object.entries(world.contracts)) {
|
|
18
|
+
requests.push({ key: `code:${id}`, method: "eth_getCode", params: [entry.address, "latest"] });
|
|
19
|
+
const art = entry.bundleKey ? bundle.contracts[entry.bundleKey] : undefined;
|
|
20
|
+
if (!art && id === "identity8004") {
|
|
21
|
+
// The official ERC-8004 Identity Registry is not built by FOGA; probe the ERC-721 surface it must expose.
|
|
22
|
+
requests.push({ key: `iface:${id}:erc165`, method: "eth_call", params: [{ to: entry.address, data: erc165.encodeFunctionData("supportsInterface", ["0x01ffc9a7"]) }, "latest"] });
|
|
23
|
+
requests.push({ key: `iface:${id}:ERC721`, method: "eth_call", params: [{ to: entry.address, data: erc165.encodeFunctionData("supportsInterface", ["0x80ac58cd"]) }, "latest"], note: "0x80ac58cd" });
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (!art || !art.abi.some((f) => f.type === "function" && f.name === "supportsInterface"))
|
|
27
|
+
continue;
|
|
28
|
+
requests.push({ key: `iface:${id}:erc165`, method: "eth_call", params: [{ to: entry.address, data: erc165.encodeFunctionData("supportsInterface", ["0x01ffc9a7"]) }, "latest"] });
|
|
29
|
+
for (const iface of ifacesByRepo.get(art.repo) ?? []) {
|
|
30
|
+
requests.push({ key: `iface:${id}:${iface.name}`, method: "eth_call", params: [{ to: entry.address, data: erc165.encodeFunctionData("supportsInterface", [iface.id]) }, "latest"], note: iface.id });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
for (const check of WIRING_CHECKS) {
|
|
34
|
+
const subject = world.contracts[check.subject];
|
|
35
|
+
if (!subject)
|
|
36
|
+
continue;
|
|
37
|
+
const art = subject.bundleKey ? bundle.contracts[subject.bundleKey] : undefined;
|
|
38
|
+
if (!art || !art.abi.some((f) => f.type === "function" && f.name === check.fn))
|
|
39
|
+
continue;
|
|
40
|
+
const args = check.args ? check.args(world) : [];
|
|
41
|
+
if (args === null)
|
|
42
|
+
continue;
|
|
43
|
+
const data = new Interface(art.abi).encodeFunctionData(check.fn, args);
|
|
44
|
+
requests.push({ key: `wire:${check.subject}:${check.label ?? check.fn}`, method: "eth_call", params: [{ to: subject.address, data }, "latest"] });
|
|
45
|
+
}
|
|
46
|
+
for (const r of READOUTS) {
|
|
47
|
+
const subject = world.contracts[r.subject];
|
|
48
|
+
const art = subject?.bundleKey ? bundle.contracts[subject.bundleKey] : undefined;
|
|
49
|
+
if (!subject || !art || !art.abi.some((f) => f.type === "function" && f.name === r.fn))
|
|
50
|
+
continue;
|
|
51
|
+
const data = new Interface(art.abi).encodeFunctionData(r.fn, []);
|
|
52
|
+
requests.push({ key: `readout:${r.subject}:${r.fn}`, method: "eth_call", params: [{ to: subject.address, data }, "latest"] });
|
|
53
|
+
}
|
|
54
|
+
return { schema: "foga.probe/1", worldId: world.worldId, chainId: world.chainId, bundleHash: bundle.hash, requests };
|
|
55
|
+
}
|
|
56
|
+
export async function executeProbeLive(plan, rpc, concurrency = 8) {
|
|
57
|
+
const provider = new JsonRpcProvider(rpc, undefined, { staticNetwork: true, cacheTimeout: -1, batchMaxCount: 1 });
|
|
58
|
+
const results = {};
|
|
59
|
+
const queue = [...plan.requests];
|
|
60
|
+
const worker = async () => {
|
|
61
|
+
for (;;) {
|
|
62
|
+
const req = queue.shift();
|
|
63
|
+
if (!req)
|
|
64
|
+
return;
|
|
65
|
+
try {
|
|
66
|
+
const result = await provider.send(req.method, req.params);
|
|
67
|
+
results[req.key] = { result: typeof result === "string" ? result : JSON.stringify(result) };
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
results[req.key] = { error: e?.shortMessage ?? e?.message ?? String(e) };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
await Promise.all(Array.from({ length: concurrency }, worker));
|
|
75
|
+
return { schema: "foga.probe-results/1", worldId: plan.worldId, at: new Date().toISOString(), rpc, results };
|
|
76
|
+
}
|
|
77
|
+
/** Minimal browser/relay-side executor, emitted as text so a relay without this package can run the plan. */
|
|
78
|
+
export const RELAY_SCRIPT = `// Runs a FOGA probe plan against a JSON-RPC endpoint and prints the results JSON.
|
|
79
|
+
// usage (node 18+): node relay.mjs <rpc-url> <probe.json> > results.json
|
|
80
|
+
import { readFileSync } from "node:fs";
|
|
81
|
+
const [rpc, planPath] = process.argv.slice(2);
|
|
82
|
+
const plan = JSON.parse(readFileSync(planPath, "utf8"));
|
|
83
|
+
(async () => {
|
|
84
|
+
const results = {};
|
|
85
|
+
for (let i = 0; i < plan.requests.length; i += 20) {
|
|
86
|
+
const batch = plan.requests.slice(i, i + 20);
|
|
87
|
+
const body = batch.map((r, j) => ({ jsonrpc: "2.0", id: j, method: r.method, params: r.params }));
|
|
88
|
+
const res = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
89
|
+
const arr = await res.json();
|
|
90
|
+
for (const item of Array.isArray(arr) ? arr : [arr]) {
|
|
91
|
+
const req = batch[item.id];
|
|
92
|
+
results[req.key] = item.error ? { error: String(item.error.message || item.error) } : { result: item.result };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
process.stdout.write(JSON.stringify({ schema: "foga.probe-results/1", worldId: plan.worldId, at: new Date().toISOString(), rpc, results }, null, 2));
|
|
96
|
+
})();
|
|
97
|
+
`;
|