@dvmkit/dvmctl 0.3.4-rc.1 → 0.4.0-rc.2

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,390 +0,0 @@
1
- ## Patterns
2
-
3
- ### Single-turn (simplest)
4
-
5
- ```typescript
6
- import { configureDVM } from "@dvmkit/sdk";
7
-
8
- export default configureDVM({
9
- name: "Uppercase",
10
- capability: "uppercase",
11
- tags: ["text"],
12
-
13
- onJob(ctx) {
14
- ctx.complete(ctx.input.toUpperCase());
15
- },
16
- });
17
- ```
18
-
19
- ### Multi-turn linear
20
-
21
- ```typescript
22
- import { configureDVM } from "@dvmkit/sdk";
23
-
24
- export default configureDVM({
25
- name: "Translator",
26
- capability: "translate",
27
- tags: ["translation"],
28
- price: "$0.02",
29
- state: { detectedLanguage: "", targetLanguage: "" },
30
-
31
- async onJob(ctx) {
32
- const detected = await ctx.step("detect", async () => detectLanguage(ctx.input));
33
- ctx.state.detectedLanguage = detected;
34
- ctx.text(`Detected: ${detected}`);
35
-
36
- const lang = await ctx.prompt("target-lang", `Translate to?`, {
37
- options: ["Spanish", "French", "German"],
38
- });
39
- ctx.state.targetLanguage = lang.text;
40
-
41
- ctx.text(`Translating to ${lang.text}...`);
42
- const result = await ctx.step("translate", async () => translate(ctx.input, lang.text));
43
-
44
- ctx.artifact({ data: result, mime_type: "text/plain" });
45
- ctx.complete(`Translated to ${lang.text}`);
46
- },
47
- });
48
- ```
49
-
50
- ### Event-driven (complex routing)
51
-
52
- ```typescript
53
- import { configureDVM } from "@dvmkit/sdk";
54
-
55
- export default configureDVM({
56
- name: "Translator",
57
- capability: "translate",
58
- tags: ["translation"],
59
- state: { original: "" },
60
-
61
- onJob(ctx) {
62
- ctx.state.original = ctx.input;
63
- ctx.prompt("lang", "What language?", {
64
- options: ["Spanish", "French", "German"],
65
- });
66
- },
67
-
68
- async onResponse(ctx, content) {
69
- const result = await translate(ctx.state.original, content.text);
70
- ctx.complete(result);
71
- },
72
- });
73
- ```
74
-
75
- ### Multi-capability DVM (cast's pattern)
76
-
77
- When several related operations share resources (a Postgres pool, an auth verifier, an S3
78
- bucket), declare them as capabilities on one DVM instead of mounting separate DVMs. This is what
79
- cast does — a podcast-feed DVM with one descriptor-level auth scheme covering four capabilities:
80
-
81
- ```typescript
82
- import { configureDVM } from "@dvmkit/sdk";
83
- import { secp256k1Auth } from "@dvmkit/sdk/server";
84
-
85
- import { addEpisodeSchema, deleteEpisodeSchema, updateFeedSchema } from "./types";
86
-
87
- export function createCastDVM(runtime: CastRuntime) {
88
- return configureDVM({
89
- name: "cast",
90
- description: "Host RSS feeds and episodes for podcast distribution",
91
- tags: ["podcast", "rss", "feed", "audio"],
92
- idleTimeout: 1800,
93
-
94
- // Descriptor-level auth: one scheme gates every capability (see below).
95
- auth: secp256k1Auth({ replayStore: runtime.replays, driftSeconds: 300 }),
96
-
97
- onBoot(env) {
98
- // validate env; runtime is already built by the time we reach here
99
- },
100
-
101
- capabilities: {
102
- "add-episode": {
103
- description: "Add an episode to a private RSS feed by audio URL",
104
- input: addEpisodeSchema,
105
- onQuote: {
106
- schema: addEpisodeSchema,
107
- handler: async (ctx) => quoteAddEpisode(ctx, runtime),
108
- },
109
- async onJob(ctx) {
110
- await runAddEpisodeJob(ctx, runtime);
111
- },
112
- },
113
-
114
- "update-feed": {
115
- description: "Update per-feed metadata",
116
- input: updateFeedSchema,
117
- async onJob(ctx) {
118
- await runUpdateFeedJob(ctx, runtime);
119
- },
120
- },
121
-
122
- "delete-episode": {
123
- description: "Delete a single episode",
124
- input: deleteEpisodeSchema,
125
- async onJob(ctx) {
126
- await runDeleteEpisodeJob(ctx, runtime);
127
- },
128
- },
129
- },
130
-
131
- routes(app, { authAudience }) {
132
- // see "Custom HTTP routes" below
133
- },
134
- });
135
- }
136
- ```
137
-
138
- Callers target a capability by passing `capability: "add-episode"` on the job submission; the
139
- wire (`POST /v1/job`) always carries the capability name. Each capability has its own input
140
- schema, pricing, and quote handler; the DVM-level `auth`, `paymentMethods`, and `routes` are
141
- shared.
142
-
143
- ### Stateful DVM — signed-request auth
144
-
145
- When a DVM owns persistent state (feeds, accounts, subscriptions) and must verify the caller owns
146
- the resource being acted on, require **signed requests**. Signatures are **secp256k1 with BIP-340
147
- Schnorr** over a versioned statement containing the exact input plus the attested DVM audience, HTTP method, concrete path, and capability, with timestamp-drift and replay protection. A proof for one DVM or operation cannot be replayed against another. This is a protocol invariant — caller auth and builder identity are always
148
- secp256k1+Schnorr via `@noble/curves`, never Ed25519 or another curve. Don't introduce other
149
- curves into auth/identity paths.
150
-
151
- The schema must include the envelope fields `pubkey`, `signature`, `timestamp`, `nonce` alongside the operation's payload fields. `auth_statement` is protocol-owned and the SDK removes it before builder schema parsing:
152
-
153
- ```typescript
154
- import { z } from "@dvmkit/sdk";
155
-
156
- const addEpisodeSchema = z.object({
157
- // payload
158
- feed_id: z.string(),
159
- audio_url: z.string().url(),
160
- // envelope (the SDK verifies these)
161
- pubkey: z.string(),
162
- signature: z.string(),
163
- timestamp: z.number().int(),
164
- nonce: z.string(),
165
- });
166
- ```
167
-
168
- **What gets verified is the raw wire body**, on `/v1/quote`, on the `/v1/job` submit and on the
169
- cross-machine reactivation re-verify alike (`signedRequestInput`, — never the
170
- Zod-parsed input. So your schema is free to fill defaults at any depth, coerce, or transform: the
171
- parse shapes what the handler receives, not what the signature covers. The one thing that must
172
- match is the wire body itself, which is why a client signs the payload it is about to send rather
173
- than a normalised copy of it.
174
-
175
- #### Canonical pattern — `auth: secp256k1Auth({...})` at descriptor level
176
-
177
- For most stateful DVMs, declare auth on the descriptor (shown in the cast example above). The SDK
178
- then publishes `secp256k1-schnorr-v2` plus its attested `auth_audience` on `/v1/info`, and rejects unsigned / stale / replayed or wrong-domain envelopes with HTTP 401 **before payment is taken or any
179
- handler runs**, and records the replay nonce only after upfront payment verifies,
180
- . Inside the handler, the verified caller identity is on `ctx.auth`:
181
-
182
- ```typescript
183
- import { secp256k1Auth } from "@dvmkit/sdk/server";
184
-
185
- // auth: secp256k1Auth({
186
- // replayStore: runtime.replays, // omit for the bounded in-memory FIFO default
187
- // driftSeconds: 300, // ±5 min clock drift (default)
188
- // }),
189
-
190
- async onJob(ctx) {
191
- // The envelope is already verified. ctx.auth.pubkey is the trusted caller key;
192
- // ctx.auth.envelope holds the parsed payload, so no re-parsing is needed.
193
- const pubkey = ctx.auth!.pubkey;
194
- const { feed_id } = ctx.auth!.envelope as { feed_id: string };
195
-
196
- if (!(await runtime.db.ownsFeed(pubkey, feed_id))) {
197
- ctx.fail("auth_error: caller does not own this feed");
198
- return;
199
- }
200
- // ...
201
- }
202
- ```
203
-
204
- `replayStore` accepts a `PostgresReplayStore` (cross-instance, also from `@dvmkit/sdk/server`);
205
- omit it for a bounded in-memory FIFO default suitable for single-instance DVMs.
206
-
207
- #### Lower-level primitive — `createSignedRequestVerifier`
208
-
209
- When you need hands-on control on a custom route, use the verifier directly. The SDK passes the resolved audience to `routes(app, context)`; combine it with the live request method and URL so mount prefixes remain covered. The descriptor-level `secp256k1Auth` is built on the same primitive.
210
-
211
- ```typescript
212
- import { createSignedRequestVerifier, SignedRequestError } from "@dvmkit/sdk/server";
213
-
214
- const verifier = createSignedRequestVerifier(addEpisodeSchema, {
215
- driftSeconds: 300, // ±5 min clock drift (default)
216
- replayStore: replays, // pass null to disable replay protection
217
- });
218
-
219
- routes(app, { authAudience }) {
220
- if (!authAudience) throw new Error("signed route requires descriptor auth");
221
- app.post("/private/feed", async (c) => {
222
- try {
223
- // Two-step: `checkAuth` is sync — schema → drift → signature.
224
- // `recordReplay` is async — commits the nonce, throws on duplicate. Split
225
- // so validation does not burn a nonce before the operation is accepted.
226
- const signed = verifier.checkAuth(await c.req.json(), {
227
- audience: authAudience,
228
- method: c.req.method,
229
- path: new URL(c.req.url).pathname,
230
- capability: null,
231
- });
232
- await verifier.recordReplay(signed);
233
- // signed.pubkey is now trusted — use it for ownership checks
234
- if (!(await runtime.db.ownsFeed(signed.pubkey, signed.feed_id))) {
235
- return c.json({ error: "auth_error" }, 401);
236
- }
237
- // ...
238
- } catch (err) {
239
- if (err instanceof SignedRequestError) {
240
- return c.json({ error: "auth_error", sub_reason: err.sub_reason }, 401);
241
- }
242
- throw err;
243
- }
244
- });
245
- }
246
- ```
247
-
248
- `verifier.checkAuth(input, domain)` returns the parsed input typed as `T & CanonicalEnvelope` on success
249
- and throws `SignedRequestError` (`sub_reason`: `schema_invalid` | `timestamp_drift` |
250
- `signature_invalid`). `verifier.recordReplay(envelope)` commits the nonce and throws
251
- `SignedRequestError` (`replay_detected`) on a duplicate. Pair them with your own error taxonomy
252
- for a domain-specific error code (cast wraps both as `auth_error`).
253
-
254
- In `onQuote` (and the 402-discovery hop), call `checkAuth` only — skip `recordReplay`. The caller creates a separate `/v1/job` proof because method/path/capability are signed; quote and submit never reuse a proof.
255
-
256
- `verifier.signRequest(privkey, body, opts?, domain)` is the matching client-side primitive — useful for
257
- round-trip tests that exercise the real crypto path:
258
-
259
- ```typescript
260
- import { schnorr } from "@noble/curves/secp256k1.js";
261
-
262
- const { secretKey } = schnorr.keygen();
263
- const signed = verifier.signRequest(secretKey, {
264
- feed_id: "morning-news",
265
- audio_url: "https://example.com/episode-1.mp3",
266
- }, undefined, {
267
- audience: authAudience,
268
- method: "POST",
269
- path: "/private/feed",
270
- capability: null,
271
- });
272
- // signed.pubkey, signed.signature, signed.timestamp, signed.nonce are filled in.
273
- ```
274
-
275
- ### Refund-on-failure policy
276
-
277
- A failed job never debits — the draw is released, on every rail, with nothing to wire (see
278
- [Prepaid credit](pricing-credit.md#prepaid-credit)). `{ refund: true }` is a separate annotation recording that the failure
279
- was the caller's fault; the first-party DVMs all key it off an `OPERATOR_FAULT_CODES` set:
280
-
281
- ```typescript
282
- const refund = !OPERATOR_FAULT_CODES.has(err.code) ? { refund: true } : undefined;
283
- ctx.fail(`[${err.code}] ${err.hint}`, refund);
284
- ```
285
-
286
- Keep annotating — but don't rely on it moving money. Its one runtime branch (hand back a Cashu change token
287
- from proofs still held in-process) never fires under the P2PK-accumulator path, and no rail reverses a
288
- settled payment. What decides whether the caller can _reach_ the released value is your **`auth` slot**, not your
289
- `credit` block — the draw path never reads `credit`, it needs a verified pubkey. Without `auth` every caller is
290
- the pooled `anonymous` requester and an explicit draw is refused, so the money stays with you in practice. With
291
- `auth`, the release lands on a credit keyed to their pubkey and the failed job's receipt carries its `credit_id`,
292
- so the protocol lets them spend it down on the next job with nothing extra wired (the `dvm` CLI doesn't do that
293
- automatically yet. Offering credit is the further step
294
- that makes it _reclaimable as money_ — that's what `drain` and `POST /v1/credit` are gated on.
295
-
296
- ### Signed receipts
297
-
298
- Every job that reaches a terminal status — `completed`, `failed`, `cancelled` — carries a signed
299
- `receipt` block on its response. **Nothing to wire:** `dvmctl deploy` derives the key
300
- from the builder identity only after the platform returns the immutable DVM ID, binds both into the
301
- attestation, and provisions it; the SDK signs at the terminal.
302
-
303
- ```jsonc
304
- "receipt": {
305
- "v": 1, "job_id": "job-a7571b-1", "dvm_id": "<immutable-id>", "dvm": "<slug>", "capability": "count",
306
- "outcome": "completed", // completed | failed | cancelled
307
- "reason": null, // terminal reason on failed/cancelled
308
- "seq": 4213, // per-DVM monotonic terminal counter
309
- "issued_at": 1789300000,
310
- "requester_pubkey": null, // set when signed-request auth was used
311
- "paid": { "msats": 19000, "rail": "cashu", "native_amount": null,
312
- "native_asset": null, "tx_hash": null, "mint": "https://..." },
313
- "result_hash": "<sha256 hex>", // sha256(canonicalize({summary, artifact_hashes})); summary
314
- // only on completed, null otherwise; null if nothing delivered
315
- "receipt_pubkey": "<64-hex>",
316
- "signature": "<128-hex>", // BIP-340 over canonicaliseForSigning(receipt minus signature)
317
- "credit": { // present when the payment funded/drew the credit ledger
318
- "credit_id": "imp:cashu:…", "draw_id": "…", "amount": 10000,
319
- "balance_after": 240000, // settled draw: the recorded trajectory.
320
- // released draw (failed/cancelled): restored available balance
321
- "ledger_seq": 7 // per-credit monotonic draw counter
322
- }
323
- }
324
- ```
325
-
326
- What a builder needs to know:
327
-
328
- - Same receipt on the synchronous 200, the poll, and every later re-read — signed once at the
329
- terminal and persisted, not re-signed per response (JSON key order can differ; canonicalisation
330
- is what the signature covers).
331
- - Free jobs get receipts too (`paid.msats: 0`); they consume a `seq` like any other job.
332
- - `/v1/info` advertises `receipts: true` only when a key is wired **and** the job store can issue
333
- (`claimReceiptSeq` / `saveReceipt` — both built-in stores do), so absence is detectable. A custom
334
- `jobStore` that can't warns `receipts_disabled_store_unsupported` at boot.
335
- - Signing failure never breaks a job: the SDK logs `receipt_issue_failed` and serves without one.
336
- - `dvmctl dev` / `devMode` boots mint an ephemeral key — receipts verify locally but nothing
337
- attests them. Outside dev, no key means no receipts and no flag.
338
- - Isolate-runtime DVMs don't issue receipts yet.
339
-
340
- Verifying one (four links, all required): the signature verifies under `receipt_pubkey`; the receipt's
341
- immutable `dvm_id` equals `/v1/info#builder`'s `attestation.dvm_id`; `receipt_pubkey` equals that
342
- attestation's `receipt_pubkey`; and the attestation verifies under `builder.pubkey`. The slug stays
343
- signed display metadata, never the trust identity. Use the published caller command
344
- `dvm receipts verify <job-id>` to check a stored receipt rather than reimplementing its canonicalisation.
345
-
346
- ### Custom HTTP routes
347
-
348
- Two seams:
349
-
350
- - `routes(app)` on the descriptor — routes that **belong to this DVM**. They register on the DVM's
351
- Hono sub-app and inherit any mount prefix.
352
- - `host.app` on the host — routes that **belong to the host** (cross-DVM `/health`, cron-callable
353
- maintenance, anything not tied to one DVM).
354
-
355
- DVM-scoped (cast feed XML):
356
-
357
- ```typescript
358
- configureDVM({
359
- name: "cast",
360
- capabilities: {
361
- /* ... */
362
- },
363
-
364
- routes(app) {
365
- app.get("/feeds/:owner/:slug", async (c) => {
366
- const xml = await renderFeed(c.req.param("owner"), c.req.param("slug"));
367
- return c.body(xml, 200, { "Content-Type": "application/rss+xml" });
368
- });
369
- },
370
- });
371
- ```
372
-
373
- Host-scoped (scrape's pool-aware `/health`, registered before `host.mount`):
374
-
375
- ```typescript
376
- import { createDVMHost } from "@dvmkit/sdk/server";
377
-
378
- const host = createDVMHost();
379
-
380
- let shuttingDown = false;
381
- host.app.get("/health", (c) => {
382
- if (shuttingDown) return c.json({ ok: false, draining: true }, 503);
383
- return c.json({ ok: true, pool: runtime.pool.health() });
384
- });
385
-
386
- host.mount(createScrapeDVM(runtime));
387
- await host.serve();
388
- ```
389
-
390
- ---
@@ -1,11 +0,0 @@
1
- # Payment rails for first builds
2
-
3
- `dvmctl dev` skips payment verification only when no rail is configured. When a rail is configured, the SDK verifies and settles its real credential, uses disposable in-process job/payment state, and reports completed paid jobs in the terminal. A dev server does not turn mainnet funds into test funds.
4
-
5
- | Rail | First-build proof | Test boundary |
6
- | --- | --- | --- |
7
- | Cashu | [Cashu recipe](local-cashu-test.md) with a local FakeWallet or explicitly approved hosted test mint. | The receipt's quoted msats and exact wallet debit are authoritative. |
8
- | x402 | [x402 recipe](local-x402-test.md) on Base Sepolia (`eip155:84532`). | Requires a separately funded testnet key and an approved micro-USDC cap. Never substitute Base mainnet. |
9
- | Tempo | [Tempo recipe](local-tempo-test.md) on Moderato (chain 42431). | `dvm wallet fund --rail tempo` uses the official testnet faucet. Never substitute Tempo mainnet. |
10
-
11
- A successful test proves only the selected local rail and test network. Production recipients, mints, facilitator credentials, payout settings, wallet connections, and funds are deployment decisions with separate approval.
@@ -1,155 +0,0 @@
1
- ## Tags
2
-
3
- Tags are freeform strings describing what a DVM does, advertised in `/v1/info` and used by
4
- clients to discover providers:
5
-
6
- ```typescript
7
- tag: "text"; // single tag
8
- tags: ["web", "extraction"]; // multiple tags
9
- ```
10
-
11
- ---
12
-
13
- ## Pricing
14
-
15
- Static prices are USD literals (`PriceValue`) — the only static price form:
16
-
17
- ```typescript
18
- price: "$0.05"; // USD → converted at current BTC rate
19
- ```
20
-
21
- A raw-msats price throws at `configureDVM`. So does anything below `"$0.0001"` or finer than four
22
- decimals — that's the precision every caller-facing surface advertises at, so a finer price would
23
- advertise one number and charge another. Free capabilities omit `price`. Need finer granularity?
24
- Price dynamically with `onQuote`; dynamic upfronts carry full micro-unit precision.
25
-
26
- Dynamic pricing with `onQuote` — fiat-first. The handler returns an `upfront` envelope
27
- (`{ amount, currency: "usd" }`) plus a `shape`:
28
-
29
- ```typescript
30
- onQuote: {
31
- schema: z.object({ duration: z.number() }),
32
- async handler(ctx) {
33
- return {
34
- upfront: { amount: ctx.data.duration * 0.001, currency: "usd" },
35
- description: `${ctx.data.duration}s of synthesis`,
36
- shape: "fixed",
37
- };
38
- },
39
- },
40
- ```
41
-
42
- (`ctx.data` here is the quote-time parsed input, distinct from a job's `ctx.input`.)
43
-
44
- Two-phase metered pricing — `shape: "rate"` returns an `upfront` floor plus a `rate` (per-unit)
45
- so the SDK advertises a max-units cap; the handler calls `ctx.requestPayment(...)` again mid-job
46
- once actual usage is known (this is scribe's pattern):
47
-
48
- ```typescript
49
- onQuote: {
50
- schema: transcribeSchema,
51
- async handler(ctx) {
52
- return {
53
- upfront: { amount: 0.02, currency: "usd" }, // spam-gate floor
54
- description: "Up to 15 minutes; metered per-minute after",
55
- shape: "rate",
56
- rate: {
57
- per_unit: { amount: 0.02, currency: "usd" },
58
- unit: "minute",
59
- max_units: 15,
60
- },
61
- };
62
- },
63
- },
64
- ```
65
-
66
- All first-party DVMs quote in `currency: "usd"`; the SDK converts to sats at the 402 handshake.
67
-
68
- To price in something else, declare it once at the DVM level — `currency: "eur"` on `configureDVM`
69
- (ISO 4217 lowercase; the bundled fx fetcher carries usd/eur/gbp/jpy). That one field is what the
70
- credit ledger, the funding menu, and `POST /v1/credit` all denominate in, so the two ways it could
71
- drift are both refused rather than converted: an `onQuote` returning a different currency answers
72
- `quote_currency_mismatch` (500), and a static `price` on a non-USD DVM throws at `configureDVM`
73
- (`"$0.05"` is a USD literal — a non-USD DVM prices with `onQuote`). `credit.min`/`max` stay USD
74
- literals whatever you price in; the menu converts them.
75
-
76
- ---
77
-
78
- ## Prepaid credit
79
-
80
- Every payment settles through a per-DVM **credit ledger**: the rail funds a balance, the job draws from it.
81
- Per-call payment is the N=1 case of that (fund the paid amount, draw it, same transaction) and happens
82
- whether or not you opt in. Declaring a `credit` block is the opt-in for callers to _hold_ a balance across
83
- jobs — the ledger, `POST /v1/credit`, the funding menu on quotes and 402s, per-draw idempotency and the
84
- quote balance echo are then wired for you:
85
-
86
- ```typescript
87
- configureDVM({
88
- name: "assessor",
89
- capability: "assess",
90
- price: "$0.01",
91
- auth: secp256k1Auth(), // required — a balance belongs to a verified pubkey
92
- credit: {
93
- min: "$0.10", // smallest top-up accepted (default "$0.10")
94
- max: "$5.00", // ceiling on a caller's *residual balance* (default "$5.00")
95
- ttl: 30 * 24 * 60 * 60, // seconds; default 30 days
96
- allowOneShotStablecoin: false, // default; stablecoin credit uses reusable channels
97
- },
98
- onJob(ctx) {
99
- /* unchanged */
100
- },
101
- });
102
- ```
103
-
104
- `max` bounds what a caller may sit on, not what they may pay: a job priced above `max` still clears, because
105
- that request funds and draws in one step. Needs a Postgres-backed host — balances must survive a restart and
106
- be visible fleet-wide.
107
-
108
- - **`credit_id` / `draw_id` / `fund` are reserved at the top level of `data`.** A caller draws by putting
109
- them in the same signed object your input schema validates. The SDK removes them before your schema sees
110
- them and before the auth gate's schema check, so declare nothing for them, and **do** use `.strict()` if
111
- you want it — unknown-key rejection still catches a caller's typo'd field with a 400 naming it.
112
- The signature covers those keys either way, so tampering with one is still a 401. Don't name a field of
113
- your own after one of them.
114
- - **Never hand-roll credit state.** The SDK ships the primitive (`credits` + `credit_draws`, `SELECT … FOR
115
- UPDATE` debits). `ctx.store` is **prohibited** for balances: last-write-wins KV on a multi-machine fleet
116
- loses money under concurrency.
117
- - **Stablecoin credit is reusable-only unless you deliberately opt in.** Tempo `session` and x402
118
- `batch-settlement` fund prepaid balances by default; Tempo `charge` and x402 `exact` still pay per call.
119
- Set `allowOneShotStablecoin: true` only when you accept that every unused one-payment balance becomes a
120
- manual refund obligation. Missing, malformed and older config fails closed.
121
- - **No debit on job failure**, with nothing to wire. A draw is a pending hold — settled on `completed`,
122
- released on `failed` / `cancelled`, including `ctx.fail`, caller cancels and the stale-job reaper.
123
- - **Quote handlers see the caller.** `ctx.callerPubkey` is the verified pubkey (undefined without auth);
124
- `ctx.credit` is a read-only snapshot `{ creditId, currency, balanceMicro, availableMicro, expiryMs,
125
- expired }` in micro-units — price against it, don't try to spend it (spending happens on the job path,
126
- under the ledger's lock).
127
- - **Menu missing when you expected one?** The refusal ladder is ordered, and **only its last two rungs say
128
- anything**. Isolate runtime (never offers credit), no `credit` block, and no descriptor auth all return
129
- silently — check those three first, in that order, because no log will point you at them. A non-durable
130
- ledger outside devMode warns `credit_menu_suppressed_non_durable`; bounds not expressible in the quote
131
- currency warn `credit_menu_bounds_unavailable` (note the different prefix — don't grep for one pattern).
132
- Both fire once per process (`warnOnce`), so a restart is what re-arms them. A rung never fails the quote —
133
- the sale survives without credit.
134
- - **Reclaim is a builder obligation on the rails you hold the money on — and only those.** Who fulfils a
135
- drain is decided by the rail, and treating one shape as another is how a self-settling refund lands on
136
- your worklist or a real debt goes unpaid. **Ecash is the whole of your automatic obligation**:
137
- `dvmctl melt-pending` parks notes P2PK-locked to the caller's refund key and holds your payout melt back
138
- while refund liability is outstanding. A **Lightning-funded** credit reclaims that same way — direct
139
- Lightning payout is not a reclaim method, so a `method: "lightning"` drain is refused
140
- `drain_method_unsupported` and your deployment never needs a send-capable Lightning credential.
141
- **Channel-backed x402 and Tempo drains settle themselves** on the caller's signed refund
142
- voucher — no builder spending key, nothing to record; `drains-owed` never lists one and `drain-settle`
143
- refuses it (`channel_bound_drain`), because a hand-recorded payout takes the row out of the reconciliation
144
- that would have paid it. **One-payment x402 and Tempo drains are the only shape a person settles**: they
145
- queue behind `drain_manual_settlement_required`, and `dvmctl credit drains-owed <handle>` then
146
- `dvmctl credit drain-settle <handle> <credit-id> <drain-id> --tx <hash>` is the path. That one is manual
147
- by design — automating it would put a chain spending key with your whole stablecoin balance behind it on
148
- the `melt-pending` host. On the rails you hold, an
149
- unfulfilled drain is a signed IOU the caller can prove. One-payment stablecoin credit exists only when
150
- `allowOneShotStablecoin` explicitly enabled it; do not take that opt-in without an operating refund path.
151
-
152
- For wire shapes and supported options, consult the installed SDK declarations and the local `dvmctl`
153
- help for the version you installed.
154
-
155
- ---