@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.
@@ -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-2K6UXDAN.js";
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-F2L6KIMD.js";
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 lockfile from "proper-lockfile";
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 lockfile.lock(TEMPO_CHANNELS_FILE, {
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 = lockfile.lockSync(TEMPO_CHANNELS_FILE, { realpath: false });
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 (existsSync(TEMPO_CHANNELS_FILE)) return;
545
+ if (existsSync2(TEMPO_CHANNELS_FILE)) return;
299
546
  try {
300
- writeFileSync(TEMPO_CHANNELS_FILE, `${JSON.stringify(emptyFile(), null, 2)}
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 (!existsSync(TEMPO_CHANNELS_FILE)) return emptyFile();
556
+ if (!existsSync2(TEMPO_CHANNELS_FILE)) return emptyFile();
310
557
  try {
311
- const parsed = JSON.parse(readFileSync(TEMPO_CHANNELS_FILE, "utf8"));
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
- writeFileSync(tmp, JSON.stringify(file, null, 2) + "\n", { mode: 384 });
338
- renameSync(tmp, TEMPO_CHANNELS_FILE);
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/shell-quote.ts
401
- function shellQuoteArg(value) {
402
- if (value === "") return "''";
403
- if (SAFE_VALUE_RE.test(value)) return value;
404
- return `'${value.replace(/'/g, "'\\''")}'`;
405
- }
406
- var SAFE_VALUE_RE = /^[a-zA-Z0-9_\-./@:=,+%]+$/;
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
- // src/lib/cli-identity-store.ts
423
- import { schnorr } from "@noble/curves/secp256k1.js";
424
- import { existsSync as existsSync2, readFileSync as readFileSync2, renameSync as renameSync2, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
425
- import lockfile2 from "proper-lockfile";
426
- var IDENTITIES_VERSION = 1;
427
- function loadIdentities() {
428
- if (!existsSync2(IDENTITIES_FILE)) return null;
429
- const raw = readFileSync2(IDENTITIES_FILE, "utf-8");
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 bytesToHex(bytes) {
485
- let s = "";
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 hexToBytes(hex) {
490
- const out = new Uint8Array(hex.length / 2);
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/cli-identity-resolve.ts
498
- function findSigningIdentityByPubkey(pubkey) {
499
- const wanted = pubkey.toLowerCase();
500
- const envKey = process.env.DVM_IDENTITY_KEY;
501
- if (envKey) {
502
- try {
503
- const derived = deriveIdentityFromPrivkey(envKey.toLowerCase());
504
- if (derived.pubkey === wanted) {
505
- return { privkey: derived.privkey, pubkey: derived.pubkey, source: { kind: "env" } };
506
- }
507
- } catch {
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
- const identities = loadIdentities()?.identities ?? {};
511
- for (const [name, entry] of Object.entries(identities)) {
512
- if (entry.pubkey.toLowerCase() === wanted) {
513
- return { privkey: entry.privkey, pubkey: entry.pubkey, source: { kind: "pubkey", name } };
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
- // src/lib/payment-rails/tempo-wallet.ts
520
- var TEMPO_CHAIN_ID = 4217;
521
- async function connectedTempoAccount() {
522
- const config = loadConfig();
523
- return tempoAccountFromConfig(config);
524
- }
525
- async function deriveTempoAddress(privateKey) {
526
- if (!isValidEvmPrivateKey(privateKey)) throw invalidTempoPrivateKey();
527
- const { privateKeyToAccount: privateKeyToAccount2 } = await import("viem/accounts");
528
- return privateKeyToAccount2(privateKey).address.toLowerCase();
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
- TEMPO_CHAIN_ID,
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
  };