@dvmkit/sdk 0.1.1-rc.7 → 0.1.3-rc.7
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 +14 -0
- package/dist/chunk-2K7E3N2D.js +1709 -0
- package/dist/{chunk-Y6W5ZZTW.js → chunk-3ZHMQCYP.js} +666 -3031
- package/dist/{chunk-TKA6ZP4M.js → chunk-4B56DEEV.js} +50 -426
- package/dist/{chunk-X3IKFWJA.js → chunk-6BQM7TOW.js} +4 -1
- package/dist/chunk-BIFLRKMO.js +87 -0
- package/dist/chunk-BQ2NMWKE.js +160 -0
- package/dist/{chunk-LDTWX7JW.js → chunk-BTZY7VPH.js} +13 -1
- package/dist/{chunk-FJDCFHW5.js → chunk-C6JHBLMW.js} +3 -81
- package/dist/{chunk-EXHBXA4U.js → chunk-DBCLBYHP.js} +13 -1
- package/dist/chunk-EPNDZ5DH.js +1010 -0
- package/dist/{chunk-P4RUVDU7.js → chunk-H2MEFVH6.js} +12 -141
- package/dist/chunk-KXZUCCEY.js +142 -0
- package/dist/{chunk-6JZIX5WW.js → chunk-LLXV32HA.js} +82 -5
- package/dist/{chunk-LWUR4CGG.js → chunk-MLRCSJYX.js} +11 -3
- package/dist/{chunk-2ABMGUDS.js → chunk-NFRM5QYP.js} +83 -10
- package/dist/chunk-RW5LP57K.js +44 -0
- package/dist/chunk-YDIYXGYL.js +384 -0
- package/dist/{chunk-JGGI65I3.js → chunk-Z4BNLUZF.js} +1 -150
- package/dist/{credit-ledger-ED6JXKVD.js → credit-ledger-2DFQHNLB.js} +2 -2
- package/dist/{credit-menu-BrTfAr7l.d.ts → credit-menu-C7zAJElJ.d.ts} +1632 -1946
- package/dist/{fx-H6K6qcH1.d.ts → fx-BF_SG2i0.d.ts} +1 -1
- package/dist/index.d.ts +7 -6
- package/dist/index.js +8 -4
- package/dist/internal/caller.d.ts +10429 -0
- package/dist/internal/caller.js +9957 -0
- package/dist/internal/index.d.ts +2 -10816
- package/dist/internal/index.js +2 -10130
- package/dist/internal/server.d.ts +404 -0
- package/dist/internal/server.js +202 -0
- package/dist/job-store-Bn23V3QU.d.ts +576 -0
- package/dist/lightning-backend-C04nH94l.d.ts +367 -0
- package/dist/{memory-credit-ledger-XJ5VQEVP.js → memory-credit-ledger-OP24Z2KO.js} +3 -3
- package/dist/{payout-reporter-3UB5WRCV.js → payout-reporter-RG6XNGPI.js} +1 -1
- package/dist/{postgres-job-store-J5F4GUWU.js → postgres-job-store-TAONYLIF.js} +1 -1
- package/dist/{revenue-reporter-JIKUPXOK.js → revenue-reporter-XXSU5KVB.js} +1 -1
- package/dist/server/index.d.ts +27 -11
- package/dist/server/index.js +93 -69
- package/dist/{job-store-DOSfYnLX.d.ts → step-cache-3cT4Shk0.d.ts} +38 -585
- package/dist/{tempo-session-store-DALMRIWN.js → tempo-session-store-2JNOKJGX.js} +2 -2
- package/dist/testing/index.d.ts +16 -4
- package/dist/testing/index.js +7 -3
- package/dist/{usd-Civ738b3.d.ts → usd-BnuXoFl5.d.ts} +1 -1
- package/dist/wallet-CJC8lwxx.d.ts +29 -0
- package/dist/{x402-FTG2GRAQ.js → x402-T2C5MX3T.js} +6 -3
- package/package.json +7 -2
- package/dist/{chunk-RU7SXHLO.js → chunk-UP2F5RRT.js} +3 -3
|
@@ -0,0 +1,1709 @@
|
|
|
1
|
+
import {
|
|
2
|
+
costAnchor,
|
|
3
|
+
formatUsd,
|
|
4
|
+
msatsToUsd
|
|
5
|
+
} from "./chunk-5URG56JJ.js";
|
|
6
|
+
import {
|
|
7
|
+
AGENT_MNEMONIC_FILE,
|
|
8
|
+
BUILDER_CONFIG_DIR,
|
|
9
|
+
BUILDER_FILE,
|
|
10
|
+
BUILDER_KEY_FILE,
|
|
11
|
+
DvmError,
|
|
12
|
+
WALLET_FILE,
|
|
13
|
+
ensureConfigDir
|
|
14
|
+
} from "./chunk-MKI6OVW4.js";
|
|
15
|
+
import {
|
|
16
|
+
canonicaliseForSigning
|
|
17
|
+
} from "./chunk-KXZUCCEY.js";
|
|
18
|
+
import {
|
|
19
|
+
callerLoggers
|
|
20
|
+
} from "./chunk-66HGCPBU.js";
|
|
21
|
+
|
|
22
|
+
// src/lib/cashu/lock-pubkey.ts
|
|
23
|
+
function toLockPubkey(raw) {
|
|
24
|
+
return raw.trim().toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// src/lib/x402/types.ts
|
|
28
|
+
function isX402SettlementReconciliationReason(value) {
|
|
29
|
+
return value === "settlement_not_on_chain" || value === "chain_unreachable" || value === "settlement_unbookmarked" || value === "facilitator_channel_state_unavailable";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/lib/secret-backup.ts
|
|
33
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
34
|
+
function backupSecretFile(file, nextContent) {
|
|
35
|
+
const content = existsSync(file) ? readFileSync(file) : null;
|
|
36
|
+
if (content === null) return null;
|
|
37
|
+
if (content.toString("utf-8").trim() === nextContent?.trim()) return null;
|
|
38
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
39
|
+
for (let n = 0; n < 100; n++) {
|
|
40
|
+
const backup = n === 0 ? `${file}.${stamp}.bak` : `${file}.${stamp}-${String(n)}.bak`;
|
|
41
|
+
try {
|
|
42
|
+
writeFileSync(backup, content, { mode: 384, flag: "wx" });
|
|
43
|
+
return backup;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (err.code !== "EEXIST") throw err;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`Could not create a backup of ${file}: 100 candidate paths already exist.`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// src/lib/wallet-agent/agent-mnemonic-file.ts
|
|
52
|
+
import {
|
|
53
|
+
chmodSync,
|
|
54
|
+
existsSync as existsSync2,
|
|
55
|
+
readFileSync as readFileSync2,
|
|
56
|
+
renameSync,
|
|
57
|
+
unlinkSync,
|
|
58
|
+
writeFileSync as writeFileSync2
|
|
59
|
+
} from "fs";
|
|
60
|
+
function readAgentMnemonic() {
|
|
61
|
+
if (!existsSync2(AGENT_MNEMONIC_FILE)) return null;
|
|
62
|
+
const raw = readFileSync2(AGENT_MNEMONIC_FILE, "utf-8");
|
|
63
|
+
return raw.trim().toLowerCase().split(/\s+/).join(" ");
|
|
64
|
+
}
|
|
65
|
+
function assertAgentMnemonicReplaceable(opts = {}) {
|
|
66
|
+
if (existsSync2(AGENT_MNEMONIC_FILE) && !opts.force) {
|
|
67
|
+
throw new DvmError(
|
|
68
|
+
"agent_mnemonic_exists",
|
|
69
|
+
`An agent wallet recovery mnemonic already exists at ${AGENT_MNEMONIC_FILE}.`,
|
|
70
|
+
"Re-run with --force to replace it. WARNING: funds locked to keys derived from the current words become unrecoverable without a backup. The replaced mnemonic is copied to a timestamped .bak sibling."
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function writeAgentMnemonic(mnemonic, opts = {}) {
|
|
75
|
+
const normalised = mnemonic.trim().toLowerCase().split(/\s+/).join(" ");
|
|
76
|
+
assertAgentMnemonicReplaceable(opts);
|
|
77
|
+
ensureConfigDir();
|
|
78
|
+
const backupFile = backupSecretFile(AGENT_MNEMONIC_FILE, normalised);
|
|
79
|
+
const tmp = `${AGENT_MNEMONIC_FILE}.tmp`;
|
|
80
|
+
writeFileSync2(tmp, normalised + "\n", { mode: 384 });
|
|
81
|
+
try {
|
|
82
|
+
renameSync(tmp, AGENT_MNEMONIC_FILE);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
try {
|
|
85
|
+
unlinkSync(tmp);
|
|
86
|
+
} catch {
|
|
87
|
+
}
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
chmodSync(AGENT_MNEMONIC_FILE, 384);
|
|
92
|
+
} catch {
|
|
93
|
+
}
|
|
94
|
+
return { backupFile };
|
|
95
|
+
}
|
|
96
|
+
function deleteAgentMnemonic() {
|
|
97
|
+
if (!existsSync2(AGENT_MNEMONIC_FILE)) return { backupFile: null };
|
|
98
|
+
const backupFile = backupSecretFile(AGENT_MNEMONIC_FILE);
|
|
99
|
+
unlinkSync(AGENT_MNEMONIC_FILE);
|
|
100
|
+
return { backupFile };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/lib/wallet-agent/mnemonic.ts
|
|
104
|
+
import { createHash } from "crypto";
|
|
105
|
+
import { HDKey } from "@scure/bip32";
|
|
106
|
+
import {
|
|
107
|
+
generateMnemonic,
|
|
108
|
+
mnemonicToEntropy,
|
|
109
|
+
mnemonicToSeedSync,
|
|
110
|
+
validateMnemonic
|
|
111
|
+
} from "@scure/bip39";
|
|
112
|
+
import { wordlist } from "@scure/bip39/wordlists/english.js";
|
|
113
|
+
var MNEMONIC_PURPOSE_INDEX = 1789;
|
|
114
|
+
var AGENT_MASTER_ROLE_INDEX = 1;
|
|
115
|
+
var MNEMONIC_STRENGTH_BITS = 128;
|
|
116
|
+
function agentMasterKeypairPath() {
|
|
117
|
+
return `m/${MNEMONIC_PURPOSE_INDEX}'/${AGENT_MASTER_ROLE_INDEX}'`;
|
|
118
|
+
}
|
|
119
|
+
function generateAgentMnemonic() {
|
|
120
|
+
return generateMnemonic(wordlist, MNEMONIC_STRENGTH_BITS);
|
|
121
|
+
}
|
|
122
|
+
function deriveAgentMasterKeypair(mnemonic) {
|
|
123
|
+
const seed = mnemonicToSeedSync(mnemonic);
|
|
124
|
+
const hd = HDKey.fromMasterSeed(seed);
|
|
125
|
+
const child = hd.derive(agentMasterKeypairPath());
|
|
126
|
+
if (!child.privateKey || !child.publicKey) {
|
|
127
|
+
throw new Error("Failed to derive agent master keypair from mnemonic");
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
privkey: Buffer.from(child.privateKey).toString("hex"),
|
|
131
|
+
pubkey: Buffer.from(child.publicKey).toString("hex")
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function validateAgentMnemonic(mnemonic) {
|
|
135
|
+
const words = mnemonic.trim().toLowerCase().split(/\s+/);
|
|
136
|
+
if (words.length !== 12) {
|
|
137
|
+
return { ok: false, reason: "wrong_length", wordCount: words.length };
|
|
138
|
+
}
|
|
139
|
+
for (let i = 0; i < words.length; i++) {
|
|
140
|
+
if (!wordlist.includes(words[i])) {
|
|
141
|
+
return { ok: false, reason: "unknown_word", badWordIndex: i + 1, badWord: words[i] };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!validateMnemonic(words.join(" "), wordlist)) {
|
|
145
|
+
return { ok: false, reason: "checksum" };
|
|
146
|
+
}
|
|
147
|
+
return { ok: true };
|
|
148
|
+
}
|
|
149
|
+
function looksLikeMnemonic(input) {
|
|
150
|
+
return /\s/.test(input.trim());
|
|
151
|
+
}
|
|
152
|
+
function mnemonicFingerprint(mnemonic) {
|
|
153
|
+
const normalised = mnemonic.trim().toLowerCase().split(/\s+/).join(" ");
|
|
154
|
+
const entropy = mnemonicToEntropy(normalised, wordlist);
|
|
155
|
+
return createHash("sha256").update(entropy).digest("hex");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// src/lib/wallet-agent/store.ts
|
|
159
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, renameSync as renameSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
160
|
+
import lockfile from "proper-lockfile";
|
|
161
|
+
var LOCK_FILE = `${WALLET_FILE}.lock`;
|
|
162
|
+
var DEFAULT_LOCK_STALE_MS = 3e4;
|
|
163
|
+
var DEFAULT_LOCK_RETRIES = 30;
|
|
164
|
+
var DEFAULT_LOCK_RETRY_INTERVAL_MS = 1e3;
|
|
165
|
+
var WALLET_VERSION = 8;
|
|
166
|
+
var RECENT_SPENDS_CAP = 10;
|
|
167
|
+
var DEFAULT_AGENT_MINT_URL = "https://mint.coinos.io";
|
|
168
|
+
function loadAgentWallet() {
|
|
169
|
+
if (!existsSync3(WALLET_FILE)) return null;
|
|
170
|
+
const raw = readFileSync3(WALLET_FILE, "utf-8");
|
|
171
|
+
let parsed;
|
|
172
|
+
try {
|
|
173
|
+
parsed = JSON.parse(raw);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
throw new DvmError(
|
|
176
|
+
"wallet_corrupt",
|
|
177
|
+
`Wallet file at ${WALLET_FILE} is not valid JSON.`,
|
|
178
|
+
`Restore a backup or delete the file. Underlying error: ${err instanceof Error ? err.message : String(err)}`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) {
|
|
182
|
+
throw new DvmError(
|
|
183
|
+
"wallet_corrupt",
|
|
184
|
+
`Wallet file at ${WALLET_FILE} is missing a version field.`,
|
|
185
|
+
"Restore a backup or run: dvm wallet init --force"
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
const version = parsed.version;
|
|
189
|
+
if (version === 1) {
|
|
190
|
+
return migrateV7ToV8(
|
|
191
|
+
migrateV6ToV7(migrateV5ToV6(migrateV4ToV5(migrateV1ToV4(parsed))))
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
if (version === 2) {
|
|
195
|
+
return migrateV7ToV8(
|
|
196
|
+
migrateV6ToV7(
|
|
197
|
+
migrateV5ToV6(
|
|
198
|
+
migrateV4ToV5(migrateV3ToV4(migrateV2ToV3(parsed)))
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (version === 3) {
|
|
204
|
+
return migrateV7ToV8(
|
|
205
|
+
migrateV6ToV7(migrateV5ToV6(migrateV4ToV5(migrateV3ToV4(parsed))))
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (version === 4) {
|
|
209
|
+
return migrateV7ToV8(
|
|
210
|
+
migrateV6ToV7(migrateV5ToV6(migrateV4ToV5(parsed)))
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
if (version === 5) {
|
|
214
|
+
return migrateV7ToV8(migrateV6ToV7(migrateV5ToV6(parsed)));
|
|
215
|
+
}
|
|
216
|
+
if (version === 6) {
|
|
217
|
+
return migrateV7ToV8(migrateV6ToV7(parsed));
|
|
218
|
+
}
|
|
219
|
+
if (version === 7) {
|
|
220
|
+
return migrateV7ToV8(parsed);
|
|
221
|
+
}
|
|
222
|
+
if (version !== WALLET_VERSION) {
|
|
223
|
+
throw new DvmError(
|
|
224
|
+
"wallet_unknown_version",
|
|
225
|
+
`Unknown wallet version ${String(version)} (this CLI understands version ${String(WALLET_VERSION)}).`,
|
|
226
|
+
"Upgrade dvm CLI: a newer version wrote this wallet."
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return parsed;
|
|
230
|
+
}
|
|
231
|
+
function saveAgentWallet(wallet) {
|
|
232
|
+
ensureConfigDir();
|
|
233
|
+
const tmp = `${WALLET_FILE}.tmp`;
|
|
234
|
+
const data = JSON.stringify(wallet, null, 2) + "\n";
|
|
235
|
+
writeFileSync3(tmp, data, { mode: 384 });
|
|
236
|
+
try {
|
|
237
|
+
renameSync2(tmp, WALLET_FILE);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
try {
|
|
240
|
+
unlinkSync2(tmp);
|
|
241
|
+
} catch {
|
|
242
|
+
}
|
|
243
|
+
throw err;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function walletExists() {
|
|
247
|
+
return existsSync3(WALLET_FILE);
|
|
248
|
+
}
|
|
249
|
+
async function withWalletLock(fn, opts = {}) {
|
|
250
|
+
ensureConfigDir();
|
|
251
|
+
const stale = opts.stale ?? DEFAULT_LOCK_STALE_MS;
|
|
252
|
+
const retries = opts.retries ?? DEFAULT_LOCK_RETRIES;
|
|
253
|
+
const interval = opts.retryIntervalMs ?? DEFAULT_LOCK_RETRY_INTERVAL_MS;
|
|
254
|
+
let release;
|
|
255
|
+
try {
|
|
256
|
+
release = await lockfile.lock(WALLET_FILE, {
|
|
257
|
+
lockfilePath: LOCK_FILE,
|
|
258
|
+
stale,
|
|
259
|
+
realpath: false,
|
|
260
|
+
retries: { retries, factor: 1, minTimeout: interval, maxTimeout: interval }
|
|
261
|
+
});
|
|
262
|
+
} catch (err) {
|
|
263
|
+
throw new DvmError(
|
|
264
|
+
"wallet_locked",
|
|
265
|
+
`Could not acquire the agent wallet lock at ${LOCK_FILE} within the retry budget.`,
|
|
266
|
+
`Another dvm CLI may be writing to ${WALLET_FILE}. Wait a moment and retry. If no other dvm CLI is running, remove ${LOCK_FILE} and try again.`,
|
|
267
|
+
{ lock_file: LOCK_FILE, underlying: err instanceof Error ? err.message : String(err) }
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
return await fn();
|
|
272
|
+
} finally {
|
|
273
|
+
try {
|
|
274
|
+
await release();
|
|
275
|
+
} catch {
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function effectiveMints(wallet) {
|
|
280
|
+
return wallet.mints.length > 0 ? wallet.mints : [DEFAULT_AGENT_MINT_URL];
|
|
281
|
+
}
|
|
282
|
+
function effectiveFundingMint(mints) {
|
|
283
|
+
return mints.length > 0 ? { url: mints[0], source: "configured" } : { url: DEFAULT_AGENT_MINT_URL, source: "fallback" };
|
|
284
|
+
}
|
|
285
|
+
function migrateV1ToV4(parsed) {
|
|
286
|
+
const proofs = Array.isArray(parsed.proofs) ? parsed.proofs : [];
|
|
287
|
+
const pending = Array.isArray(parsed.pending_mints) ? parsed.pending_mints : [];
|
|
288
|
+
if (proofs.length > 0 || pending.length > 0) {
|
|
289
|
+
throw new DvmError(
|
|
290
|
+
"wallet_unknown_version",
|
|
291
|
+
`Cannot migrate v1 wallet at ${WALLET_FILE}: contains ${proofs.length} proofs and ${pending.length} pending mints under the legacy schema.`,
|
|
292
|
+
"v1 wallets predate the fund flow and are expected to be empty. If this wallet was somehow populated, restore a backup or run: dvm wallet init --force"
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
version: 4,
|
|
297
|
+
lock_privkey: String(parsed.recovery_privkey ?? parsed.lock_privkey),
|
|
298
|
+
lock_pubkey: String(parsed.recovery_pubkey ?? parsed.lock_pubkey),
|
|
299
|
+
mints: Array.isArray(parsed.mints) ? parsed.mints : [],
|
|
300
|
+
proofs: [],
|
|
301
|
+
pending_mints: [],
|
|
302
|
+
recent_spends: [],
|
|
303
|
+
created_at: typeof parsed.created_at === "string" ? parsed.created_at : (/* @__PURE__ */ new Date()).toISOString()
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
function migrateV2ToV3(parsed) {
|
|
307
|
+
return {
|
|
308
|
+
...parsed,
|
|
309
|
+
version: 3,
|
|
310
|
+
recent_spends: []
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function migrateV3ToV4(parsed) {
|
|
314
|
+
return {
|
|
315
|
+
version: 4,
|
|
316
|
+
lock_privkey: String(parsed.recovery_privkey ?? parsed.lock_privkey),
|
|
317
|
+
lock_pubkey: String(parsed.recovery_pubkey ?? parsed.lock_pubkey),
|
|
318
|
+
mints: Array.isArray(parsed.mints) ? parsed.mints : [],
|
|
319
|
+
proofs: Array.isArray(parsed.proofs) ? parsed.proofs : [],
|
|
320
|
+
pending_mints: Array.isArray(parsed.pending_mints) ? parsed.pending_mints : [],
|
|
321
|
+
recent_spends: Array.isArray(parsed.recent_spends) ? parsed.recent_spends : [],
|
|
322
|
+
created_at: typeof parsed.created_at === "string" ? parsed.created_at : (/* @__PURE__ */ new Date()).toISOString()
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
function migrateV4ToV5(parsed) {
|
|
326
|
+
return {
|
|
327
|
+
version: 5,
|
|
328
|
+
lock_privkey: String(parsed.lock_privkey),
|
|
329
|
+
lock_pubkey: String(parsed.lock_pubkey),
|
|
330
|
+
mints: Array.isArray(parsed.mints) ? parsed.mints : [],
|
|
331
|
+
proofs: Array.isArray(parsed.proofs) ? parsed.proofs : [],
|
|
332
|
+
pending_mints: Array.isArray(parsed.pending_mints) ? parsed.pending_mints : [],
|
|
333
|
+
pending_submissions: Array.isArray(parsed.pending_submissions) ? parsed.pending_submissions : [],
|
|
334
|
+
recent_spends: Array.isArray(parsed.recent_spends) ? parsed.recent_spends : [],
|
|
335
|
+
created_at: typeof parsed.created_at === "string" ? parsed.created_at : (/* @__PURE__ */ new Date()).toISOString()
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
function migrateV5ToV6(parsed) {
|
|
339
|
+
return {
|
|
340
|
+
version: 6,
|
|
341
|
+
lock_privkey: String(parsed.lock_privkey),
|
|
342
|
+
lock_pubkey: String(parsed.lock_pubkey),
|
|
343
|
+
mnemonic_fingerprint: typeof parsed.mnemonic_fingerprint === "string" ? parsed.mnemonic_fingerprint : null,
|
|
344
|
+
mints: Array.isArray(parsed.mints) ? parsed.mints : [],
|
|
345
|
+
proofs: Array.isArray(parsed.proofs) ? parsed.proofs : [],
|
|
346
|
+
nut13_counters: isCounterMap(parsed.nut13_counters) ? parsed.nut13_counters : {},
|
|
347
|
+
pending_mints: Array.isArray(parsed.pending_mints) ? parsed.pending_mints : [],
|
|
348
|
+
pending_submissions: Array.isArray(parsed.pending_submissions) ? parsed.pending_submissions : [],
|
|
349
|
+
recent_spends: Array.isArray(parsed.recent_spends) ? parsed.recent_spends : [],
|
|
350
|
+
created_at: typeof parsed.created_at === "string" ? parsed.created_at : (/* @__PURE__ */ new Date()).toISOString()
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function migrateV6ToV7(parsed) {
|
|
354
|
+
return {
|
|
355
|
+
version: 7,
|
|
356
|
+
lock_privkey: String(parsed.lock_privkey),
|
|
357
|
+
lock_pubkey: String(parsed.lock_pubkey),
|
|
358
|
+
mnemonic_fingerprint: typeof parsed.mnemonic_fingerprint === "string" ? parsed.mnemonic_fingerprint : null,
|
|
359
|
+
mints: Array.isArray(parsed.mints) ? parsed.mints : [],
|
|
360
|
+
proofs: Array.isArray(parsed.proofs) ? parsed.proofs : [],
|
|
361
|
+
nut13_counters: isCounterMap(parsed.nut13_counters) ? parsed.nut13_counters : {},
|
|
362
|
+
pending_mints: Array.isArray(parsed.pending_mints) ? parsed.pending_mints : [],
|
|
363
|
+
pending_submissions: Array.isArray(parsed.pending_submissions) ? parsed.pending_submissions : [],
|
|
364
|
+
recent_spends: Array.isArray(parsed.recent_spends) ? parsed.recent_spends : [],
|
|
365
|
+
created_at: typeof parsed.created_at === "string" ? parsed.created_at : (/* @__PURE__ */ new Date()).toISOString()
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
function migrateV7ToV8(parsed) {
|
|
369
|
+
return {
|
|
370
|
+
version: WALLET_VERSION,
|
|
371
|
+
lock_privkey: String(parsed.lock_privkey),
|
|
372
|
+
lock_pubkey: String(parsed.lock_pubkey),
|
|
373
|
+
mnemonic_fingerprint: typeof parsed.mnemonic_fingerprint === "string" ? parsed.mnemonic_fingerprint : null,
|
|
374
|
+
mints: Array.isArray(parsed.mints) ? parsed.mints : [],
|
|
375
|
+
proofs: Array.isArray(parsed.proofs) ? parsed.proofs : [],
|
|
376
|
+
nut13_counters: isCounterMap(parsed.nut13_counters) ? parsed.nut13_counters : {},
|
|
377
|
+
pending_mints: Array.isArray(parsed.pending_mints) ? parsed.pending_mints : [],
|
|
378
|
+
pending_melts: Array.isArray(parsed.pending_melts) ? parsed.pending_melts : [],
|
|
379
|
+
pending_submissions: Array.isArray(parsed.pending_submissions) ? parsed.pending_submissions : [],
|
|
380
|
+
recent_spends: Array.isArray(parsed.recent_spends) ? parsed.recent_spends : [],
|
|
381
|
+
created_at: typeof parsed.created_at === "string" ? parsed.created_at : (/* @__PURE__ */ new Date()).toISOString()
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
function isCounterMap(value) {
|
|
385
|
+
if (typeof value !== "object" || value === null) return false;
|
|
386
|
+
for (const inner of Object.values(value)) {
|
|
387
|
+
if (typeof inner !== "object" || inner === null) return false;
|
|
388
|
+
for (const n of Object.values(inner)) {
|
|
389
|
+
if (typeof n !== "number" || !Number.isFinite(n)) return false;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// src/lib/wallet-agent/nut13-counters.ts
|
|
396
|
+
var PersistentCounterSource = class {
|
|
397
|
+
constructor(mintUrl) {
|
|
398
|
+
this.mintUrl = mintUrl;
|
|
399
|
+
}
|
|
400
|
+
mintUrl;
|
|
401
|
+
/**
|
|
402
|
+
* Reserve `n` counters for `keysetId`. Returns `{ start, count: n }`
|
|
403
|
+
* and bumps the persisted `next` to `start + n` under the wallet lock.
|
|
404
|
+
*
|
|
405
|
+
* Per cashu-ts contract, `n === 0` MUST NOT mutate state and is a
|
|
406
|
+
* pure peek — we honour that by returning `{ start, count: 0 }`
|
|
407
|
+
* without acquiring the lock.
|
|
408
|
+
*/
|
|
409
|
+
async reserve(keysetId, n) {
|
|
410
|
+
if (n === 0) {
|
|
411
|
+
const wallet = loadAgentWallet();
|
|
412
|
+
const current = wallet?.nut13_counters[this.mintUrl]?.[keysetId] ?? 0;
|
|
413
|
+
return { start: current, count: 0 };
|
|
414
|
+
}
|
|
415
|
+
return withWalletLock(() => {
|
|
416
|
+
const wallet = this.loadOrThrow();
|
|
417
|
+
const current = wallet.nut13_counters[this.mintUrl]?.[keysetId] ?? 0;
|
|
418
|
+
saveAgentWallet(this.withCounter(wallet, keysetId, current + n));
|
|
419
|
+
return { start: current, count: n };
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Monotonic bump: ensure the persisted `next` for `keysetId` is at
|
|
424
|
+
* least `minNext`. No-op if the wallet is already ahead. Used by
|
|
425
|
+
* cashu-ts when the mint reports `outputs_already_signed` — the
|
|
426
|
+
* losing wallet rolls forward past the colliding counter.
|
|
427
|
+
*/
|
|
428
|
+
async advanceToAtLeast(keysetId, minNext) {
|
|
429
|
+
await withWalletLock(() => {
|
|
430
|
+
const wallet = this.loadOrThrow();
|
|
431
|
+
const current = wallet.nut13_counters[this.mintUrl]?.[keysetId] ?? 0;
|
|
432
|
+
if (current >= minNext) return;
|
|
433
|
+
saveAgentWallet(this.withCounter(wallet, keysetId, minNext));
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Snapshot the per-keyset `next` map for this mint. Useful for
|
|
438
|
+
* `dvm wallet show` and test assertions; cashu-ts itself does not
|
|
439
|
+
* depend on `snapshot` being implemented.
|
|
440
|
+
*/
|
|
441
|
+
snapshot() {
|
|
442
|
+
const wallet = loadAgentWallet();
|
|
443
|
+
const counters = wallet?.nut13_counters[this.mintUrl] ?? {};
|
|
444
|
+
const out = {};
|
|
445
|
+
for (const [keyset, next] of Object.entries(counters)) {
|
|
446
|
+
if (typeof next === "number") out[keyset] = next;
|
|
447
|
+
}
|
|
448
|
+
return Promise.resolve(out);
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Hard-set the persisted `next` for `keysetId`. Used by
|
|
452
|
+
* `dvm wallet restore` to re-anchor counters after walking
|
|
453
|
+
* `batchRestore` per keyset. cashu-ts also exposes this on
|
|
454
|
+
* `wallet.counters.setNext`.
|
|
455
|
+
*/
|
|
456
|
+
async setNext(keysetId, next) {
|
|
457
|
+
await withWalletLock(() => {
|
|
458
|
+
const wallet = this.loadOrThrow();
|
|
459
|
+
saveAgentWallet(this.withCounter(wallet, keysetId, next));
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
loadOrThrow() {
|
|
463
|
+
const wallet = loadAgentWallet();
|
|
464
|
+
if (!wallet) {
|
|
465
|
+
throw new DvmError(
|
|
466
|
+
"wallet_missing",
|
|
467
|
+
"NUT-13 counter persistence requires an agent wallet at ~/.dvm/wallet.json.",
|
|
468
|
+
"Run `dvm wallet init` or `dvm wallet recover --file <path>` (or pipe the mnemonic via stdin) first."
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
return wallet;
|
|
472
|
+
}
|
|
473
|
+
withCounter(wallet, keysetId, next) {
|
|
474
|
+
return {
|
|
475
|
+
...wallet,
|
|
476
|
+
nut13_counters: {
|
|
477
|
+
...wallet.nut13_counters,
|
|
478
|
+
[this.mintUrl]: {
|
|
479
|
+
...wallet.nut13_counters[this.mintUrl] ?? {},
|
|
480
|
+
[keysetId]: next
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
// src/lib/cashu/health.ts
|
|
488
|
+
var DEFAULT_REQUIRED_NUTS = [4, 5, 7, 9, 10, 11, 20];
|
|
489
|
+
async function checkMintHealth(mintUrl, opts) {
|
|
490
|
+
const timeoutMs = opts?.timeoutMs ?? 5e3;
|
|
491
|
+
const retries = opts?.retries ?? 3;
|
|
492
|
+
const backoff = opts?.backoffMs ?? [200, 500, 1e3];
|
|
493
|
+
const fetchImpl = opts?.fetchImpl ?? fetch;
|
|
494
|
+
const url = mintUrl.replace(/\/+$/, "") + "/v1/info";
|
|
495
|
+
let lastError;
|
|
496
|
+
let lastLatency = 0;
|
|
497
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
498
|
+
if (attempt > 0) {
|
|
499
|
+
const delay = backoff[Math.min(attempt - 1, backoff.length - 1)];
|
|
500
|
+
await sleep(delay);
|
|
501
|
+
}
|
|
502
|
+
const startedAt = Date.now();
|
|
503
|
+
try {
|
|
504
|
+
const res = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
505
|
+
lastLatency = Date.now() - startedAt;
|
|
506
|
+
if (!res.ok) {
|
|
507
|
+
lastError = { code: "http_error", message: `HTTP ${res.status}` };
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
let info;
|
|
511
|
+
try {
|
|
512
|
+
info = await res.json();
|
|
513
|
+
} catch (err) {
|
|
514
|
+
lastError = {
|
|
515
|
+
code: "parse_error",
|
|
516
|
+
message: err instanceof Error ? err.message : String(err)
|
|
517
|
+
};
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
return parseInfo(mintUrl, info, lastLatency);
|
|
521
|
+
} catch (err) {
|
|
522
|
+
lastLatency = Date.now() - startedAt;
|
|
523
|
+
lastError = classifyFetchError(err);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return {
|
|
527
|
+
mintUrl,
|
|
528
|
+
ok: false,
|
|
529
|
+
latencyMs: lastLatency,
|
|
530
|
+
error: lastError ?? { code: "network", message: "no attempts recorded" }
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
function parseInfo(mintUrl, info, latencyMs) {
|
|
534
|
+
if (!isRecord(info)) {
|
|
535
|
+
return {
|
|
536
|
+
mintUrl,
|
|
537
|
+
ok: false,
|
|
538
|
+
latencyMs,
|
|
539
|
+
error: { code: "parse_error", message: "/v1/info response is not an object" }
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
const pubkey = typeof info.pubkey === "string" ? info.pubkey : void 0;
|
|
543
|
+
const version = typeof info.version === "string" ? info.version : void 0;
|
|
544
|
+
if (!pubkey || !version) {
|
|
545
|
+
const missing = [];
|
|
546
|
+
if (!pubkey) missing.push("pubkey");
|
|
547
|
+
if (!version) missing.push("version");
|
|
548
|
+
return {
|
|
549
|
+
mintUrl,
|
|
550
|
+
ok: false,
|
|
551
|
+
latencyMs,
|
|
552
|
+
pubkey,
|
|
553
|
+
version,
|
|
554
|
+
error: {
|
|
555
|
+
code: "missing_fields",
|
|
556
|
+
message: `NUT-06 missing required fields: ${missing.join(", ")}`
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
const nutsRaw = isRecord(info.nuts) ? info.nuts : {};
|
|
561
|
+
const supportedNuts = Object.keys(nutsRaw).sort(byNutKey);
|
|
562
|
+
const missingRequiredNuts = DEFAULT_REQUIRED_NUTS.map(String).filter((n) => !(n in nutsRaw));
|
|
563
|
+
const bolt11SatMintSupported = hasBolt11Sat(nutsRaw["4"]);
|
|
564
|
+
const bolt11SatMeltSupported = hasBolt11Sat(nutsRaw["5"]);
|
|
565
|
+
return {
|
|
566
|
+
mintUrl,
|
|
567
|
+
ok: true,
|
|
568
|
+
latencyMs,
|
|
569
|
+
pubkey,
|
|
570
|
+
version,
|
|
571
|
+
supportedNuts,
|
|
572
|
+
missingRequiredNuts,
|
|
573
|
+
bolt11SatMintSupported,
|
|
574
|
+
bolt11SatMeltSupported,
|
|
575
|
+
mintingEnabled: canMintBolt11Sat(nutsRaw),
|
|
576
|
+
meltingEnabled: canMeltBolt11Sat(nutsRaw),
|
|
577
|
+
swapEnabled: canSwap(nutsRaw),
|
|
578
|
+
mintAmountBounds: mintAmountBounds(nutsRaw),
|
|
579
|
+
meltAmountBounds: meltAmountBounds(nutsRaw),
|
|
580
|
+
nutCompliant: evaluateNutSupport(nutsRaw).ok,
|
|
581
|
+
nutsRaw
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
function canMintBolt11Sat(nuts) {
|
|
585
|
+
return isMethodEnabled(nuts["4"]);
|
|
586
|
+
}
|
|
587
|
+
function canMeltBolt11Sat(nuts) {
|
|
588
|
+
return isMethodEnabled(nuts["5"]);
|
|
589
|
+
}
|
|
590
|
+
function canSwap(nuts) {
|
|
591
|
+
const node = nuts["3"];
|
|
592
|
+
if (!isRecord(node)) return true;
|
|
593
|
+
if (node.disabled === true) return false;
|
|
594
|
+
return node.supported !== false;
|
|
595
|
+
}
|
|
596
|
+
function isMethodEnabled(node) {
|
|
597
|
+
if (!hasBolt11Sat(node)) return false;
|
|
598
|
+
return node.disabled !== true;
|
|
599
|
+
}
|
|
600
|
+
function mintAmountBounds(nuts) {
|
|
601
|
+
return boundsOf(nuts["4"]);
|
|
602
|
+
}
|
|
603
|
+
function meltAmountBounds(nuts) {
|
|
604
|
+
return boundsOf(nuts["5"]);
|
|
605
|
+
}
|
|
606
|
+
function amountBoundsVerdict(sats, bounds) {
|
|
607
|
+
if (!bounds) return "ok";
|
|
608
|
+
if (bounds.minSats !== null && sats < bounds.minSats) return "below_min";
|
|
609
|
+
if (bounds.maxSats !== null && sats > bounds.maxSats) return "above_max";
|
|
610
|
+
return "ok";
|
|
611
|
+
}
|
|
612
|
+
function boundsOf(node) {
|
|
613
|
+
const method = findBolt11Sat(node);
|
|
614
|
+
return {
|
|
615
|
+
minSats: method ? toBoundSats(method.min_amount) : null,
|
|
616
|
+
maxSats: method ? toBoundSats(method.max_amount) : null
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
function toBoundSats(value) {
|
|
620
|
+
if (typeof value === "bigint") {
|
|
621
|
+
return value >= 0n && value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : null;
|
|
622
|
+
}
|
|
623
|
+
const n = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
|
|
624
|
+
if (!Number.isFinite(n) || n < 0) return null;
|
|
625
|
+
return Math.floor(n);
|
|
626
|
+
}
|
|
627
|
+
function evaluateNutSupport(nuts, requiredNuts = DEFAULT_REQUIRED_NUTS) {
|
|
628
|
+
const missingNuts = requiredNuts.filter((n) => {
|
|
629
|
+
const entry = nuts[String(n)];
|
|
630
|
+
if (entry === void 0 || entry === null) return true;
|
|
631
|
+
return typeof entry === "object" && entry.supported === false;
|
|
632
|
+
});
|
|
633
|
+
const missingBolt11 = [];
|
|
634
|
+
if (!hasBolt11Sat(nuts["4"])) missingBolt11.push("mint");
|
|
635
|
+
if (!hasBolt11Sat(nuts["5"])) missingBolt11.push("melt");
|
|
636
|
+
return {
|
|
637
|
+
ok: missingNuts.length === 0 && missingBolt11.length === 0,
|
|
638
|
+
missingNuts: missingNuts.length > 0 ? [...missingNuts] : void 0,
|
|
639
|
+
missingBolt11: missingBolt11.length > 0 ? missingBolt11 : void 0
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
function assertNutSupport(mintUrl, nuts, requiredNuts = DEFAULT_REQUIRED_NUTS) {
|
|
643
|
+
const verdict = evaluateNutSupport(nuts, requiredNuts);
|
|
644
|
+
if (verdict.missingNuts && verdict.missingNuts.length > 0) {
|
|
645
|
+
throw new DvmError(
|
|
646
|
+
"cashu_nut_support_missing",
|
|
647
|
+
`Mint ${mintUrl} does not support required NUTs: ${verdict.missingNuts.join(", ")}. Required: ${requiredNuts.join(", ")}.`,
|
|
648
|
+
"Choose a mint that supports all required NUT capabilities for P2PK-locked Cashu payments."
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
if (verdict.missingBolt11?.includes("mint")) {
|
|
652
|
+
throw new DvmError(
|
|
653
|
+
"cashu_nut_support_missing",
|
|
654
|
+
`Mint ${mintUrl} does not advertise bolt11/sat method on NUT-4 (mint quotes).`,
|
|
655
|
+
"Choose a mint that advertises bolt11/sat on NUT-4 mint quotes."
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
if (verdict.missingBolt11?.includes("melt")) {
|
|
659
|
+
throw new DvmError(
|
|
660
|
+
"cashu_nut_support_missing",
|
|
661
|
+
`Mint ${mintUrl} does not advertise bolt11/sat method on NUT-5 (melt quotes).`,
|
|
662
|
+
"Choose a mint that advertises bolt11/sat on NUT-5 melt quotes."
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
function hasBolt11Sat(node) {
|
|
667
|
+
return findBolt11Sat(node) !== void 0;
|
|
668
|
+
}
|
|
669
|
+
function findBolt11Sat(node) {
|
|
670
|
+
if (!isRecord(node)) return void 0;
|
|
671
|
+
const methods = node.methods;
|
|
672
|
+
if (!Array.isArray(methods)) return void 0;
|
|
673
|
+
return methods.find((m) => isRecord(m) && m.method === "bolt11" && m.unit === "sat");
|
|
674
|
+
}
|
|
675
|
+
function classifyFetchError(err) {
|
|
676
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
677
|
+
if (err instanceof Error) {
|
|
678
|
+
if (err.name === "TimeoutError" || err.name === "AbortError") {
|
|
679
|
+
return { code: "timeout", message };
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return { code: "network", message };
|
|
683
|
+
}
|
|
684
|
+
function isRecord(value) {
|
|
685
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
686
|
+
}
|
|
687
|
+
function byNutKey(a, b) {
|
|
688
|
+
const an = Number(a);
|
|
689
|
+
const bn = Number(b);
|
|
690
|
+
if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn;
|
|
691
|
+
return a.localeCompare(b);
|
|
692
|
+
}
|
|
693
|
+
function sleep(ms) {
|
|
694
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// src/lib/cashu/wallet.ts
|
|
698
|
+
import { Wallet } from "@cashu/cashu-ts";
|
|
699
|
+
import { mnemonicToSeedSync as mnemonicToSeedSync2 } from "@scure/bip39";
|
|
700
|
+
var walletCache = /* @__PURE__ */ new Map();
|
|
701
|
+
var keysetResidueRegistry = /* @__PURE__ */ new Map();
|
|
702
|
+
var NUT13_MODULUS = BigInt(2 ** 31 - 1);
|
|
703
|
+
function assertNoKeysetCollision(keysets, mintUrl) {
|
|
704
|
+
for (const keyset of keysets) {
|
|
705
|
+
let residue;
|
|
706
|
+
try {
|
|
707
|
+
residue = BigInt("0x" + keyset.id) % NUT13_MODULUS;
|
|
708
|
+
} catch {
|
|
709
|
+
throw new DvmError(
|
|
710
|
+
"cashu_keyset_collision",
|
|
711
|
+
`Mint ${mintUrl} has keyset ID "${keyset.id}" that is not valid hex. Cannot verify NUT-13 collision safety.`,
|
|
712
|
+
"Contact the mint operator to ensure the mint returns standard keyset IDs."
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
const existing = keysetResidueRegistry.get(residue);
|
|
716
|
+
if (existing && existing.keysetId !== keyset.id) {
|
|
717
|
+
throw new DvmError(
|
|
718
|
+
"cashu_keyset_collision",
|
|
719
|
+
`Keyset collision detected: mint ${mintUrl} keyset ${keyset.id} and mint ${existing.mintUrl} keyset ${existing.keysetId} share the same 31-bit NUT-13 residue. Cannot safely derive deterministic secrets.`,
|
|
720
|
+
"Contact the affected mint operators to rotate their keyset IDs."
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
keysetResidueRegistry.set(residue, { keysetId: keyset.id, mintUrl });
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
async function getWallet(mintUrl) {
|
|
727
|
+
const cached = walletCache.get(mintUrl);
|
|
728
|
+
if (cached) return cached;
|
|
729
|
+
return callerLoggers.cashu.span("cashu.get_wallet", { mint_url: mintUrl }, async () => {
|
|
730
|
+
let nut13 = loadNut13Config(mintUrl);
|
|
731
|
+
let wallet = await buildAndLoadWallet(mintUrl, nut13);
|
|
732
|
+
if (nut13) {
|
|
733
|
+
if (wallet.getKeyset().id.startsWith("00")) {
|
|
734
|
+
nut13 = null;
|
|
735
|
+
wallet = await buildAndLoadWallet(mintUrl, null);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
assertNutSupport(mintUrl, wallet.getMintInfo().cache.nuts);
|
|
739
|
+
if (nut13) {
|
|
740
|
+
const restoreKeysets = wallet.keyChain.getKeysets().filter((keyset) => keyset.unit === wallet.unit && !keyset.id.startsWith("00"));
|
|
741
|
+
assertNoKeysetCollision(restoreKeysets, mintUrl);
|
|
742
|
+
}
|
|
743
|
+
walletCache.set(mintUrl, wallet);
|
|
744
|
+
return wallet;
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
async function buildAndLoadWallet(mintUrl, nut13) {
|
|
748
|
+
const wallet = nut13 ? new Wallet(mintUrl, nut13) : new Wallet(mintUrl);
|
|
749
|
+
try {
|
|
750
|
+
await wallet.loadMint();
|
|
751
|
+
} catch {
|
|
752
|
+
throw new DvmError(
|
|
753
|
+
"cashu_mint_unreachable",
|
|
754
|
+
`Cannot connect to mint: ${mintUrl}`,
|
|
755
|
+
"The mint is not responding. Try again later."
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
return wallet;
|
|
759
|
+
}
|
|
760
|
+
function loadNut13Config(mintUrl) {
|
|
761
|
+
const wallet = loadAgentWallet();
|
|
762
|
+
if (!wallet?.mnemonic_fingerprint) return null;
|
|
763
|
+
const mnemonic = readAgentMnemonic();
|
|
764
|
+
if (!mnemonic) return null;
|
|
765
|
+
const actual = mnemonicFingerprint(mnemonic);
|
|
766
|
+
if (actual !== wallet.mnemonic_fingerprint) {
|
|
767
|
+
throw new DvmError(
|
|
768
|
+
"wallet_mnemonic_mismatch",
|
|
769
|
+
"The mnemonic at ~/.dvm/wallet.mnemonic does not match the fingerprint recorded on ~/.dvm/wallet.json.",
|
|
770
|
+
"Either restore the matching mnemonic file from a backup, or run `dvm wallet restore --force --file <path>` (or pipe the mnemonic via stdin) with the wallet's actual mnemonic to rebuild from scratch."
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
return {
|
|
774
|
+
bip39seed: mnemonicToSeedSync2(mnemonic),
|
|
775
|
+
counterSource: new PersistentCounterSource(mintUrl)
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// src/lib/x402/batch-abi.ts
|
|
780
|
+
var X402_CHANNEL_CONFIG_COMPONENTS = [
|
|
781
|
+
{ name: "payer", type: "address" },
|
|
782
|
+
{ name: "payerAuthorizer", type: "address" },
|
|
783
|
+
{ name: "receiver", type: "address" },
|
|
784
|
+
{ name: "receiverAuthorizer", type: "address" },
|
|
785
|
+
{ name: "token", type: "address" },
|
|
786
|
+
{ name: "withdrawDelay", type: "uint40" },
|
|
787
|
+
{ name: "salt", type: "bytes32" }
|
|
788
|
+
];
|
|
789
|
+
var X402_BATCH_CHANNEL_ABI = [
|
|
790
|
+
{
|
|
791
|
+
type: "function",
|
|
792
|
+
name: "initiateWithdraw",
|
|
793
|
+
inputs: [
|
|
794
|
+
{ name: "config", type: "tuple", components: X402_CHANNEL_CONFIG_COMPONENTS },
|
|
795
|
+
{ name: "amount", type: "uint128" }
|
|
796
|
+
],
|
|
797
|
+
outputs: [],
|
|
798
|
+
stateMutability: "nonpayable"
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
type: "function",
|
|
802
|
+
name: "finalizeWithdraw",
|
|
803
|
+
inputs: [{ name: "config", type: "tuple", components: X402_CHANNEL_CONFIG_COMPONENTS }],
|
|
804
|
+
outputs: [],
|
|
805
|
+
stateMutability: "nonpayable"
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
type: "function",
|
|
809
|
+
name: "channels",
|
|
810
|
+
inputs: [{ name: "channelId", type: "bytes32" }],
|
|
811
|
+
outputs: [
|
|
812
|
+
{ name: "balance", type: "uint128" },
|
|
813
|
+
{ name: "totalClaimed", type: "uint128" }
|
|
814
|
+
],
|
|
815
|
+
stateMutability: "view"
|
|
816
|
+
},
|
|
817
|
+
{
|
|
818
|
+
type: "function",
|
|
819
|
+
name: "pendingWithdrawals",
|
|
820
|
+
inputs: [{ name: "channelId", type: "bytes32" }],
|
|
821
|
+
outputs: [
|
|
822
|
+
{ name: "amount", type: "uint128" },
|
|
823
|
+
{ name: "initiatedAt", type: "uint40" }
|
|
824
|
+
],
|
|
825
|
+
stateMutability: "view"
|
|
826
|
+
},
|
|
827
|
+
{
|
|
828
|
+
type: "function",
|
|
829
|
+
name: "refundNonce",
|
|
830
|
+
inputs: [{ name: "channelId", type: "bytes32" }],
|
|
831
|
+
outputs: [{ name: "", type: "uint256" }],
|
|
832
|
+
stateMutability: "view"
|
|
833
|
+
},
|
|
834
|
+
{
|
|
835
|
+
type: "event",
|
|
836
|
+
name: "WithdrawInitiated",
|
|
837
|
+
inputs: [
|
|
838
|
+
{ name: "channelId", type: "bytes32", indexed: true },
|
|
839
|
+
{ name: "amount", type: "uint128", indexed: false },
|
|
840
|
+
{ name: "finalizeAfter", type: "uint40", indexed: false }
|
|
841
|
+
]
|
|
842
|
+
},
|
|
843
|
+
{
|
|
844
|
+
type: "event",
|
|
845
|
+
name: "WithdrawFinalized",
|
|
846
|
+
inputs: [
|
|
847
|
+
{ name: "channelId", type: "bytes32", indexed: true },
|
|
848
|
+
{ name: "sender", type: "address", indexed: true },
|
|
849
|
+
{ name: "amount", type: "uint128", indexed: false }
|
|
850
|
+
]
|
|
851
|
+
}
|
|
852
|
+
];
|
|
853
|
+
|
|
854
|
+
// src/lib/receipt-key.ts
|
|
855
|
+
import { schnorr, secp256k1 } from "@noble/curves/secp256k1.js";
|
|
856
|
+
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
857
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
858
|
+
import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
|
|
859
|
+
var RECEIPT_KDF_SALT = "dvmkit/receipt/v1";
|
|
860
|
+
function deriveReceiptSecret(builderSecretHex, dvmId) {
|
|
861
|
+
const ikm = hexToBytes(builderSecretHex);
|
|
862
|
+
const salt = new TextEncoder().encode(RECEIPT_KDF_SALT);
|
|
863
|
+
for (let counter = 0; counter < 256; counter++) {
|
|
864
|
+
const info = new TextEncoder().encode(
|
|
865
|
+
counter === 0 ? `${RECEIPT_KDF_SALT}/${dvmId}` : `${RECEIPT_KDF_SALT}/${dvmId}#${String(counter)}`
|
|
866
|
+
);
|
|
867
|
+
const candidate = hkdf(sha256, ikm, salt, info, 32);
|
|
868
|
+
if (secp256k1.utils.isValidSecretKey(candidate)) return bytesToHex(candidate);
|
|
869
|
+
}
|
|
870
|
+
throw new Error(`deriveReceiptSecret: no valid secp256k1 scalar for DVM ID "${dvmId}"`);
|
|
871
|
+
}
|
|
872
|
+
function receiptPubkeyFromSecret(secretHex) {
|
|
873
|
+
return bytesToHex(schnorr.getPublicKey(hexToBytes(secretHex)));
|
|
874
|
+
}
|
|
875
|
+
function deriveReceiptKeypair(builderSecretHex, dvmId) {
|
|
876
|
+
const secret = deriveReceiptSecret(builderSecretHex, dvmId);
|
|
877
|
+
return { secret, pubkey: receiptPubkeyFromSecret(secret) };
|
|
878
|
+
}
|
|
879
|
+
function generateEphemeralReceiptKey() {
|
|
880
|
+
const { secretKey, publicKey } = schnorr.keygen();
|
|
881
|
+
return { secret: bytesToHex(secretKey), pubkey: bytesToHex(publicKey) };
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// src/lib/builder-identity.ts
|
|
885
|
+
import {
|
|
886
|
+
chmodSync as chmodSync3,
|
|
887
|
+
existsSync as existsSync5,
|
|
888
|
+
mkdirSync as mkdirSync2,
|
|
889
|
+
readFileSync as readFileSync5,
|
|
890
|
+
renameSync as renameSync4,
|
|
891
|
+
statSync,
|
|
892
|
+
unlinkSync as unlinkSync4,
|
|
893
|
+
writeFileSync as writeFileSync5
|
|
894
|
+
} from "fs";
|
|
895
|
+
import { schnorr as schnorr2 } from "@noble/curves/secp256k1.js";
|
|
896
|
+
import { sha256 as sha2562 } from "@noble/hashes/sha2.js";
|
|
897
|
+
|
|
898
|
+
// src/lib/builder-store.ts
|
|
899
|
+
import {
|
|
900
|
+
chmodSync as chmodSync2,
|
|
901
|
+
existsSync as existsSync4,
|
|
902
|
+
mkdirSync,
|
|
903
|
+
readFileSync as readFileSync4,
|
|
904
|
+
renameSync as renameSync3,
|
|
905
|
+
unlinkSync as unlinkSync3,
|
|
906
|
+
writeFileSync as writeFileSync4
|
|
907
|
+
} from "fs";
|
|
908
|
+
var CURRENT_VERSION = 2;
|
|
909
|
+
function loadBuilderStore() {
|
|
910
|
+
if (!existsSync4(BUILDER_FILE)) {
|
|
911
|
+
return { version: CURRENT_VERSION, dvms: {} };
|
|
912
|
+
}
|
|
913
|
+
const raw = readFileSync4(BUILDER_FILE, "utf-8");
|
|
914
|
+
let parsed;
|
|
915
|
+
try {
|
|
916
|
+
parsed = JSON.parse(raw);
|
|
917
|
+
} catch (err) {
|
|
918
|
+
throw new Error(
|
|
919
|
+
`${BUILDER_FILE} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
920
|
+
{ cause: err }
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
924
|
+
throw new Error(`${BUILDER_FILE} is not a JSON object`);
|
|
925
|
+
}
|
|
926
|
+
const values = parsed;
|
|
927
|
+
const version = values.version;
|
|
928
|
+
if (version === 1) {
|
|
929
|
+
return migrateV1Store(parsed);
|
|
930
|
+
}
|
|
931
|
+
if (version !== CURRENT_VERSION) {
|
|
932
|
+
throw new Error(
|
|
933
|
+
`${BUILDER_FILE} has unknown version ${String(version)} (expected ${CURRENT_VERSION})`
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
if (typeof values.dvms !== "object" || values.dvms === null) {
|
|
937
|
+
throw new Error(`${BUILDER_FILE} is missing the dvms object`);
|
|
938
|
+
}
|
|
939
|
+
return {
|
|
940
|
+
version: CURRENT_VERSION,
|
|
941
|
+
...typeof values.identity_pubkey === "string" ? { identity_pubkey: values.identity_pubkey } : {},
|
|
942
|
+
dvms: values.dvms
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
function loadIdentityPubkey() {
|
|
946
|
+
return loadBuilderStore().identity_pubkey ?? null;
|
|
947
|
+
}
|
|
948
|
+
function setIdentityPubkey(pubkey) {
|
|
949
|
+
mkdirSync(BUILDER_CONFIG_DIR, { recursive: true, mode: 448 });
|
|
950
|
+
const store = loadBuilderStore();
|
|
951
|
+
store.identity_pubkey = pubkey;
|
|
952
|
+
writeBuilderStore(store);
|
|
953
|
+
}
|
|
954
|
+
function writeBuilderStore(store) {
|
|
955
|
+
const content = JSON.stringify(store, null, 2) + "\n";
|
|
956
|
+
backupSecretFile(BUILDER_FILE, content);
|
|
957
|
+
const tmp = `${BUILDER_FILE}.tmp`;
|
|
958
|
+
try {
|
|
959
|
+
writeFileSync4(tmp, content, { mode: 384 });
|
|
960
|
+
renameSync3(tmp, BUILDER_FILE);
|
|
961
|
+
} catch (err) {
|
|
962
|
+
try {
|
|
963
|
+
unlinkSync3(tmp);
|
|
964
|
+
} catch {
|
|
965
|
+
}
|
|
966
|
+
throw err;
|
|
967
|
+
}
|
|
968
|
+
try {
|
|
969
|
+
chmodSync2(BUILDER_FILE, 384);
|
|
970
|
+
} catch {
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
function migrateV1Store(parsed) {
|
|
974
|
+
if (typeof parsed.dvms !== "object" || parsed.dvms === null) {
|
|
975
|
+
throw new Error(`${BUILDER_FILE} is missing the dvms object`);
|
|
976
|
+
}
|
|
977
|
+
const dvms = {};
|
|
978
|
+
for (const [handle, rawEntry] of Object.entries(parsed.dvms)) {
|
|
979
|
+
if (typeof rawEntry !== "object" || rawEntry === null) {
|
|
980
|
+
throw new Error(`${BUILDER_FILE} has an invalid DVM entry for ${handle}`);
|
|
981
|
+
}
|
|
982
|
+
const entry = rawEntry;
|
|
983
|
+
const dvmId = entry.canonical_dvm_id;
|
|
984
|
+
if (typeof dvmId !== "string") {
|
|
985
|
+
throw new Error(`${BUILDER_FILE} has no canonical_dvm_id for ${handle}`);
|
|
986
|
+
}
|
|
987
|
+
if (Object.prototype.hasOwnProperty.call(dvms, dvmId)) {
|
|
988
|
+
throw new Error(`${BUILDER_FILE} has multiple entries for canonical_dvm_id ${dvmId}`);
|
|
989
|
+
}
|
|
990
|
+
dvms[dvmId] = { ...entry, handle };
|
|
991
|
+
}
|
|
992
|
+
return {
|
|
993
|
+
version: CURRENT_VERSION,
|
|
994
|
+
...typeof parsed.identity_pubkey === "string" ? { identity_pubkey: parsed.identity_pubkey } : {},
|
|
995
|
+
dvms
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// src/lib/builder-identity.ts
|
|
1000
|
+
function generateIdentity() {
|
|
1001
|
+
const { secretKey, publicKey } = schnorr2.keygen();
|
|
1002
|
+
return { pubkey: bytesToHex2(publicKey), secret: bytesToHex2(secretKey) };
|
|
1003
|
+
}
|
|
1004
|
+
function loadIdentitySecret() {
|
|
1005
|
+
if (!existsSync5(BUILDER_KEY_FILE)) return null;
|
|
1006
|
+
warnAndTightenLooseKeyMode();
|
|
1007
|
+
const raw = readFileSync5(BUILDER_KEY_FILE, "utf-8").trim();
|
|
1008
|
+
if (!/^[0-9a-f]{64}$/.test(raw)) {
|
|
1009
|
+
throw new DvmError(
|
|
1010
|
+
"identity_secret_malformed",
|
|
1011
|
+
`${BUILDER_KEY_FILE} is not a 32-byte hex secret.`,
|
|
1012
|
+
"Delete the file and re-run `dvmctl identity create` (this rotates the key \u2014 existing DVM attestations need re-deploy)."
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
return raw;
|
|
1016
|
+
}
|
|
1017
|
+
function writeIdentity(keypair, opts = {}) {
|
|
1018
|
+
const existing = loadIdentityPubkey();
|
|
1019
|
+
if (existing && !opts.force) {
|
|
1020
|
+
throw new DvmError(
|
|
1021
|
+
"identity_exists",
|
|
1022
|
+
"A builder identity already exists.",
|
|
1023
|
+
"Pass --force to overwrite. Any DVMs deployed under the old key will need to be re-deployed to refresh their /v1/info attestation."
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
mkdirSync2(BUILDER_CONFIG_DIR, { recursive: true, mode: 448 });
|
|
1027
|
+
const tmp = `${BUILDER_KEY_FILE}.tmp`;
|
|
1028
|
+
writeFileSync5(tmp, keypair.secret + "\n", { mode: 384 });
|
|
1029
|
+
try {
|
|
1030
|
+
renameSync4(tmp, BUILDER_KEY_FILE);
|
|
1031
|
+
} catch (err) {
|
|
1032
|
+
try {
|
|
1033
|
+
unlinkSync4(tmp);
|
|
1034
|
+
} catch {
|
|
1035
|
+
}
|
|
1036
|
+
throw err;
|
|
1037
|
+
}
|
|
1038
|
+
try {
|
|
1039
|
+
chmodSync3(BUILDER_KEY_FILE, 384);
|
|
1040
|
+
} catch {
|
|
1041
|
+
}
|
|
1042
|
+
setIdentityPubkey(keypair.pubkey);
|
|
1043
|
+
}
|
|
1044
|
+
function hashCapabilities(capabilities) {
|
|
1045
|
+
return bytesToHex2(sha2562(canonicaliseForSigning(capabilities)));
|
|
1046
|
+
}
|
|
1047
|
+
function buildAttestation(args) {
|
|
1048
|
+
return {
|
|
1049
|
+
dvm_id: args.dvmId,
|
|
1050
|
+
slug: args.slug,
|
|
1051
|
+
capabilities_hash: hashCapabilities(args.capabilities),
|
|
1052
|
+
builder_pubkey: args.builderPubkey,
|
|
1053
|
+
...args.receiptPubkey ? { receipt_pubkey: args.receiptPubkey } : {},
|
|
1054
|
+
deployed_at: args.deployedAt ?? Math.floor(Date.now() / 1e3)
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
function signAttestation(payload, secretHex) {
|
|
1058
|
+
const secretBytes = hexToBytes2(secretHex);
|
|
1059
|
+
const canonicalBytes = canonicaliseForSigning(payload);
|
|
1060
|
+
return bytesToHex2(schnorr2.sign(canonicalBytes, secretBytes));
|
|
1061
|
+
}
|
|
1062
|
+
function verifyAttestation(payload, signatureHex, pubkeyHex) {
|
|
1063
|
+
try {
|
|
1064
|
+
const canonicalBytes = canonicaliseForSigning(payload);
|
|
1065
|
+
return schnorr2.verify(hexToBytes2(signatureHex), canonicalBytes, hexToBytes2(pubkeyHex));
|
|
1066
|
+
} catch {
|
|
1067
|
+
return false;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
function warnAndTightenLooseKeyMode() {
|
|
1071
|
+
if (process.platform === "win32") return;
|
|
1072
|
+
try {
|
|
1073
|
+
const mode = statSync(BUILDER_KEY_FILE).mode & 511;
|
|
1074
|
+
if ((mode & 63) === 0) return;
|
|
1075
|
+
console.warn(
|
|
1076
|
+
JSON.stringify({
|
|
1077
|
+
level: "warn",
|
|
1078
|
+
event: "builder_identity_key_loose_mode",
|
|
1079
|
+
message: `${BUILDER_KEY_FILE} is group/other-readable (mode ${mode.toString(8).padStart(3, "0")}). The builder signing key should be owner-only \u2014 re-tightening to 0600. A cp/scp/git checkout can widen the mode; verify nobody else read it in the meantime.`,
|
|
1080
|
+
path: BUILDER_KEY_FILE,
|
|
1081
|
+
mode: mode.toString(8).padStart(3, "0")
|
|
1082
|
+
})
|
|
1083
|
+
);
|
|
1084
|
+
chmodSync3(BUILDER_KEY_FILE, 384);
|
|
1085
|
+
} catch {
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
function bytesToHex2(bytes) {
|
|
1089
|
+
let s = "";
|
|
1090
|
+
for (const b of bytes) s += b.toString(16).padStart(2, "0");
|
|
1091
|
+
return s;
|
|
1092
|
+
}
|
|
1093
|
+
function hexToBytes2(hex) {
|
|
1094
|
+
if (hex.length % 2 !== 0) {
|
|
1095
|
+
throw new Error(`hex string has odd length: ${String(hex.length)}`);
|
|
1096
|
+
}
|
|
1097
|
+
const out = new Uint8Array(hex.length / 2);
|
|
1098
|
+
for (let i = 0; i < out.length; i++) {
|
|
1099
|
+
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
1100
|
+
}
|
|
1101
|
+
return out;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// src/lib/receipt.ts
|
|
1105
|
+
import { schnorr as schnorr3 } from "@noble/curves/secp256k1.js";
|
|
1106
|
+
import { sha256 as sha2563 } from "@noble/hashes/sha2.js";
|
|
1107
|
+
import { bytesToHex as bytesToHex3, hexToBytes as hexToBytes3 } from "@noble/hashes/utils.js";
|
|
1108
|
+
function computeResultHash(summary, artifactContents) {
|
|
1109
|
+
const artifactHashes = artifactContents.map(
|
|
1110
|
+
(content) => bytesToHex3(sha2563(canonicaliseForSigning(content)))
|
|
1111
|
+
);
|
|
1112
|
+
return bytesToHex3(
|
|
1113
|
+
sha2563(
|
|
1114
|
+
canonicaliseForSigning({
|
|
1115
|
+
summary: summary ?? null,
|
|
1116
|
+
artifact_hashes: artifactHashes
|
|
1117
|
+
})
|
|
1118
|
+
)
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
function signReceipt(unsigned, secretHex) {
|
|
1122
|
+
const bytes = canonicaliseForSigning(unsigned);
|
|
1123
|
+
const signature = bytesToHex3(schnorr3.sign(bytes, hexToBytes3(secretHex)));
|
|
1124
|
+
return { ...unsigned, signature };
|
|
1125
|
+
}
|
|
1126
|
+
function verifyReceipt(receipt) {
|
|
1127
|
+
try {
|
|
1128
|
+
const { signature, ...unsigned } = receipt;
|
|
1129
|
+
const bytes = canonicaliseForSigning(unsigned);
|
|
1130
|
+
return schnorr3.verify(hexToBytes3(signature), bytes, hexToBytes3(receipt.receipt_pubkey));
|
|
1131
|
+
} catch {
|
|
1132
|
+
return false;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
function isSignedJobReceipt(value) {
|
|
1136
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1137
|
+
const r = value;
|
|
1138
|
+
const paid = r.paid;
|
|
1139
|
+
return r.v === 1 && typeof r.job_id === "string" && typeof r.dvm_id === "string" && typeof r.dvm === "string" && typeof r.capability === "string" && (r.outcome === "completed" || r.outcome === "failed" || r.outcome === "cancelled") && typeof r.seq === "number" && typeof r.issued_at === "number" && typeof r.receipt_pubkey === "string" && typeof r.signature === "string" && !!paid && typeof paid === "object" && !Array.isArray(paid) && typeof paid.msats === "number";
|
|
1140
|
+
}
|
|
1141
|
+
function signDrainReceipt(unsigned, secretHex) {
|
|
1142
|
+
const bytes = canonicaliseForSigning(unsigned);
|
|
1143
|
+
const signature = bytesToHex3(schnorr3.sign(bytes, hexToBytes3(secretHex)));
|
|
1144
|
+
return { ...unsigned, signature };
|
|
1145
|
+
}
|
|
1146
|
+
function signFundingReceipt(unsigned, secretHex) {
|
|
1147
|
+
const bytes = canonicaliseForSigning(unsigned);
|
|
1148
|
+
const signature = bytesToHex3(schnorr3.sign(bytes, hexToBytes3(secretHex)));
|
|
1149
|
+
return { ...unsigned, signature };
|
|
1150
|
+
}
|
|
1151
|
+
function isFundingReceipt(value) {
|
|
1152
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1153
|
+
const r = value;
|
|
1154
|
+
return r.v === 1 && r.kind === "credit_funding" && typeof r.credit_id === "string" && typeof r.fund_id === "string" && typeof r.funded_micro === "number" && typeof r.balance_after_micro === "number" && typeof r.ledger_seq === "number" && typeof r.issued_at === "number" && typeof r.caller_pubkey === "string" && typeof r.dvm_id === "string" && typeof r.dvm === "string" && typeof r.receipt_pubkey === "string" && typeof r.signature === "string";
|
|
1155
|
+
}
|
|
1156
|
+
function verifyFundingReceipt(receipt) {
|
|
1157
|
+
try {
|
|
1158
|
+
const { signature, ...unsigned } = receipt;
|
|
1159
|
+
const bytes = canonicaliseForSigning(unsigned);
|
|
1160
|
+
return schnorr3.verify(hexToBytes3(signature), bytes, hexToBytes3(receipt.receipt_pubkey));
|
|
1161
|
+
} catch {
|
|
1162
|
+
return false;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
function isDrainReceipt(value) {
|
|
1166
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1167
|
+
const r = value;
|
|
1168
|
+
return r.v === 1 && r.kind === "credit_drain" && typeof r.credit_id === "string" && typeof r.drain_id === "string" && (r.event === "requested" || r.event === "parked" || r.event === "picked_up" || r.event === "sent" || r.event === "released") && typeof r.method === "string" && typeof r.amount_micro === "number" && typeof r.issued_at === "number" && typeof r.caller_pubkey === "string" && typeof r.dvm_id === "string" && typeof r.dvm === "string" && typeof r.receipt_pubkey === "string" && typeof r.signature === "string";
|
|
1169
|
+
}
|
|
1170
|
+
function verifyDrainReceipt(receipt) {
|
|
1171
|
+
try {
|
|
1172
|
+
const { signature, ...unsigned } = receipt;
|
|
1173
|
+
const bytes = canonicaliseForSigning(unsigned);
|
|
1174
|
+
return schnorr3.verify(hexToBytes3(signature), bytes, hexToBytes3(receipt.receipt_pubkey));
|
|
1175
|
+
} catch {
|
|
1176
|
+
return false;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// src/lib/receipt-verify.ts
|
|
1181
|
+
function attestedDvmIdentity(builder) {
|
|
1182
|
+
const builderPubkey = authenticatedBuilderPubkey(builder);
|
|
1183
|
+
if (!builderPubkey) return void 0;
|
|
1184
|
+
const dvmId = builder.attestation.dvm_id;
|
|
1185
|
+
if (typeof dvmId !== "string" || dvmId.length === 0) return void 0;
|
|
1186
|
+
return { dvmId, builderPubkey };
|
|
1187
|
+
}
|
|
1188
|
+
function authenticatedBuilderPubkey(builder) {
|
|
1189
|
+
if (!isTrustAnchor(builder)) return void 0;
|
|
1190
|
+
const pubkey = builder.pubkey.trim().toLowerCase();
|
|
1191
|
+
const attestedPubkey = builder.attestation.builder_pubkey;
|
|
1192
|
+
if (!/^[0-9a-f]{64}$/.test(pubkey) || typeof attestedPubkey !== "string" || attestedPubkey.trim().toLowerCase() !== pubkey) {
|
|
1193
|
+
return void 0;
|
|
1194
|
+
}
|
|
1195
|
+
const payload = builder.attestation;
|
|
1196
|
+
return verifyAttestation(payload, builder.signature, pubkey) ? pubkey : void 0;
|
|
1197
|
+
}
|
|
1198
|
+
function verifyJobReceipt(args) {
|
|
1199
|
+
const { receipt, builder, expectedJobId, delivered } = args;
|
|
1200
|
+
const checks = {
|
|
1201
|
+
job_id: checkJobId(receipt, expectedJobId),
|
|
1202
|
+
signature: verifyReceipt(receipt),
|
|
1203
|
+
attestation: "absent",
|
|
1204
|
+
result_hash: "unchecked"
|
|
1205
|
+
};
|
|
1206
|
+
if (checks.job_id === "mismatch") {
|
|
1207
|
+
return { status: "invalid", checks, reason: "job_id_mismatch" };
|
|
1208
|
+
}
|
|
1209
|
+
if (!checks.signature) {
|
|
1210
|
+
return { status: "invalid", checks, reason: "signature_invalid" };
|
|
1211
|
+
}
|
|
1212
|
+
checks.attestation = checkReceiptAttestation(receipt, builder);
|
|
1213
|
+
if (checks.attestation === "invalid") {
|
|
1214
|
+
return { status: "invalid", checks, reason: "attestation_invalid" };
|
|
1215
|
+
}
|
|
1216
|
+
if (checks.attestation === "identity_mismatch") {
|
|
1217
|
+
return { status: "invalid", checks, reason: "dvm_identity_mismatch" };
|
|
1218
|
+
}
|
|
1219
|
+
if (checks.attestation === "key_mismatch") {
|
|
1220
|
+
return { status: "invalid", checks, reason: "receipt_key_mismatch" };
|
|
1221
|
+
}
|
|
1222
|
+
checks.result_hash = checkResultHash(receipt, delivered);
|
|
1223
|
+
if (checks.result_hash === "mismatch") {
|
|
1224
|
+
return { status: "invalid", checks, reason: "result_hash_mismatch" };
|
|
1225
|
+
}
|
|
1226
|
+
return {
|
|
1227
|
+
status: checks.attestation === "ok" ? "verified" : "verified_unattested",
|
|
1228
|
+
checks
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
function checkReceiptAttestation(receipt, builder) {
|
|
1232
|
+
if (!isTrustAnchor(builder)) return "absent";
|
|
1233
|
+
const payload = builder.attestation;
|
|
1234
|
+
if (!authenticatedBuilderPubkey(builder)) return "invalid";
|
|
1235
|
+
if (typeof payload.dvm_id !== "string" || payload.dvm_id !== receipt.dvm_id) {
|
|
1236
|
+
return "identity_mismatch";
|
|
1237
|
+
}
|
|
1238
|
+
const attested = builder.attestation.receipt_pubkey;
|
|
1239
|
+
if (typeof attested !== "string" || attested.length === 0) return "absent";
|
|
1240
|
+
return attested.toLowerCase() === receipt.receipt_pubkey.toLowerCase() ? "ok" : "key_mismatch";
|
|
1241
|
+
}
|
|
1242
|
+
function verifyDrainReceiptChain(args) {
|
|
1243
|
+
const { receipt, builder, expectedDrainId, expectedCreditId } = args;
|
|
1244
|
+
const named = isDrainReceipt(receipt) ? receipt : void 0;
|
|
1245
|
+
const checks = {
|
|
1246
|
+
drain_id: checkDrainField(named?.drain_id, expectedDrainId),
|
|
1247
|
+
credit_id: checkDrainField(named?.credit_id, expectedCreditId),
|
|
1248
|
+
signature: named !== void 0 && verifyDrainReceipt(named),
|
|
1249
|
+
attestation: "absent"
|
|
1250
|
+
};
|
|
1251
|
+
if (checks.drain_id === "mismatch") {
|
|
1252
|
+
return { status: "invalid", checks, reason: "drain_id_mismatch" };
|
|
1253
|
+
}
|
|
1254
|
+
if (checks.credit_id === "mismatch") {
|
|
1255
|
+
return { status: "invalid", checks, reason: "credit_id_mismatch" };
|
|
1256
|
+
}
|
|
1257
|
+
if (named === void 0 || !checks.signature) {
|
|
1258
|
+
return { status: "invalid", checks, reason: "signature_invalid" };
|
|
1259
|
+
}
|
|
1260
|
+
checks.attestation = checkReceiptAttestation(named, builder);
|
|
1261
|
+
if (checks.attestation === "invalid") {
|
|
1262
|
+
return { status: "invalid", checks, reason: "attestation_invalid" };
|
|
1263
|
+
}
|
|
1264
|
+
if (checks.attestation === "identity_mismatch") {
|
|
1265
|
+
return { status: "invalid", checks, reason: "dvm_identity_mismatch" };
|
|
1266
|
+
}
|
|
1267
|
+
if (checks.attestation === "key_mismatch") {
|
|
1268
|
+
return { status: "invalid", checks, reason: "receipt_key_mismatch" };
|
|
1269
|
+
}
|
|
1270
|
+
return {
|
|
1271
|
+
status: checks.attestation === "ok" ? "verified" : "verified_unattested",
|
|
1272
|
+
checks
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
function verifyFundingReceiptChain(args) {
|
|
1276
|
+
const named = isFundingReceipt(args.receipt) ? args.receipt : void 0;
|
|
1277
|
+
const check = (actual, expected) => expected === void 0 ? "unchecked" : actual === expected ? "ok" : "mismatch";
|
|
1278
|
+
const checks = {
|
|
1279
|
+
credit_id: check(named?.credit_id, args.expectedCreditId),
|
|
1280
|
+
fund_id: check(named?.fund_id, args.expectedFundId),
|
|
1281
|
+
funded_micro: check(named?.funded_micro, args.expectedFundedMicro),
|
|
1282
|
+
balance_after_micro: check(named?.balance_after_micro, args.expectedBalanceAfterMicro),
|
|
1283
|
+
ledger_seq: check(named?.ledger_seq, args.expectedLedgerSeq),
|
|
1284
|
+
caller_pubkey: check(named?.caller_pubkey, args.expectedCallerPubkey),
|
|
1285
|
+
signature: named !== void 0 && verifyFundingReceipt(named),
|
|
1286
|
+
attestation: "absent"
|
|
1287
|
+
};
|
|
1288
|
+
const mismatch = [
|
|
1289
|
+
["credit_id", "credit_id_mismatch"],
|
|
1290
|
+
["fund_id", "fund_id_mismatch"],
|
|
1291
|
+
["funded_micro", "funding_amount_mismatch"],
|
|
1292
|
+
["balance_after_micro", "funding_balance_mismatch"],
|
|
1293
|
+
["ledger_seq", "funding_ledger_seq_mismatch"],
|
|
1294
|
+
["caller_pubkey", "caller_pubkey_mismatch"]
|
|
1295
|
+
];
|
|
1296
|
+
for (const [field, reason] of mismatch) {
|
|
1297
|
+
if (checks[field] === "mismatch") return { status: "invalid", checks, reason, receipt: named };
|
|
1298
|
+
}
|
|
1299
|
+
if (!named || !checks.signature) {
|
|
1300
|
+
return { status: "invalid", checks, reason: "signature_invalid", receipt: named };
|
|
1301
|
+
}
|
|
1302
|
+
checks.attestation = checkReceiptAttestation(named, args.builder);
|
|
1303
|
+
if (checks.attestation === "invalid") {
|
|
1304
|
+
return { status: "invalid", checks, reason: "attestation_invalid", receipt: named };
|
|
1305
|
+
}
|
|
1306
|
+
if (checks.attestation === "identity_mismatch") {
|
|
1307
|
+
return { status: "invalid", checks, reason: "dvm_identity_mismatch", receipt: named };
|
|
1308
|
+
}
|
|
1309
|
+
if (checks.attestation === "key_mismatch") {
|
|
1310
|
+
return { status: "invalid", checks, reason: "receipt_key_mismatch", receipt: named };
|
|
1311
|
+
}
|
|
1312
|
+
return {
|
|
1313
|
+
status: checks.attestation === "ok" ? "verified" : "verified_unattested",
|
|
1314
|
+
checks,
|
|
1315
|
+
receipt: named
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
function weakestReceiptVerdict(statuses) {
|
|
1319
|
+
if (statuses.length === 0) return "absent";
|
|
1320
|
+
if (statuses.includes("invalid")) return "invalid";
|
|
1321
|
+
if (statuses.includes("absent")) return "absent";
|
|
1322
|
+
if (statuses.includes("verified_unattested")) return "verified_unattested";
|
|
1323
|
+
return "verified";
|
|
1324
|
+
}
|
|
1325
|
+
function drainReceiptsDisplay(status, sound, total) {
|
|
1326
|
+
const tally = `${String(sound)} of ${String(total)}`;
|
|
1327
|
+
switch (status) {
|
|
1328
|
+
case "verified":
|
|
1329
|
+
return `Verified reclaim receipts (${tally}): signed by the key this DVM's builder identity attests.`;
|
|
1330
|
+
case "verified_unattested":
|
|
1331
|
+
return `Reclaim receipts, signed but unattested (${tally}): the signature holds, but this DVM publishes no builder identity to vouch for the signing key.`;
|
|
1332
|
+
case "invalid":
|
|
1333
|
+
return `UNVERIFIED reclaim receipts: only ${tally} hold up \u2014 the refund is still owed, but this evidence is not sound.`;
|
|
1334
|
+
case "absent":
|
|
1335
|
+
return "No reclaim receipts: this DVM returned no signed evidence of the drain.";
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
function drainReceiptsHint(status) {
|
|
1339
|
+
switch (status) {
|
|
1340
|
+
case "verified":
|
|
1341
|
+
return "The refund evidence chains to this DVM's published builder identity, and is stored locally with that identity pinned beside it \u2014 so an unhonoured refund stays provable against the builder, not against an anonymous key.";
|
|
1342
|
+
case "verified_unattested":
|
|
1343
|
+
return "The receipts are signed, but nothing vouches for the signing key \u2014 expected for a locally-run or development DVM, not for a deployed one. Stored either way; the refund is owed regardless.";
|
|
1344
|
+
case "invalid":
|
|
1345
|
+
return "The refund is unaffected \u2014 this is a problem with the proof, not the money. Keep this output, and treat the reclaim as unattested when deciding how much to leave on deposit at this DVM.";
|
|
1346
|
+
case "absent":
|
|
1347
|
+
return "This DVM countersigned nothing, so the only artifact behind this reclaim is the drain request the caller signed. Weaker evidence than a countersigned drain, and worth weighing before funding here again.";
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
function receiptDisplay(receipt, status, rate = 0) {
|
|
1351
|
+
const what = `${receipt.dvm}/${receipt.capability} (DVM ID ${receipt.dvm_id})`;
|
|
1352
|
+
const when = receiptIssuedAtIso(receipt)?.slice(0, 10);
|
|
1353
|
+
return `${VERDICT_PREFIX[status]}: paid ${formatPaid(receipt.paid.msats, rate)} for ${what}, ${receipt.outcome}${when ? ` ${when}` : ""}.`;
|
|
1354
|
+
}
|
|
1355
|
+
function receiptIssuedAtIso(receipt) {
|
|
1356
|
+
const seconds = receipt.issued_at;
|
|
1357
|
+
return typeof seconds === "number" ? epochMsToIso(seconds * 1e3) : null;
|
|
1358
|
+
}
|
|
1359
|
+
function epochMsToIso(ms) {
|
|
1360
|
+
if (typeof ms !== "number" || !Number.isFinite(ms) || Math.abs(ms) > MAX_TIMESTAMP_MS) {
|
|
1361
|
+
return null;
|
|
1362
|
+
}
|
|
1363
|
+
return new Date(ms).toISOString();
|
|
1364
|
+
}
|
|
1365
|
+
function receiptHint(status, jobId) {
|
|
1366
|
+
const verifyCmd = jobId ? `'dvm receipts verify ${jobId}'` : "'dvm receipts verify <job-id>'";
|
|
1367
|
+
switch (status) {
|
|
1368
|
+
case "verified":
|
|
1369
|
+
return `Proof of purchase is stored locally. Re-check it any time with ${verifyCmd}, or hand over the raw bundle with 'dvm receipts export'.`;
|
|
1370
|
+
case "verified_unattested":
|
|
1371
|
+
return `The receipt's signature is valid, but this DVM publishes no builder identity to vouch for the signing key \u2014 expected for a locally-run or development DVM, not for a deployed one. Stored either way; inspect it with ${verifyCmd}.`;
|
|
1372
|
+
case "invalid":
|
|
1373
|
+
return `The job itself is unaffected \u2014 this is a problem with the proof, not the result. Run ${verifyCmd} to see which check failed before relying on this receipt as evidence.`;
|
|
1374
|
+
case "absent":
|
|
1375
|
+
return "This DVM does not issue signed receipts, so there is no proof of purchase to store. Nothing went wrong.";
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
function receiptFailureDetail(reason) {
|
|
1379
|
+
switch (reason) {
|
|
1380
|
+
case "job_id_mismatch":
|
|
1381
|
+
return "The receipt names a different job than the one it was collected for \u2014 it is not proof of this job, whoever signed it.";
|
|
1382
|
+
case "drain_id_mismatch":
|
|
1383
|
+
return "The receipt names a different reclaim than the one it is filed under \u2014 it is not evidence of this drain, whoever signed it.";
|
|
1384
|
+
case "credit_id_mismatch":
|
|
1385
|
+
return "The receipt names a different credit than the one this reclaim was against \u2014 it is not evidence of this refund, whoever signed it.";
|
|
1386
|
+
case "fund_id_mismatch":
|
|
1387
|
+
return "The receipt names a different funding operation than the caller submitted.";
|
|
1388
|
+
case "funding_amount_mismatch":
|
|
1389
|
+
return "The signed funded amount differs from the provider's funding response.";
|
|
1390
|
+
case "funding_balance_mismatch":
|
|
1391
|
+
return "The signed post-funding balance differs from the provider's fresh balance response.";
|
|
1392
|
+
case "funding_ledger_seq_mismatch":
|
|
1393
|
+
return "The signed funding sequence differs from the provider's fresh balance response.";
|
|
1394
|
+
case "caller_pubkey_mismatch":
|
|
1395
|
+
return "The funding receipt names a different caller than the key that submitted the funding.";
|
|
1396
|
+
case "signature_invalid":
|
|
1397
|
+
return "The receipt's signature does not verify under the key it names \u2014 the bytes were altered, or it was never signed by that key.";
|
|
1398
|
+
case "attestation_invalid":
|
|
1399
|
+
return "The DVM's published builder attestation does not verify under its own builder pubkey.";
|
|
1400
|
+
case "dvm_identity_mismatch":
|
|
1401
|
+
return "The receipt names a different immutable DVM ID than the builder attestation, so it is not evidence for this provider.";
|
|
1402
|
+
case "receipt_key_mismatch":
|
|
1403
|
+
return "The receipt was signed by a different key than the one the DVM's builder attested.";
|
|
1404
|
+
case "result_hash_mismatch":
|
|
1405
|
+
return "The receipt attests a different result than the one delivered.";
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
var MAX_TIMESTAMP_MS = 864e13;
|
|
1409
|
+
function checkJobId(receipt, expectedJobId) {
|
|
1410
|
+
if (expectedJobId === void 0) return "unchecked";
|
|
1411
|
+
return receipt.job_id === expectedJobId ? "ok" : "mismatch";
|
|
1412
|
+
}
|
|
1413
|
+
function checkDrainField(actual, expected) {
|
|
1414
|
+
if (expected === void 0 || actual === void 0) return "unchecked";
|
|
1415
|
+
return actual === expected ? "ok" : "mismatch";
|
|
1416
|
+
}
|
|
1417
|
+
var VERDICT_PREFIX = {
|
|
1418
|
+
verified: "Verified receipt",
|
|
1419
|
+
verified_unattested: "Receipt (signed, unattested)",
|
|
1420
|
+
invalid: "UNVERIFIED receipt",
|
|
1421
|
+
absent: "No receipt"
|
|
1422
|
+
};
|
|
1423
|
+
function checkResultHash(receipt, delivered) {
|
|
1424
|
+
if (!delivered) return "unchecked";
|
|
1425
|
+
if (receipt.result_hash === null) return "unchecked";
|
|
1426
|
+
const summary = receipt.outcome === "completed" ? delivered.summary : void 0;
|
|
1427
|
+
let recomputed;
|
|
1428
|
+
try {
|
|
1429
|
+
recomputed = computeResultHash(summary, delivered.artifactContents);
|
|
1430
|
+
} catch {
|
|
1431
|
+
return "unchecked";
|
|
1432
|
+
}
|
|
1433
|
+
return recomputed === receipt.result_hash ? "ok" : "mismatch";
|
|
1434
|
+
}
|
|
1435
|
+
function isTrustAnchor(value) {
|
|
1436
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1437
|
+
const anchor = value;
|
|
1438
|
+
return typeof anchor.pubkey === "string" && typeof anchor.signature === "string" && !!anchor.attestation && typeof anchor.attestation === "object" && !Array.isArray(anchor.attestation);
|
|
1439
|
+
}
|
|
1440
|
+
function formatPaid(msats, rate) {
|
|
1441
|
+
if (msats === 0) return "nothing (free job)";
|
|
1442
|
+
const sats = msats % 1e3 === 0 ? msats / 1e3 : Math.round(msats / 1e3 * 100) / 100;
|
|
1443
|
+
const satsLabel = `${sats.toLocaleString("en-US")} sats`;
|
|
1444
|
+
if (rate <= 0) return satsLabel;
|
|
1445
|
+
const usd = msatsToUsd(msats, rate);
|
|
1446
|
+
return `~$${formatUsd(usd)} (${satsLabel}, ${costAnchor(usd)})`;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// src/lib/request-id.ts
|
|
1450
|
+
var MAX_REQUEST_ID_LENGTH = 128;
|
|
1451
|
+
var REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
|
|
1452
|
+
function isPublicRequestId(value) {
|
|
1453
|
+
return typeof value === "string" && value.length > 0 && value.length <= MAX_REQUEST_ID_LENGTH && REQUEST_ID_PATTERN.test(value);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// src/lib/traceparent.ts
|
|
1457
|
+
var TRACEPARENT_RE = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
|
1458
|
+
var ALL_ZERO_TRACE = "00000000000000000000000000000000";
|
|
1459
|
+
var ALL_ZERO_SPAN = "0000000000000000";
|
|
1460
|
+
function parseTraceparent(header) {
|
|
1461
|
+
if (!header) return null;
|
|
1462
|
+
const match = TRACEPARENT_RE.exec(header.trim());
|
|
1463
|
+
if (!match) return null;
|
|
1464
|
+
const [, traceId, parentSpanId, flagsHex] = match;
|
|
1465
|
+
if (traceId === ALL_ZERO_TRACE || parentSpanId === ALL_ZERO_SPAN) return null;
|
|
1466
|
+
return { traceId, parentSpanId, flags: parseInt(flagsHex, 16) };
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// src/lib/wallet-agent/keypair.ts
|
|
1470
|
+
import { secp256k1 as secp256k12 } from "@noble/curves/secp256k1.js";
|
|
1471
|
+
var PRIVKEY_HEX_RE = /^[0-9a-f]{64}$/i;
|
|
1472
|
+
function derivePubkeyFromPrivkey(privkey) {
|
|
1473
|
+
if (!PRIVKEY_HEX_RE.test(privkey)) {
|
|
1474
|
+
throw new DvmError(
|
|
1475
|
+
"invalid_privkey",
|
|
1476
|
+
"Privkey must be 64 hex characters (32 bytes).",
|
|
1477
|
+
"Provide a valid secp256k1 private key as a 64-char hex string."
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
const bytes = hexToBytes4(privkey);
|
|
1481
|
+
if (!secp256k12.utils.isValidSecretKey(bytes)) {
|
|
1482
|
+
throw new DvmError(
|
|
1483
|
+
"invalid_privkey",
|
|
1484
|
+
"Privkey is not a valid secp256k1 scalar (zero or out of curve order).",
|
|
1485
|
+
"Generate a fresh key with: dvm wallet init"
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
const pub = secp256k12.getPublicKey(bytes);
|
|
1489
|
+
return bytesToHex4(pub);
|
|
1490
|
+
}
|
|
1491
|
+
function bytesToHex4(bytes) {
|
|
1492
|
+
return Buffer.from(bytes).toString("hex");
|
|
1493
|
+
}
|
|
1494
|
+
function hexToBytes4(hex) {
|
|
1495
|
+
return Uint8Array.from(Buffer.from(hex, "hex"));
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// src/lib/wallet-agent/sign.ts
|
|
1499
|
+
import { schnorr as schnorr4 } from "@noble/curves/secp256k1.js";
|
|
1500
|
+
var PUBKEY_COMPRESSED_HEX_RE = /^0[23][0-9a-f]{64}$/i;
|
|
1501
|
+
var PUBKEY_XONLY_HEX_RE = /^[0-9a-f]{64}$/i;
|
|
1502
|
+
var SIG_HEX_RE = /^[0-9a-f]{128}$/i;
|
|
1503
|
+
function signChallenge(privkeyHex, challenge) {
|
|
1504
|
+
if (!PRIVKEY_HEX_RE.test(privkeyHex)) {
|
|
1505
|
+
throw new DvmError(
|
|
1506
|
+
"invalid_privkey",
|
|
1507
|
+
"Privkey must be 64 hex characters (32 bytes).",
|
|
1508
|
+
"Provide a valid secp256k1 private key as a 64-char hex string."
|
|
1509
|
+
);
|
|
1510
|
+
}
|
|
1511
|
+
if (challenge.length !== 32) {
|
|
1512
|
+
throw new DvmError(
|
|
1513
|
+
"invalid_challenge",
|
|
1514
|
+
`Challenge must be exactly 32 bytes; got ${challenge.length}.`,
|
|
1515
|
+
"Pass the SHA-256 digest output (32 bytes)."
|
|
1516
|
+
);
|
|
1517
|
+
}
|
|
1518
|
+
const sig = schnorr4.sign(challenge, hexToBytes5(privkeyHex));
|
|
1519
|
+
return bytesToHex5(sig);
|
|
1520
|
+
}
|
|
1521
|
+
function verifyChallenge(pubkeyHex, challenge, sigHex) {
|
|
1522
|
+
if (!SIG_HEX_RE.test(sigHex)) return false;
|
|
1523
|
+
if (challenge.length !== 32) return false;
|
|
1524
|
+
let xonly;
|
|
1525
|
+
if (PUBKEY_COMPRESSED_HEX_RE.test(pubkeyHex)) {
|
|
1526
|
+
xonly = hexToBytes5(pubkeyHex.slice(2));
|
|
1527
|
+
} else if (PUBKEY_XONLY_HEX_RE.test(pubkeyHex)) {
|
|
1528
|
+
xonly = hexToBytes5(pubkeyHex);
|
|
1529
|
+
} else {
|
|
1530
|
+
return false;
|
|
1531
|
+
}
|
|
1532
|
+
try {
|
|
1533
|
+
return schnorr4.verify(hexToBytes5(sigHex), challenge, xonly);
|
|
1534
|
+
} catch {
|
|
1535
|
+
return false;
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
function hexToBytes5(hex) {
|
|
1539
|
+
return Uint8Array.from(Buffer.from(hex, "hex"));
|
|
1540
|
+
}
|
|
1541
|
+
function bytesToHex5(bytes) {
|
|
1542
|
+
return Buffer.from(bytes).toString("hex");
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// src/lib/cashu/admin-auth.ts
|
|
1546
|
+
import { randomBytes } from "crypto";
|
|
1547
|
+
import { sha256 as sha2564 } from "@noble/hashes/sha2.js";
|
|
1548
|
+
var ADMIN_AUTH_NONCE_TTL_SECONDS = 300;
|
|
1549
|
+
var ADMIN_AUTH_CLOCK_SKEW_SECONDS = 30;
|
|
1550
|
+
var ADMIN_STATE_UNAVAILABLE = "admin_state_unavailable";
|
|
1551
|
+
function buildAdminChallenge(args) {
|
|
1552
|
+
const msg = `dvmkit-admin:${args.method}:${args.path}:${args.dvmId}:${args.nonceHex}:${args.unixSeconds}`;
|
|
1553
|
+
return sha2564(new TextEncoder().encode(msg));
|
|
1554
|
+
}
|
|
1555
|
+
function buildAdminAuthHeader(args) {
|
|
1556
|
+
const nowMs = args.now ? args.now() : Date.now();
|
|
1557
|
+
const unixSeconds = Math.floor(nowMs / 1e3);
|
|
1558
|
+
const nonceBytes = args.randomNonce ? args.randomNonce() : Uint8Array.from(randomBytes(16));
|
|
1559
|
+
const nonceHex = Buffer.from(nonceBytes).toString("hex");
|
|
1560
|
+
const challenge = buildAdminChallenge({
|
|
1561
|
+
method: args.method,
|
|
1562
|
+
path: args.path,
|
|
1563
|
+
dvmId: args.dvmId,
|
|
1564
|
+
nonceHex,
|
|
1565
|
+
unixSeconds
|
|
1566
|
+
});
|
|
1567
|
+
const sigHex = signChallenge(args.privkeyHex, challenge);
|
|
1568
|
+
return {
|
|
1569
|
+
header: `P2PK ${nonceHex}.${unixSeconds}.${sigHex}`,
|
|
1570
|
+
nonceHex,
|
|
1571
|
+
unixSeconds
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// src/lib/monotonic-clock.ts
|
|
1576
|
+
function createMonotonicClock() {
|
|
1577
|
+
let anchorWallMs = Date.now();
|
|
1578
|
+
let anchorMonoMs = performance.now();
|
|
1579
|
+
return () => {
|
|
1580
|
+
const wallMs = Date.now();
|
|
1581
|
+
const monoMs = performance.now();
|
|
1582
|
+
const projectedMs = anchorWallMs + (monoMs - anchorMonoMs);
|
|
1583
|
+
const nowMs = wallMs >= projectedMs ? wallMs : projectedMs;
|
|
1584
|
+
anchorWallMs = nowMs;
|
|
1585
|
+
anchorMonoMs = monoMs;
|
|
1586
|
+
return Math.floor(nowMs);
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
// src/lib/cashu/verify.ts
|
|
1591
|
+
import { getTokenMetadata } from "@cashu/cashu-ts";
|
|
1592
|
+
function verifyCashuToken(token, acceptedMints) {
|
|
1593
|
+
let mint, unit, totalMsats;
|
|
1594
|
+
try {
|
|
1595
|
+
const meta = getTokenMetadata(token);
|
|
1596
|
+
mint = meta.mint;
|
|
1597
|
+
unit = meta.unit;
|
|
1598
|
+
totalMsats = meta.amount.toNumber() * 1e3;
|
|
1599
|
+
} catch {
|
|
1600
|
+
throw new DvmError(
|
|
1601
|
+
"cashu_invalid_token",
|
|
1602
|
+
"The Cashu token could not be decoded.",
|
|
1603
|
+
"Ensure the token is a valid cashuA... or cashuB... string."
|
|
1604
|
+
);
|
|
1605
|
+
}
|
|
1606
|
+
const normalised = acceptedMints.map(normaliseMintUrl);
|
|
1607
|
+
if (!normalised.includes(normaliseMintUrl(mint))) {
|
|
1608
|
+
throw new DvmError(
|
|
1609
|
+
"cashu_mint_not_accepted",
|
|
1610
|
+
`Mint ${mint} is not accepted by this provider.`,
|
|
1611
|
+
`Accepted mints: ${acceptedMints.join(", ")}`
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1614
|
+
return { mint, totalMsats, unit };
|
|
1615
|
+
}
|
|
1616
|
+
function normaliseMintUrl(url) {
|
|
1617
|
+
try {
|
|
1618
|
+
const u = new URL(url);
|
|
1619
|
+
u.hostname = u.hostname.toLowerCase();
|
|
1620
|
+
u.pathname = u.pathname.replace(/\/+/g, "/").replace(/\/+$/, "") || "/";
|
|
1621
|
+
return u.toString().replace(/\/$/, "");
|
|
1622
|
+
} catch {
|
|
1623
|
+
return url.toLowerCase().replace(/\/+$/, "");
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// src/lib/payment-rails/tempo-wire.ts
|
|
1628
|
+
var TEMPO_SESSION_UPDATED_REASON = "Tempo session updated; attach the next voucher to fund this credit.";
|
|
1629
|
+
|
|
1630
|
+
export {
|
|
1631
|
+
toLockPubkey,
|
|
1632
|
+
isX402SettlementReconciliationReason,
|
|
1633
|
+
verifyCashuToken,
|
|
1634
|
+
normaliseMintUrl,
|
|
1635
|
+
TEMPO_SESSION_UPDATED_REASON,
|
|
1636
|
+
backupSecretFile,
|
|
1637
|
+
readAgentMnemonic,
|
|
1638
|
+
assertAgentMnemonicReplaceable,
|
|
1639
|
+
writeAgentMnemonic,
|
|
1640
|
+
deleteAgentMnemonic,
|
|
1641
|
+
generateAgentMnemonic,
|
|
1642
|
+
deriveAgentMasterKeypair,
|
|
1643
|
+
validateAgentMnemonic,
|
|
1644
|
+
looksLikeMnemonic,
|
|
1645
|
+
mnemonicFingerprint,
|
|
1646
|
+
WALLET_VERSION,
|
|
1647
|
+
RECENT_SPENDS_CAP,
|
|
1648
|
+
DEFAULT_AGENT_MINT_URL,
|
|
1649
|
+
loadAgentWallet,
|
|
1650
|
+
saveAgentWallet,
|
|
1651
|
+
walletExists,
|
|
1652
|
+
withWalletLock,
|
|
1653
|
+
effectiveMints,
|
|
1654
|
+
effectiveFundingMint,
|
|
1655
|
+
PersistentCounterSource,
|
|
1656
|
+
checkMintHealth,
|
|
1657
|
+
canSwap,
|
|
1658
|
+
meltAmountBounds,
|
|
1659
|
+
amountBoundsVerdict,
|
|
1660
|
+
assertNutSupport,
|
|
1661
|
+
getWallet,
|
|
1662
|
+
X402_BATCH_CHANNEL_ABI,
|
|
1663
|
+
deriveReceiptSecret,
|
|
1664
|
+
receiptPubkeyFromSecret,
|
|
1665
|
+
deriveReceiptKeypair,
|
|
1666
|
+
generateEphemeralReceiptKey,
|
|
1667
|
+
generateIdentity,
|
|
1668
|
+
loadIdentitySecret,
|
|
1669
|
+
writeIdentity,
|
|
1670
|
+
hashCapabilities,
|
|
1671
|
+
buildAttestation,
|
|
1672
|
+
signAttestation,
|
|
1673
|
+
verifyAttestation,
|
|
1674
|
+
computeResultHash,
|
|
1675
|
+
signReceipt,
|
|
1676
|
+
verifyReceipt,
|
|
1677
|
+
isSignedJobReceipt,
|
|
1678
|
+
signDrainReceipt,
|
|
1679
|
+
signFundingReceipt,
|
|
1680
|
+
isFundingReceipt,
|
|
1681
|
+
verifyFundingReceipt,
|
|
1682
|
+
isDrainReceipt,
|
|
1683
|
+
verifyDrainReceipt,
|
|
1684
|
+
attestedDvmIdentity,
|
|
1685
|
+
authenticatedBuilderPubkey,
|
|
1686
|
+
verifyJobReceipt,
|
|
1687
|
+
checkReceiptAttestation,
|
|
1688
|
+
verifyDrainReceiptChain,
|
|
1689
|
+
verifyFundingReceiptChain,
|
|
1690
|
+
weakestReceiptVerdict,
|
|
1691
|
+
drainReceiptsDisplay,
|
|
1692
|
+
drainReceiptsHint,
|
|
1693
|
+
receiptDisplay,
|
|
1694
|
+
receiptIssuedAtIso,
|
|
1695
|
+
epochMsToIso,
|
|
1696
|
+
receiptHint,
|
|
1697
|
+
receiptFailureDetail,
|
|
1698
|
+
isPublicRequestId,
|
|
1699
|
+
parseTraceparent,
|
|
1700
|
+
PRIVKEY_HEX_RE,
|
|
1701
|
+
derivePubkeyFromPrivkey,
|
|
1702
|
+
verifyChallenge,
|
|
1703
|
+
ADMIN_AUTH_NONCE_TTL_SECONDS,
|
|
1704
|
+
ADMIN_AUTH_CLOCK_SKEW_SECONDS,
|
|
1705
|
+
ADMIN_STATE_UNAVAILABLE,
|
|
1706
|
+
buildAdminChallenge,
|
|
1707
|
+
buildAdminAuthHeader,
|
|
1708
|
+
createMonotonicClock
|
|
1709
|
+
};
|