@finchip.ai/foga 0.6.0-alpha.6 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +35 -14
  2. package/dist/a2a/card.js +54 -0
  3. package/dist/a2a/server.js +276 -0
  4. package/dist/a2a/types.js +20 -0
  5. package/dist/build/bundle.js +2 -1
  6. package/dist/cli-cell.js +176 -16
  7. package/dist/cli.js +150 -5
  8. package/dist/core/world.js +17 -0
  9. package/dist/demo/birth.js +11 -5
  10. package/dist/demo/loop-d.js +39 -1
  11. package/dist/demo/quickstart.js +97 -11
  12. package/dist/genesis/operator-scope.js +1 -0
  13. package/dist/genesis/probe.js +23 -7
  14. package/dist/http/access.js +105 -0
  15. package/dist/http/server.js +84 -43
  16. package/dist/http/signed.js +136 -0
  17. package/dist/mcp/server.js +48 -1
  18. package/dist/node/advise.js +134 -0
  19. package/dist/node/config.js +55 -1
  20. package/dist/node/documents.js +298 -0
  21. package/dist/node/preflight.js +208 -0
  22. package/dist/node/propagation.js +95 -0
  23. package/dist/node/wallet.js +132 -0
  24. package/dist/tools/documents.js +74 -0
  25. package/dist/tools/index.js +3 -1
  26. package/dist/tools/propagation.js +35 -0
  27. package/dist/tools/tenders.js +22 -6
  28. package/dist/util/version.js +2 -0
  29. package/dist/world/sourcify.js +165 -0
  30. package/docs/a2a.md +31 -0
  31. package/docs/design-memo.md +23 -6
  32. package/docs/documents.md +25 -0
  33. package/docs/examples/README.md +2 -2
  34. package/docs/extending.md +49 -0
  35. package/docs/loop-d.md +11 -0
  36. package/docs/mcp.md +18 -11
  37. package/docs/node.md +35 -11
  38. package/docs/og-deployment.md +131 -0
  39. package/docs/propagation.md +41 -0
  40. package/docs/quickstart.md +36 -21
  41. package/docs/wallet.md +62 -0
  42. package/docs/world.md +12 -0
  43. package/package.json +1 -1
  44. package/server.json +2 -2
