mycomesh-consumer 0.3.0 → 0.5.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.
package/README.md CHANGED
@@ -8,26 +8,32 @@ Requires Node.js 20 or newer. No Docker or Python.
8
8
 
9
9
  ```sh
10
10
  npm install --global mycomesh-consumer
11
- mycomesh-consumer init
12
- mycomesh-consumer setup --owner-key-file owner.key --faucet 100000000 --deposit 100000000
13
- mycomesh-consumer request "What is MycoMesh?"
14
- mycomesh-consumer serve # http://127.0.0.1:8110/v1 (responses, chat/completions, models; streaming)
11
+ mycomesh-consumer init # payment key + password-protected owner wallet
12
+ mycomesh-consumer setup --deposit 20000000
13
+ mycomesh-consumer request --stream "What is MycoMesh?"
14
+ mycomesh-consumer serve # http://127.0.0.1:8110/v1 (responses, chat/completions, models; live streaming)
15
15
  ```
16
16
 
17
- - `init` creates the payment key in `~/.mycomesh/v11` (it never leaves the machine).
18
- - `setup` uses the owner account (which needs Sepolia ETH for gas) to deposit
19
- tUSDC and authorize the payment key up to `--max-per-request` units per request.
20
- `--faucet UNITS` first mints testnet tUSDC to the owner.
21
- - `balance --owner-key-file owner.key` shows the deposit.
17
+ - `init` creates the payment key and an owner wallet in `~/.mycomesh/v11`. The wallet is an
18
+ Ethereum V3 keystore (MetaMask can import it) protected by a password from
19
+ `MYCOMESH_WALLET_PASSWORD` or a terminal prompt; `--owner-key-file` uses your own key instead.
20
+ - `setup --deposit UNITS` deposits tUSDC and authorizes the payment key up to `--max-per-request`
21
+ units per request. On the testnet it first tops the wallet up from the faucet (gas ETH and tUSDC);
22
+ `faucet` does that on its own.
23
+ - `balance` shows the deposit.
22
24
  - `request "..." [--model gpt-5.5] [--provider SIGNER]` prints the verified answer,
23
25
  the fee and the settlement key.
24
- - `serve` runs a local OpenAI-compatible endpoint for Codex and other clients;
25
- set `MYCOMESH_CONSUMER_API_KEY` to require a bearer token.
26
- - `dispute <settlement-key|last> --owner-key-file owner.key --statement "..."`
26
+ - `serve` runs the local web console at `http://127.0.0.1:8110/` (chat, wallet, deposit and
27
+ withdrawal, request history and disputes, network status) and an OpenAI-compatible endpoint for
28
+ Codex and other clients; set `MYCOMESH_CONSUMER_API_KEY` to require a bearer token. Only pages from
29
+ this machine can call it: other websites and DNS-rebinding hosts are refused.
30
+ - `withdraw [UNITS]` requests a withdrawal; run it again after the delay to receive the funds.
31
+ - `dispute <settlement-key|last> --statement "..."`
27
32
  reveals a recorded request and response to a randomly drawn Provider-AI jury
28
33
  within the 24-hour dispute window. The 1 tUSDC reporter bond is returned and a
29
34
  bounty paid when fraud is confirmed.
30
35
 
31
36
  Each response is decrypted locally and checked against the Provider-signed
32
- receipt before it is shown. The bundled network manifest is
37
+ receipt before it is shown; streamed text is checked against it too. Relays come from the bundled
38
+ manifest and the on-chain Relay directory. The bundled network manifest is
33
39
  `networks/mycomesh-v11-sepolia.json`; use `--network FILE` for another.
@@ -6,6 +6,7 @@
6
6
  "stablecoin": "0xbce27efad4191277167fe76819581a97959048cf",
7
7
  "registry": "0x0ff3210e8e8abf5f5bfa73a39e60db7416b6c8c7",
8
8
  "relay_directory": "0xa0aaad9393f51cb5786c69f152133a174588bdab",
