@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,276 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { createMcpHandler } from "@modelcontextprotocol/server";
|
|
4
|
+
import { buildMcpServer } from "../mcp/server.js";
|
|
5
|
+
import { PUBLIC_CALLER } from "../tools/index.js";
|
|
6
|
+
const KEY_PREFIX = "foga";
|
|
7
|
+
/** Make an API key for a grant: shown once, stored only as its hash. */
|
|
8
|
+
export function newApiKey(id) {
|
|
9
|
+
const key = `${KEY_PREFIX}.${id}.${randomBytes(24).toString("base64url")}`;
|
|
10
|
+
return { key, sha256: "0x" + createHash("sha256").update(key).digest("hex") };
|
|
11
|
+
}
|
|
12
|
+
/** The caller an Authorization header establishes: the public without one, the grant of a valid key, or null for an invalid key. */
|
|
13
|
+
export function callerFromAuthorization(cfg, header) {
|
|
14
|
+
if (!header)
|
|
15
|
+
return { ...PUBLIC_CALLER, entrance: "http" };
|
|
16
|
+
const m = /^Bearer\s+(\S+)$/i.exec(header.trim());
|
|
17
|
+
if (!m)
|
|
18
|
+
return null;
|
|
19
|
+
const key = m[1];
|
|
20
|
+
const parts = key.split(".");
|
|
21
|
+
if (parts.length !== 3 || parts[0] !== KEY_PREFIX)
|
|
22
|
+
return null;
|
|
23
|
+
const grant = cfg.access.keys.find((k) => k.id === parts[1]);
|
|
24
|
+
if (!grant)
|
|
25
|
+
return null;
|
|
26
|
+
const given = Buffer.from(createHash("sha256").update(key).digest("hex"), "hex");
|
|
27
|
+
const stored = Buffer.from(grant.sha256.slice(2), "hex");
|
|
28
|
+
if (given.length !== stored.length || !timingSafeEqual(given, stored))
|
|
29
|
+
return null;
|
|
30
|
+
return { id: grant.id, entrance: "http", level: grant.level, visibility: grant.visibility };
|
|
31
|
+
}
|
|
32
|
+
/** A token bucket per client address: `limit` requests per minute, refilled continuously. */
|
|
33
|
+
class RateLimiter {
|
|
34
|
+
perMinute;
|
|
35
|
+
buckets = new Map();
|
|
36
|
+
constructor(perMinute) {
|
|
37
|
+
this.perMinute = perMinute;
|
|
38
|
+
}
|
|
39
|
+
take(key) {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
const b = this.buckets.get(key) ?? { tokens: this.perMinute, at: now };
|
|
42
|
+
b.tokens = Math.min(this.perMinute, b.tokens + ((now - b.at) / 60_000) * this.perMinute);
|
|
43
|
+
b.at = now;
|
|
44
|
+
if (b.tokens < 1) {
|
|
45
|
+
this.buckets.set(key, b);
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
b.tokens -= 1;
|
|
49
|
+
this.buckets.set(key, b);
|
|
50
|
+
if (this.buckets.size > 10_000)
|
|
51
|
+
for (const [k, v] of this.buckets)
|
|
52
|
+
if (now - v.at > 120_000)
|
|
53
|
+
this.buckets.delete(k);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
58
|
+
function hostnameOf(value) {
|
|
59
|
+
if (!value)
|
|
60
|
+
return null;
|
|
61
|
+
try {
|
|
62
|
+
return new URL(`http://${value}`).hostname;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function json(res, status, body) {
|
|
69
|
+
const text = JSON.stringify(body, (_k, v) => (typeof v === "bigint" ? v.toString() : v));
|
|
70
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "content-length": Buffer.byteLength(text) });
|
|
71
|
+
res.end(text);
|
|
72
|
+
}
|
|
73
|
+
async function readBody(req, max) {
|
|
74
|
+
const chunks = [];
|
|
75
|
+
let size = 0;
|
|
76
|
+
for await (const chunk of req) {
|
|
77
|
+
size += chunk.length;
|
|
78
|
+
if (size > max)
|
|
79
|
+
return null;
|
|
80
|
+
chunks.push(chunk);
|
|
81
|
+
}
|
|
82
|
+
return Buffer.concat(chunks);
|
|
83
|
+
}
|
|
84
|
+
/** The client address: the socket's, or the first X-Forwarded-For entry when the socket is the loopback (a reverse proxy on this host). */
|
|
85
|
+
function clientAddress(req) {
|
|
86
|
+
const remote = req.socket.remoteAddress ?? "unknown";
|
|
87
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
88
|
+
if (forwarded && (remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1")) {
|
|
89
|
+
const first = (Array.isArray(forwarded) ? forwarded[0] : forwarded).split(",")[0].trim();
|
|
90
|
+
if (first)
|
|
91
|
+
return first;
|
|
92
|
+
}
|
|
93
|
+
return remote;
|
|
94
|
+
}
|
|
95
|
+
export function createHttpServer(o) {
|
|
96
|
+
const { node, catalog } = o;
|
|
97
|
+
const cfg = node.config;
|
|
98
|
+
const http = cfg.runtime.http;
|
|
99
|
+
const log = o.log ?? node.log;
|
|
100
|
+
const limiter = new RateLimiter(http.rateLimitPerMinute);
|
|
101
|
+
let loopback = false;
|
|
102
|
+
const mcp = createMcpHandler((ctx) => {
|
|
103
|
+
const caller = ctx.authInfo?.extra?.caller ?? { ...PUBLIC_CALLER, entrance: "http" };
|
|
104
|
+
return buildMcpServer({ node, catalog, caller, version: o.version });
|
|
105
|
+
}, { legacy: "stateless", maxRequestBodySize: http.maxBodyBytes, onerror: (e) => log(`mcp: ${e.message}`) });
|
|
106
|
+
async function handle(req, res) {
|
|
107
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
108
|
+
const method = req.method ?? "GET";
|
|
109
|
+
// the edge: budget per address, the Host this server answers for, the Origin a browser sends
|
|
110
|
+
if (!limiter.take(clientAddress(req)))
|
|
111
|
+
return json(res, 429, { ok: false, gate: "rate", reason: `more than ${http.rateLimitPerMinute} requests a minute from this address` });
|
|
112
|
+
const host = hostnameOf(req.headers.host);
|
|
113
|
+
const allowedHosts = http.allowedHosts.length ? http.allowedHosts : loopback ? [...LOCAL_HOSTS] : null;
|
|
114
|
+
if (allowedHosts && (!host || !allowedHosts.includes(host)))
|
|
115
|
+
return json(res, 403, { ok: false, gate: "host", reason: "this server does not answer for that Host" });
|
|
116
|
+
const origin = req.headers.origin;
|
|
117
|
+
if (origin) {
|
|
118
|
+
const originHost = hostnameOf(origin.replace(/^https?:\/\//, ""));
|
|
119
|
+
const allowedOrigins = [...http.allowedOrigins, ...(loopback ? LOCAL_HOSTS : [])];
|
|
120
|
+
if (!originHost || !allowedOrigins.includes(originHost))
|
|
121
|
+
return json(res, 403, { ok: false, gate: "origin", reason: "Origin not allowed" });
|
|
122
|
+
}
|
|
123
|
+
// who is calling
|
|
124
|
+
const caller = callerFromAuthorization(cfg, req.headers.authorization);
|
|
125
|
+
if (!caller)
|
|
126
|
+
return json(res, 401, { ok: false, gate: "permission", reason: "the Authorization header carries no key this node knows" });
|
|
127
|
+
if (url.pathname === "/mcp") {
|
|
128
|
+
// the SDK reads and bounds the body itself; headers pass through, the caller rides in authInfo
|
|
129
|
+
const headers = new Headers();
|
|
130
|
+
for (const [k, v] of Object.entries(req.headers))
|
|
131
|
+
if (v !== undefined && k !== "authorization")
|
|
132
|
+
headers.set(k, Array.isArray(v) ? v.join(", ") : v);
|
|
133
|
+
const body = method === "GET" || method === "HEAD" || method === "DELETE" ? undefined : await readBody(req, http.maxBodyBytes);
|
|
134
|
+
if (body === null)
|
|
135
|
+
return json(res, 413, { ok: false, gate: "input", reason: `request body over ${http.maxBodyBytes} bytes` });
|
|
136
|
+
const request = new Request(url, { method, headers, body: body ? new Uint8Array(body) : undefined });
|
|
137
|
+
const authInfo = { token: caller.id, clientId: caller.id, scopes: [caller.level], extra: { caller } };
|
|
138
|
+
const response = await mcp.fetch(request, { authInfo });
|
|
139
|
+
const out = {};
|
|
140
|
+
response.headers.forEach((v, k) => (out[k] = v));
|
|
141
|
+
out["cache-control"] = "no-store";
|
|
142
|
+
res.writeHead(response.status, out);
|
|
143
|
+
if (response.body)
|
|
144
|
+
for await (const chunk of response.body)
|
|
145
|
+
res.write(chunk);
|
|
146
|
+
res.end();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (method === "GET" && url.pathname === "/healthz") {
|
|
150
|
+
let block = null;
|
|
151
|
+
try {
|
|
152
|
+
block = await node.kernel.blockNumber();
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
block = null;
|
|
156
|
+
}
|
|
157
|
+
return json(res, block === null ? 503 : 200, { ok: block !== null, cell: cfg.name, account: node.cell?.address ?? null, worldId: node.world.file.worldId, chainId: node.chainId, block, version: o.version });
|
|
158
|
+
}
|
|
159
|
+
if (method === "GET" && url.pathname === "/") {
|
|
160
|
+
const r = await catalog.invoke(node, "cell_status", {}, caller);
|
|
161
|
+
const decisions = node.store.summary().decisions;
|
|
162
|
+
return json(res, 200, { foga: o.version, cell: cfg.name, status: r.ok ? r.output : null, decisions, endpoints: { mcp: "/mcp", api: "/api/<capability>", health: "/healthz" } });
|
|
163
|
+
}
|
|
164
|
+
if (method === "GET" && url.pathname === "/api/capabilities")
|
|
165
|
+
return json(res, 200, { caller: { id: caller.id, level: caller.level, visibility: caller.visibility }, capabilities: catalog.list(caller) });
|
|
166
|
+
const jobMatch = /^\/api\/jobs\/([^/]+)$/.exec(url.pathname);
|
|
167
|
+
if (method === "GET" && jobMatch)
|
|
168
|
+
return answer(res, await catalog.invoke(node, "job_status", { jobId: decodeURIComponent(jobMatch[1]) }, caller));
|
|
169
|
+
const opMatch = /^\/api\/operations\/([^/]+)$/.exec(url.pathname);
|
|
170
|
+
if (method === "GET" && opMatch)
|
|
171
|
+
return answer(res, await catalog.invoke(node, "operation_status", { operationId: decodeURIComponent(opMatch[1]) }, caller));
|
|
172
|
+
if (method === "GET" && url.pathname === "/api/decisions")
|
|
173
|
+
return answer(res, await catalog.invoke(node, "decision_list", Object.fromEntries(url.searchParams), caller));
|
|
174
|
+
const capMatch = /^\/api\/([a-z][a-z0-9_]*)$/.exec(url.pathname);
|
|
175
|
+
if (capMatch) {
|
|
176
|
+
const name = capMatch[1];
|
|
177
|
+
const cap = catalog.get(name);
|
|
178
|
+
if (!cap)
|
|
179
|
+
return json(res, 404, { ok: false, gate: "input", reason: `unknown capability ${name}` });
|
|
180
|
+
if (method !== "POST" && !(method === "GET" && cap.action === "read"))
|
|
181
|
+
return json(res, 405, { ok: false, gate: "input", reason: "POST the input as JSON (GET for a read with query parameters)" });
|
|
182
|
+
let input = {};
|
|
183
|
+
if (method === "POST") {
|
|
184
|
+
if (!/^application\/json\b/i.test(req.headers["content-type"] ?? ""))
|
|
185
|
+
return json(res, 415, { ok: false, gate: "input", reason: "Content-Type must be application/json" });
|
|
186
|
+
const body = await readBody(req, http.maxBodyBytes);
|
|
187
|
+
if (body === null)
|
|
188
|
+
return json(res, 413, { ok: false, gate: "input", reason: `request body over ${http.maxBodyBytes} bytes` });
|
|
189
|
+
try {
|
|
190
|
+
input = body.length ? JSON.parse(body.toString("utf8")) : {};
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return json(res, 400, { ok: false, gate: "input", reason: "the body is not JSON" });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
else
|
|
197
|
+
input = Object.fromEntries(url.searchParams);
|
|
198
|
+
const mode = (url.searchParams.get("mode") ?? "act");
|
|
199
|
+
if (!["simulate", "build", "act"].includes(mode))
|
|
200
|
+
return json(res, 400, { ok: false, gate: "input", reason: "mode must be simulate, build or act" });
|
|
201
|
+
return answer(res, await catalog.invoke(node, name, input, caller, { mode }));
|
|
202
|
+
}
|
|
203
|
+
if (url.pathname.startsWith("/.well-known/") || url.pathname.startsWith("/documents/"))
|
|
204
|
+
return json(res, 404, { ok: false, reason: "not published yet" });
|
|
205
|
+
json(res, 404, { ok: false, reason: "no such path" });
|
|
206
|
+
}
|
|
207
|
+
function answer(res, r) {
|
|
208
|
+
if (r.ok)
|
|
209
|
+
return json(res, 200, r);
|
|
210
|
+
// credentials and permission are HTTP's business; a business refusal is an ordinary answer with ok: false
|
|
211
|
+
if (r.gate === "permission")
|
|
212
|
+
return json(res, 403, r);
|
|
213
|
+
if (r.gate === "input")
|
|
214
|
+
return json(res, 400, r);
|
|
215
|
+
json(res, 200, r);
|
|
216
|
+
}
|
|
217
|
+
const server = createServer((req, res) => {
|
|
218
|
+
handle(req, res).catch((e) => {
|
|
219
|
+
log(`http: ${req.method} ${req.url}: ${e.message}`);
|
|
220
|
+
if (!res.headersSent)
|
|
221
|
+
json(res, 500, { ok: false, reason: "internal error" });
|
|
222
|
+
else
|
|
223
|
+
res.end();
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
server.requestTimeout = 60_000;
|
|
227
|
+
server.headersTimeout = 15_000;
|
|
228
|
+
return {
|
|
229
|
+
server,
|
|
230
|
+
listen(hostPort) {
|
|
231
|
+
const [hostRaw, portRaw] = splitHostPort(hostPort);
|
|
232
|
+
loopback = LOCAL_HOSTS.has(hostRaw);
|
|
233
|
+
return new Promise((resolve, reject) => {
|
|
234
|
+
server.once("error", reject);
|
|
235
|
+
server.listen(Number(portRaw), hostRaw === "[::1]" ? "::1" : hostRaw, () => {
|
|
236
|
+
const addr = server.address();
|
|
237
|
+
const port = typeof addr === "object" && addr ? addr.port : Number(portRaw);
|
|
238
|
+
const url = `http://${hostRaw.includes(":") && !hostRaw.startsWith("[") ? `[${hostRaw}]` : hostRaw}:${port}`;
|
|
239
|
+
resolve({ server, url, close: () => new Promise((r) => { server.closeAllConnections(); server.close(() => r()); }) });
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function splitHostPort(hostPort) {
|
|
246
|
+
const i = hostPort.lastIndexOf(":");
|
|
247
|
+
if (i < 0)
|
|
248
|
+
return ["127.0.0.1", hostPort];
|
|
249
|
+
return [hostPort.slice(0, i) || "127.0.0.1", hostPort.slice(i + 1)];
|
|
250
|
+
}
|
|
251
|
+
/** Run the watchers on the poll interval beside the server; returns a stop function. */
|
|
252
|
+
export function startWatchLoop(watchers, intervalMs, log) {
|
|
253
|
+
let running = false;
|
|
254
|
+
let stopped = false;
|
|
255
|
+
const tick = async () => {
|
|
256
|
+
if (running || stopped)
|
|
257
|
+
return;
|
|
258
|
+
running = true;
|
|
259
|
+
try {
|
|
260
|
+
const r = await watchers.tick();
|
|
261
|
+
log(`${r.at} block ${r.block}: anchor ${r.anchor}, heartbeat ${r.heartbeat.sent ? "sent" : "not sent"} (${r.heartbeat.reason}); ${r.discovered.length} new tender(s); ${r.settled.length} settled, ${r.withdrawn.length} withdrawn; safety ${r.safety.verdict}${r.safety.changed ? " (changed)" : ""}`);
|
|
262
|
+
}
|
|
263
|
+
catch (e) {
|
|
264
|
+
log(`watch: ${e.message}`);
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
running = false;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
const timer = setInterval(() => void tick(), intervalMs);
|
|
271
|
+
void tick();
|
|
272
|
+
return () => {
|
|
273
|
+
stopped = true;
|
|
274
|
+
clearInterval(timer);
|
|
275
|
+
};
|
|
276
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Contract } from "ethers";
|
|
2
|
+
import { ReasonName, VerdictName } from "../core/asf.js";
|
|
3
|
+
/**
|
|
4
|
+
* A cell as the kernel drives it: one FogaCellAccount, the owner key for administration and the operator key for
|
|
5
|
+
* acting. Every act of the cell is an `execute` through the account, keyed for the operation store.
|
|
6
|
+
*/
|
|
7
|
+
export class Cell {
|
|
8
|
+
kernel;
|
|
9
|
+
name;
|
|
10
|
+
address;
|
|
11
|
+
roles;
|
|
12
|
+
iface;
|
|
13
|
+
constructor(kernel, name, address, roles) {
|
|
14
|
+
this.kernel = kernel;
|
|
15
|
+
this.name = name;
|
|
16
|
+
this.address = address;
|
|
17
|
+
this.roles = roles;
|
|
18
|
+
this.iface = kernel.world.ifaceOf("foga:FogaCellAccount");
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Deploy the account from the owner key. The requirement may name the account itself (as adopter / subject),
|
|
22
|
+
* so it is built from the address predicted for the deployment nonce and checked against the receipt.
|
|
23
|
+
*/
|
|
24
|
+
static async deploy(kernel, p) {
|
|
25
|
+
const safety = kernel.asf.bindingRegistryAddress;
|
|
26
|
+
const owner = kernel.address(p.roles.owner);
|
|
27
|
+
const operator = kernel.address(p.roles.operator);
|
|
28
|
+
const recovery = p.roles.recovery ?? owner;
|
|
29
|
+
let predictedFor = null;
|
|
30
|
+
const { address } = await kernel.deploy(p.key, p.roles.owner, "foga:FogaCellAccount", [], (predicted) => {
|
|
31
|
+
predictedFor = predicted;
|
|
32
|
+
return [safety, owner, operator, p.roles.guardian, recovery, p.requirement(predicted)];
|
|
33
|
+
});
|
|
34
|
+
if (predictedFor && predictedFor.toLowerCase() !== address.toLowerCase()) {
|
|
35
|
+
throw new Error(`cell ${p.name}: deployed at ${address} but the requirement was built for ${predictedFor}; the deployer's nonce moved between prediction and inclusion`);
|
|
36
|
+
}
|
|
37
|
+
return new Cell(kernel, p.name, address, p.roles);
|
|
38
|
+
}
|
|
39
|
+
contract(runner) {
|
|
40
|
+
return new Contract(this.address, this.iface, runner ?? this.kernel.world.provider);
|
|
41
|
+
}
|
|
42
|
+
// ---------------------------------------------------------------------------- acting
|
|
43
|
+
/** The calldata of `execute(target, value, data)` on this account. */
|
|
44
|
+
encodeExecute(target, value, data) {
|
|
45
|
+
return this.iface.encodeFunctionData("execute", [target, value, data]);
|
|
46
|
+
}
|
|
47
|
+
/** Act as the cell: `execute(target, value, data)` signed by the operator (default) or the owner; `binding` ties the operation to the request that asked for it (the node). */
|
|
48
|
+
async execute(key, target, data, opts = {}) {
|
|
49
|
+
const signer = opts.by === "owner" ? this.roles.owner : this.roles.operator;
|
|
50
|
+
return this.kernel.tx.send({
|
|
51
|
+
key,
|
|
52
|
+
signer,
|
|
53
|
+
kind: "execute",
|
|
54
|
+
binding: opts.binding,
|
|
55
|
+
build: () => ({ to: this.address, data: this.encodeExecute(target, opts.value ?? 0n, data) }),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** Owner administration (setOperator, setAIDRegistry, setCommitmentClass, setDeriskAllowed, ...). */
|
|
59
|
+
async admin(key, fn, args, binding) {
|
|
60
|
+
return this.kernel.tx.send({
|
|
61
|
+
key,
|
|
62
|
+
signer: this.roles.owner,
|
|
63
|
+
kind: "admin",
|
|
64
|
+
binding,
|
|
65
|
+
build: () => ({ to: this.address, data: this.iface.encodeFunctionData(fn, args) }),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/** Adopt a safety binding as the account and present it for a clause (owner-level bootstrap). */
|
|
69
|
+
async adoptBinding(key, input, clauseIndex = 0) {
|
|
70
|
+
const confirmed = await this.admin(key, "adoptBinding", [input, clauseIndex]);
|
|
71
|
+
const bindingId = this.kernel.event(confirmed.receipt, this.iface, "BindingAdopted", this.address).bindingId;
|
|
72
|
+
return { bindingId, confirmed };
|
|
73
|
+
}
|
|
74
|
+
// ---------------------------------------------------------------------------- views
|
|
75
|
+
async status() {
|
|
76
|
+
const r = await this.contract().status();
|
|
77
|
+
const reasons = Array.from(r[1], (x) => Number(x));
|
|
78
|
+
return {
|
|
79
|
+
verdict: Number(r[0]),
|
|
80
|
+
verdictName: VerdictName[Number(r[0])] ?? String(r[0]),
|
|
81
|
+
reasons,
|
|
82
|
+
reasonNames: reasons.map((x) => ReasonName[x] ?? String(x)),
|
|
83
|
+
restrictions: Array.from(r[2], (x) => Number(x)),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
async presentedBindings() {
|
|
87
|
+
return Array.from(await this.contract().presentedBindings(), String);
|
|
88
|
+
}
|
|
89
|
+
async owner() {
|
|
90
|
+
return this.contract().owner();
|
|
91
|
+
}
|
|
92
|
+
async operator() {
|
|
93
|
+
return this.contract().operator();
|
|
94
|
+
}
|
|
95
|
+
async balance() {
|
|
96
|
+
return this.kernel.balance(this.address);
|
|
97
|
+
}
|
|
98
|
+
get operatorAddress() {
|
|
99
|
+
return this.kernel.address(this.roles.operator);
|
|
100
|
+
}
|
|
101
|
+
get ownerAddress() {
|
|
102
|
+
return this.kernel.address(this.roles.owner);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Interface } from "ethers";
|
|
2
|
+
/**
|
|
3
|
+
* Revert decoding across every interface the kernel knows (world contracts, the cell account, the ERC-8004
|
|
4
|
+
* surface). A revert that bubbles out of FogaCellAccount.execute arrives as CallFailed(bytes) with the inner
|
|
5
|
+
* revert data inside; it is unwrapped so the message names the real cause.
|
|
6
|
+
*/
|
|
7
|
+
export class RevertDecoder {
|
|
8
|
+
ifaces = [];
|
|
9
|
+
seen = new Set();
|
|
10
|
+
add(iface, name) {
|
|
11
|
+
const key = name ?? iface.fragments.map((f) => f.format("sighash")).join("|");
|
|
12
|
+
if (this.seen.has(key))
|
|
13
|
+
return;
|
|
14
|
+
this.seen.add(key);
|
|
15
|
+
this.ifaces.push(iface);
|
|
16
|
+
}
|
|
17
|
+
/** Human-readable description of revert data, or null when nothing matches. */
|
|
18
|
+
describe(data) {
|
|
19
|
+
if (!data || data === "0x")
|
|
20
|
+
return null;
|
|
21
|
+
const selector = data.slice(0, 10);
|
|
22
|
+
if (selector === "0x08c379a0") {
|
|
23
|
+
try {
|
|
24
|
+
const [msg] = new Interface(["error Error(string)"]).decodeErrorResult("Error", data);
|
|
25
|
+
return `Error(${JSON.stringify(msg)})`;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
/* fall through */
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (selector === "0x4e487b71") {
|
|
32
|
+
try {
|
|
33
|
+
const [code] = new Interface(["error Panic(uint256)"]).decodeErrorResult("Panic", data);
|
|
34
|
+
return `Panic(${code})`;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
/* fall through */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const iface of this.ifaces) {
|
|
41
|
+
let parsed = null;
|
|
42
|
+
try {
|
|
43
|
+
parsed = iface.parseError(data);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
parsed = null;
|
|
47
|
+
}
|
|
48
|
+
if (!parsed)
|
|
49
|
+
continue;
|
|
50
|
+
if (parsed.name === "CallFailed" && parsed.args.length === 1) {
|
|
51
|
+
const inner = this.describe(String(parsed.args[0]));
|
|
52
|
+
return `CallFailed(${inner ?? String(parsed.args[0])})`;
|
|
53
|
+
}
|
|
54
|
+
const args = parsed.args.map((a) => (typeof a === "bigint" ? a.toString() : Array.isArray(a) ? JSON.stringify(a.map(String)) : String(a)));
|
|
55
|
+
return `${parsed.name}(${args.join(", ")})`;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
/** Extract revert data from an ethers error (CallException, or a JSON-RPC error with data). */
|
|
60
|
+
static revertData(err) {
|
|
61
|
+
if (!err)
|
|
62
|
+
return null;
|
|
63
|
+
if (typeof err.data === "string")
|
|
64
|
+
return err.data;
|
|
65
|
+
if (err.info?.error?.data && typeof err.info.error.data === "string")
|
|
66
|
+
return err.info.error.data;
|
|
67
|
+
if (err.error?.data && typeof err.error.data === "string")
|
|
68
|
+
return err.error.data;
|
|
69
|
+
const m = /0x[0-9a-fA-F]{8,}/.exec(err.info?.error?.message ?? err.shortMessage ?? err.message ?? "");
|
|
70
|
+
return m ? m[0] : null;
|
|
71
|
+
}
|
|
72
|
+
explain(err) {
|
|
73
|
+
const data = RevertDecoder.revertData(err);
|
|
74
|
+
const described = this.describe(data);
|
|
75
|
+
const base = err?.shortMessage ?? err?.message ?? String(err);
|
|
76
|
+
return described ? `${described} [${base}]` : base;
|
|
77
|
+
}
|
|
78
|
+
}
|