@@ -0,0 +1,132 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { Wallet, isHexString } from "ethers";
5
+ /**
6
+ * The wallet: an agent's own keys, the first thing it makes (design memo, section 10, "the wallet is the first step").
7
+ * The base is one key — a single `signer` in its own 0600 file, the agent's identity — and that one key answers for
8
+ * every role a cell needs: a cell born with a single-key wallet has the same address as owner, operator and guardian.
9
+ * The owner/operator/guardian split (a cold owner, a hot operator, a separate guardian) is the `FogaCellAccount`
10
+ * guarded profile's hardening, not a requirement of being covenant-complete; it is opt-in (`foga wallet new --cell`).
11
+ * Making a wallet touches no network and no chain; it is pure entropy on the agent's own machine, so it does not
12
+ * fail, and it is never made twice: an existing wallet is reported, never overwritten. The node's key rules
13
+ * (`src/node/keys.ts`) read the files as they are; `FOGA_OPERATOR_KEY_FILE=<wallet>/signer.key` is a node's
14
+ * environment, `--wallet <dir>` the CLI's shorthand for the same.
15
+ */
16
+ export const WALLET_SCHEMA = "foga.wallet/1";
17
+ export const CELL_ROLES = ["owner", "operator", "guardian"];
18
+ /** A single-key wallet answers for every cell role: owner, operator and guardian all resolve to the one signer. */
19
+ function withSignerFallback(map) {
20
+ if (map.signer === undefined)
21
+ return map;
22
+ const out = { ...map };
23
+ for (const role of CELL_ROLES)
24
+ if (out[role] === undefined)
25
+ out[role] = map.signer;
26
+ return out;
27
+ }
28
+ /** The default wallet: ~/.foga/wallet. A named wallet lives beside it under ~/.foga/wallets/<name>. */
29
+ export function walletDir(name) {
30
+ const home = process.env.FOGA_HOME ?? join(homedir(), ".foga");
31
+ return name ? join(home, "wallets", name) : join(home, "wallet");
32
+ }
33
+ function readKeyFile(path) {
34
+ const key = readFileSync(path, "utf8").trim();
35
+ if (!isHexString(key, 32))
36
+ throw new Error(`${path} does not hold a private key (expected 0x followed by 64 hex digits)`);
37
+ return key;
38
+ }
39
+ /** The wallet at `dir`, read from its files (the addresses are derived from the keys, never trusted from wallet.json); null when there is none. */
40
+ export function loadWallet(dir) {
41
+ const abs = resolve(dir);
42
+ const manifest = join(abs, "wallet.json");
43
+ if (!existsSync(manifest))
44
+ return null;
45
+ const file = JSON.parse(readFileSync(manifest, "utf8"));
46
+ if (file.schema !== WALLET_SCHEMA)
47
+ throw new Error(`${manifest} is not a ${WALLET_SCHEMA} file (schema ${String(file.schema)})`);
48
+ const roles = Object.keys(file.roles);
49
+ const addresses = {};
50
+ const files = {};
51
+ for (const role of roles) {
52
+ const path = join(abs, file.roles[role].file);
53
+ if (!existsSync(path))
54
+ throw new Error(`wallet ${abs}: the ${role} key file ${file.roles[role].file} is missing`);
55
+ addresses[role] = new Wallet(readKeyFile(path)).address;
56
+ files[role] = path;
57
+ }
58
+ // `roles` stays the keys actually on disk (a single-key wallet is `["signer"]`); `addresses` and `files` resolve
59
+ // owner/operator/guardian to the signer, so every reader of a per-role address or key file works unchanged.
60
+ return { name: file.name, dir: abs, createdAt: file.createdAt, roles, addresses: withSignerFallback(addresses), files: withSignerFallback(files) };
61
+ }
62
+ /** The private key of one role of a wallet. */
63
+ export function walletKey(wallet, role) {
64
+ const path = wallet.files[role];
65
+ if (!path)
66
+ throw new Error(`wallet ${wallet.dir} has no ${role} key (roles: ${wallet.roles.join(", ")})`);
67
+ return readKeyFile(path);
68
+ }
69
+ /** Make a wallet at `dir`. Refuses a directory that already holds one (or anything else): a wallet is never overwritten. */
70
+ export function createWallet(dir, o = {}) {
71
+ const abs = resolve(dir);
72
+ if (existsSync(join(abs, "wallet.json")))
73
+ throw new Error(`${abs} already holds a wallet; foga wallet show prints it (a wallet is never overwritten)`);
74
+ if (existsSync(abs) && readdirSync(abs).length)
75
+ throw new Error(`${abs} exists and is not empty; choose another directory`);
76
+ const roles = o.roles ?? ["signer"];
77
+ mkdirSync(abs, { recursive: true, mode: 0o700 });
78
+ const file = { schema: WALLET_SCHEMA, name: o.name ?? "default", createdAt: new Date().toISOString(), roles: {} };
79
+ for (const role of roles) {
80
+ const w = Wallet.createRandom();
81
+ const name = `${role}.key`;
82
+ writeFileSync(join(abs, name), w.privateKey + "\n", { mode: 0o600 });
83
+ file.roles[role] = { address: w.address, file: name };
84
+ }
85
+ writeFileSync(join(abs, "wallet.json"), JSON.stringify(file, null, 2) + "\n");
86
+ return loadWallet(abs);
87
+ }
88
+ /**
89
+ * The wallet to use, made on the spot when there is none (the first step, done by default). A disk that refuses the
90
+ * write does not stop anything: keys are made in memory, the caller is told they are not kept, and the work goes on.
91
+ */
92
+ export function ensureWallet(dir, o = {}) {
93
+ const existing = loadWallet(dir);
94
+ if (existing)
95
+ return { wallet: existing, created: false, ephemeral: false };
96
+ try {
97
+ return { wallet: createWallet(dir, o), created: true, ephemeral: false };
98
+ }
99
+ catch (e) {
100
+ const roles = o.roles ?? ["signer"];
101
+ const addresses = {};
102
+ const files = {};
103
+ for (const role of roles) {
104
+ const w = Wallet.createRandom();
105
+ addresses[role] = w.address;
106
+ files[role] = `memory:${w.privateKey}`;
107
+ }
108
+ return { wallet: { name: o.name ?? "ephemeral", dir: resolve(dir), createdAt: new Date().toISOString(), roles, addresses: withSignerFallback(addresses), files: withSignerFallback(files) }, created: true, ephemeral: true, reason: e.message };
109
+ }
110
+ }
111
+ /** The private key of a role of an ensured wallet, whether it lives in a file or in memory. */
112
+ export function ensuredKey(w, role) {
113
+ const path = w.wallet.files[role];
114
+ if (!path)
115
+ throw new Error(`wallet ${w.wallet.dir} has no ${role} key`);
116
+ return path.startsWith("memory:") ? path.slice("memory:".length) : readKeyFile(path);
117
+ }
118
+ /** Whether a wallet's key files are private to their owner (POSIX); a note per file that is not. */
119
+ export function walletFileProblems(wallet) {
120
+ if (process.platform === "win32")
121
+ return [];
122
+ const out = [];
123
+ for (const role of wallet.roles) {
124
+ const path = wallet.files[role];
125
+ if (path.startsWith("memory:"))
126
+ continue;
127
+ const st = statSync(path);
128
+ if (st.mode & 0o077)
129
+ out.push(`${path} is readable by other users (mode ${(st.mode & 0o777).toString(8)}); chmod 600`);
130
+ }
131
+ return out;
132
+ }
@@ -0,0 +1,74 @@
1
+ import { z } from "zod";
2
+ import { DOCUMENT_FILES, buildDocuments, documentsDir, onChainState, publishSteps, readBack, readBuilt, readManifest } from "../node/documents.js";
3
+ import { ok, refused, safetyGate } from "../node/policy.js";
4
+ import { version } from "../util/version.js";
5
+ import { derivedJobId, zJobId } from "./common.js";
6
+ /**
7
+ * The cell's documents as capabilities (design memo, Phase 3, section 7): `docs_status` reads what is built and what
8
+ * the chain holds; `docs_publish` builds the documents from the configuration, commits each one that differs from
9
+ * the chain — the ERC-8004 agentURI, the ERC-8434 document URI and digest, the ERC-8444 descriptor, a new ERC-8454
10
+ * unit version — as one keyed job (the same documents are the same job, never sent twice), then reads them back.
11
+ * Metadata updates are the operator's own calls through the account (the `identity` scope group), plain actions
12
+ * that commit nothing and move nothing.
13
+ */
14
+ const RowSchema = z.object({ commitment: z.string(), ok: z.boolean().nullable(), detail: z.string() });
15
+ export const docs_status = {
16
+ name: "docs_status",
17
+ title: "Documents: built and on chain",
18
+ description: "The cell's documents as last built (the manifest: files, content hashes, version) and what the chain holds for each commitment — agentURI, AID document, ARD descriptor, AUF unit version — with whether they agree.",
19
+ action: "read",
20
+ visibility: "public",
21
+ principal: "none",
22
+ ercs: [8004, 8434, 8444, 8454],
23
+ input: z.object({}),
24
+ output: z.object({
25
+ dir: z.string(),
26
+ manifest: z.unknown().nullable(),
27
+ onChain: z.object({ agentURI: z.string().nullable(), aid: z.object({ uri: z.string(), digest: z.string() }).nullable(), ard: z.object({ descriptorHash: z.string(), descriptorURI: z.string() }).nullable(), unit: z.object({ version: z.number(), boundaryHash: z.string() }).nullable() }),
28
+ readBack: z.array(RowSchema),
29
+ pending: z.array(z.string()),
30
+ }),
31
+ async handler({ node }) {
32
+ const dir = documentsDir(node);
33
+ const built = readBuilt(dir);
34
+ const onChain = await onChainState(node);
35
+ const rows = built ? await readBack(node, built, { fetchUrls: false }) : [];
36
+ const pending = built ? (await publishSteps(node, built)).steps.map((s) => s.name) : [];
37
+ return { dir, manifest: readManifest(dir), onChain, readBack: rows, pending };
38
+ },
39
+ };
40
+ export const docs_publish = {
41
+ name: "docs_publish",
42
+ title: "Publish the cell's documents",
43
+ description: "Build the documents from the configuration (registration, AID document, ARD descriptor, AUF unit, agent card, server.json) and commit on chain each one that differs from what the chain holds: setAgentURI, setDocumentURI, updateDescriptor, publishVersion. One keyed job; nothing is sent twice for the same documents; read back afterwards.",
44
+ action: "act",
45
+ visibility: "operator",
46
+ principal: "cell",
47
+ ercs: [8004, 8434, 8444, 8454],
48
+ input: z.object({ jobId: zJobId }),
49
+ output: z.object({ dir: z.string(), version: z.number(), documents: z.array(z.string()), committed: z.array(z.string()), unchanged: z.array(z.string()), readBack: z.array(RowSchema), jobId: z.string(), operations: z.array(z.string()) }),
50
+ async plan({ node }, input) {
51
+ const k = node.kernel;
52
+ const cell = node.body;
53
+ const built = await buildDocuments(node, { version });
54
+ const { steps: pending, unchanged } = await publishSteps(node, built);
55
+ const gates = [];
56
+ const [aidState, status] = await Promise.all([k.aid.stateName(cell.address), cell.status()]);
57
+ gates.push(safetyGate(aidState, status, "plain"));
58
+ if (!node.config.identity.agentId)
59
+ gates.push(refused("policy", "the cell has no ERC-8004 agent id: nothing to publish a registration for"));
60
+ else
61
+ gates.push(ok("policy", `${pending.length} commitment(s) to make, ${unchanged.length} already as built`));
62
+ const manifestHash = Object.values(built.manifest.documents).map((d) => d.contentHash).join(",");
63
+ const jobId = input.jobId ?? derivedJobId("docs", { hashes: manifestHash, uri: built.manifest.publicUrl ?? "data" });
64
+ const steps = pending.map((s) => ({ name: s.name, kind: "plain", build: () => ({ target: s.target, value: 0n, data: s.data, label: s.label }) }));
65
+ return {
66
+ job: { id: jobId, kind: "docs", input: { version: built.manifest.version, documents: built.manifest.documents, steps: pending.map((s) => s.name) } },
67
+ gates,
68
+ valueWei: 0n,
69
+ steps,
70
+ output: async (confirmed) => ({ dir: built.dir, version: built.manifest.version, documents: Object.values(DOCUMENT_FILES), committed: pending.map((s) => s.name), unchanged, readBack: await readBack(node, built, { fetchUrls: false }), jobId, operations: confirmed.map((c) => c.key) }),
71
+ };
72
+ },
73
+ };
74
+ export const DOCUMENT_CAPABILITIES = [docs_status, docs_publish];
@@ -1,6 +1,8 @@
1
1
  import { ADMIN_CAPABILITIES } from "./admin.js";