9
+ "probe_ledger": "0x30055eae6645cce8754f834a17689d20a3795ca3",
9
10
  "deployment_block": 11809185,
10
11
  "rpc_urls": [
11
12
  "https://ethereum-sepolia-rpc.publicnode.com",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mycomesh-consumer",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "MycoMesh V11 Consumer: a custodied deposit, relay-blind encrypted requests to any Provider, and a local OpenAI-compatible endpoint.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,6 +16,7 @@
16
16
  "files": [
17
17
  "bin/mycomesh-consumer.mjs",
18
18
  "src/*.mjs",
19
+ "src/web/*.html",
19
20
  "networks/mycomesh-v11-sepolia.json",
20
21
  "networks/mycomesh-testnet-ca.crt",
21
22
  "README.md"
@@ -0,0 +1,93 @@
1
+ // Consumer account operations shared by the CLI and the local web console.
2
+ import { addressOf, encodeCall } from "./eip712.mjs";
3
+ import { rpcCall, sendTransaction } from "./chain.mjs";
4
+ import { directoryRelays, httpJson } from "./consumer.mjs";
5
+ import { buildEvidence, evidenceHash, openDisputeCall, reportId } from "./disputes.mjs";
6
+
7
+ export const STATUSES = ["none", "pending", "disputed", "released", "confirmed", "dismissed", "timed_out", "jury_unavailable", "voided"];
8
+
9
+ async function words(network, to, signature, pairs = []) {
10
+ const raw = (await rpcCall(network.rpc_urls, "eth_call", [{ to, data: encodeCall(signature, pairs) }, "latest"])).slice(2);
11
+ return Array.from({ length: raw.length / 64 }, (_, i) => BigInt(`0x${raw.slice(i * 64, i * 64 + 64)}`));
12
+ }
13
+
14
+ /** Balances of the owner wallet and the payment key's grant; amounts are strings of base units. */
15
+ export async function walletStatus(network, owner, key) {
16
+ const [eth, [tokens], [deposit], withdrawal, grant, params] = await Promise.all([
17
+ rpcCall(network.rpc_urls, "eth_getBalance", [owner, "latest"]),
18
+ words(network, network.stablecoin, "balanceOf(address)", [["address", owner]]),
19
+ words(network, network.settlement, "availableBalance(address)", [["address", owner]]),
20
+ words(network, network.settlement, "withdrawals(address)", [["address", owner]]),
21
+ key ? words(network, network.settlement, "keyGrants(address)", [["address", key]]) : Promise.resolve(null),
22
+ words(network, network.settlement, "params()"),
23
+ ]);
24
+ return {
25
+ owner, key, eth_wei: BigInt(eth).toString(), wallet_tokens: tokens.toString(), deposit: deposit.toString(),
26
+ withdrawal: { amount: withdrawal[0].toString(), available_at: Number(withdrawal[1]) },
27
+ grant: grant && { active: Boolean(grant[3]) && `0x${grant[0].toString(16).padStart(40, "0")}` === owner.toLowerCase(),
28
+ max_per_request: grant[1].toString() },
29
+ dispute_window: Number(params[0]), reporter_bond: params[3].toString(), faucet: Boolean(network.faucet_url),
30
+ };
31
+ }
32
+
33
+ export async function faucet(network, address) {
34
+ if (!network.faucet_url) throw new Error("this network has no faucet");
35
+ const reply = await httpJson(`${network.faucet_url}/v11/faucet`, { method: "POST", body: { address }, ca: network.tls_ca, timeoutMs: 400_000 });
36
+ if (reply.status !== 200) throw new Error(`faucet: ${reply.body?.error || reply.status}`);
37
+ return reply.body;
38
+ }
39
+
40
+ /** Deposit and grant the payment key; on testnets, top the wallet up from the faucet first. */
41
+ export async function setup(network, { ownerPrivate, key, deposit, limit, forceFaucet = false, log = () => {} }) {
42
+ const owner = addressOf(ownerPrivate);
43
+ const status = await walletStatus(network, owner, key);
44
+ if (network.faucet_url && (forceFaucet || BigInt(status.eth_wei) < 2_000_000_000_000_000n || BigInt(status.wallet_tokens) < deposit)) {
45
+ log(`funding ${owner} from the testnet faucet...`);
46
+ await faucet(network, owner);
47
+ }
48
+ const send = (to, data) => sendTransaction(network.rpc_urls, ownerPrivate, { to, data });
49
+ await send(network.stablecoin, encodeCall("approve(address,uint256)", [["address", network.settlement], ["uint", deposit]]));
50
+ await send(network.settlement, encodeCall("deposit(uint256)", [["uint", deposit]]));
51
+ if (!status.grant?.active || BigInt(status.grant.max_per_request) !== limit) {
52
+ await send(network.settlement, encodeCall("registerKey(address,uint256,uint64)", [["address", key], ["uint", limit], ["uint", 0]]));
53
+ }
54
+ return { owner, key, deposit: deposit.toString(), max_per_request: limit.toString() };
55
+ }
56
+
57
+ /** Withdrawals take two steps: request (funds stop backing requests), then withdraw after the delay. */
58
+ export async function requestWithdrawal(network, ownerPrivate, amount) {
59
+ await sendTransaction(network.rpc_urls, ownerPrivate, { to: network.settlement,
60
+ data: encodeCall("requestWithdrawal(uint256)", [["uint", amount]]) });
61
+ }
62
+
63
+ export async function completeWithdrawal(network, ownerPrivate) {
64
+ await sendTransaction(network.rpc_urls, ownerPrivate, { to: network.settlement, data: encodeCall("withdraw()", []) });
65
+ }
66
+
67
+ export async function settlementStatus(network, key) {
68
+ const fields = await words(network, network.settlement, "settlementInfo(bytes32)", [["bytes32", key]]);
69
+ return { status: STATUSES[Number(fields[14])] || "unknown", release_at: Number(fields[13]), fee: fields[10].toString() };
70
+ }
71
+
72
+ /** Commit the evidence on-chain (with the reporter bond), then hand it to every Relay's dispute desk. */
73
+ export async function dispute(network, ownerPrivate, record, { reasonCode, statement }) {
74
+ const evidence = buildEvidence(record, { reasonCode, statement });
75
+ const digest = evidenceHash(evidence);
76
+ const [bond] = (await words(network, network.settlement, "params()")).slice(3, 4);
77
+ if (bond > 0n) {
78
+ await sendTransaction(network.rpc_urls, ownerPrivate, { to: network.stablecoin,
79
+ data: encodeCall("approve(address,uint256)", [["address", network.settlement], ["uint", bond]]) });
80
+ }
81
+ await sendTransaction(network.rpc_urls, ownerPrivate, { to: network.settlement, data: openDisputeCall(record.settlement_key, digest) });
82
+ let relays = network.relays;
83
+ try { relays = [...relays, ...await directoryRelays(network)]; } catch {}
84
+ const accepted = [];
85
+ for (const relay of relays) {
86
+ try {
87
+ const reply = await httpJson(`${relay.url}/v11/evidence`, { method: "POST", body: evidence, ca: network.tls_ca, pin: relay.pin, timeoutMs: 30_000 });
88
+ if (reply.status === 200) accepted.push(relay.url);
89
+ } catch {}
90
+ }
91
+ return { settlement_key: record.settlement_key, evidence_hash: digest,
92
+ report_id: reportId(record.settlement_key, addressOf(ownerPrivate), digest), relays_accepted: accepted };
93
+ }
package/src/cli.mjs CHANGED
@@ -5,11 +5,15 @@ import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { parseArgs } from "node:util";
8
- import { addressOf, encodeCall, settlementKey } from "./eip712.mjs";
9
- import { availableBalance, rpcCall, sendTransaction } from "./chain.mjs";
10
- import { Consumer, directoryRelays, httpJson, loadNetwork, serveConsumer } from "./consumer.mjs";
8
+ import { addressOf, settlementKey } from "./eip712.mjs";
9
+ import { availableBalance } from "./chain.mjs";
10
+ import { completeWithdrawal, dispute, faucet, requestWithdrawal, setup, walletStatus } from "./account.mjs";
11
+ import { Consumer, loadNetwork, serveConsumer } from "./consumer.mjs";
11
12
  import { createWallet, ownerAddress, ownerKey, walletAddress } from "./wallet.mjs";
12
- import { buildEvidence, evidenceHash, loadRequest, openDisputeCall, reportId } from "./disputes.mjs";
13
+ import { loadRequest } from "./disputes.mjs";
14
+ import { addTenant, loadTenants, revokeTenant, setBudget, tenantKey, tenantStatus } from "./tenants.mjs";
15
+ import { spawn } from "node:child_process";
16
+ import { statSync } from "node:fs";
13
17
 
14
18
  const DEFAULT_NETWORK = join(dirname(fileURLToPath(import.meta.url)), "../networks/mycomesh-v11-sepolia.json");
15
19
 
@@ -23,9 +27,13 @@ const USAGE = `Usage: mycomesh-consumer <command> [options]
23
27
  deposit into the settlement contract and authorize the payment key
24
28
  (on testnets, tops the wallet up from the faucet first)
25
29
  balance [--owner ADDR] show the custodied deposit
30
+ withdraw [UNITS] request a withdrawal of the deposit; run again after the delay to receive it
26
31
  request "prompt" [--stream]
27
32
  send one request and print the verified answer
28
- serve [--port 8110] run the local OpenAI-compatible endpoint with streaming (default)
33
+ serve [--port 8110] run the local web console and OpenAI-compatible endpoint (default)
34
+ tenant add NAME [--budget UNITS] [--max-per-request UNITS]
35
+ multi-tenant accounts: a payment key and API key per tenant, capped on-chain
36
+ tenant list | tenant budget NAME UNITS | tenant revoke NAME
29
37
  dispute <settlement-key|last> [--reason CODE] [--statement TEXT]
30
38
  reveal a recorded request and response to a Provider-AI jury
31
39
  (within 24 hours; the reporter bond is returned if fraud is confirmed)
@@ -66,7 +74,8 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
66
74
  port: { type: "string", default: "8110" }, host: { type: "string", default: "127.0.0.1" },
67
75
  "max-output-tokens": { type: "string", default: "4096" }, help: { type: "boolean" },
68
76
  reason: { type: "string", default: "unrelated_response" }, faucet: { type: "boolean" }, provider: { type: "string" },
69
- statement: { type: "string", default: "" }, stream: { type: "boolean" },
77
+ statement: { type: "string", default: "" }, stream: { type: "boolean" }, budget: { type: "string" },
78
+ "no-browser": { type: "boolean" },
70
79
  },
71
80
  });
72
81
  const [command = "serve", ...rest] = positionals;
@@ -88,38 +97,37 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
88
97
  return 0;
89
98
  }
90
99
  const network = loadNetwork(values.network || process.env.MYCOMESH_NETWORK || DEFAULT_NETWORK);
91
- const faucet = async (address) => {
92
- if (!network.faucet_url) throw new Error("this network has no faucet");
93
- const reply = await httpJson(`${network.faucet_url}/v11/faucet`, { method: "POST", body: { address }, ca: network.tls_ca, timeoutMs: 400_000 });
94
- if (reply.status !== 200) throw new Error(`faucet: ${reply.body?.error || reply.status}`);
95
- return reply.body;
96
- };
97
100
  if (command === "faucet") {
98
101
  const address = values.owner || ownerAddress(values["owner-key-file"], dir) || walletAddress(await createWallet(dir));
99
- stdout.write(`${JSON.stringify(await faucet(address))}\n`);
102
+ stdout.write(`${JSON.stringify(await faucet(network, address))}\n`);
100
103
  return 0;
101
104
  }
102
105
  if (command === "setup") {
103
106
  const keyPrivate = paymentKey(dir);
104
107
  if (!values.deposit) throw new Error("--deposit UNITS is required");
105
108
  if (!values["owner-key-file"]) await createWallet(dir);
106
- const address = ownerAddress(values["owner-key-file"], dir);
107
- const deposit = BigInt(values.deposit);
108
- const limit = BigInt(values["max-per-request"] || values["max-fee"]);
109
- const eth = BigInt(await rpcCall(network.rpc_urls, "eth_getBalance", [address, "latest"]));
110
- const tokens = BigInt(await rpcCall(network.rpc_urls, "eth_call", [{ to: network.stablecoin,
111
- data: encodeCall("balanceOf(address)", [["address", address]]) }, "latest"]));
112
- if (network.faucet_url && (values.faucet || eth < 2_000_000_000_000_000n || tokens < deposit)) {
113
- stdout.write(`funding ${address} from the testnet faucet...\n`);
114
- await faucet(address);
115
- }
109
+ const result = await setup(network, {
110
+ ownerPrivate: await ownerKey(values["owner-key-file"], dir), key: addressOf(keyPrivate), deposit: BigInt(values.deposit),
111
+ limit: BigInt(values["max-per-request"] || values["max-fee"]), forceFaucet: values.faucet,
112
+ log: (line) => stdout.write(`${line}\n`),
113
+ });
114
+ stdout.write(`deposited ${result.deposit} and authorized ${result.key} up to ${result.max_per_request} per request\n`);
115
+ return 0;
116
+ }
117
+ if (command === "withdraw") {
116
118
  const owner = await ownerKey(values["owner-key-file"], dir);
117
- await sendTransaction(network.rpc_urls, owner, { to: network.stablecoin,
118
- data: encodeCall("approve(address,uint256)", [["address", network.settlement], ["uint", deposit]]) });
119
- await sendTransaction(network.rpc_urls, owner, { to: network.settlement, data: encodeCall("deposit(uint256)", [["uint", deposit]]) });
120
- await sendTransaction(network.rpc_urls, owner, { to: network.settlement,
121
- data: encodeCall("registerKey(address,uint256,uint64)", [["address", addressOf(keyPrivate)], ["uint", limit], ["uint", 0]]) });
122
- stdout.write(`deposited ${deposit} and authorized ${addressOf(keyPrivate)} up to ${limit} per request\n`);
119
+ const status = await walletStatus(network, addressOf(owner), null);
120
+ if (BigInt(status.withdrawal.amount) > 0n) {
121
+ if (status.withdrawal.available_at > Math.floor(Date.now() / 1000)) {
122
+ throw new Error(`the withdrawal of ${status.withdrawal.amount} unlocks at ${new Date(status.withdrawal.available_at * 1000).toISOString()}`);
123
+ }
124
+ await completeWithdrawal(network, owner);
125
+ stdout.write(`withdrew ${status.withdrawal.amount}\n`);
126
+ return 0;
127
+ }
128
+ const amount = BigInt(rest[0] || status.deposit);
129
+ await requestWithdrawal(network, owner, amount);
130
+ stdout.write(`requested a withdrawal of ${amount}; run \`withdraw\` again after the delay\n`);
123
131
  return 0;
124
132
  }
