@dvmkit/sdk 0.1.0-rc.4 → 0.1.0-rc.6
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/dist/{chunk-F2L6KIMD.js → chunk-5URG56JJ.js} +5 -143
- package/dist/{chunk-EDU6COY2.js → chunk-7AKBC4PW.js} +754 -150
- package/dist/chunk-ANFX5HEG.js +186 -0
- package/dist/{chunk-4UVRXDNY.js → chunk-CMDI3ENK.js} +342 -176
- package/dist/{chunk-GJD7PVWY.js → chunk-FJDCFHW5.js} +26 -12
- package/dist/{chunk-AAJNGQMC.js → chunk-JGGI65I3.js} +1 -1
- package/dist/{chunk-2K6UXDAN.js → chunk-KSQEFVFJ.js} +623 -691
- package/dist/{chunk-XYTSDAPH.js → chunk-LDTWX7JW.js} +1 -1
- package/dist/chunk-MKI6OVW4.js +194 -0
- package/dist/{chunk-AZBXSXQT.js → chunk-P4RUVDU7.js} +1 -0
- package/dist/chunk-TSWKITGR.js +772 -0
- package/dist/{credit-menu-ClA1JyYW.d.ts → credit-menu-CYkETVM4.d.ts} +22 -3
- package/dist/{fx-ptKVFOwq.d.ts → fx-Pf4Ey4f_.d.ts} +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -2
- package/dist/internal/index.d.ts +4974 -85
- package/dist/internal/index.js +3742 -167
- package/dist/{job-store-DxFqDPYq.d.ts → job-store-BtCaLnvJ.d.ts} +23 -1
- package/dist/server/index.d.ts +5 -5
- package/dist/server/index.js +8 -21
- package/dist/tempo-lifecycle-SQL3KLEZ.js +13 -0
- package/dist/tempo-wallet-QOLEIPCV.js +31 -0
- package/dist/testing/index.d.ts +1 -1
- package/dist/{usd-Cha_j80I.d.ts → usd-D7fW2S7I.d.ts} +1 -1
- package/dist/{x402-XXFQQAAD.js → x402-FTG2GRAQ.js} +6 -4
- package/package.json +1 -1
|
@@ -1,40 +1,279 @@
|
|
|
1
|
+
import {
|
|
2
|
+
connectedTempoAccount,
|
|
3
|
+
tempoBalanceCheckHint,
|
|
4
|
+
tempoTokenReference
|
|
5
|
+
} from "./chunk-ANFX5HEG.js";
|
|
1
6
|
import {
|
|
2
7
|
fundingReachableAt,
|
|
3
|
-
isValidEvmPrivateKey,
|
|
4
8
|
listCredits,
|
|
5
|
-
loadConfig,
|
|
6
9
|
maxAdvertisedMicro,
|
|
7
10
|
normalizeCreditEndpoint,
|
|
8
11
|
withinAdvertisedTolerance
|
|
9
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-KSQEFVFJ.js";
|
|
13
|
+
import {
|
|
14
|
+
costAnchor,
|
|
15
|
+
formatUsd,
|
|
16
|
+
formatUsdcMicrounits
|
|
17
|
+
} from "./chunk-5URG56JJ.js";
|
|
10
18
|
import {
|
|
11
19
|
DvmError,
|
|
12
20
|
IDENTITIES_FILE,
|
|
13
21
|
TEMPO_CHANNELS_FILE,
|
|
14
|
-
costAnchor,
|
|
15
22
|
ensureConfigDir,
|
|
16
|
-
formatUsd,
|
|
17
23
|
resolveRpcHttpTransportOptions,
|
|
18
24
|
resolveRpcOverride
|
|
19
|
-
} from "./chunk-
|
|
25
|
+
} from "./chunk-MKI6OVW4.js";
|
|
20
26
|
import {
|
|
21
27
|
redactUrl,
|
|
22
28
|
redactUrlsInText
|
|
23
29
|
} from "./chunk-FUJ36YDV.js";
|
|
24
30
|
|
|
31
|
+
// src/lib/cli-identity-store.ts
|
|
32
|
+
import { schnorr } from "@noble/curves/secp256k1.js";
|
|
33
|
+
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "fs";
|
|
34
|
+
import lockfile from "proper-lockfile";
|
|
35
|
+
var DEFAULT_LOCK_STALE_MS = 3e4;
|
|
36
|
+
var DEFAULT_LOCK_RETRIES = 30;
|
|
37
|
+
var DEFAULT_LOCK_RETRY_INTERVAL_MS = 1e3;
|
|
38
|
+
var IDENTITIES_VERSION = 1;
|
|
39
|
+
function identitiesFileExists() {
|
|
40
|
+
return existsSync(IDENTITIES_FILE);
|
|
41
|
+
}
|
|
42
|
+
function loadIdentities() {
|
|
43
|
+
if (!existsSync(IDENTITIES_FILE)) return null;
|
|
44
|
+
const raw = readFileSync(IDENTITIES_FILE, "utf-8");
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(raw);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
throw new DvmError(
|
|
50
|
+
"identities_corrupt",
|
|
51
|
+
`Identities file at ${IDENTITIES_FILE} is not valid JSON.`,
|
|
52
|
+
`Restore a backup or delete the file and run 'dvm init'. Underlying error: ${err instanceof Error ? err.message : String(err)}`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) {
|
|
56
|
+
throw new DvmError(
|
|
57
|
+
"identities_corrupt",
|
|
58
|
+
`Identities file at ${IDENTITIES_FILE} is missing a version field.`,
|
|
59
|
+
"Restore a backup or delete the file and run 'dvm init'."
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const version = parsed.version;
|
|
63
|
+
if (version !== IDENTITIES_VERSION) {
|
|
64
|
+
throw new DvmError(
|
|
65
|
+
"identities_unknown_version",
|
|
66
|
+
`Unknown identities file version ${String(version)} (this CLI understands version ${String(IDENTITIES_VERSION)}).`,
|
|
67
|
+
"Upgrade dvm CLI: a newer version wrote this file."
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
const identities = parsed.identities;
|
|
71
|
+
if (typeof identities !== "object" || identities === null || Array.isArray(identities)) {
|
|
72
|
+
throw new DvmError(
|
|
73
|
+
"identities_corrupt",
|
|
74
|
+
`Identities file at ${IDENTITIES_FILE} has a malformed identities map.`,
|
|
75
|
+
"Restore a backup or delete the file and run 'dvm init'."
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
version: IDENTITIES_VERSION,
|
|
80
|
+
identities
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function saveIdentities(file) {
|
|
84
|
+
ensureConfigDir();
|
|
85
|
+
const tmp = `${IDENTITIES_FILE}.tmp`;
|
|
86
|
+
const data = JSON.stringify(file, null, 2) + "\n";
|
|
87
|
+
writeFileSync(tmp, data, { mode: 384 });
|
|
88
|
+
try {
|
|
89
|
+
renameSync(tmp, IDENTITIES_FILE);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
try {
|
|
92
|
+
unlinkSync(tmp);
|
|
93
|
+
} catch {
|
|
94
|
+
}
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function withIdentitiesLock(fn, opts = {}) {
|
|
99
|
+
ensureConfigDir();
|
|
100
|
+
const lockPath = `${IDENTITIES_FILE}.lock`;
|
|
101
|
+
const stale = opts.stale ?? DEFAULT_LOCK_STALE_MS;
|
|
102
|
+
const retries = opts.retries ?? DEFAULT_LOCK_RETRIES;
|
|
103
|
+
const interval = opts.retryIntervalMs ?? DEFAULT_LOCK_RETRY_INTERVAL_MS;
|
|
104
|
+
let release;
|
|
105
|
+
try {
|
|
106
|
+
release = await lockfile.lock(IDENTITIES_FILE, {
|
|
107
|
+
lockfilePath: lockPath,
|
|
108
|
+
stale,
|
|
109
|
+
realpath: false,
|
|
110
|
+
retries: { retries, factor: 1, minTimeout: interval, maxTimeout: interval }
|
|
111
|
+
});
|
|
112
|
+
} catch (err) {
|
|
113
|
+
throw new DvmError(
|
|
114
|
+
"identities_locked",
|
|
115
|
+
`Could not acquire the identities lock at ${lockPath} within the retry budget.`,
|
|
116
|
+
`Another dvm CLI may be writing to ${IDENTITIES_FILE}. Wait a moment and retry. If no other dvm CLI is running, remove ${lockPath} and try again.`,
|
|
117
|
+
{ lock_file: lockPath, underlying: err instanceof Error ? err.message : String(err) }
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
return await fn();
|
|
122
|
+
} finally {
|
|
123
|
+
try {
|
|
124
|
+
await release();
|
|
125
|
+
} catch {
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function generateIdentity(now = Date.now()) {
|
|
130
|
+
const { secretKey, publicKey } = schnorr.keygen();
|
|
131
|
+
return {
|
|
132
|
+
privkey: bytesToHex(secretKey),
|
|
133
|
+
pubkey: bytesToHex(publicKey),
|
|
134
|
+
createdAt: now
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function deriveIdentityFromPrivkey(privkeyHex, now = Date.now()) {
|
|
138
|
+
if (!/^[0-9a-f]{64}$/.test(privkeyHex)) {
|
|
139
|
+
throw new DvmError(
|
|
140
|
+
"invalid_privkey",
|
|
141
|
+
"Private key must be 64 lowercase hex characters (32 bytes).",
|
|
142
|
+
"Generate a fresh one with 'dvm identity create <name>'."
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const privkeyBytes = hexToBytes(privkeyHex);
|
|
146
|
+
const pubkeyBytes = schnorr.getPublicKey(privkeyBytes);
|
|
147
|
+
return {
|
|
148
|
+
privkey: privkeyHex,
|
|
149
|
+
pubkey: bytesToHex(pubkeyBytes),
|
|
150
|
+
createdAt: now
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function bytesToHex(bytes) {
|
|
154
|
+
let s = "";
|
|
155
|
+
for (const b of bytes) s += b.toString(16).padStart(2, "0");
|
|
156
|
+
return s;
|
|
157
|
+
}
|
|
158
|
+
function hexToBytes(hex) {
|
|
159
|
+
const out = new Uint8Array(hex.length / 2);
|
|
160
|
+
for (let i = 0; i < out.length; i++) {
|
|
161
|
+
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/lib/cli-identity-resolve.ts
|
|
167
|
+
function resolveSigningIdentity(opts) {
|
|
168
|
+
if (opts.as !== void 0) {
|
|
169
|
+
const file2 = loadIdentities();
|
|
170
|
+
const entry = file2?.identities[opts.as];
|
|
171
|
+
if (!entry) {
|
|
172
|
+
throw new DvmError(
|
|
173
|
+
"auth_required",
|
|
174
|
+
`No identity named '${opts.as}' is configured.`,
|
|
175
|
+
availableHint(file2?.identities, "create one with 'dvm identity create <name>'.")
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
privkey: entry.privkey,
|
|
180
|
+
pubkey: entry.pubkey,
|
|
181
|
+
source: { kind: "flag", name: opts.as }
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const envKey = process.env.DVM_IDENTITY_KEY;
|
|
185
|
+
if (envKey) {
|
|
186
|
+
const derived = deriveIdentityFromPrivkey(envKey.toLowerCase());
|
|
187
|
+
return { privkey: derived.privkey, pubkey: derived.pubkey, source: { kind: "env" } };
|
|
188
|
+
}
|
|
189
|
+
const file = loadIdentities();
|
|
190
|
+
const identities = file?.identities ?? {};
|
|
191
|
+
const names = Object.keys(identities);
|
|
192
|
+
const configuredDefault = opts.config?.defaultIdentity;
|
|
193
|
+
if (configuredDefault !== void 0) {
|
|
194
|
+
if (!Object.hasOwn(identities, configuredDefault)) {
|
|
195
|
+
throw new DvmError(
|
|
196
|
+
"auth_required",
|
|
197
|
+
`Configured default identity '${configuredDefault}' no longer exists.`,
|
|
198
|
+
availableHint(identities, "set a new default with 'dvm identity use <name>'.")
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const entry = identities[configuredDefault];
|
|
202
|
+
return {
|
|
203
|
+
privkey: entry.privkey,
|
|
204
|
+
pubkey: entry.pubkey,
|
|
205
|
+
source: { kind: "default", name: configuredDefault }
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (names.length === 1) {
|
|
209
|
+
const onlyName = names[0];
|
|
210
|
+
const entry = identities[onlyName];
|
|
211
|
+
return {
|
|
212
|
+
privkey: entry.privkey,
|
|
213
|
+
pubkey: entry.pubkey,
|
|
214
|
+
source: { kind: "sole", name: onlyName }
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
if (names.length === 0) {
|
|
218
|
+
throw new DvmError(
|
|
219
|
+
"auth_required",
|
|
220
|
+
"This DVM requires a signed request but no caller identity is configured.",
|
|
221
|
+
"Run 'dvm identity create <name>' (or 'dvm init') to generate one, or pass DVM_IDENTITY_KEY=<hex>."
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
throw new DvmError(
|
|
225
|
+
"auth_required",
|
|
226
|
+
"Multiple identities are configured but none is selected.",
|
|
227
|
+
`Pass --as <name>, set a default with 'dvm identity use <name>', or export DVM_IDENTITY_KEY=<hex>. Available: ${names.join(", ")}.`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
function findSigningIdentityByPubkey(pubkey) {
|
|
231
|
+
const wanted = pubkey.toLowerCase();
|
|
232
|
+
const envKey = process.env.DVM_IDENTITY_KEY;
|
|
233
|
+
if (envKey) {
|
|
234
|
+
try {
|
|
235
|
+
const derived = deriveIdentityFromPrivkey(envKey.toLowerCase());
|
|
236
|
+
if (derived.pubkey === wanted) {
|
|
237
|
+
return { privkey: derived.privkey, pubkey: derived.pubkey, source: { kind: "env" } };
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const identities = loadIdentities()?.identities ?? {};
|
|
243
|
+
for (const [name, entry] of Object.entries(identities)) {
|
|
244
|
+
if (entry.pubkey.toLowerCase() === wanted) {
|
|
245
|
+
return { privkey: entry.privkey, pubkey: entry.pubkey, source: { kind: "pubkey", name } };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
function availableHint(identities, suffix) {
|
|
251
|
+
const names = identities ? Object.keys(identities) : [];
|
|
252
|
+
if (names.length === 0) return `No identities are configured \u2014 ${suffix}`;
|
|
253
|
+
return `Available identities: ${names.join(", ")}. ${suffix}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// src/lib/shell-quote.ts
|
|
257
|
+
function shellQuoteArg(value) {
|
|
258
|
+
if (value === "") return "''";
|
|
259
|
+
if (SAFE_VALUE_RE.test(value)) return value;
|
|
260
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
261
|
+
}
|
|
262
|
+
var SAFE_VALUE_RE = /^[a-zA-Z0-9_\-./@:=,+%]+$/;
|
|
263
|
+
|
|
25
264
|
// src/lib/payment-rails/tempo-channel-store.ts
|
|
26
265
|
import { AsyncLocalStorage } from "async_hooks";
|
|
27
266
|
import { randomUUID } from "crypto";
|
|
28
|
-
import { existsSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
267
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
29
268
|
import { Session as TempoSession } from "mppx/tempo";
|
|
30
|
-
import
|
|
269
|
+
import lockfile2 from "proper-lockfile";
|
|
31
270
|
import { isAddress, zeroAddress } from "viem";
|
|
32
271
|
var TEMPO_CHARGE_PENDING_STALE_MS = 24 * 60 * 60 * 1e3;
|
|
33
272
|
var tempoLockOwner = new AsyncLocalStorage();
|
|
34
273
|
async function withTempoChannelLock(operation) {
|
|
35
274
|
if (tempoLockOwner.getStore()?.active) return operation();
|
|
36
275
|
ensureTempoChannelFile();
|
|
37
|
-
const release = await
|
|
276
|
+
const release = await lockfile2.lock(TEMPO_CHANNELS_FILE, {
|
|
38
277
|
realpath: false,
|
|
39
278
|
retries: { retries: 20, minTimeout: 25, maxTimeout: 250 }
|
|
40
279
|
});
|
|
@@ -191,6 +430,14 @@ function tempoChannelControlledBy(controller, address) {
|
|
|
191
430
|
const candidate = address.toLowerCase();
|
|
192
431
|
return controller.payer.toLowerCase() === candidate || controller.authorizedSigner.toLowerCase() === candidate;
|
|
193
432
|
}
|
|
433
|
+
async function retireTempoChannel(association) {
|
|
434
|
+
await createTempoChannelStore({
|
|
435
|
+
callerPubkey: association.callerPubkey,
|
|
436
|
+
endpoint: association.endpoint,
|
|
437
|
+
creditId: association.creditId,
|
|
438
|
+
operator: association.operator
|
|
439
|
+
}).delete(association.channelKey);
|
|
440
|
+
}
|
|
194
441
|
async function retireTempoChannelScope(association) {
|
|
195
442
|
await createTempoChannelStore({
|
|
196
443
|
callerPubkey: association.callerPubkey,
|
|
@@ -280,7 +527,7 @@ function tryWithTempoChannelLockSync(operation) {
|
|
|
280
527
|
ensureTempoChannelFile();
|
|
281
528
|
let release;
|
|
282
529
|
try {
|
|
283
|
-
release =
|
|
530
|
+
release = lockfile2.lockSync(TEMPO_CHANNELS_FILE, { realpath: false });
|
|
284
531
|
} catch (error) {
|
|
285
532
|
if (error.code === "ELOCKED") return void 0;
|
|
286
533
|
throw error;
|
|
@@ -295,9 +542,9 @@ function tryWithTempoChannelLockSync(operation) {
|
|
|
295
542
|
}
|
|
296
543
|
function ensureTempoChannelFile() {
|
|
297
544
|
ensureConfigDir();
|
|
298
|
-
if (
|
|
545
|
+
if (existsSync2(TEMPO_CHANNELS_FILE)) return;
|
|
299
546
|
try {
|
|
300
|
-
|
|
547
|
+
writeFileSync2(TEMPO_CHANNELS_FILE, `${JSON.stringify(emptyFile(), null, 2)}
|
|
301
548
|
`, {
|
|
302
549
|
mode: 384,
|
|
303
550
|
flag: "wx"
|
|
@@ -306,9 +553,9 @@ function ensureTempoChannelFile() {
|
|
|
306
553
|
}
|
|
307
554
|
}
|
|
308
555
|
function loadFile() {
|
|
309
|
-
if (!
|
|
556
|
+
if (!existsSync2(TEMPO_CHANNELS_FILE)) return emptyFile();
|
|
310
557
|
try {
|
|
311
|
-
const parsed = JSON.parse(
|
|
558
|
+
const parsed = JSON.parse(readFileSync2(TEMPO_CHANNELS_FILE, "utf8"));
|
|
312
559
|
if (parsed.version === 1) return migrateLegacyFile(parsed);
|
|
313
560
|
if (parsed.version !== 2 || !isStringRecord(parsed.values) || !Array.isArray(parsed.associations) || !parsed.associations.every(isTempoChannelAssociation)) {
|
|
314
561
|
throw new Error("unsupported Tempo channel store shape");
|
|
@@ -334,8 +581,8 @@ function loadFile() {
|
|
|
334
581
|
function saveFile(file) {
|
|
335
582
|
ensureConfigDir();
|
|
336
583
|
const tmp = `${TEMPO_CHANNELS_FILE}.${randomUUID()}.tmp`;
|
|
337
|
-
|
|
338
|
-
|
|
584
|
+
writeFileSync2(tmp, JSON.stringify(file, null, 2) + "\n", { mode: 384 });
|
|
585
|
+
renameSync2(tmp, TEMPO_CHANNELS_FILE);
|
|
339
586
|
}
|
|
340
587
|
function emptyFile() {
|
|
341
588
|
return { version: 2, values: {}, associations: [], charges: [] };
|
|
@@ -397,15 +644,19 @@ function isLegacyTempoChannelAssociation(value) {
|
|
|
397
644
|
return typeof association.endpoint === "string" && typeof association.callerPubkey === "string" && typeof association.creditId === "string" && typeof association.channelKey === "string" && typeof association.channelId === "string" && typeof association.cumulativeAmount === "string" && typeof association.deposit === "string" && typeof association.chainId === "number" && typeof association.opened === "boolean" && typeof association.updatedAt === "number";
|
|
398
645
|
}
|
|
399
646
|
|
|
400
|
-
// src/lib/
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
}
|
|
406
|
-
|
|
647
|
+
// src/lib/payment-rails/tempo-session.ts
|
|
648
|
+
import { Credential } from "mppx";
|
|
649
|
+
import { Mppx } from "mppx/client";
|
|
650
|
+
import { Session as TempoSession2 } from "mppx/tempo";
|
|
651
|
+
import { decodeFunctionData, formatUnits, isAddress as isAddress2, zeroAddress as zeroAddress2 } from "viem";
|
|
652
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
653
|
+
import { Transaction } from "viem/tempo";
|
|
407
654
|
|
|
408
655
|
// src/lib/rail-labels.ts
|
|
656
|
+
var PAYMENT_RAIL_KEYS = ["cashu", "cashu_p2pk", "x402", "tempo"];
|
|
657
|
+
function isPaymentRailKey(value) {
|
|
658
|
+
return typeof value === "string" && PAYMENT_RAIL_KEYS.includes(value);
|
|
659
|
+
}
|
|
409
660
|
function fundingRailLabel(rail) {
|
|
410
661
|
switch (rail) {
|
|
411
662
|
case "cashu":
|
|
@@ -418,145 +669,80 @@ function fundingRailLabel(rail) {
|
|
|
418
669
|
return "Tempo stablecoin";
|
|
419
670
|
}
|
|
420
671
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
let parsed;
|
|
431
|
-
try {
|
|
432
|
-
parsed = JSON.parse(raw);
|
|
433
|
-
} catch (err) {
|
|
434
|
-
throw new DvmError(
|
|
435
|
-
"identities_corrupt",
|
|
436
|
-
`Identities file at ${IDENTITIES_FILE} is not valid JSON.`,
|
|
437
|
-
`Restore a backup or delete the file and run 'dvm init'. Underlying error: ${err instanceof Error ? err.message : String(err)}`
|
|
438
|
-
);
|
|
439
|
-
}
|
|
440
|
-
if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) {
|
|
441
|
-
throw new DvmError(
|
|
442
|
-
"identities_corrupt",
|
|
443
|
-
`Identities file at ${IDENTITIES_FILE} is missing a version field.`,
|
|
444
|
-
"Restore a backup or delete the file and run 'dvm init'."
|
|
445
|
-
);
|
|
446
|
-
}
|
|
447
|
-
const version = parsed.version;
|
|
448
|
-
if (version !== IDENTITIES_VERSION) {
|
|
449
|
-
throw new DvmError(
|
|
450
|
-
"identities_unknown_version",
|
|
451
|
-
`Unknown identities file version ${String(version)} (this CLI understands version ${String(IDENTITIES_VERSION)}).`,
|
|
452
|
-
"Upgrade dvm CLI: a newer version wrote this file."
|
|
453
|
-
);
|
|
454
|
-
}
|
|
455
|
-
const identities = parsed.identities;
|
|
456
|
-
if (typeof identities !== "object" || identities === null || Array.isArray(identities)) {
|
|
457
|
-
throw new DvmError(
|
|
458
|
-
"identities_corrupt",
|
|
459
|
-
`Identities file at ${IDENTITIES_FILE} has a malformed identities map.`,
|
|
460
|
-
"Restore a backup or delete the file and run 'dvm init'."
|
|
461
|
-
);
|
|
462
|
-
}
|
|
463
|
-
return {
|
|
464
|
-
version: IDENTITIES_VERSION,
|
|
465
|
-
identities
|
|
466
|
-
};
|
|
467
|
-
}
|
|
468
|
-
function deriveIdentityFromPrivkey(privkeyHex, now = Date.now()) {
|
|
469
|
-
if (!/^[0-9a-f]{64}$/.test(privkeyHex)) {
|
|
470
|
-
throw new DvmError(
|
|
471
|
-
"invalid_privkey",
|
|
472
|
-
"Private key must be 64 lowercase hex characters (32 bytes).",
|
|
473
|
-
"Generate a fresh one with 'dvm identity create <name>'."
|
|
474
|
-
);
|
|
672
|
+
function paymentRailLabel(rail) {
|
|
673
|
+
switch (rail) {
|
|
674
|
+
case "cashu":
|
|
675
|
+
case "cashu_p2pk":
|
|
676
|
+
return "ecash";
|
|
677
|
+
case "x402":
|
|
678
|
+
return "x402 stablecoin";
|
|
679
|
+
case "tempo":
|
|
680
|
+
return "Tempo stablecoin";
|
|
475
681
|
}
|
|
476
|
-
const privkeyBytes = hexToBytes(privkeyHex);
|
|
477
|
-
const pubkeyBytes = schnorr.getPublicKey(privkeyBytes);
|
|
478
|
-
return {
|
|
479
|
-
privkey: privkeyHex,
|
|
480
|
-
pubkey: bytesToHex(pubkeyBytes),
|
|
481
|
-
createdAt: now
|
|
482
|
-
};
|
|
483
682
|
}
|
|
484
|
-
function
|
|
485
|
-
|
|
486
|
-
for (const b of bytes) s += b.toString(16).padStart(2, "0");
|
|
487
|
-
return s;
|
|
683
|
+
function probeRailLabel(rail) {
|
|
684
|
+
return rail === "lightning" ? "Lightning float" : fundingRailLabel(rail);
|
|
488
685
|
}
|
|
489
|
-
function
|
|
490
|
-
|
|
491
|
-
for (let i = 0; i < out.length; i++) {
|
|
492
|
-
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
493
|
-
}
|
|
494
|
-
return out;
|
|
686
|
+
function railLabelAtSentenceStart(label) {
|
|
687
|
+
return label === "ecash" ? "Ecash" : label;
|
|
495
688
|
}
|
|
496
689
|
|
|
497
|
-
// src/lib/
|
|
498
|
-
function
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
690
|
+
// src/lib/wallet-rail-copy.ts
|
|
691
|
+
function rpcEndpointSuffix(rail) {
|
|
692
|
+
const url = rail.details?.rpc_url;
|
|
693
|
+
return typeof url === "string" && url.length > 0 ? ` via ${url}` : "";
|
|
694
|
+
}
|
|
695
|
+
function rpcEndpointLine(rail) {
|
|
696
|
+
const url = rail.details?.rpc_url;
|
|
697
|
+
if (typeof url !== "string" || url.length === 0) return null;
|
|
698
|
+
const source = rail.details?.rpc_source === "override" ? rpcOverrideEnvVar(rail.rail) : null;
|
|
699
|
+
return `read from ${url} (${source ?? "bundled default"})`;
|
|
700
|
+
}
|
|
701
|
+
function unreachableRailDisplay(rail) {
|
|
702
|
+
if (rail.error?.code === "x402_unsupported_network") {
|
|
703
|
+
const network = rail.details?.network;
|
|
704
|
+
return typeof network === "string" ? `x402 rail is configured for ${network}, a chain it can't settle on.` : "x402 rail is configured for a chain it can't settle on.";
|
|
509
705
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
706
|
+
return nwcRejectionDisplay(rail) ?? `${railLabelAtSentenceStart(probeRailLabel(rail.rail))} rail is configured but not reachable.`;
|
|
707
|
+
}
|
|
708
|
+
function nwcRejectionDisplay(rail) {
|
|
709
|
+
switch (rail.error?.code) {
|
|
710
|
+
case "float_unauthorized":
|
|
711
|
+
return "The Lightning wallet no longer recognises this connection \u2014 it looks revoked or expired.";
|
|
712
|
+
case "float_connection_restricted":
|
|
713
|
+
return "The Lightning wallet recognises this connection but won't let it do what the agent needs.";
|
|
714
|
+
default:
|
|
715
|
+
return null;
|
|
515
716
|
}
|
|
516
|
-
return null;
|
|
517
717
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
}
|
|
530
|
-
async function tempoAccountFromConfig(config) {
|
|
531
|
-
if (config?.tempo?.method !== "tempo") return null;
|
|
532
|
-
const apiKey = config.tempo.apiKey;
|
|
533
|
-
if (!apiKey || !isValidEvmPrivateKey(apiKey)) return null;
|
|
534
|
-
const privateKey = apiKey;
|
|
535
|
-
const address = await deriveTempoAddress(privateKey);
|
|
536
|
-
return { privateKey, address };
|
|
537
|
-
}
|
|
538
|
-
function invalidTempoPrivateKey() {
|
|
539
|
-
return new DvmError(
|
|
540
|
-
"invalid_api_key",
|
|
541
|
-
"A Tempo private key must be a valid 0x-prefixed 32-byte hex secp256k1 private key.",
|
|
542
|
-
"Generate one with `cast wallet new` or `openssl rand -hex 32` (and prepend 0x)."
|
|
543
|
-
);
|
|
718
|
+
function unreachableRailHint(rail) {
|
|
719
|
+
if (rail.error?.hint) return rail.error.hint;
|
|
720
|
+
switch (rail.rail) {
|
|
721
|
+
case "lightning":
|
|
722
|
+
return "Re-run 'dvm wallet connect' with the connection string piped in.";
|
|
723
|
+
case "cashu":
|
|
724
|
+
return "Check mint health with 'dvm wallet mint-health'.";
|
|
725
|
+
case "x402":
|
|
726
|
+
return "Check DVM_X402_RPC_URL, or ensure viem is installed.";
|
|
727
|
+
case "tempo":
|
|
728
|
+
return "Check DVM_TEMPO_RPC_URL, or ensure viem is installed.";
|
|
729
|
+
}
|
|
544
730
|
}
|
|
545
|
-
|
|
546
|
-
// src/lib/payment-rails/tempo-session.ts
|
|
547
|
-
import { Credential } from "mppx";
|
|
548
|
-
import { Mppx } from "mppx/client";
|
|
549
|
-
import { Session as TempoSession2 } from "mppx/tempo";
|
|
550
|
-
import { decodeFunctionData, formatUnits, isAddress as isAddress2, zeroAddress as zeroAddress2 } from "viem";
|
|
551
|
-
import { privateKeyToAccount } from "viem/accounts";
|
|
552
|
-
import { Transaction } from "viem/tempo";
|
|
553
|
-
|
|
554
|
-
// src/lib/wallet-rail-copy.ts
|
|
555
731
|
function tempoKeyImportRoutes(opts) {
|
|
556
732
|
const force = opts.replacingConnectedKey ? " --force" : "";
|
|
557
733
|
return `'dvm wallet tempo-connect --key-file <path>${force}', or DVM_TEMPO_KEY=<key> dvm wallet tempo-connect${force}`;
|
|
558
734
|
}
|
|
559
735
|
var TEMPO_EXIT_KEY_MISSING_HINT = `Reconnect that exact key (${tempoKeyImportRoutes({ replacingConnectedKey: false })}) before attempting an on-chain exit.`;
|
|
736
|
+
function rpcOverrideEnvVar(rail) {
|
|
737
|
+
switch (rail) {
|
|
738
|
+
case "x402":
|
|
739
|
+
return "DVM_X402_RPC_URL";
|
|
740
|
+
case "tempo":
|
|
741
|
+
return "DVM_TEMPO_RPC_URL";
|
|
742
|
+
default:
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
560
746
|
|
|
561
747
|
// src/lib/payment-rails/tempo-session.ts
|
|
562
748
|
function offersTempoCreditSession(menu) {
|
|
@@ -810,6 +996,53 @@ function tempoSessionChallengeMicro(challenge) {
|
|
|
810
996
|
}
|
|
811
997
|
return Number(micro);
|
|
812
998
|
}
|
|
999
|
+
async function createTempoCloseCredential(args) {
|
|
1000
|
+
const entry = await loadTempoChannelEntry(args.association);
|
|
1001
|
+
if (!entry) {
|
|
1002
|
+
throw new DvmError(
|
|
1003
|
+
"tempo_channel_not_found",
|
|
1004
|
+
`The Tempo channel for credit ${args.association.creditId} is missing locally.`,
|
|
1005
|
+
"Use 'dvm wallet tempo-exit <dvm>' only when the channel record is present; otherwise contact the DVM operator with the credit id."
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
if (BigInt(args.cumulativeAmountMicro) > entry.cumulativeAmount) {
|
|
1009
|
+
throw new DvmError(
|
|
1010
|
+
"tempo_close_amount_unauthorized",
|
|
1011
|
+
`The DVM asked to close this Tempo channel at $${formatUsdcMicrounits(BigInt(args.cumulativeAmountMicro))}, above the $${formatUsdcMicrounits(entry.cumulativeAmount)} this wallet has authorized on it.`,
|
|
1012
|
+
`No credential was signed and nothing is stranded: run 'dvm wallet tempo-exit <dvm>' to recover the channel's unsettled balance on-chain, and tell the DVM's operator that its close request exceeded the caller's authorization.`
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
const durableStore = createTempoChannelStore({
|
|
1016
|
+
endpoint: args.association.endpoint,
|
|
1017
|
+
callerPubkey: args.association.callerPubkey,
|
|
1018
|
+
creditId: args.association.creditId,
|
|
1019
|
+
operator: args.association.operator
|
|
1020
|
+
});
|
|
1021
|
+
const method = TempoSession2.Client.session({
|
|
1022
|
+
account: privateKeyToAccount(args.account.privateKey),
|
|
1023
|
+
// mppx normally deletes a channel as soon as it signs a manual close.
|
|
1024
|
+
// The credential has not reached the DVM at that point, so keep the
|
|
1025
|
+
// unilateral-exit record until wallet tempo-exit independently confirms the
|
|
1026
|
+
// cooperative close on-chain and retires it.
|
|
1027
|
+
channelStore: {
|
|
1028
|
+
get: (key) => durableStore.get(key),
|
|
1029
|
+
set: (next) => durableStore.set(next),
|
|
1030
|
+
delete: () => void 0
|
|
1031
|
+
},
|
|
1032
|
+
...args.getClient ? { getClient: args.getClient } : {}
|
|
1033
|
+
});
|
|
1034
|
+
return method.createCredential({
|
|
1035
|
+
challenge: args.challenge,
|
|
1036
|
+
context: {
|
|
1037
|
+
action: "close",
|
|
1038
|
+
descriptor: entry.descriptor,
|
|
1039
|
+
cumulativeAmountRaw: String(args.cumulativeAmountMicro)
|
|
1040
|
+
}
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
async function advanceTempoChannelExit(args) {
|
|
1044
|
+
return withTempoChannelLock(() => advanceTempoChannelExitLocked(args));
|
|
1045
|
+
}
|
|
813
1046
|
async function reconcileTempoChannelOnChain(args) {
|
|
814
1047
|
const { association } = args;
|
|
815
1048
|
const unread = {
|
|
@@ -850,6 +1083,168 @@ async function reconcileTempoChannelOnChain(args) {
|
|
|
850
1083
|
return { ...read, retired: false, error: asTempoReadError(err) };
|
|
851
1084
|
}
|
|
852
1085
|
}
|
|
1086
|
+
async function advanceTempoChannelExitLocked(args) {
|
|
1087
|
+
const association = loadTempoChannelAssociation(
|
|
1088
|
+
args.association.endpoint,
|
|
1089
|
+
args.association.callerPubkey,
|
|
1090
|
+
args.association.creditId
|
|
1091
|
+
);
|
|
1092
|
+
if (association?.channelId.toLowerCase() !== args.association.channelId.toLowerCase()) {
|
|
1093
|
+
throw new DvmError(
|
|
1094
|
+
"tempo_channel_not_found",
|
|
1095
|
+
`No local Tempo channel entry remains for credit ${args.association.creditId}.`
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
const entry = await loadTempoChannelEntry(association);
|
|
1099
|
+
if (!entry) {
|
|
1100
|
+
if (args.abandon) {
|
|
1101
|
+
await retireTempoChannel(association);
|
|
1102
|
+
return {
|
|
1103
|
+
status: "abandoned",
|
|
1104
|
+
descriptorMissing: true,
|
|
1105
|
+
channelId: association.channelId
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
throw new DvmError(
|
|
1109
|
+
"tempo_channel_not_found",
|
|
1110
|
+
`The Tempo channel record for credit ${association.creditId} has no stored descriptor.`,
|
|
1111
|
+
`Run 'dvm wallet tempo-exit ${association.endpoint} --abandon' to retire this unusable local record. Nothing will be broadcast, because there is no descriptor from which to address the escrow.`
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
const identity = { channelId: entry.channelId, channelToken: entry.descriptor.token };
|
|
1115
|
+
const account = args.account;
|
|
1116
|
+
const controller = tempoDescriptorController(entry.descriptor);
|
|
1117
|
+
if (account && controller && !tempoChannelControlledBy(controller, account.address)) {
|
|
1118
|
+
if (!args.abandon) throw foreignChannelExitError(association, controller, account);
|
|
1119
|
+
await retireTempoChannelScope(association);
|
|
1120
|
+
return {
|
|
1121
|
+
status: "abandoned",
|
|
1122
|
+
foreignPayer: controller.payer,
|
|
1123
|
+
channelId: association.channelId
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
const rpc = await resolveTempoExitRpc(entry.chainId);
|
|
1127
|
+
const client = args.client ?? (account ? await tempoWalletClient(account, rpc) : await tempoPublicClient(rpc));
|
|
1128
|
+
const chain = redactingExitChain(
|
|
1129
|
+
args.chain ?? TempoSession2.Precompile.Chain,
|
|
1130
|
+
rpc,
|
|
1131
|
+
entry.descriptor.token
|
|
1132
|
+
);
|
|
1133
|
+
let state = await chain.getChannelState(client, entry.channelId, entry.escrow);
|
|
1134
|
+
const amounts = () => {
|
|
1135
|
+
const authorized = entry.cumulativeAmount > state.settled ? entry.cumulativeAmount : state.settled;
|
|
1136
|
+
return {
|
|
1137
|
+
depositMicro: Number(state.deposit),
|
|
1138
|
+
settledMicro: Number(state.settled),
|
|
1139
|
+
guaranteedMicro: Number(state.deposit > authorized ? state.deposit - authorized : 0n),
|
|
1140
|
+
// Clamped because the escrow zeroes a withdrawn channel's deposit while
|
|
1141
|
+
// keeping its settled total, so a closed channel reads deposit 0 against
|
|
1142
|
+
// a positive settled and the raw subtraction goes negative.
|
|
1143
|
+
likelyMicro: Number(state.deposit > state.settled ? state.deposit - state.settled : 0n)
|
|
1144
|
+
};
|
|
1145
|
+
};
|
|
1146
|
+
if (state.deposit === 0n) {
|
|
1147
|
+
if (classifyTempoChannelState(entry, state) === "closed") {
|
|
1148
|
+
await retireTempoChannelScope(association);
|
|
1149
|
+
return { status: "already_closed", ...identity, ...amounts() };
|
|
1150
|
+
}
|
|
1151
|
+
if (args.abandon) {
|
|
1152
|
+
await retireTempoChannel(association);
|
|
1153
|
+
return { status: "abandoned", ...identity, ...amounts() };
|
|
1154
|
+
}
|
|
1155
|
+
return { status: "unconfirmed", ...identity, ...amounts() };
|
|
1156
|
+
}
|
|
1157
|
+
if (args.abandon) {
|
|
1158
|
+
throw new DvmError(
|
|
1159
|
+
"tempo_channel_on_chain",
|
|
1160
|
+
`The Tempo channel for credit ${association.creditId} holds $${formatUsdcMicrounits(state.deposit)} on-chain, so its record cannot be abandoned.`,
|
|
1161
|
+
account ? `Run 'dvm wallet tempo-exit' without --abandon to close it and recover the collateral.` : `No Tempo key is connected, so this record could not be checked against one. Reconnect the key that opened this channel (${tempoKeyImportRoutes({ replacingConnectedKey: false })}) and run 'dvm wallet tempo-exit' without --abandon to recover the collateral. Retiring the record is offered only once a connected key shows the channel is not this wallet's.`
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
if (!account) throw tempoExitKeyMissingError();
|
|
1165
|
+
const exitAccount = privateKeyToAccount(account.privateKey);
|
|
1166
|
+
const transactionOptions = {
|
|
1167
|
+
account: exitAccount,
|
|
1168
|
+
feePayer: exitAccount,
|
|
1169
|
+
candidateFeeTokens: [entry.descriptor.token]
|
|
1170
|
+
};
|
|
1171
|
+
if (state.closeRequestedAt === 0) {
|
|
1172
|
+
const hash2 = await chain.requestCloseOnChain(
|
|
1173
|
+
client,
|
|
1174
|
+
entry.descriptor,
|
|
1175
|
+
entry.escrow,
|
|
1176
|
+
transactionOptions
|
|
1177
|
+
);
|
|
1178
|
+
await chain.waitForSuccessfulReceipt(client, hash2);
|
|
1179
|
+
state = await chain.getChannelState(client, entry.channelId, entry.escrow);
|
|
1180
|
+
return {
|
|
1181
|
+
status: "close_requested",
|
|
1182
|
+
...identity,
|
|
1183
|
+
transactionHash: hash2,
|
|
1184
|
+
withdrawAvailableAt: (state.closeRequestedAt + 900) * 1e3,
|
|
1185
|
+
...amounts()
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
const withdrawAvailableAt = state.closeRequestedAt + 900;
|
|
1189
|
+
const now = args.nowSeconds ?? Math.floor(Date.now() / 1e3);
|
|
1190
|
+
if (now < withdrawAvailableAt) {
|
|
1191
|
+
return {
|
|
1192
|
+
status: "waiting",
|
|
1193
|
+
...identity,
|
|
1194
|
+
withdrawAvailableAt: withdrawAvailableAt * 1e3,
|
|
1195
|
+
...amounts()
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
const withdrawnAmounts = amounts();
|
|
1199
|
+
const hash = await chain.withdrawOnChain(
|
|
1200
|
+
client,
|
|
1201
|
+
entry.descriptor,
|
|
1202
|
+
entry.escrow,
|
|
1203
|
+
transactionOptions
|
|
1204
|
+
);
|
|
1205
|
+
await chain.waitForSuccessfulReceipt(client, hash);
|
|
1206
|
+
state = await chain.getChannelState(client, entry.channelId, entry.escrow);
|
|
1207
|
+
await retireTempoChannelScope(association);
|
|
1208
|
+
return {
|
|
1209
|
+
status: "withdrawn",
|
|
1210
|
+
...identity,
|
|
1211
|
+
transactionHash: hash,
|
|
1212
|
+
...withdrawnAmounts
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
function foreignChannelExitError(association, controller, account) {
|
|
1216
|
+
const abandon = [
|
|
1217
|
+
"dvm wallet tempo-exit",
|
|
1218
|
+
shellQuoteArg(association.endpoint),
|
|
1219
|
+
"--credit-id",
|
|
1220
|
+
shellQuoteArg(association.creditId),
|
|
1221
|
+
"--abandon"
|
|
1222
|
+
].join(" ");
|
|
1223
|
+
return new DvmError(
|
|
1224
|
+
"tempo_wallet_mismatch",
|
|
1225
|
+
`The connected Tempo key ${account.address} is not the wallet recorded for the channel with ${association.endpoint} \u2014 that channel was opened by ${controller.payer}.`,
|
|
1226
|
+
// `--force` is live here and only here among the exit refusals: reaching
|
|
1227
|
+
// this error took a connected `account`, so the reconnect is a replacement
|
|
1228
|
+
// the import refuses without it (internal-review).
|
|
1229
|
+
`Nothing was signed or broadcast, and the channel's collateral is untouched \u2014 only ${controller.payer} can move it. Reconnect that exact key (${tempoKeyImportRoutes({ replacingConnectedKey: true })}) and retry, or run '${abandon}' to retire the local record. Retiring it discards this machine's only descriptor for that channel, so restoring the key afterwards would no longer recover the collateral; the record blocks nothing while it stands.`,
|
|
1230
|
+
{
|
|
1231
|
+
anchor: "tempo_channel_payer_mismatch",
|
|
1232
|
+
endpoint: association.endpoint,
|
|
1233
|
+
credit_id: association.creditId,
|
|
1234
|
+
channel_id: association.channelId,
|
|
1235
|
+
recorded_payer: controller.payer,
|
|
1236
|
+
recorded_authorized_signer: controller.authorizedSigner,
|
|
1237
|
+
connected_payer: account.address
|
|
1238
|
+
}
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
function tempoExitKeyMissingError() {
|
|
1242
|
+
return new DvmError(
|
|
1243
|
+
"tempo_wallet_missing",
|
|
1244
|
+
"The Tempo key that controls this channel is not connected.",
|
|
1245
|
+
TEMPO_EXIT_KEY_MISSING_HINT
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
853
1248
|
async function readTempoChannelState(args) {
|
|
854
1249
|
const entry = await loadTempoChannelEntry(args.association);
|
|
855
1250
|
if (!entry) {
|
|
@@ -927,6 +1322,18 @@ async function tempoPublicClient(rpc) {
|
|
|
927
1322
|
transport: transportOptions ? http(rpc.url, transportOptions) : http(rpc.url)
|
|
928
1323
|
});
|
|
929
1324
|
}
|
|
1325
|
+
async function tempoWalletClient(account, rpc) {
|
|
1326
|
+
const { createWalletClient, http } = await import("viem");
|
|
1327
|
+
const transportOptions = resolveRpcHttpTransportOptions(
|
|
1328
|
+
rpc.source,
|
|
1329
|
+
process.env.DVM_TEMPO_RPC_HEADERS_JSON
|
|
1330
|
+
);
|
|
1331
|
+
return createWalletClient({
|
|
1332
|
+
account: privateKeyToAccount(account.privateKey),
|
|
1333
|
+
chain: rpc.chain,
|
|
1334
|
+
transport: transportOptions ? http(rpc.url, transportOptions) : http(rpc.url)
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
930
1337
|
function readSessionDeposit(credential) {
|
|
931
1338
|
let payload;
|
|
932
1339
|
try {
|
|
@@ -1017,6 +1424,182 @@ function isTransportFailure(err) {
|
|
|
1017
1424
|
}
|
|
1018
1425
|
return transport;
|
|
1019
1426
|
}
|
|
1427
|
+
function redactingExitChain(chain, rpc, token) {
|
|
1428
|
+
const guard = async (run) => {
|
|
1429
|
+
try {
|
|
1430
|
+
return await run();
|
|
1431
|
+
} catch (err) {
|
|
1432
|
+
if (err instanceof DvmError) throw err;
|
|
1433
|
+
const cause = redactUrlsInText(err instanceof Error ? err.message : String(err));
|
|
1434
|
+
if (!isTransportFailure(err)) {
|
|
1435
|
+
throw new DvmError(
|
|
1436
|
+
"tempo_exit_failed",
|
|
1437
|
+
`Tempo channel exit failed: ${cause}`,
|
|
1438
|
+
`This failed on-chain rather than in transit, so changing DVM_TEMPO_RPC_URL will not help \u2014 a transaction the escrow rejected reads like this, and so does one this wallet cannot pay for. The exit is caller-paid and Tempo charges its fees in a stablecoin rather than a separate gas token, so check that the wallet holds a spendable balance of ${tempoTokenReference(token)} outside the channel. ${tempoBalanceCheckHint(token)} Then re-run 'dvm wallet tempo-exit' \u2014 it re-reads the chain and resumes from wherever the channel actually is. If it fails the same way with the wallet funded, the escrow is refusing the exit and the channel needs a closer look.`
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
throw new DvmError(
|
|
1442
|
+
"tempo_rpc_error",
|
|
1443
|
+
`Tempo channel exit failed: ${cause}`,
|
|
1444
|
+
// Which knob is the caller's to turn depends on whose endpoint this was.
|
|
1445
|
+
rpc.source === "override" ? `DVM_TEMPO_RPC_URL sent this exit to ${redactUrl(rpc.url)}. Correct it, or unset it to fall back to Tempo's bundled endpoint, then re-run 'dvm wallet tempo-exit' \u2014 it re-reads the chain and resumes from wherever the channel actually is.` : `Verify connectivity to Tempo's bundled endpoint (${redactUrl(rpc.url)}), or send the exit somewhere else with DVM_TEMPO_RPC_URL, then re-run 'dvm wallet tempo-exit' \u2014 it re-reads the chain and resumes from wherever the channel actually is.`
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
};
|
|
1449
|
+
return {
|
|
1450
|
+
getChannelState: (...args) => guard(() => chain.getChannelState(...args)),
|
|
1451
|
+
requestCloseOnChain: (...args) => guard(() => chain.requestCloseOnChain(...args)),
|
|
1452
|
+
waitForSuccessfulReceipt: (...args) => guard(() => chain.waitForSuccessfulReceipt(...args)),
|
|
1453
|
+
withdrawOnChain: (...args) => guard(() => chain.withdrawOnChain(...args))
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// src/lib/payment-rails/tempo-lifecycle.ts
|
|
1458
|
+
async function assertTempoWalletIdle(intent) {
|
|
1459
|
+
const connected = await connectedTempoAccount();
|
|
1460
|
+
if (!connected) return;
|
|
1461
|
+
const records = await ownedTempoChannelRecords(connected.address);
|
|
1462
|
+
const verdicts = await Promise.all(
|
|
1463
|
+
records.map(async (association) => ({
|
|
1464
|
+
association,
|
|
1465
|
+
chain: await reconcileTempoChannelOnChain({ association })
|
|
1466
|
+
}))
|
|
1467
|
+
);
|
|
1468
|
+
let blocker;
|
|
1469
|
+
for (const status of BLOCKING_ORDER) {
|
|
1470
|
+
blocker = verdicts.find((verdict) => verdict.chain.status === status);
|
|
1471
|
+
if (blocker) break;
|
|
1472
|
+
}
|
|
1473
|
+
if (!blocker) return;
|
|
1474
|
+
const blocking = verdicts.filter((verdict) => verdict.chain.status !== "closed").length;
|
|
1475
|
+
throw blocker.chain.status === "unreadable" ? blocker.chain.error?.code === "tempo_channel_not_found" ? descriptorlessChannelError(blocker.association, blocker.chain, intent, blocking) : unreadableChainError(blocker.association, blocker.chain, intent, blocking) : liveChannelError(blocker.association, blocker.chain, intent, blocking);
|
|
1476
|
+
}
|
|
1477
|
+
async function ownedTempoChannelRecords(address) {
|
|
1478
|
+
const records = listTempoChannelAssociations();
|
|
1479
|
+
const controlled = await Promise.all(
|
|
1480
|
+
records.map(
|
|
1481
|
+
async (association) => tempoChannelControlledBy(
|
|
1482
|
+
await loadTempoChannelController(association).catch(() => void 0),
|
|
1483
|
+
address
|
|
1484
|
+
)
|
|
1485
|
+
)
|
|
1486
|
+
);
|
|
1487
|
+
return records.filter((_, index) => controlled[index]);
|
|
1488
|
+
}
|
|
1489
|
+
var BLOCKING_ORDER = ["open", "closing", "unconfirmed", "unreadable"];
|
|
1490
|
+
function liveChannelError(association, chain, intent, blocking) {
|
|
1491
|
+
const depositMicro = chain.status === "unconfirmed" ? Number(association.deposit) : chain.depositMicro;
|
|
1492
|
+
const amount = `$${formatUsdcMicrounits(BigInt(depositMicro))}`;
|
|
1493
|
+
const message = chain.status === "open" ? `The connected Tempo key still holds ${amount} of collateral in an open channel with ${association.endpoint}.` : chain.status === "closing" ? `The connected Tempo key has ${amount} still escrowed on its channel with ${association.endpoint}, where a close has been requested on-chain and is in its 15-minute grace period.` : `The connected Tempo key signed an open committing ${amount} to a channel with ${association.endpoint} that the escrow has no record of, so that collateral may or may not be locked.`;
|
|
1494
|
+
const caller = findSigningIdentityByPubkey(association.callerPubkey);
|
|
1495
|
+
if (!caller) {
|
|
1496
|
+
return new DvmError(
|
|
1497
|
+
"tempo_channels_active",
|
|
1498
|
+
message,
|
|
1499
|
+
`Only this key can close that channel, but its caller identity (${association.callerPubkey}) is no longer configured. Restore that identity first, then retry the wallet ${intent === "replace" ? "replacement" : "disconnect"}; the key and channel record were left untouched.${remainingClause(blocking)}`,
|
|
1500
|
+
channelData(association, chain, depositMicro, blocking)
|
|
1501
|
+
);
|
|
1502
|
+
}
|
|
1503
|
+
const identityName = caller.source.kind === "env" ? void 0 : caller.source.name;
|
|
1504
|
+
const selectors = { creditId: association.creditId, identityName };
|
|
1505
|
+
const exit = tempoExitCommand(association.endpoint, selectors);
|
|
1506
|
+
const abandon = tempoExitCommand(association.endpoint, selectors, true);
|
|
1507
|
+
const fund = creditCommand("fund", association.endpoint, selectors);
|
|
1508
|
+
const remedy = chain.status === "open" ? (
|
|
1509
|
+
// The endpoint can hold several credits and caller identities. Both
|
|
1510
|
+
// selectors keep every remedy pointed at the channel just named.
|
|
1511
|
+
`Empty the channel first \u2014 '${creditCommand("drain", association.endpoint, selectors)}' for the cooperative exit, or '${exit}' if the DVM is unavailable`
|
|
1512
|
+
) : chain.status === "closing" ? `'${exit}' withdraws what remains once the grace period ends` : `'${exit}' reads the chain and says which it is, and '${fund}' resumes a funding still pending for it. Once that open is known never to have landed, '${abandon}' retires the record`;
|
|
1513
|
+
return new DvmError(
|
|
1514
|
+
"tempo_channels_active",
|
|
1515
|
+
message,
|
|
1516
|
+
`Only this key can close that channel. ${remedy} \u2014 and ${intentClause(intent)}.${remainingClause(blocking)}`,
|
|
1517
|
+
channelData(association, chain, depositMicro, blocking)
|
|
1518
|
+
);
|
|
1519
|
+
}
|
|
1520
|
+
function tempoExitCommand(endpoint, selectors, abandon = false) {
|
|
1521
|
+
const parts = [
|
|
1522
|
+
"dvm wallet tempo-exit",
|
|
1523
|
+
shellQuoteArg(endpoint),
|
|
1524
|
+
"--credit-id",
|
|
1525
|
+
shellQuoteArg(selectors.creditId)
|
|
1526
|
+
];
|
|
1527
|
+
if (selectors.identityName !== void 0) {
|
|
1528
|
+
parts.push("--as", shellQuoteArg(selectors.identityName));
|
|
1529
|
+
}
|
|
1530
|
+
if (abandon) parts.push("--abandon");
|
|
1531
|
+
return parts.join(" ");
|
|
1532
|
+
}
|
|
1533
|
+
function creditCommand(verb, endpoint, selectors) {
|
|
1534
|
+
const parts = [
|
|
1535
|
+
`dvm credit ${verb}`,
|
|
1536
|
+
shellQuoteArg(endpoint),
|
|
1537
|
+
"--credit-id",
|
|
1538
|
+
shellQuoteArg(selectors.creditId)
|
|
1539
|
+
];
|
|
1540
|
+
if (selectors.identityName !== void 0) {
|
|
1541
|
+
parts.push("--as", shellQuoteArg(selectors.identityName));
|
|
1542
|
+
}
|
|
1543
|
+
return parts.join(" ");
|
|
1544
|
+
}
|
|
1545
|
+
function unreadableChainError(association, chain, intent, blocking) {
|
|
1546
|
+
const cause = chain.error;
|
|
1547
|
+
return new DvmError(
|
|
1548
|
+
cause?.code ?? "tempo_chain_read_failed",
|
|
1549
|
+
`The Tempo channel with ${association.endpoint} could not be read, so there is no way to show it is empty: ${cause?.message ?? "the chain read failed"}`,
|
|
1550
|
+
`The Tempo key is untouched, and the local channel record was kept \u2014 it is the only route back to any collateral the escrow does hold. ${cause?.hint ? `${cause.hint} Then ${intentClause(intent)}.` : `Restore the chain read, then ${intentClause(intent)}.`}${remainingClause(blocking)}`,
|
|
1551
|
+
channelData(association, chain, Number(association.deposit), blocking)
|
|
1552
|
+
);
|
|
1553
|
+
}
|
|
1554
|
+
function descriptorlessChannelError(association, chain, intent, blocking) {
|
|
1555
|
+
const message = `The saved Tempo channel with ${association.endpoint} has no stored descriptor, so its escrow state cannot be read or closed from this machine.`;
|
|
1556
|
+
const data = {
|
|
1557
|
+
...channelData(association, chain, Number(association.deposit), blocking),
|
|
1558
|
+
anchor: "tempo_channel_descriptor_missing"
|
|
1559
|
+
};
|
|
1560
|
+
const caller = findSigningIdentityByPubkey(association.callerPubkey);
|
|
1561
|
+
if (!caller) {
|
|
1562
|
+
return new DvmError(
|
|
1563
|
+
"tempo_channel_not_found",
|
|
1564
|
+
message,
|
|
1565
|
+
`The caller identity that owns this record (${association.callerPubkey}) is no longer configured. Restore that identity first, then retire the descriptor-less record; the Tempo key and record were left untouched.${remainingClause(blocking)}`,
|
|
1566
|
+
data
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
const identityName = caller.source.kind === "env" ? void 0 : caller.source.name;
|
|
1570
|
+
const abandon = tempoExitCommand(
|
|
1571
|
+
association.endpoint,
|
|
1572
|
+
{ creditId: association.creditId, identityName },
|
|
1573
|
+
true
|
|
1574
|
+
);
|
|
1575
|
+
return new DvmError(
|
|
1576
|
+
"tempo_channel_not_found",
|
|
1577
|
+
message,
|
|
1578
|
+
`Run '${abandon}' to retire this unusable local record without broadcasting anything, then ${intentClause(intent)}.${remainingClause(blocking)}`,
|
|
1579
|
+
data
|
|
1580
|
+
);
|
|
1581
|
+
}
|
|
1582
|
+
function channelData(association, chain, depositMicro, blocking) {
|
|
1583
|
+
return {
|
|
1584
|
+
anchor: "tempo_channel_live",
|
|
1585
|
+
endpoint: association.endpoint,
|
|
1586
|
+
credit_id: association.creditId,
|
|
1587
|
+
channel_id: association.channelId,
|
|
1588
|
+
chain_id: association.chainId,
|
|
1589
|
+
chain_status: chain.status,
|
|
1590
|
+
deposit_micro: depositMicro,
|
|
1591
|
+
...chain.status === "unreadable" ? {} : { settled_micro: chain.settledMicro },
|
|
1592
|
+
// How many records are still in the way, so an agent knows whether clearing
|
|
1593
|
+
// the one named above is the whole job.
|
|
1594
|
+
blocking_channels: blocking
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
function remainingClause(blocking) {
|
|
1598
|
+
return blocking > 1 ? ` ${blocking - 1} other recorded channel${blocking > 2 ? "s" : ""} still ${blocking > 2 ? "block" : "blocks"} this too \u2014 'dvm wallet balance' lists them all with what the escrow says about each.` : "";
|
|
1599
|
+
}
|
|
1600
|
+
function intentClause(intent) {
|
|
1601
|
+
return intent === "replace" ? "retry the replacement" : "retry 'dvm wallet tempo-disconnect'";
|
|
1602
|
+
}
|
|
1020
1603
|
|
|
1021
1604
|
export {
|
|
1022
1605
|
TEMPO_CHARGE_PENDING_STALE_MS,
|
|
@@ -1032,10 +1615,28 @@ export {
|
|
|
1032
1615
|
listResumableTempoChargeFundings,
|
|
1033
1616
|
clearPendingTempoChargeFunding,
|
|
1034
1617
|
shellQuoteArg,
|
|
1618
|
+
isPaymentRailKey,
|
|
1035
1619
|
fundingRailLabel,
|
|
1620
|
+
paymentRailLabel,
|
|
1621
|
+
probeRailLabel,
|
|
1622
|
+
railLabelAtSentenceStart,
|
|
1623
|
+
rpcEndpointSuffix,
|
|
1624
|
+
rpcEndpointLine,
|
|
1625
|
+
unreachableRailDisplay,
|
|
1626
|
+
nwcRejectionDisplay,
|
|
1627
|
+
unreachableRailHint,
|
|
1628
|
+
tempoKeyImportRoutes,
|
|
1629
|
+
TEMPO_EXIT_KEY_MISSING_HINT,
|
|
1630
|
+
IDENTITIES_VERSION,
|
|
1631
|
+
identitiesFileExists,
|
|
1632
|
+
loadIdentities,
|
|
1633
|
+
saveIdentities,
|
|
1634
|
+
withIdentitiesLock,
|
|
1635
|
+
generateIdentity,
|
|
1636
|
+
deriveIdentityFromPrivkey,
|
|
1637
|
+
resolveSigningIdentity,
|
|
1036
1638
|
findSigningIdentityByPubkey,
|
|
1037
|
-
|
|
1038
|
-
connectedTempoAccount,
|
|
1639
|
+
assertTempoWalletIdle,
|
|
1039
1640
|
offersTempoCreditSession,
|
|
1040
1641
|
offersTempoCreditCharge,
|
|
1041
1642
|
withholdsTempoCreditSession,
|
|
@@ -1053,5 +1654,8 @@ export {
|
|
|
1053
1654
|
assertTempoSessionVoucherWithinAsk,
|
|
1054
1655
|
persistTempoOpenCredential,
|
|
1055
1656
|
tempoSessionChallenge,
|
|
1657
|
+
tempoSessionChallengeMicro,
|
|
1658
|
+
createTempoCloseCredential,
|
|
1659
|
+
advanceTempoChannelExit,
|
|
1056
1660
|
reconcileTempoChannelOnChain
|
|
1057
1661
|
};
|