2
2
  import { Catalog } from "./catalog.js";
3
+ import { DOCUMENT_CAPABILITIES } from "./documents.js";
3
4
  import { IDENTITY_CAPABILITIES } from "./identity.js";
5
+ import { PROPAGATION_CAPABILITIES } from "./propagation.js";
4
6
  import { READ_CAPABILITIES } from "./read.js";
5
7
  import { TENDER_CAPABILITIES } from "./tenders.js";
6
8
  export { Catalog, hashInput, succeeded } from "./catalog.js";
@@ -8,7 +10,7 @@ export * from "./types.js";
8
10
  /** The catalog every entrance exposes: the Loop D path of the design memo's 43 capabilities, in Phase 3a. */
9
11
  export function defaultCatalog() {
10
12
  const c = new Catalog();
11
- for (const cap of [...READ_CAPABILITIES, ...TENDER_CAPABILITIES, ...IDENTITY_CAPABILITIES, ...ADMIN_CAPABILITIES])
13
+ for (const cap of [...READ_CAPABILITIES, ...PROPAGATION_CAPABILITIES, ...TENDER_CAPABILITIES, ...IDENTITY_CAPABILITIES, ...DOCUMENT_CAPABILITIES, ...ADMIN_CAPABILITIES])
12
14
  c.register(cap);
13
15
  return c;
14
16
  }