125
133
  if (command === "balance") {
@@ -130,31 +138,28 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
130
138
  }
131
139
  if (command === "dispute") {
132
140
  const owner = await ownerKey(values["owner-key-file"], dir);
133
- const record = loadRequest(dir, rest[0] || "last");
134
- const evidence = buildEvidence(record, { reasonCode: values.reason, statement: values.statement || rest.slice(1).join(" ") });
135
- const digest = evidenceHash(evidence);
136
- // The reporter bond comes from the owner's wallet and is returned when fraud is confirmed.
137
- const params = await rpcCall(network.rpc_urls, "eth_call", [{ to: network.settlement, data: encodeCall("params()", []) }, "latest"]);
138
- const bond = BigInt(`0x${params.slice(2 + 64 * 3, 2 + 64 * 4)}`);
139
- if (bond > 0n) {
140
- await sendTransaction(network.rpc_urls, owner, { to: network.stablecoin,
141
- data: encodeCall("approve(address,uint256)", [["address", network.settlement], ["uint", bond]]) });
142
- }
143
- await sendTransaction(network.rpc_urls, owner, { to: network.settlement, data: openDisputeCall(record.settlement_key, digest) });
144
- const accepted = [];
145
- let relays = network.relays;
146
- try { relays = [...relays, ...await directoryRelays(network)]; } catch {}
147
- for (const relay of relays) {
148
- try {
149
- const reply = await httpJson(`${relay.url}/v11/evidence`, { method: "POST", body: evidence, ca: network.tls_ca, timeoutMs: 30_000 });
150
- if (reply.status === 200) accepted.push(relay.url);
151
- } catch {}
141
+ const result = await dispute(network, owner, loadRequest(dir, rest[0] || "last"),
142
+ { reasonCode: values.reason, statement: values.statement || rest.slice(1).join(" ") });
143
+ stdout.write(`${JSON.stringify(result)}\n`);
144
+ return result.relays_accepted.length ? 0 : 2;
145
+ }
146
+ if (command === "tenant") {
147
+ const [action, name, amount] = rest;
148
+ if (action === "list") { stdout.write(`${JSON.stringify(await tenantStatus(network, dir), null, 2)}\n`); return 0; }
149
+ const owner = await ownerKey(values["owner-key-file"], dir);
150
+ if (action === "add") {
151
+ const created = await addTenant(network, dir, owner, name, {
152
+ maxPerRequest: BigInt(values["max-per-request"] || values["max-fee"]), budget: BigInt(values.budget || 0) });
153
+ stdout.write(`${JSON.stringify(created)}\nThe API key is shown once; give it to the tenant (Authorization: Bearer ...).\n`);
154
+ return 0;
152
155
  }
153
- stdout.write(`${JSON.stringify({ settlement_key: record.settlement_key, evidence_hash: digest,
154
- report_id: reportId(record.settlement_key, addressOf(owner), digest), relays_accepted: accepted })}\n`);
155
- return accepted.length ? 0 : 2;
156
+ const tenant = loadTenants(dir)[name];
157
+ if (!tenant) throw new Error(`no tenant ${name}`);
158
+ if (action === "budget") { await setBudget(network, owner, tenant.key, BigInt(amount)); stdout.write(`budget of ${name} set to ${amount}\n`); return 0; }
159
+ if (action === "revoke") { await revokeTenant(network, dir, owner, name); stdout.write(`revoked ${name}\n`); return 0; }
160
+ throw new Error("tenant add | list | budget | revoke");
156
161
  }
157
- const keyPrivate = paymentKey(dir);
162
+ const keyPrivate = paymentKey(dir, command === "serve");
158
163
  const consumer = new Consumer({ network, keyPrivate, maxFee: Number(values["max-fee"]), journalDir: dir });
159
164
  if (command === "request") {
160
165
  const { response, receipt } = await consumer.request({
@@ -169,9 +174,32 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
169
174
  return 0;
170
175
  }
171
176
  if (command === "serve") {
172
- const server = await serveConsumer(consumer, { host: values.host, port: Number(values.port), apiKey: process.env.MYCOMESH_CONSUMER_API_KEY });
177
+ // Tenants are reloaded when tenants.json changes, so `tenant add` works while serving.
178
+ let cache = { mtime: -1, byHash: {} };
179
+ const tenants = () => {
180
+ const path = join(dir, "tenants.json");
181
+ const mtime = existsSync(path) ? statSync(path).mtimeMs : 0;
182
+ if (mtime !== cache.mtime) {
183
+ const byHash = {};
184
+ for (const [name, tenant] of Object.entries(loadTenants(dir))) {
185
+ if (tenant.revoked) continue;
186
+ byHash[tenant.api_key_sha256] = new Consumer({ network, keyPrivate: tenantKey(dir, name), maxFee: Number(tenant.max_per_request),
187
+ journalDir: join(dir, "tenants", name) });
188
+ }
189
+ cache = { mtime, byHash };
190
+ }
191
+ return cache.byHash;
192
+ };
193
+ const server = await serveConsumer(consumer, { host: values.host, port: Number(values.port),
194
+ apiKey: process.env.MYCOMESH_CONSUMER_API_KEY, dataDir: dir, tenants });
173
195
  const { port } = server.address();
196
+ const url = `http://127.0.0.1:${port}/`;
174
197
  stdout.write(`MycoMesh V11 Consumer on http://${values.host}:${port}/v1 (payment key ${consumer.key})\n`);
198
+ stdout.write(`Web console: ${url}\n`);
199
+ if (!values["no-browser"] && process.stdout.isTTY && !process.env.CI) {
200
+ const opener = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
201
+ spawn(opener[0], opener[1], { stdio: "ignore", detached: true }).on("error", () => {}).unref();
202
+ }
175
203
  return new Promise(() => {});
176
204
  }
177
205
  throw new Error(`unknown command ${command}\n${USAGE}`);
@@ -0,0 +1,104 @@
1
+ // The local web console: the Bitcoin-node model, a UI served by this machine to this machine.
2
+ // The browser only ever talks to 127.0.0.1; this process reaches the network and verifies everything.
3
+ import { readFileSync } from "node:fs";
4
+ import { addressOf } from "./eip712.mjs";
5
+ import { completeWithdrawal, dispute, faucet, requestWithdrawal, settlementStatus, setup, walletStatus } from "./account.mjs";
6
+ import { listRequests, loadRequest } from "./disputes.mjs";
7
+ import { outputText } from "./protocol.mjs";
8
+ import { createWallet, ownerAddress, unlockWallet, walletAddress } from "./wallet.mjs";
9
+ import { addTenant, loadTenants, revokeTenant, setBudget, tenantStatus } from "./tenants.mjs";
10
+
11
+ const PAGE = new URL("./web/console.html", import.meta.url);
12
+
13
+ /**
14
+ * Only this machine's own pages may call the local node: reject other Host
15
+ * names (DNS rebinding) and cross-site Origins (a web page spending the deposit).
16
+ */
17
+ export function localRequestAllowed(req, port) {
18
+ const allowed = new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
19
+ if (!allowed.has(String(req.headers.host || ""))) return false;
20
+ const origin = req.headers.origin;
21
+ return !origin || allowed.has(origin.replace(/^http:\/\//, ""));
22
+ }
23
+
24
+ export function consoleRoutes({ consumer, dataDir }) {
25
+ const network = consumer.network;
26
+ const owner = () => ownerAddress(undefined, dataDir);
27
+ const withWallet = (body) => unlockWallet(dataDir, body.password);
28
+ const units = (value, name) => {
29
+ if (!/^[0-9]+$/.test(String(value ?? ""))) throw Object.assign(new Error(`${name} must be a whole number of base units`), { status: 400 });
30
+ return BigInt(value);
31
+ };
32
+ return {
33
+ "GET /": () => ({ type: "text/html; charset=utf-8", body: readFileSync(PAGE, "utf8") }),
34
+ "GET /api/status": async () => {
35
+ const address = owner();
36
+ return {
37
+ network: { id: network.network_id, chain_id: network.chain_id, settlement: network.settlement, faucet: Boolean(network.faucet_url) },
38
+ payment_key: consumer.key, max_fee: String(consumer.maxFee),
39
+ wallet: address ? await walletStatus(network, address, consumer.key) : null,
40
+ };
41
+ },
42
+ "GET /api/network": async () => {
43
+ const relays = [];
44
+ for (const relay of await consumer.relays()) {
45
+ let health = null; let providers = [];
46
+ try { health = (await consumer.fetchJson(`${relay.url}/health`, { ca: network.tls_ca, pin: relay.pin, timeoutMs: 5_000 })).body; } catch {}
47
+ try { providers = await consumer.providers(relay); } catch {}
48
+ relays.push({ url: relay.url, signer: relay.signer, pinned: Boolean(relay.pin), healthy: Boolean(health?.ok), providers: providers.map((p) => ({
49
+ signer: p.provider_signer, models: p.models, prices: p.prices, capacity: p.capacity })) });
50
+ }
51
+ const records = await consumer.reputations().catch(() => ({}));
52
+ for (const relay of relays) for (const provider of relay.providers) provider.reputation = records[provider.signer] || null;
53
+ return { relays };
54
+ },
55
+ "GET /api/history": async () => {
56
+ const entries = listRequests(dataDir, outputText, 50);
57
+ await Promise.all(entries.map(async (entry) => {
58
+ try { Object.assign(entry, await settlementStatus(network, entry.settlement_key)); } catch { entry.status = "unknown"; }
59
+ }));
60
+ return { entries };
61
+ },
62
+ "POST /api/wallet": async (body) => ({ owner: walletAddress(await createWallet(dataDir, body.password)) }),
63
+ "POST /api/faucet": async () => {
64
+ const address = owner();
65
+ if (!address) throw Object.assign(new Error("create the wallet first"), { status: 400 });
66
+ return faucet(network, address);
67
+ },
68
+ "POST /api/setup": async (body) => setup(network, {
69
+ ownerPrivate: withWallet(body), key: consumer.key, deposit: units(body.deposit, "deposit"),
70
+ limit: units(body.max_per_request ?? consumer.maxFee, "max_per_request"),
71
+ }),
72
+ "POST /api/withdraw": async (body) => {
73
+ const ownerPrivate = withWallet(body);
74
+ const status = await walletStatus(network, addressOf(ownerPrivate), null);
75
+ if (BigInt(status.withdrawal.amount) > 0n) {
76
+ if (status.withdrawal.available_at > Math.floor(Date.now() / 1000)) {
77
+ throw Object.assign(new Error("the pending withdrawal is still locked"), { status: 409 });
78
+ }
79
+ await completeWithdrawal(network, ownerPrivate);
80
+ return { withdrawn: status.withdrawal.amount };
81
+ }
82
+ const amount = body.amount ? units(body.amount, "amount") : BigInt(status.deposit);
83
+ await requestWithdrawal(network, ownerPrivate, amount);
84
+ return { requested: amount.toString() };
85
+ },
86
+ "GET /api/tenants": async () => ({ tenants: await tenantStatus(network, dataDir) }),
87
+ "POST /api/tenants": async (body) => addTenant(network, dataDir, withWallet(body), String(body.name || ""), {
88
+ maxPerRequest: units(body.max_per_request ?? consumer.maxFee, "max_per_request"),
89
+ budget: body.budget ? units(body.budget, "budget") : 0n,
90
+ }),
91
+ "POST /api/tenants/budget": async (body) => {
92
+ const tenant = loadTenants(dataDir)[String(body.name)];
93
+ if (!tenant) throw Object.assign(new Error("no such tenant"), { status: 404 });
94
+ await setBudget(network, withWallet(body), tenant.key, units(body.budget, "budget"));
95
+ return { name: body.name, budget: String(body.budget) };
96
+ },
97
+ "POST /api/tenants/revoke": async (body) => {
98
+ await revokeTenant(network, dataDir, withWallet(body), String(body.name));
99
+ return { revoked: body.name };
100
+ },
101
+ "POST /api/dispute": async (body) => dispute(network, withWallet(body), loadRequest(dataDir, String(body.settlement_key)),
102
+ { reasonCode: String(body.reason_code || "unrelated_response"), statement: String(body.statement || "") }),
103
+ };
104
+ }