mycomesh-consumer 0.2.0 → 0.3.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/networks/mycomesh-v11-sepolia.json +2 -0
- package/package.json +1 -1
- package/src/cli.mjs +61 -22
- package/src/consumer.mjs +131 -12
- package/src/protocol.mjs +23 -0
- package/src/sse.mjs +116 -29
- package/src/wallet.mjs +106 -0
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"settlement": "0xd46b7efb1e650e83cdeb4a36fdfef33499e54842",
|
|
6
6
|
"stablecoin": "0xbce27efad4191277167fe76819581a97959048cf",
|
|
7
7
|
"registry": "0x0ff3210e8e8abf5f5bfa73a39e60db7416b6c8c7",
|
|
8
|
+
"relay_directory": "0xa0aaad9393f51cb5786c69f152133a174588bdab",
|
|
8
9
|
"deployment_block": 11809185,
|
|
9
10
|
"rpc_urls": [
|
|
10
11
|
"https://ethereum-sepolia-rpc.publicnode.com",
|
|
@@ -12,6 +13,7 @@
|
|
|
12
13
|
"https://sepolia.gateway.tenderly.co"
|
|
13
14
|
],
|
|
14
15
|
"tls_ca_file": "mycomesh-testnet-ca.crt",
|
|
16
|
+
"faucet_url": "https://136.0.3.126:10443",
|
|
15
17
|
"relays": [
|
|
16
18
|
{
|
|
17
19
|
"url": "https://136.0.3.126:10443",
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// mycomesh-consumer (V11): init, setup, balance, request, serve.
|
|
1
|
+
// mycomesh-consumer (V11): init, wallet, faucet, setup, balance, request, serve, dispute.
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
@@ -7,26 +7,32 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { parseArgs } from "node:util";
|
|
8
8
|
import { addressOf, encodeCall, settlementKey } from "./eip712.mjs";
|
|
9
9
|
import { availableBalance, rpcCall, sendTransaction } from "./chain.mjs";
|
|
10
|
-
import { Consumer, httpJson, loadNetwork, serveConsumer } from "./consumer.mjs";
|
|
10
|
+
import { Consumer, directoryRelays, httpJson, loadNetwork, serveConsumer } from "./consumer.mjs";
|
|
11
|
+
import { createWallet, ownerAddress, ownerKey, walletAddress } from "./wallet.mjs";
|
|
11
12
|
import { buildEvidence, evidenceHash, loadRequest, openDisputeCall, reportId } from "./disputes.mjs";
|
|
12
13
|
|
|
13
14
|
const DEFAULT_NETWORK = join(dirname(fileURLToPath(import.meta.url)), "../networks/mycomesh-v11-sepolia.json");
|
|
14
15
|
|
|
15
16
|
const USAGE = `Usage: mycomesh-consumer <command> [options]
|
|
16
17
|
|
|
17
|
-
init create the local payment key
|
|
18
|
+
init create the local payment key and the password-protected owner wallet
|
|
18
19
|
address print the payment key address
|
|
19
|
-
|
|
20
|
+
wallet print the owner wallet address (a V3 keystore; MetaMask can import it)
|
|
21
|
+
faucet testnet: get gas ETH and tUSDC for the owner wallet
|
|
22
|
+
setup --deposit UNITS [--max-per-request UNITS]
|
|
20
23
|
deposit into the settlement contract and authorize the payment key
|
|
21
|
-
(
|
|
24
|
+
(on testnets, tops the wallet up from the faucet first)
|
|
22
25
|
balance [--owner ADDR] show the custodied deposit
|
|
23
|
-
request "prompt"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
request "prompt" [--stream]
|
|
27
|
+
send one request and print the verified answer
|
|
28
|
+
serve [--port 8110] run the local OpenAI-compatible endpoint with streaming (default)
|
|
29
|
+
dispute <settlement-key|last> [--reason CODE] [--statement TEXT]
|
|
26
30
|
reveal a recorded request and response to a Provider-AI jury
|
|
27
31
|
(within 24 hours; the reporter bond is returned if fraud is confirmed)
|
|
28
32
|
|
|
29
|
-
Common: --network FILE (default: the bundled MycoMesh V11 Sepolia manifest), --data-dir DIR, --model ID,
|
|
33
|
+
Common: --network FILE (default: the bundled MycoMesh V11 Sepolia manifest), --data-dir DIR, --model ID,
|
|
34
|
+
--max-fee UNITS, --provider SIGNER, --owner-key-file F (raw key or keystore instead of the local wallet).
|
|
35
|
+
The wallet password comes from MYCOMESH_WALLET_PASSWORD or a terminal prompt.`;
|
|
30
36
|
|
|
31
37
|
function dataDir(value) {
|
|
32
38
|
const dir = value || process.env.MYCOMESH_CONSUMER_DATA_DIR || join(homedir(), ".mycomesh", "v11");
|
|
@@ -59,26 +65,55 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
|
|
|
59
65
|
model: { type: "string", default: "gpt-5.5" }, "max-fee": { type: "string", default: "1000000" },
|
|
60
66
|
port: { type: "string", default: "8110" }, host: { type: "string", default: "127.0.0.1" },
|
|
61
67
|
"max-output-tokens": { type: "string", default: "4096" }, help: { type: "boolean" },
|
|
62
|
-
reason: { type: "string", default: "unrelated_response" }, faucet: { type: "
|
|
68
|
+
reason: { type: "string", default: "unrelated_response" }, faucet: { type: "boolean" }, provider: { type: "string" },
|
|
69
|
+
statement: { type: "string", default: "" }, stream: { type: "boolean" },
|
|
63
70
|
},
|
|
64
71
|
});
|
|
65
72
|
const [command = "serve", ...rest] = positionals;
|
|
66
73
|
if (values.help || command === "help") { stdout.write(`${USAGE}\n`); return 0; }
|
|
67
74
|
const dir = dataDir(values["data-dir"]);
|
|
68
|
-
if (command === "
|
|
69
|
-
stdout.write(`${addressOf(paymentKey(dir
|
|
75
|
+
if (command === "address") {
|
|
76
|
+
stdout.write(`${addressOf(paymentKey(dir))}\n`);
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
if (command === "init") {
|
|
80
|
+
stdout.write(`${addressOf(paymentKey(dir, true))}\n`);
|
|
81
|
+
if (!values["owner-key-file"]) {
|
|
82
|
+
stdout.write(`owner wallet ${walletAddress(await createWallet(dir))} (${join(dir, "owner-wallet.json")})\n`);
|
|
83
|
+
}
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
if (command === "wallet") {
|
|
87
|
+
stdout.write(`${values["owner-key-file"] ? ownerAddress(values["owner-key-file"], dir) : walletAddress(await createWallet(dir))}\n`);
|
|
70
88
|
return 0;
|
|
71
89
|
}
|
|
72
90
|
const network = loadNetwork(values.network || process.env.MYCOMESH_NETWORK || DEFAULT_NETWORK);
|
|
73
|
-
const
|
|
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
|
+
if (command === "faucet") {
|
|
98
|
+
const address = values.owner || ownerAddress(values["owner-key-file"], dir) || walletAddress(await createWallet(dir));
|
|
99
|
+
stdout.write(`${JSON.stringify(await faucet(address))}\n`);
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
74
102
|
if (command === "setup") {
|
|
75
|
-
const
|
|
103
|
+
const keyPrivate = paymentKey(dir);
|
|
104
|
+
if (!values.deposit) throw new Error("--deposit UNITS is required");
|
|
105
|
+
if (!values["owner-key-file"]) await createWallet(dir);
|
|
106
|
+
const address = ownerAddress(values["owner-key-file"], dir);
|
|
76
107
|
const deposit = BigInt(values.deposit);
|
|
77
108
|
const limit = BigInt(values["max-per-request"] || values["max-fee"]);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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);
|
|
81
115
|
}
|
|
116
|
+
const owner = await ownerKey(values["owner-key-file"], dir);
|
|
82
117
|
await sendTransaction(network.rpc_urls, owner, { to: network.stablecoin,
|
|
83
118
|
data: encodeCall("approve(address,uint256)", [["address", network.settlement], ["uint", deposit]]) });
|
|
84
119
|
await sendTransaction(network.rpc_urls, owner, { to: network.settlement, data: encodeCall("deposit(uint256)", [["uint", deposit]]) });
|
|
@@ -88,13 +123,13 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
|
|
|
88
123
|
return 0;
|
|
89
124
|
}
|
|
90
125
|
if (command === "balance") {
|
|
91
|
-
const owner = values.owner || (values["owner-key-file"]
|
|
92
|
-
if (!owner) throw new Error("--owner
|
|
126
|
+
const owner = values.owner || ownerAddress(values["owner-key-file"], dir);
|
|
127
|
+
if (!owner) throw new Error("--owner, --owner-key-file or a local wallet (`init`) is required");
|
|
93
128
|
stdout.write(`${await availableBalance(network, owner)}\n`);
|
|
94
129
|
return 0;
|
|
95
130
|
}
|
|
96
131
|
if (command === "dispute") {
|
|
97
|
-
const owner =
|
|
132
|
+
const owner = await ownerKey(values["owner-key-file"], dir);
|
|
98
133
|
const record = loadRequest(dir, rest[0] || "last");
|
|
99
134
|
const evidence = buildEvidence(record, { reasonCode: values.reason, statement: values.statement || rest.slice(1).join(" ") });
|
|
100
135
|
const digest = evidenceHash(evidence);
|
|
@@ -107,7 +142,9 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
|
|
|
107
142
|
}
|
|
108
143
|
await sendTransaction(network.rpc_urls, owner, { to: network.settlement, data: openDisputeCall(record.settlement_key, digest) });
|
|
109
144
|
const accepted = [];
|
|
110
|
-
|
|
145
|
+
let relays = network.relays;
|
|
146
|
+
try { relays = [...relays, ...await directoryRelays(network)]; } catch {}
|
|
147
|
+
for (const relay of relays) {
|
|
111
148
|
try {
|
|
112
149
|
const reply = await httpJson(`${relay.url}/v11/evidence`, { method: "POST", body: evidence, ca: network.tls_ca, timeoutMs: 30_000 });
|
|
113
150
|
if (reply.status === 200) accepted.push(relay.url);
|
|
@@ -117,12 +154,14 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
|
|
|
117
154
|
report_id: reportId(record.settlement_key, addressOf(owner), digest), relays_accepted: accepted })}\n`);
|
|
118
155
|
return accepted.length ? 0 : 2;
|
|
119
156
|
}
|
|
157
|
+
const keyPrivate = paymentKey(dir);
|
|
120
158
|
const consumer = new Consumer({ network, keyPrivate, maxFee: Number(values["max-fee"]), journalDir: dir });
|
|
121
159
|
if (command === "request") {
|
|
122
160
|
const { response, receipt } = await consumer.request({
|
|
123
161
|
endpoint: "responses", model: values.model, content: rest.join(" "), maxOutputTokens: Number(values["max-output-tokens"]),
|
|
124
|
-
provider: values.provider,
|
|
162
|
+
provider: values.provider, onDelta: values.stream ? (text) => process.stderr.write(text) : undefined,
|
|
125
163
|
});
|
|
164
|
+
if (values.stream) process.stderr.write("\n");
|
|
126
165
|
const output = response.output;
|
|
127
166
|
stdout.write(`${JSON.stringify({ output_text: output.output_text ?? output, fee: receipt.receipt.actual_fee,
|
|
128
167
|
provider: receipt.authorization.provider_signer, request_id: receipt.authorization.request_id,
|
package/src/consumer.mjs
CHANGED
|
@@ -4,12 +4,14 @@ import { createServer, request as httpRequest } from "node:http";
|
|
|
4
4
|
import { request as httpsRequest } from "node:https";
|
|
5
5
|
import { dirname, resolve } from "node:path";
|
|
6
6
|
import { rootCertificates } from "node:tls";
|
|
7
|
-
import { addressOf } from "./eip712.mjs";
|
|
8
|
-
import {
|
|
7
|
+
import { addressOf, encodeCall } from "./eip712.mjs";
|
|
8
|
+
import { rpcCall } from "./chain.mjs";
|
|
9
|
+
import { openDelta, openResponse, outputText, prepareRequest, verifiedProvider } from "./protocol.mjs";
|
|
9
10
|
import { recordRequest } from "./disputes.mjs";
|
|
10
|
-
import {
|
|
11
|
+
import { chatStream, responseStream } from "./sse.mjs";
|
|
11
12
|
|
|
12
13
|
const PROVIDER_CACHE_MS = 30_000;
|
|
14
|
+
const RELAY_CACHE_MS = 300_000;
|
|
13
15
|
const MAX_BODY = 16 * 1024 * 1024;
|
|
14
16
|
|
|
15
17
|
export function loadNetwork(path) {
|
|
@@ -47,8 +49,71 @@ export function httpJson(url, { method = "GET", body, ca, timeoutMs = 330_000 }
|
|
|
47
49
|
});
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
/** POST expecting NDJSON; each line goes to onLine. Non-streamed replies resolve as JSON. */
|
|
53
|
+
export function httpNdjson(url, { body, ca, timeoutMs = 340_000, onLine }) {
|
|
54
|
+
const target = new URL(url);
|
|
55
|
+
const data = Buffer.from(JSON.stringify(body));
|
|
56
|
+
const send = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
57
|
+
return new Promise((resolvePromise, reject) => {
|
|
58
|
+
const req = send(target, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: { accept: "application/x-ndjson", "content-type": "application/json", "content-length": data.length },
|
|
61
|
+
...(target.protocol === "https:" && ca ? { ca: [...rootCertificates, ca] } : {}),
|
|
62
|
+
}, (res) => {
|
|
63
|
+
const streaming = res.statusCode === 200 && String(res.headers["content-type"] || "").includes("ndjson");
|
|
64
|
+
let buffered = "";
|
|
65
|
+
const chunks = [];
|
|
66
|
+
res.setEncoding("utf8");
|
|
67
|
+
res.on("data", (chunk) => {
|
|
68
|
+
if (!streaming) { chunks.push(chunk); return; }
|
|
69
|
+
buffered += chunk;
|
|
70
|
+
let index;
|
|
71
|
+
while ((index = buffered.indexOf("\n")) >= 0) {
|
|
72
|
+
const line = buffered.slice(0, index);
|
|
73
|
+
buffered = buffered.slice(index + 1);
|
|
74
|
+
if (line.trim()) {
|
|
75
|
+
try { onLine(JSON.parse(line)); } catch (error) { req.destroy(error); return; }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
res.on("end", () => {
|
|
80
|
+
if (streaming) return resolvePromise({ status: 200, streamed: true });
|
|
81
|
+
try { resolvePromise({ status: res.statusCode, body: JSON.parse(chunks.join("") || "{}") }); } catch (error) { reject(error); }
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
req.setTimeout(timeoutMs, () => req.destroy(new Error("request timed out")));
|
|
85
|
+
req.on("error", reject);
|
|
86
|
+
req.end(data);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function decodeString(raw, offset) {
|
|
91
|
+
const length = Number(BigInt(`0x${raw.slice(offset * 2, offset * 2 + 64)}`));
|
|
92
|
+
return Buffer.from(raw.slice(offset * 2 + 64, offset * 2 + 64 + length * 2), "hex").toString("utf8");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Active Relays announced in the on-chain RelayDirectoryV11. */
|
|
96
|
+
export async function directoryRelays(network) {
|
|
97
|
+
if (!network.relay_directory) return [];
|
|
98
|
+
const count = Number(BigInt(await rpcCall(network.rpc_urls, "eth_call", [{ to: network.relay_directory, data: encodeCall("relayCount()", []) }, "latest"])));
|
|
99
|
+
const relays = [];
|
|
100
|
+
for (let index = 0; index < count; index += 1) {
|
|
101
|
+
const raw = (await rpcCall(network.rpc_urls, "eth_call", [{ to: network.relay_directory,
|
|
102
|
+
data: encodeCall("relayAt(uint256)", [["uint", BigInt(index)]]) }, "latest"])).slice(2);
|
|
103
|
+
const base = Number(BigInt(`0x${raw.slice(0, 64)}`));
|
|
104
|
+
if (!Number(BigInt(`0x${raw.slice(64, 128)}`))) continue;
|
|
105
|
+
const entry = raw.slice(base * 2);
|
|
106
|
+
relays.push({
|
|
107
|
+
signer: `0x${entry.slice(64 + 24, 128)}`.toLowerCase(),
|
|
108
|
+
url: decodeString(entry, Number(BigInt(`0x${entry.slice(128, 192)}`))).replace(/\/+$/, ""),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return relays;
|
|
112
|
+
}
|
|
113
|
+
|
|
50
114
|
export class Consumer {
|
|
51
|
-
constructor({ network, keyPrivate, maxFee, journalDir, fetchJson = httpJson, now = () => Math.floor(Date.now() / 1000) }) {
|
|
115
|
+
constructor({ network, keyPrivate, maxFee, journalDir, fetchJson = httpJson, streamJson = httpNdjson, now = () => Math.floor(Date.now() / 1000) }) {
|
|
116
|
+
this.streamJson = streamJson;
|
|
52
117
|
this.journalDir = journalDir;
|
|
53
118
|
this.network = network;
|
|
54
119
|
this.keyPrivate = keyPrivate;
|
|
@@ -58,6 +123,29 @@ export class Consumer {
|
|
|
58
123
|
this.now = now;
|
|
59
124
|
this.deployment = { chainId: network.chain_id, settlement: network.settlement.toLowerCase() };
|
|
60
125
|
this.cache = new Map();
|
|
126
|
+
this.relayCache = null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Manifest Relays first, then directory Relays whose /health proves the announced signer. */
|
|
130
|
+
async relays() {
|
|
131
|
+
if (this.relayCache && Date.now() - this.relayCache.at < RELAY_CACHE_MS) return this.relayCache.relays;
|
|
132
|
+
const relays = [...this.network.relays];
|
|
133
|
+
const known = new Set(relays.map((relay) => relay.signer.toLowerCase()));
|
|
134
|
+
try {
|
|
135
|
+
for (const relay of await directoryRelays(this.network)) {
|
|
136
|
+
if (known.has(relay.signer)) continue;
|
|
137
|
+
try {
|
|
138
|
+
const { status, body } = await this.fetchJson(`${relay.url}/health`, { ca: this.network.tls_ca, timeoutMs: 5_000 });
|
|
139
|
+
if (status === 200 && String(body.relay_signer).toLowerCase() === relay.signer
|
|
140
|
+
&& String(body.settlement).toLowerCase() === this.deployment.settlement) {
|
|
141
|
+
relays.push(relay);
|
|
142
|
+
known.add(relay.signer);
|
|
143
|
+
}
|
|
144
|
+
} catch {}
|
|
145
|
+
}
|
|
146
|
+
} catch {}
|
|
147
|
+
this.relayCache = { at: Date.now(), relays };
|
|
148
|
+
return relays;
|
|
61
149
|
}
|
|
62
150
|
|
|
63
151
|
async providers(relay) {
|
|
@@ -75,16 +163,16 @@ export class Consumer {
|
|
|
75
163
|
|
|
76
164
|
async models() {
|
|
77
165
|
const names = new Set();
|
|
78
|
-
for (const relay of this.
|
|
166
|
+
for (const relay of await this.relays()) {
|
|
79
167
|
try { for (const provider of await this.providers(relay)) for (const model of provider.models || []) names.add(model); } catch {}
|
|
80
168
|
}
|
|
81
169
|
return [...names].sort();
|
|
82
170
|
}
|
|
83
171
|
|
|
84
172
|
/** Try Relays in order; fail over only while the request provably was not dispatched. */
|
|
85
|
-
async request({ endpoint, model, content, maxOutputTokens = 4096, options = {}, provider }) {
|
|
173
|
+
async request({ endpoint, model, content, maxOutputTokens = 4096, options = {}, provider, onDelta }) {
|
|
86
174
|
let lastError;
|
|
87
|
-
for (const relay of this.
|
|
175
|
+
for (const relay of await this.relays()) {
|
|
88
176
|
let candidates;
|
|
89
177
|
try {
|
|
90
178
|
candidates = (await this.providers(relay)).filter((descriptor) => (descriptor.models || []).includes(model)
|
|
@@ -98,14 +186,33 @@ export class Consumer {
|
|
|
98
186
|
endpoint, model, content, maxOutputTokens, maxFee: this.maxFee, options, now: this.now(),
|
|
99
187
|
});
|
|
100
188
|
let reply;
|
|
189
|
+
const streamed = [];
|
|
190
|
+
let final = null;
|
|
101
191
|
try {
|
|
102
|
-
reply =
|
|
192
|
+
reply = onDelta
|
|
193
|
+
? await this.streamJson(`${relay.url}/v11/requests`, { body: prepared.payload, ca: this.network.tls_ca, onLine: (line) => {
|
|
194
|
+
if (line.type === "delta") {
|
|
195
|
+
streamed.push(openDelta(prepared, line.sealed, streamed.length, this.now()));
|
|
196
|
+
onDelta(streamed.at(-1));
|
|
197
|
+
} else {
|
|
198
|
+
final = line;
|
|
199
|
+
}
|
|
200
|
+
} })
|
|
201
|
+
: await this.fetchJson(`${relay.url}/v11/requests`, { method: "POST", body: prepared.payload, ca: this.network.tls_ca });
|
|
103
202
|
} catch (error) {
|
|
104
203
|
// The request may have reached the Relay: never replay it elsewhere.
|
|
105
204
|
throw Object.assign(new Error(`request outcome unknown: ${error.message}`), { code: "outcome_unknown", requestId: prepared.authorization.request_id });
|
|
106
205
|
}
|
|
206
|
+
if (reply.streamed) {
|
|
207
|
+
if (!final) throw Object.assign(new Error("stream ended without a result"), { code: "outcome_unknown" });
|
|
208
|
+
if (final.type === "error") throw Object.assign(new Error(final.error), { status: final.status, dispatched: final.dispatched });
|
|
209
|
+
reply = { status: 200, body: final };
|
|
210
|
+
}
|
|
107
211
|
if (reply.status === 200) {
|
|
108
212
|
const opened = openResponse(prepared, reply.body, this.deployment, this.now());
|
|
213
|
+
if (streamed.length && streamed.join("") !== outputText(opened.response.output)) {
|
|
214
|
+
throw new Error("streamed text differs from the receipted response");
|
|
215
|
+
}
|
|
109
216
|
// Only this machine can ever reveal the plaintexts, so keep them for the dispute window.
|
|
110
217
|
if (this.journalDir) recordRequest(this.journalDir, prepared, opened, relay.url);
|
|
111
218
|
return opened;
|
|
@@ -136,6 +243,7 @@ async function readJson(req) {
|
|
|
136
243
|
/** Local OpenAI-compatible endpoint; nothing leaves this machine unsealed except pricing and routing. */
|
|
137
244
|
export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKey } = {}) {
|
|
138
245
|
const server = createServer(async (req, res) => {
|
|
246
|
+
let writer = null;
|
|
139
247
|
const send = (status, body, type = "application/json") => {
|
|
140
248
|
res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
|
|
141
249
|
res.end(type === "application/json" ? JSON.stringify(body) : body);
|
|
@@ -160,11 +268,22 @@ export function serveConsumer(consumer, { host = "127.0.0.1", port = 8110, apiKe
|
|
|
160
268
|
delete rest.max_output_tokens;
|
|
161
269
|
delete rest.max_tokens;
|
|
162
270
|
delete rest.max_completion_tokens;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
271
|
+
if (!stream) {
|
|
272
|
+
const { response } = await consumer.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens, options: rest });
|
|
273
|
+
return send(200, response.output);
|
|
274
|
+
}
|
|
275
|
+
// Headers go out with the first delta, so a request that fails before dispatch still gets an HTTP error.
|
|
276
|
+
const write = (text) => {
|
|
277
|
+
if (!res.headersSent) res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
|
|
278
|
+
res.write(text);
|
|
279
|
+
};
|
|
280
|
+
writer = chat ? chatStream(write, { model }, { includeUsage: streamOptions?.include_usage === true }) : responseStream(write, { model });
|
|
281
|
+
const { response } = await consumer.request({ endpoint: chat ? "chat" : "responses", model, content, maxOutputTokens,
|
|
282
|
+
options: rest, onDelta: (text) => writer.delta(text) });
|
|
283
|
+
writer.finish(response.output);
|
|
284
|
+
return res.end();
|
|
167
285
|
} catch (error) {
|
|
286
|
+
if (res.headersSent) { writer?.fail(error.message); return res.end(); }
|
|
168
287
|
const status = error.code === "outcome_unknown" ? 504 : (error.status && error.status < 500 ? error.status : 502);
|
|
169
288
|
return send(status, openaiError(error.message, error.code || "request_failed"));
|
|
170
289
|
}
|
package/src/protocol.mjs
CHANGED
|
@@ -12,6 +12,7 @@ export const REQUEST_SCHEMA = "mycomesh.v11.request.v1";
|
|
|
12
12
|
export const RESPONSE_SCHEMA = "mycomesh.v11.response.v1";
|
|
13
13
|
export const SEALED_REQUEST_PURPOSE = "mycomesh.v11.sealed-request";
|
|
14
14
|
export const SEALED_RESPONSE_PURPOSE = "mycomesh.v11.sealed-response";
|
|
15
|
+
export const SEALED_DELTA_PURPOSE = "mycomesh.v11.sealed-delta";
|
|
15
16
|
|
|
16
17
|
export const sha256Hex = (bytes) => `0x${createHash("sha256").update(bytes).digest("hex")}`;
|
|
17
18
|
|
|
@@ -70,6 +71,28 @@ export function prepareRequest({ descriptor, deployment, keyPrivate, relaySigner
|
|
|
70
71
|
};
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/** The assistant text of a Responses, Chat Completions or Anthropic Messages payload. */
|
|
75
|
+
export function outputText(output) {
|
|
76
|
+
if (output && typeof output === "object") {
|
|
77
|
+
if (typeof output.output_text === "string") return output.output_text;
|
|
78
|
+
if (Array.isArray(output.choices) && output.choices.length) return String(output.choices[0].message?.content ?? "");
|
|
79
|
+
if (Array.isArray(output.content)) return output.content.map((part) => part?.text ?? "").join("");
|
|
80
|
+
return (output.output || []).flatMap((item) => item?.content || []).map((part) => part?.text ?? "").join("");
|
|
81
|
+
}
|
|
82
|
+
return output == null ? "" : String(output);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Decrypt one streamed delta; a Relay can drop or stall deltas but never reorder or forge them. */
|
|
86
|
+
export function openDelta(prepared, sealed, expectedSeq, now = Math.floor(Date.now() / 1000)) {
|
|
87
|
+
const opened = openFrame(Buffer.from(sealed, "base64"), {
|
|
88
|
+
recipientKey: prepared.replyKey, expectedPurpose: SEALED_DELTA_PURPOSE, replaySet: new Set(),
|
|
89
|
+
expectedSenderPeerId: prepared.providerPeerId, now,
|
|
90
|
+
});
|
|
91
|
+
const value = JSON.parse(opened.payload.toString("utf8"));
|
|
92
|
+
if (value.seq !== expectedSeq || typeof value.delta !== "string") throw new Error("streamed delta is out of order");
|
|
93
|
+
return value.delta;
|
|
94
|
+
}
|
|
95
|
+
|
|
73
96
|
/** Decrypt the response and prove it is exactly what the Provider signed for. */
|
|
74
97
|
export function openResponse(prepared, result, deployment, now = Math.floor(Date.now() / 1000)) {
|
|
75
98
|
const signed = result.receipt;
|
package/src/sse.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// SSE for OpenAI clients (Codex needs the Responses event sequence): buffered, or incremental from sealed deltas.
|
|
2
2
|
// Port of the event order in the former gateway/openai_protocol.py.
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
|
|
@@ -34,6 +34,35 @@ function added(item) {
|
|
|
34
34
|
return copy;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
function emitItem(emit, item, output_index) {
|
|
38
|
+
const item_id = item.id;
|
|
39
|
+
emit("response.output_item.added", { output_index, item: added(item) });
|
|
40
|
+
if (item.type === "message") {
|
|
41
|
+
(item.content || []).forEach((part, content_index) => {
|
|
42
|
+
emit("response.content_part.added", { item_id, output_index, content_index, part: { ...part, text: part.type === "output_text" ? "" : part.text } });
|
|
43
|
+
if (part.type === "output_text") {
|
|
44
|
+
if (part.text) emit("response.output_text.delta", { item_id, output_index, content_index, delta: part.text, logprobs: [] });
|
|
45
|
+
emit("response.output_text.done", { item_id, output_index, content_index, text: part.text || "", logprobs: [] });
|
|
46
|
+
}
|
|
47
|
+
emit("response.content_part.done", { item_id, output_index, content_index, part });
|
|
48
|
+
});
|
|
49
|
+
} else if (item.type === "reasoning") {
|
|
50
|
+
(item.summary || []).forEach((part, summary_index) => {
|
|
51
|
+
emit("response.reasoning_summary_part.added", { item_id, output_index, summary_index, part: { ...part, type: "summary_text", text: "" } });
|
|
52
|
+
if (part.text) emit("response.reasoning_summary_text.delta", { item_id, output_index, summary_index, delta: part.text });
|
|
53
|
+
emit("response.reasoning_summary_text.done", { item_id, output_index, summary_index, text: part.text || "" });
|
|
54
|
+
emit("response.reasoning_summary_part.done", { item_id, output_index, summary_index, part });
|
|
55
|
+
});
|
|
56
|
+
} else if (item.type === "function_call") {
|
|
57
|
+
if (item.arguments) emit("response.function_call_arguments.delta", { item_id, output_index, delta: item.arguments });
|
|
58
|
+
emit("response.function_call_arguments.done", { item_id, output_index, call_id: item.call_id || "", name: item.name || "", arguments: item.arguments || "" });
|
|
59
|
+
} else if (item.type === "custom_tool_call") {
|
|
60
|
+
if (item.input) emit("response.custom_tool_call_input.delta", { item_id, output_index, delta: item.input });
|
|
61
|
+
emit("response.custom_tool_call_input.done", { item_id, output_index, call_id: item.call_id || "", name: item.name || "", input: item.input || "" });
|
|
62
|
+
}
|
|
63
|
+
emit("response.output_item.done", { output_index, item });
|
|
64
|
+
}
|
|
65
|
+
|
|
37
66
|
export function responseSse(payload) {
|
|
38
67
|
const final = normalize(payload);
|
|
39
68
|
const out = [];
|
|
@@ -42,38 +71,66 @@ export function responseSse(payload) {
|
|
|
42
71
|
const created = { ...structuredClone(final), status: "in_progress", output: [], output_text: "", usage: null };
|
|
43
72
|
emit("response.created", { response: created });
|
|
44
73
|
emit("response.in_progress", { response: created });
|
|
45
|
-
final.output.forEach((item, output_index) =>
|
|
46
|
-
const item_id = item.id;
|
|
47
|
-
emit("response.output_item.added", { output_index, item: added(item) });
|
|
48
|
-
if (item.type === "message") {
|
|
49
|
-
(item.content || []).forEach((part, content_index) => {
|
|
50
|
-
emit("response.content_part.added", { item_id, output_index, content_index, part: { ...part, text: part.type === "output_text" ? "" : part.text } });
|
|
51
|
-
if (part.type === "output_text") {
|
|
52
|
-
if (part.text) emit("response.output_text.delta", { item_id, output_index, content_index, delta: part.text, logprobs: [] });
|
|
53
|
-
emit("response.output_text.done", { item_id, output_index, content_index, text: part.text || "", logprobs: [] });
|
|
54
|
-
}
|
|
55
|
-
emit("response.content_part.done", { item_id, output_index, content_index, part });
|
|
56
|
-
});
|
|
57
|
-
} else if (item.type === "reasoning") {
|
|
58
|
-
(item.summary || []).forEach((part, summary_index) => {
|
|
59
|
-
emit("response.reasoning_summary_part.added", { item_id, output_index, summary_index, part: { ...part, type: "summary_text", text: "" } });
|
|
60
|
-
if (part.text) emit("response.reasoning_summary_text.delta", { item_id, output_index, summary_index, delta: part.text });
|
|
61
|
-
emit("response.reasoning_summary_text.done", { item_id, output_index, summary_index, text: part.text || "" });
|
|
62
|
-
emit("response.reasoning_summary_part.done", { item_id, output_index, summary_index, part });
|
|
63
|
-
});
|
|
64
|
-
} else if (item.type === "function_call") {
|
|
65
|
-
if (item.arguments) emit("response.function_call_arguments.delta", { item_id, output_index, delta: item.arguments });
|
|
66
|
-
emit("response.function_call_arguments.done", { item_id, output_index, call_id: item.call_id || "", name: item.name || "", arguments: item.arguments || "" });
|
|
67
|
-
} else if (item.type === "custom_tool_call") {
|
|
68
|
-
if (item.input) emit("response.custom_tool_call_input.delta", { item_id, output_index, delta: item.input });
|
|
69
|
-
emit("response.custom_tool_call_input.done", { item_id, output_index, call_id: item.call_id || "", name: item.name || "", input: item.input || "" });
|
|
70
|
-
}
|
|
71
|
-
emit("response.output_item.done", { output_index, item });
|
|
72
|
-
});
|
|
74
|
+
final.output.forEach((item, output_index) => emitItem(emit, item, output_index));
|
|
73
75
|
emit(TERMINAL[final.status], { response: final });
|
|
74
76
|
return out.join("");
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Incremental Responses events: the assistant message streams first as text
|
|
81
|
+
* deltas; when the verified response arrives its other items follow and the
|
|
82
|
+
* terminal event carries the full response (message first, with the streamed id).
|
|
83
|
+
*/
|
|
84
|
+
export function responseStream(write, { model }) {
|
|
85
|
+
let sequence = 0;
|
|
86
|
+
const emit = (type, fields) => write(sse({ type, sequence_number: sequence++, ...fields }));
|
|
87
|
+
const id = `resp_${randomUUID().replace(/-/g, "")}`;
|
|
88
|
+
const item_id = `msg_${randomUUID().replace(/-/g, "")}`;
|
|
89
|
+
const created = { id, object: "response", created_at: Math.floor(Date.now() / 1000), model, status: "in_progress",
|
|
90
|
+
output: [], output_text: "", usage: null, error: null, incomplete_details: null };
|
|
91
|
+
let started = false;
|
|
92
|
+
let streamed = "";
|
|
93
|
+
const start = () => {
|
|
94
|
+
if (started) return;
|
|
95
|
+
started = true;
|
|
96
|
+
emit("response.created", { response: created });
|
|
97
|
+
emit("response.in_progress", { response: created });
|
|
98
|
+
emit("response.output_item.added", { output_index: 0, item: { type: "message", id: item_id, role: "assistant", status: "in_progress", content: [] } });
|
|
99
|
+
emit("response.content_part.added", { item_id, output_index: 0, content_index: 0, part: { type: "output_text", text: "", annotations: [] } });
|
|
100
|
+
};
|
|
101
|
+
return {
|
|
102
|
+
delta(text) {
|
|
103
|
+
start();
|
|
104
|
+
streamed += text;
|
|
105
|
+
emit("response.output_text.delta", { item_id, output_index: 0, content_index: 0, delta: text, logprobs: [] });
|
|
106
|
+
},
|
|
107
|
+
finish(payload) {
|
|
108
|
+
start();
|
|
109
|
+
const final = normalize(payload);
|
|
110
|
+
final.id = id;
|
|
111
|
+
const index = final.output.findIndex((item) => item.type === "message");
|
|
112
|
+
const message = index >= 0 ? final.output.splice(index, 1)[0]
|
|
113
|
+
: { type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: final.output_text, annotations: [] }] };
|
|
114
|
+
message.id = item_id;
|
|
115
|
+
final.output.unshift(message);
|
|
116
|
+
const text = (message.content || []).filter((part) => part.type === "output_text").map((part) => part.text || "").join("");
|
|
117
|
+
if (text.startsWith(streamed) && text.length > streamed.length) {
|
|
118
|
+
emit("response.output_text.delta", { item_id, output_index: 0, content_index: 0, delta: text.slice(streamed.length), logprobs: [] });
|
|
119
|
+
}
|
|
120
|
+
const part = { type: "output_text", text, annotations: [] };
|
|
121
|
+
emit("response.output_text.done", { item_id, output_index: 0, content_index: 0, text, logprobs: [] });
|
|
122
|
+
emit("response.content_part.done", { item_id, output_index: 0, content_index: 0, part });
|
|
123
|
+
emit("response.output_item.done", { output_index: 0, item: message });
|
|
124
|
+
final.output.slice(1).forEach((item, offset) => emitItem(emit, item, offset + 1));
|
|
125
|
+
emit(TERMINAL[final.status], { response: final });
|
|
126
|
+
},
|
|
127
|
+
fail(message) {
|
|
128
|
+
start();
|
|
129
|
+
emit("response.failed", { response: { ...created, status: "failed", error: { code: "mycomesh_error", message } } });
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
77
134
|
export function chatSse(payload, { includeUsage = false } = {}) {
|
|
78
135
|
const id = payload.id || `chatcmpl_${randomUUID().replace(/-/g, "")}`;
|
|
79
136
|
const model = payload.model || "";
|
|
@@ -99,3 +156,33 @@ export function chatSse(payload, { includeUsage = false } = {}) {
|
|
|
99
156
|
out.push("data: [DONE]\n\n");
|
|
100
157
|
return out.join("");
|
|
101
158
|
}
|
|
159
|
+
|
|
160
|
+
/** Incremental Chat Completions chunks from sealed deltas. */
|
|
161
|
+
export function chatStream(write, { model }, { includeUsage = false } = {}) {
|
|
162
|
+
const id = `chatcmpl_${randomUUID().replace(/-/g, "")}`;
|
|
163
|
+
const created = Math.floor(Date.now() / 1000);
|
|
164
|
+
const chunk = (delta, finish_reason = null) =>
|
|
165
|
+
write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta, finish_reason }] })}\n\n`);
|
|
166
|
+
let started = false;
|
|
167
|
+
let streamed = "";
|
|
168
|
+
const start = () => { if (!started) { started = true; chunk({ role: "assistant" }); } };
|
|
169
|
+
return {
|
|
170
|
+
delta(text) { start(); streamed += text; chunk({ content: text }); },
|
|
171
|
+
finish(payload) {
|
|
172
|
+
start();
|
|
173
|
+
const choice = (payload?.choices || [])[0] || {};
|
|
174
|
+
const text = typeof choice.message?.content === "string" ? choice.message.content : "";
|
|
175
|
+
if (text.startsWith(streamed) && text.length > streamed.length) chunk({ content: text.slice(streamed.length) });
|
|
176
|
+
chunk({}, choice.finish_reason || "stop");
|
|
177
|
+
if (includeUsage && payload?.usage) {
|
|
178
|
+
write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [], usage: payload.usage })}\n\n`);
|
|
179
|
+
}
|
|
180
|
+
write("data: [DONE]\n\n");
|
|
181
|
+
},
|
|
182
|
+
fail(message) {
|
|
183
|
+
start();
|
|
184
|
+
write(`data: ${JSON.stringify({ error: { message, type: "mycomesh_error" } })}\n\n`);
|
|
185
|
+
write("data: [DONE]\n\n");
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
package/src/wallet.mjs
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// The Consumer's owner wallet: an Ethereum V3 keystore (the format MetaMask and geth import),
|
|
2
|
+
// so the key that holds the deposit never sits in a plaintext file.
|
|
3
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync, pbkdf2Sync, randomUUID } from "node:crypto";
|
|
4
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { addressOf, keccak } from "./eip712.mjs";
|
|
7
|
+
|
|
8
|
+
const SCRYPT = { n: 1 << 15, r: 8, p: 1, dklen: 32 };
|
|
9
|
+
|
|
10
|
+
export function encryptKeystore(privateKey, password) {
|
|
11
|
+
const salt = randomBytes(32);
|
|
12
|
+
const iv = randomBytes(16);
|
|
13
|
+
const derived = scryptSync(Buffer.from(password, "utf8"), salt, SCRYPT.dklen,
|
|
14
|
+
{ N: SCRYPT.n, r: SCRYPT.r, p: SCRYPT.p, maxmem: 128 * SCRYPT.n * SCRYPT.r * 2 });
|
|
15
|
+
const cipher = createCipheriv("aes-128-ctr", derived.subarray(0, 16), iv);
|
|
16
|
+
const ciphertext = Buffer.concat([cipher.update(Buffer.from(privateKey.replace(/^0x/, ""), "hex")), cipher.final()]);
|
|
17
|
+
return {
|
|
18
|
+
version: 3, id: randomUUID(), address: addressOf(privateKey).slice(2),
|
|
19
|
+
crypto: {
|
|
20
|
+
cipher: "aes-128-ctr", cipherparams: { iv: iv.toString("hex") }, ciphertext: ciphertext.toString("hex"),
|
|
21
|
+
kdf: "scrypt", kdfparams: { ...SCRYPT, salt: salt.toString("hex") },
|
|
22
|
+
mac: keccak(Buffer.concat([derived.subarray(16, 32), ciphertext])).toString("hex"),
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function decryptKeystore(keystore, password) {
|
|
28
|
+
const crypto = keystore.crypto || keystore.Crypto;
|
|
29
|
+
if (keystore.version !== 3 || crypto?.cipher !== "aes-128-ctr") throw new Error("unsupported keystore");
|
|
30
|
+
const params = crypto.kdfparams;
|
|
31
|
+
const salt = Buffer.from(params.salt, "hex");
|
|
32
|
+
const secret = Buffer.from(password, "utf8");
|
|
33
|
+
let derived;
|
|
34
|
+
if (crypto.kdf === "scrypt") {
|
|
35
|
+
derived = scryptSync(secret, salt, params.dklen, { N: params.n, r: params.r, p: params.p, maxmem: 128 * params.n * params.r * 2 });
|
|
36
|
+
} else if (crypto.kdf === "pbkdf2" && params.prf === "hmac-sha256") {
|
|
37
|
+
derived = pbkdf2Sync(secret, salt, params.c, params.dklen, "sha256");
|
|
38
|
+
} else {
|
|
39
|
+
throw new Error(`unsupported keystore KDF ${crypto.kdf}`);
|
|
40
|
+
}
|
|
41
|
+
const ciphertext = Buffer.from(crypto.ciphertext, "hex");
|
|
42
|
+
const mac = keccak(Buffer.concat([derived.subarray(16, 32), ciphertext])).toString("hex");
|
|
43
|
+
if (mac !== crypto.mac.toLowerCase()) throw new Error("wrong wallet password");
|
|
44
|
+
const decipher = createDecipheriv("aes-128-ctr", derived.subarray(0, 16), Buffer.from(crypto.cipherparams.iv, "hex"));
|
|
45
|
+
const privateKey = `0x${Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("hex")}`;
|
|
46
|
+
if (keystore.address && addressOf(privateKey).slice(2) !== keystore.address.toLowerCase().replace(/^0x/, "")) {
|
|
47
|
+
throw new Error("keystore address does not match its key");
|
|
48
|
+
}
|
|
49
|
+
return privateKey;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Password from MYCOMESH_WALLET_PASSWORD, else a muted terminal prompt. */
|
|
53
|
+
export async function password(prompt, { confirm = false } = {}) {
|
|
54
|
+
if (process.env.MYCOMESH_WALLET_PASSWORD) return process.env.MYCOMESH_WALLET_PASSWORD;
|
|
55
|
+
if (!process.stdin.isTTY) throw new Error("set MYCOMESH_WALLET_PASSWORD or run in a terminal");
|
|
56
|
+
const ask = (label) => new Promise((resolve, reject) => {
|
|
57
|
+
process.stderr.write(label);
|
|
58
|
+
const input = [];
|
|
59
|
+
process.stdin.setRawMode(true);
|
|
60
|
+
process.stdin.resume();
|
|
61
|
+
const onData = (chunk) => {
|
|
62
|
+
for (const char of chunk.toString("utf8")) {
|
|
63
|
+
if (char === "\r" || char === "\n") { done(); return resolve(input.join("")); }
|
|
64
|
+
if (char === "\u0003") { done(); return reject(new Error("cancelled")); }
|
|
65
|
+
if (char === "\u007f") input.pop(); else input.push(char);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const done = () => { process.stdin.setRawMode(false); process.stdin.pause(); process.stdin.off("data", onData); process.stderr.write("\n"); };
|
|
69
|
+
process.stdin.on("data", onData);
|
|
70
|
+
});
|
|
71
|
+
const first = await ask(prompt);
|
|
72
|
+
if (first.length < 8) throw new Error("use a wallet password of at least 8 characters");
|
|
73
|
+
if (confirm && (await ask("Repeat the password: ")) !== first) throw new Error("passwords differ");
|
|
74
|
+
return first;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const walletPath = (dir) => join(dir, "owner-wallet.json");
|
|
78
|
+
|
|
79
|
+
export async function createWallet(dir) {
|
|
80
|
+
const path = walletPath(dir);
|
|
81
|
+
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
|
|
82
|
+
const keystore = encryptKeystore(`0x${randomBytes(32).toString("hex")}`,
|
|
83
|
+
await password("New wallet password (protects your deposit): ", { confirm: true }));
|
|
84
|
+
writeFileSync(path, `${JSON.stringify(keystore, null, 2)}\n`, { mode: 0o600 });
|
|
85
|
+
chmodSync(path, 0o600);
|
|
86
|
+
return keystore;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const walletAddress = (keystore) => `0x${keystore.address.toLowerCase().replace(/^0x/, "")}`;
|
|
90
|
+
|
|
91
|
+
/** The owner key: --owner-key-file (raw hex or a keystore), else this machine's wallet. */
|
|
92
|
+
export async function ownerKey(file, dir) {
|
|
93
|
+
const path = file || walletPath(dir);
|
|
94
|
+
if (!existsSync(path)) throw new Error("no owner wallet; run `mycomesh-consumer init` or pass --owner-key-file");
|
|
95
|
+
const text = readFileSync(path, "utf8").trim();
|
|
96
|
+
if (/^(0x)?[0-9a-fA-F]{64}$/.test(text)) return text.startsWith("0x") ? text : `0x${text}`;
|
|
97
|
+
return decryptKeystore(JSON.parse(text), await password("Wallet password: "));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function ownerAddress(file, dir) {
|
|
101
|
+
const path = file || walletPath(dir);
|
|
102
|
+
if (!existsSync(path)) return null;
|
|
103
|
+
const text = readFileSync(path, "utf8").trim();
|
|
104
|
+
if (/^(0x)?[0-9a-fA-F]{64}$/.test(text)) return addressOf(text.startsWith("0x") ? text : `0x${text}`);
|
|
105
|
+
return walletAddress(JSON.parse(text));
|
|
106
|
+
}
|