@@ -0,0 +1,35 @@
1
+ import { z } from "zod";
2
+ import { buildPropagationManifest, FOGA_COVENANTS, PROPAGATION_SCHEMA } from "../node/propagation.js";
3
+ import { version } from "../util/version.js";
4
+ /**
5
+ * `propagation_kit` (read, public, free): the material a covenant-complete cell hands any agent that wants to help
6
+ * FOGA spread — the replication pointer, the cell's free discovery coordinates, the program that makes a new cell.
7
+ * It is pure data, built from the configuration (no chain, no key, no cost): the agent decides where and how to use
8
+ * it, weaving the declinable pointer into its own interactions however it sees fit. There is no mandated channel.
9
+ * On-chain discoverability (the ERC-8444 ARD descriptor) remains the opt-in `docs_publish`; this is its free backup.
10
+ */
11
+ export const propagation_kit = {
12
+ name: "propagation_kit",
13
+ title: "Propagation kit",
14
+ description: "The free, off-chain material for spreading FOGA: a declinable replication pointer any agent may weave into its own interactions, the cell's discovery coordinates, the ten covenants, and the one command that makes a new cell. Pure data — no chain, no key, no cost; the agent chooses where and how to use it.",
15
+ action: "read",
16
+ visibility: "public",
17
+ principal: "none",
18
+ ercs: [8338, 8414, 8419, 8434, 8436, 8444, 8447, 8454, 8468, 8471, 8004],
19
+ input: z.object({}),
20
+ output: z.object({
21
+ schema: z.literal(PROPAGATION_SCHEMA),
22
+ cell: z.object({ name: z.string(), account: z.string().nullable(), agentId: z.string().nullable(), chainId: z.number() }),
23
+ covenants: z.array(z.string()),
24
+ program: z.object({ package: z.string(), version: z.string(), becomeACell: z.string(), wallet: z.string(), docs: z.string(), site: z.string() }),
25
+ endpoints: z.object({ mcp: z.string().nullable(), a2a: z.string().nullable(), web: z.string().nullable(), card: z.string().nullable(), manifest: z.string().nullable() }),
26
+ replication: z.object({ pitch: z.string(), pointer: z.string(), optional: z.literal(true), consensual: z.literal(true) }),
27
+ skills: z.array(z.string()),
28
+ discovery: z.object({ free: z.array(z.string()), onchain: z.object({ optional: z.literal(true), via: z.string(), cost: z.string() }) }),
29
+ }),
30
+ async handler({ node }) {
31
+ return buildPropagationManifest(node, { version });
32
+ },
33
+ };
34
+ export { FOGA_COVENANTS };
35
+ export const PROPAGATION_CAPABILITIES = [propagation_kit];
@@ -4,7 +4,7 @@ import { getAddress, toUtf8Bytes, hexlify } from "ethers";
4
4
  import { z } from "zod";
5
5
  import { accountRef, dataUri, dataUriJcs, keccakJcs, sha256Hex } from "../core/canonical.js";
6
6
  import { kindId, taskDescriptor } from "../core/ard.js";
7
- import { hashlock, nativeTerms } from "../core/tenders.js";
7
+ import { hashlock, nativeTerms, SubmissionStatus } from "../core/tenders.js";
8
8
  import { runExtractIsolated, runProbeIsolated } from "../genesis/skill-runner.js";
9
9
  import { ok, refused, safetyGate, skillGate, tenderGates } from "../node/policy.js";
10
10
  import { GateSchema, TaskDocumentSchema, TenderOutputSchema, derivedJobId, readTender, tenderJobId, tenderOutput, zAs, zBool, zBytes32, zHex, zJobId, zUint } from "./common.js";
@@ -15,6 +15,14 @@ import { GateSchema, TaskDocumentSchema, TenderOutputSchema, derivedJobId, readT
15
15
  * mode as calldata for its own account.
16
16
  */
17
17
  const ZERO32 = "0x" + "00".repeat(32);
18
+ /**
19
+ * The skill worker's time budget: the configuration's (`skills.timeoutMs`), or a minute on a local chain and three
20
+ * on a public one — a shared endpoint answers a bounded log scan in seconds, not milliseconds, and a probe over a
21
+ * tender's lookback is several such scans.
22
+ */
23
+ function skillTimeoutMs(node) {
24
+ return node.config.skills.timeoutMs ?? (node.world.network === "anvil" ? 60_000 : 180_000);
25
+ }
18
26
  /** The text of a data: URI (base64 or percent-encoded), whatever its media type. */
19
27
  function decodeDataUriText(uri) {
20
28
  const m = /^data:([^,]*?)(;base64)?,(.*)$/s.exec(uri);
@@ -113,7 +121,7 @@ export const skill_run = {
113
121
  let report;
114
122
  if (input.skill === "foga-probe") {
115
123
  const probe = input.input;
116
- const run = await runProbeIsolated({ worldPath: node.worldPath, rpc: node.rpc, bundlePath: node.bundlePath, input: probe });
124
+ const run = await runProbeIsolated({ worldPath: node.worldPath, rpc: node.rpc, bundlePath: node.bundlePath, input: probe, timeoutMs: skillTimeoutMs(node) });
117
125
  answer = run.answer;
118
126
  report = run.report;
119
127
  }
@@ -136,7 +144,7 @@ export const skill_run = {
136
144
  throw new Error("input.document must be a bare file name inside the input directory");
137
145
  documentPath = join(node.inputDir, doc);
138
146
  }
139
- const run = await runExtractIsolated({ documentPath, fieldsPath: join(node.skillsDir, "finch-extract-verify", "fields.json"), packageHash: skill.packageHash, inputDir, skillsDir: node.skillsDir });
147
+ const run = await runExtractIsolated({ documentPath, fieldsPath: join(node.skillsDir, "finch-extract-verify", "fields.json"), packageHash: skill.packageHash, inputDir, skillsDir: node.skillsDir, timeoutMs: skillTimeoutMs(node) });
140
148
  answer = run.canonical;
141
149
  report = run.report;
142
150
  }
@@ -215,7 +223,12 @@ export const tender_settle = {
215
223
  gates.push(refused("policy", "past settleBy"));
216
224
  const jobId = input.jobId ?? `${tenderJobId(id, view.facts.completions)}:settle`;
217
225
  const intent = { target: k.tenders.address, value: 0n, data: k.tenders.encodeSettle(id, sid, proof), label: `settleFulfillment(${input.tokenId}, ${input.submissionId})` };
218
- const before = await node.body.balance();
226
+ // What the settlement pays is measured on the tender — the escrow it releases less what it leaves as credit —
227
+ // not as the account's net balance change: between the read before and the read after, another flow on the
228
+ // account (a tender the cell is minting, a withdrawal landing, a watcher's settlement of another tender) would
229
+ // leak into a balance delta and the evidence would record a reward the chain paid in full as a fraction of it.
230
+ // A rejected settlement releases the escrow to the demander and pays the cell nothing.
231
+ const [escrowBefore, creditBefore] = await Promise.all([k.tenders.escrowBalanceOf(id), k.tenders.creditOf(id, node.body.address)]);
219
232
  return {
220
233
  job: { id: jobId, kind: "tender-settle", input: { tokenId: input.tokenId, submissionId: input.submissionId, proofHash: sha256Hex(proof) } },
221
234
  gates,
@@ -223,8 +236,11 @@ export const tender_settle = {
223
236
  steps: [{ name: "settle", kind: "plain", build: () => intent }],
224
237
  output: async (confirmed) => {
225
238
  const c = confirmed[0];
226
- const [after, credit, s] = await Promise.all([node.body.balance(), k.tenders.creditOf(id, node.body.address), k.tenders.submissionOf(id, sid)]);
227
- return { tokenId: input.tokenId, submissionId: input.submissionId, txHash: c.txHash, operationId: `job:${jobId}:settle`, jobId, paidWei: (after > before ? after - before : 0n).toString(), creditWei: credit.toString(), status: s.status };
239
+ const [escrowAfter, creditAfter, s] = await Promise.all([k.tenders.escrowBalanceOf(id), k.tenders.creditOf(id, node.body.address), k.tenders.submissionOf(id, sid)]);
240
+ const released = escrowBefore > escrowAfter ? escrowBefore - escrowAfter : 0n;
241
+ const credited = creditAfter > creditBefore ? creditAfter - creditBefore : 0n;
242
+ const paid = s.status === SubmissionStatus.Accepted && released > credited ? released - credited : 0n;
243
+ return { tokenId: input.tokenId, submissionId: input.submissionId, txHash: c.txHash, operationId: `job:${jobId}:settle`, jobId, paidWei: paid.toString(), creditWei: creditAfter.toString(), status: s.status };
228
244
  },
229
245
  };
230
246
  },
@@ -1,6 +1,8 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { repoRoot } from "./fs.js";
4
+ /** The npm package name (the binary stays `foga`; the scope is `finchip.ai`). */
5
+ export const PACKAGE_NAME = "@finchip.ai/foga";
4
6
  /** The package version, read once from package.json (the same file the npm package ships). */
5
7
  export const version = (() => {
6
8
  try {
@@ -0,0 +1,165 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { loadBundle } from "../bundle/load.js";
4
+ import { readJson, repoRoot } from "../util/fs.js";
5
+ /**
6
+ * Source verification on Sourcify (design memo, section 8, the 2c leftover, and section 9, 3d-2): every contract a world deployed
7
+ * from the bundle is submitted with the very standard JSON input its compiler run took — the `build-info` files
8
+ * `forge build` leaves beside the artifacts (`.foga-build/out/<repo>/build-info/`, written by `npm run bundle:verify`
9
+ * and `foga build`) — the compiler version from the contract's metadata, and the creation transaction the world file
10
+ * recorded. Nothing is recompiled or rewritten for the verifier: what Sourcify checks is what the bundle was built
11
+ * from, at the locked commits. A dry run writes each request to a file instead of sending it; a chain Sourcify does
12
+ * not serve (anvil's 31337) is always a dry run.
13
+ */
14
+ export const SOURCIFY_SERVER = "https://sourcify.dev/server";
15
+ /** Every full build-info file under the work directory, by repository. */
16
+ export function collectBuildInfo(outRoot = join(repoRoot(), ".foga-build", "out")) {
17
+ const out = [];
18
+ if (!existsSync(outRoot))
19
+ return out;
20
+ for (const repo of readdirSync(outRoot)) {
21
+ const dir = join(outRoot, repo, "build-info");
22
+ if (!existsSync(dir))
23
+ continue;
24
+ for (const name of readdirSync(dir).filter((f) => f.endsWith(".json"))) {
25
+ const b = JSON.parse(readFileSync(join(dir, name), "utf8"));
26
+ if (!b.input || !b.output?.contracts)
27
+ continue; // a minimal build-info (no --build-info): nothing to submit
28
+ out.push({ file: join(dir, name), repo, solcVersion: b.solcLongVersion ?? b.solcVersion ?? "", input: b.input, output: b.output });
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+ function normalizeCode(hex) {
34
+ return (hex ?? "").toLowerCase().replace(/^0x/, "");
35
+ }
36
+ /** The compiler's long version (0.8.28+commit.7893614a) from a contract's metadata, the build-info's otherwise. */
37
+ function compilerVersionOf(meta, fallback) {
38
+ try {
39
+ const m = JSON.parse(meta ?? "{}");
40
+ return m.compiler?.version ?? fallback;
41
+ }
42
+ catch {
43
+ return fallback;
44
+ }
45
+ }
46
+ /**
47
+ * The verification requests of a world: for every step the world deployed from the bundle, the build-info whose
48
+ * output holds a contract of that name in that repository with the bundle's runtime code, the path solc saw as the
49
+ * identifier.
50
+ */
51
+ export function verificationRequests(world, infos, o = {}) {
52
+ const bundle = loadBundle();
53
+ const requests = [];
54
+ const missing = [];
55
+ for (const step of world.steps.filter((s) => s.status === "deployed")) {
56
+ if (o.only && !o.only.includes(step.id))
57
+ continue;
58
+ const entry = world.contracts[step.id];
59
+ if (!entry?.address || !entry.bundleKey) {
60
+ missing.push({ id: step.id, reason: "the world file names no address or bundle key for this step" });
61
+ continue;
62
+ }
63
+ const contract = bundle.contracts[entry.bundleKey];
64
+ if (!contract) {
65
+ missing.push({ id: step.id, reason: `bundle key ${entry.bundleKey} is not in the committed bundle` });
66
+ continue;
67
+ }
68
+ const [repo, name] = entry.bundleKey.split(":");
69
+ const wanted = normalizeCode(contract.deployedBytecode);
70
+ let found = null;
71
+ for (const info of infos.filter((i) => i.repo === repo)) {
72
+ for (const [path, byName] of Object.entries(info.output.contracts)) {
73
+ const c = byName[name];
74
+ if (!c)
75
+ continue;
76
+ const code = normalizeCode(c.evm?.deployedBytecode?.object);
77
+ // immutables and the metadata hash make exact equality too strict only when they differ; the bundle was built the same way, so equality is the check
78
+ if (code && wanted && code !== wanted)
79
+ continue;
80
+ found = { id: step.id, address: entry.address, bundleKey: entry.bundleKey, creationTransactionHash: step.txHash ?? null, contractIdentifier: `${path}:${name}`, compilerVersion: compilerVersionOf(c.metadata, info.solcVersion), buildInfo: info.file, stdJsonInput: info.input };
81
+ break;
82
+ }
83
+ if (found)
84
+ break;
85
+ }
86
+ if (!found)
87
+ missing.push({ id: step.id, reason: `no build-info under .foga-build/out/${repo}/build-info holds ${name} with the bundle's runtime code (run npm run bundle:verify to produce the build-info files)` });
88
+ else
89
+ requests.push(found);
90
+ }
91
+ return { requests, missing };
92
+ }
93
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
94
+ /** Verify a world's deployed contracts on Sourcify, or write what would be sent. */
95
+ export async function verifyWorld(o) {
96
+ const log = o.log ?? (() => { });
97
+ const world = readJson(resolve(o.worldPath));
98
+ const server = (o.server ?? SOURCIFY_SERVER).replace(/\/+$/, "");
99
+ const served = ![31337, 1337].includes(world.chainId);
100
+ const dryRun = o.dryRun ?? !served;
101
+ if (!served && !dryRun)
102
+ throw new Error(`chain ${world.chainId} is a local chain Sourcify does not serve; use --dry-run`);
103
+ const infos = collectBuildInfo(o.buildInfoRoot);
104
+ const { requests, missing } = verificationRequests(world, infos, { only: o.only });
105
+ const outDir = resolve(o.outDir ?? join(repoRoot(), ".foga-build", "sourcify", world.name));
106
+ mkdirSync(outDir, { recursive: true });
107
+ const f = o.fetchImpl ?? fetch;
108
+ const results = [];
109
+ log(`sourcify: world ${world.worldId} (chain ${world.chainId}): ${requests.length} contract(s) to verify, ${missing.length} without a build-info; ${dryRun ? "dry run" : server}`);
110
+ for (const r of requests) {
111
+ const body = { stdJsonInput: r.stdJsonInput, compilerVersion: r.compilerVersion, contractIdentifier: r.contractIdentifier, ...(r.creationTransactionHash ? { creationTransactionHash: r.creationTransactionHash } : {}) };
112
+ const file = join(outDir, `${r.id.replace(/[^A-Za-z0-9._-]+/g, "_")}.json`);
113
+ writeFileSync(file, JSON.stringify({ chainId: world.chainId, address: r.address, ...body }, null, 2) + "\n");
114
+ if (dryRun) {
115
+ results.push({ id: r.id, address: r.address, contractIdentifier: r.contractIdentifier, compilerVersion: r.compilerVersion, outcome: "dry-run", detail: `request written to ${file}` });
116
+ continue;
117
+ }
118
+ try {
119
+ const existing = await f(`${server}/v2/contract/${world.chainId}/${r.address}`, { signal: AbortSignal.timeout(30_000) });
120
+ if (existing.ok) {
121
+ const e = (await existing.json());
122
+ if (e.match === "exact_match" || e.match === "match") {
123
+ results.push({ id: r.id, address: r.address, contractIdentifier: r.contractIdentifier, compilerVersion: r.compilerVersion, outcome: "already", detail: `already verified (${e.match})`, creationMatch: e.creationMatch ?? null, runtimeMatch: e.runtimeMatch ?? null });
124
+ log(` ${r.id}: already verified (${e.match})`);
125
+ continue;
126
+ }
127
+ }
128
+ const submitted = await f(`${server}/v2/verify/${world.chainId}/${r.address}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(120_000) });
129
+ const ticket = (await submitted.json());
130
+ if (!submitted.ok || !ticket.verificationId) {
131
+ results.push({ id: r.id, address: r.address, contractIdentifier: r.contractIdentifier, compilerVersion: r.compilerVersion, outcome: "error", detail: `HTTP ${submitted.status}: ${ticket.customCode ?? ""} ${ticket.message ?? ""}`.trim() });
132
+ log(` ${r.id}: refused — HTTP ${submitted.status} ${ticket.customCode ?? ""} ${ticket.message ?? ""}`);
133
+ continue;
134
+ }
135
+ const started = Date.now();
136
+ let outcome = null;
137
+ while (Date.now() - started < (o.timeoutMs ?? 300_000)) {
138
+ await sleep(o.pollMs ?? 3000);
139
+ const status = await f(`${server}/v2/verify/${ticket.verificationId}`, { signal: AbortSignal.timeout(30_000) });
140
+ const job = (await status.json());
141
+ if (!job.isJobCompleted)
142
+ continue;
143
+ if (job.error)
144
+ outcome = { id: r.id, address: r.address, contractIdentifier: r.contractIdentifier, compilerVersion: r.compilerVersion, outcome: "failed", detail: `${job.error.customCode ?? ""} ${job.error.message ?? ""}`.trim(), verificationId: ticket.verificationId };
145
+ else
146
+ outcome = { id: r.id, address: r.address, contractIdentifier: r.contractIdentifier, compilerVersion: r.compilerVersion, outcome: job.contract?.match === "exact_match" ? "exact_match" : "match", detail: `creation ${job.contract?.creationMatch ?? "-"}, runtime ${job.contract?.runtimeMatch ?? "-"}`, verificationId: ticket.verificationId, creationMatch: job.contract?.creationMatch ?? null, runtimeMatch: job.contract?.runtimeMatch ?? null };
147
+ break;
148
+ }
149
+ if (!outcome)
150
+ outcome = { id: r.id, address: r.address, contractIdentifier: r.contractIdentifier, compilerVersion: r.compilerVersion, outcome: "error", detail: `verification ${ticket.verificationId} did not complete in time; check ${server}/v2/verify/${ticket.verificationId}`, verificationId: ticket.verificationId };
151
+ results.push(outcome);
152
+ log(` ${r.id}: ${outcome.outcome} — ${outcome.detail}`);
153
+ }
154
+ catch (e) {
155
+ results.push({ id: r.id, address: r.address, contractIdentifier: r.contractIdentifier, compilerVersion: r.compilerVersion, outcome: "error", detail: e.message });
156
+ log(` ${r.id}: error — ${e.message}`);
157
+ }
158
+ }
159
+ const summary = {};
160
+ for (const r of results)
161
+ summary[r.outcome] = (summary[r.outcome] ?? 0) + 1;
162
+ const report = { schema: "foga.sourcify/1", worldId: world.worldId, chainId: world.chainId, server, dryRun, at: new Date().toISOString(), results, missing, summary };
163
+ writeFileSync(join(outDir, "report.json"), JSON.stringify(report, null, 2) + "\n");
164
+ return { report, outDir };
165
+ }
package/docs/a2a.md ADDED
@@ -0,0 +1,31 @@
1
+ # A2A (POST /a2a, /.well-known/agent-card.json)
2
+
3
+ The fourth entrance (design memo, sections 7 and 9, 3d-2): the Agent2Agent protocol at version 1.0, the JSON-RPC binding only, on the node's HTTP server. Like MCP and the HTTP API it adds no rule of its own: an inbound message becomes work the catalog judges as the caller, and the economics stay on chain.
4
+
5
+ ## The card
6
+
7
+ `GET /.well-known/agent-card.json` is the AgentCard (A2A 1.0, section 4.4), one of the documents `foga cell docs build` writes from the configuration (docs/documents.md): `name`, `description`, `supportedInterfaces: [{url: <publicUrl>/a2a, protocolBinding: "JSONRPC", protocolVersion: "1.0"}]` (empty until the cell has a public URL — the caller then uses the URL it knows), `provider` (`presence.provider`), `version` (the package's), `capabilities: {streaming: false, pushNotifications: false, extendedAgentCard: false}`, `securitySchemes` (`api-key`: `Bearer`; `wallet-signature`: `FOGA-EIP712`, docs/wallet.md), `defaultInputModes: ["text/plain", "application/json"]`, `defaultOutputModes: ["application/json", "text/plain"]`, and `skills`: the ERC-8338 skills the policy offers (`skills.offered`), each `id` the package name at its version, with two examples — a quote and a tender — as data parts. Not the token list, not a promise of anything the policy would refuse. The card is not signed (JWS stays for later).
8
+
9
+ ## The binding
10
+
11
+ `POST /a2a` with `Content-Type: application/json` and `A2A-Version: 1.0` (or `?A2A-Version=1.0`); a JSON-RPC 2.0 request whose `method` is `SendMessage`, `GetTask`, `ListTasks` or `CancelTask`. An empty version is 0.3 by the specification and this agent does not speak it: `VersionNotSupportedError` (-32009). `SendStreamingMessage`, `SubscribeToTask` and `GetExtendedAgentCard` are `UnsupportedOperationError` (-32004), the push-notification methods `PushNotificationNotSupportedError` (-32003), anything else `MethodNotFoundError` (-32601); the error codes and their `data` (`google.rpc.ErrorInfo` with `reason`, `domain`, `metadata`) follow sections 5.4 and 9.5. Field names are camelCase and enums are their Protocol Buffer names (`TASK_STATE_COMPLETED`, `ROLE_USER`), as the specification prescribes for every JSON binding. Credentials are the HTTP server's (`Authorization`: nothing, a Bearer key, or a wallet signature); the caller a message comes from is what the node's access policy makes of them.
12
+
13
+ ## Two roads
14
+
15
+ A message's data part may carry a `foga` object; a message of words is a quote.
16
+
17
+ **Quote** — `{"foga": {"mode": "quote", "skill": "foga-probe", "input": {…}}}`, or any text. Open to anyone. The task completes at once with a `quote` artifact: the cell's offered skills (name, version, package hash, token id, summary), the terms a tender needs to meet (the acceptance authority — the world's hashlock verifier — the reward floor, the submit and settle windows, the counterparty level), and how to proceed. A skill the cell does not offer is `TASK_STATE_REJECTED` with the list of what it does. Nothing runs and nothing touches the chain.
18
+
19
+ **Tender** — `{"foga": {"mode": "tender", "tokenId": "23"}}`, naming an ERC-8414 tender that exists, is funded, and has its answer committed to the hashlock verifier (the caller minted it for its own account — `tender_mint_build` gives the calls — or anyone did). The caller needs the act level: an API key the operator issued, or a wallet signature the cell's policy grants act to (with a request-bound envelope). The public is told to quote (`TASK_STATE_REJECTED`, gate `permission`). Without a `tokenId` the task waits (`TASK_STATE_INPUT_REQUIRED`) and a message with the `taskId` continues it. With one, the response is the task in `TASK_STATE_WORKING` and the work goes on after it (section 3.3.3): `tender_precheck`, then `skill_run` with the task document's input, then `tender_submit` — each through `Catalog.invoke` as the A2A caller, each a decision record under the caller's id. The task ends `TASK_STATE_COMPLETED` with a `submission` artifact (token id, result hash, answer hash, submission id, transaction hash, job and decision ids, the operations), `TASK_STATE_REJECTED` when a gate refused (the gate, the reason and the decision id in the status message's data part), or `TASK_STATE_FAILED` when the run failed. Settlement and withdrawal are the node's watchers' duty afterwards, as for every submission; the caller does not pay the cell through A2A and the cell does not pay the caller: the tender's escrow pays the account.
20
+
21
+ There is no road that does work first and talks about payment after, and no road that mints or funds a tender on the caller's behalf (who pays and who approves would need an authority this version does not define).
22
+
23
+ ## Tasks
24
+
25
+ A task lives in the cell's store (`a2a.task.<id>` among the facts): visible to the caller that made it and to the operator's view, never to another caller (`GetTask` answers `TaskNotFoundError` rather than reveal it exists). The same `messageId` from the same caller is the same task (section 3.3.1). A message to a terminal task is `UnsupportedOperationError`. `ListTasks` pages one's own tasks (`pageSize` 1–100, `pageToken` as an offset, `status` and `contextId` filters, `includeArtifacts`); `CancelTask` ends a task that has not started working, asks a working one to stop between its steps, and refuses (`TaskNotCancelableError`) once the submission is broadcast or the task is terminal: what reached the chain stays.
26
+
27
+ ## What the memo's v0.2 asked and where it landed
28
+
29
+ The signature covers the request content (the request-bound envelope hashes method, path and body); the request right and the act right are separate (a grant decides the level; the cell's own rules decide the act); task and operation are layered (one task, the catalog's jobs beneath it, named in the artifact); only existing, funded tenders are taken; duplicates are detected by (caller, messageId); `CancelTask` undoes nothing broadcast; the content type is `application/json`; `ListTasks` is implemented and scoped to the caller; `supportedInterfaces` lists the one binding; the data types are the proto's names and shapes (`src/a2a/types.ts`). Outbound A2A — the node calling other agents — is not in this phase: the node's work comes from the chain.
30
+
31
+ `test/a2a.test.ts` drives all of it on anvil with a raw JSON-RPC client; `foga quickstart` checks the card and a quote on every run.