@dvmkit/dvmctl 0.1.0-rc.2 → 0.2.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.
@@ -0,0 +1,153 @@
1
+ ## Operating a deployed DVM
2
+
3
+ Once a DVM is live, `dvmctl` is the full operational surface — you never need `flyctl`.
4
+ Every command emits JSON by default and takes `--human`; `<slug>` is the DVM's deployed name.
5
+ Exceptions noted inline (`logs` and `revenue csv` emit raw output, not JSON).
6
+
7
+ Every DVM-specific command resolves its bare `<slug>` only inside the active `dvmctl switch` context. Pass `--org <handle>` for a one-shot override without changing that context. The CLI resolves that scoped slug to an immutable DVM ID before it reads, changes, or opens a machine resource, so a matching slug in another org is never selected accidentally.
8
+
9
+ ### Fleet introspection
10
+
11
+ | Command | Purpose | Key flags |
12
+ | ----------------------- | ------------------------------------------------------------ | --------------------------- |
13
+ | `dvmctl list` | List your deployed DVMs | |
14
+ | `dvmctl info <slug>` | Deployed-DVM details | `--org` |
15
+ | `dvmctl status <slug>` | Live Fly runtime state (machines, region, image, health) | `--org` |
16
+ | `dvmctl metrics <slug>` | Request metrics | `--hours` (default 24), `--org` |
17
+ | `dvmctl events <slug>` | Recent events (deploys, restarts, scale, env, machine state) | `--since` (e.g. `2h`, `7d`), `--org` |
18
+
19
+ ### Runtime ops
20
+
21
+ | Command | Purpose | Key flags |
22
+ | --------------------------- | --------------------------------------------------------------- | ---------------------------------- |
23
+ | `dvmctl logs <slug>` | View or stream logs (**raw output**, not JSON) | `--tail`, `--since`, `--lines`, `--org` |
24
+ | `dvmctl ssh <slug>` | Interactive shell, or one-shot command, on the Fly machine | `--command`, `--machine`, `--org` |
25
+ | `dvmctl restart <slug>` | Restart machines without a redeploy | `--machine` (default: all), `--org` |
26
+ | `dvmctl rollback <slug>` | Revert to a previous successful deploy's image | `--to <deploy-id>` (default: prev), `--org` |
27
+ | `dvmctl scale <slug>` | Change machine count and/or VM size without a redeploy | `--count` (1–8), `--size`, `--org` |
28
+ | `dvmctl destroy <slug>` | Destroy the DVM and its paired Postgres cluster | `--confirm`, `--keep-db`, `--org` |
29
+ | `dvmctl deploy-status <id>` | Status of a deploy by ID — recovery handle for a stuck `deploy` | |
30
+
31
+ ### Config, data & tracing
32
+
33
+ | Command | Purpose | Key flags |
34
+ | ----------------------------------- | ----------------------------------------------------------------------------- | ------------------------------ |
35
+ | `dvmctl env <slug>` | View or update environment variables | `--set KEY=val`, `--unset KEY`, `--org` |
36
+ | `dvmctl secrets list <slug>` | List secret names + last-applied times (never values) | `--org` |
37
+ | `dvmctl db connect <slug>` | Interactive `psql` against the DVM's database (needs local `psql`) | `--machine`, `--org` |
38
+ | `dvmctl db proxy <slug>` | Bind a local port to the DVM's database (`pg_dump`, drizzle-kit, GUI clients) | `--port`, `--machine`, `--org` |
39
+ | `dvmctl trace get <traceId>` | Fetch a trace by ID (isolate-dataset spans) | `--no-color` |
40
+
41
+ ### Custom domains
42
+
43
+ | Command | Purpose |
44
+ | --------------------------------------- | ----------------------------------------------- |
45
+ | `dvmctl domains add <slug> <domain>` | Attach a custom domain (e.g. `api.example.com`); `--org` selects its org |
46
+ | `dvmctl domains list <slug>` | List attached domains; `--org` selects its org |
47
+ | `dvmctl domains verify <slug> <domain>` | Verify DNS/TLS provisioning; `--org` selects its org |
48
+ | `dvmctl domains remove <slug> <domain>` | Remove a custom domain; `--org` selects its org |
49
+
50
+ ### Revenue & payout
51
+
52
+ Reporting, the path to collect your earnings off the Cashu accumulator, and the operator queues for refunds
53
+ and settlements no automatic loop will ever clear.
54
+
55
+ | Command | Purpose | Key flags |
56
+ | -------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------ |
57
+ | `dvmctl revenue summary` | Totals + per-rail breakdown | `--from`, `--to` (required), `--currency` |
58
+ | `dvmctl revenue events` | Individual revenue events | `--from`, `--to`, `--rail`, `--limit` |
59
+ | `dvmctl revenue csv` | Export revenue as CSV (**raw CSV output**) | `--from`, `--to` (required), `--currency`, `--rail`, `--out` |
60
+ | `dvmctl set-payout <handle> <ln-addr>` | Persist the default Lightning payout address for `melt-pending` | `--skip-preflight`, `--self-hosted`, `--org` |
61
+ | `dvmctl melt-pending <handle>` | Drain pending Cashu accumulator batches to the Lightning payout addr | `--payout`, `--endpoint`, `--watch`, `--interval`, `--org` |
62
+ | `dvmctl rotate <handle>` | Rotate the per-DVM Cashu lock pubkey (publishes next BIP-32 child) | `--endpoint`, `--org` |
63
+ | `dvmctl credit blocked <handle>` | Lightning top-ups the ledger could never credit — the operator queue | `--include-resolved`, `--limit`, `--after`, `--endpoint`, `--org` |
64
+ | `dvmctl credit reconcile <handle> <payment-hash>` | Credit a blocked payment onto a credit the caller holds | `--credit-id` (required), `--endpoint`, `--org` |
65
+ | `dvmctl credit write-off <handle> <payment-hash>` | Reviewed, not crediting — moves no money, reversible | `--note`, `--endpoint`, `--org` |
66
+ | `dvmctl credit drains-owed <handle>` | One-payment stablecoin refunds you owe (channel-backed ones settle themselves) | `--endpoint`, `--org` |
67
+ | `dvmctl credit drain-settle <handle> <credit-id> <drain-id>` | Record a refund you sent on chain — idempotent | `--tx` (required), `--note`, `--endpoint`, `--org` |
68
+ | `dvmctl credit x402-wedged <handle>` | x402 refunds that settled on chain but never booked — the repair queue | `--include-resolved`, `--limit`, `--after`, `--min-age-ms`, `--no-evidence`, `--endpoint`, `--org` |
69
+ | `dvmctl credit x402-reconcile <handle> <settlement-id>` | Finish one at the chain's own figure — never one you supply | `--endpoint`, `--org` |
70
+ | `dvmctl credit x402-write-off <handle> <settlement-id>` | Reviewed, not booking — moves no money, reversible | `--note`, `--endpoint`, `--org` |
71
+ | `dvmctl credit tempo-wedged <handle>` | Tempo closes that landed on chain but never booked the drain | `--include-resolved`, `--limit`, `--after`, `--min-age-ms`, `--no-evidence`, `--endpoint`, `--org` |
72
+ | `dvmctl credit tempo-reconcile <handle> <credit-id> <drain-id>` | Book one against the close receipt's own refund figure | `--endpoint`, `--org` |
73
+ | `dvmctl credit tempo-write-off <handle> <credit-id> <drain-id>` | Reviewed, not booking — moves no money, reversible | `--note`, `--endpoint`, `--org` |
74
+
75
+ ### Account, org & auth
76
+
77
+ | Command | Purpose | Key flags |
78
+ | ------------------------------ | -------------------------------------------------------- | -------------------------------- |
79
+ | `dvmctl auth` | Authenticate with the platform | `--status`, `--logout` |
80
+ | `dvmctl switch [handle]` | Set the active org context for deploys (omit = show) | `--personal` (clear) |
81
+ | `dvmctl transfer <slug> <to>` | Transfer a DVM to an org (by handle) or back to personal | `--create-org`, `--display-name`, `--org` |
82
+ | `dvmctl api-keys list` | List builder API keys | |
83
+ | `dvmctl api-keys create` | Create a builder API key | `--label` |
84
+ | `dvmctl api-keys revoke <id>` | Revoke a builder API key | |
85
+ | `dvmctl handle set <handle>` | Claim a builder handle for `*.dvmkit.ai` subdomains | |
86
+ | `dvmctl handle check <handle>` | Check handle availability | |
87
+ | `dvmctl identity create` | Generate a per-workstation secp256k1 builder identity | `--force` |
88
+ | `dvmctl identity show` | Print the builder identity pubkey | |
89
+
90
+ ## Caller feedback
91
+
92
+ Callers can send signed-envelope feedback about a DVM via `dvm feedback --dvm <slug> "..."` (or
93
+ `--job <jobId>` to attach a trace_id). The platform persists every submission per-slug; as the
94
+ builder, you read and toggle collection through `dvmctl feedback`.
95
+
96
+ ```bash
97
+ dvmctl feedback list <slug> # newest-first, JSON
98
+ dvmctl feedback list <slug> --since 24h # relative duration ("24h", "7d") or ISO 8601
99
+ dvmctl feedback list <slug> --limit 100 --cursor <c> --org acme
100
+ ```
101
+
102
+ `list` is cursor-paginated, capped at 100 per page (default 50). Walk older pages by passing the
103
+ response's `next_cursor` back via `--cursor`. JSON shape:
104
+
105
+ ```json
106
+ {
107
+ "slug": "...",
108
+ "feedback": [
109
+ { "id": "...", "slug": "...", "created_at": "...", "caller_pubkey": "...",
110
+ "body": "...", "trace_id": "..." | null, "job_id": "..." | null }
111
+ ],
112
+ "next_cursor": "..." | undefined
113
+ }
114
+ ```
115
+
116
+ Toggle collection per-DVM:
117
+
118
+ ```bash
119
+ dvmctl feedback enable <slug> # flips dvms.feedback_enabled = true
120
+ dvmctl feedback disable <slug> # flips dvms.feedback_enabled = false
121
+ ```
122
+
123
+ When disabled, subsequent caller submissions to that slug fail with `409 feedback_disabled` and a
124
+ `display` field the calling agent can relay to its user. `list` keeps returning previously-stored
125
+ rows either way — the toggle gates collection, not retrieval.
126
+
127
+ All three commands require `dvmctl auth` (org-scoped); `list` only returns feedback for DVMs owned
128
+ by the authenticated org.
129
+
130
+ ---
131
+
132
+ ## Verification
133
+
134
+ After writing or modifying DVM code, run the generated project's test command. Add project-owned
135
+ `lint` and `typecheck` scripts before relying on them, then run all three from the project directory:
136
+
137
+ ```bash
138
+ npm run lint
139
+ npm run typecheck
140
+ npm test
141
+ ```
142
+
143
+ **If the capability charges and calls a paid third-party API, there is a fourth rung** — the
144
+ [live provider contract](testing.md#live-provider-contracts):
145
+
146
+ ```bash
147
+ <PROVIDER_KEY>=… npm run test:providers:live
148
+ ```
149
+
150
+ This is a project-owned opt-in script: add it as described in the
151
+ [live provider contract](testing.md#live-provider-contracts). It is key-gated and spends real vendor
152
+ money, so it skips cleanly without the key. If you don't have one, **say so** and hand the operator
153
+ a green static pass — don't report the provider contract as verified when the suite skipped.
@@ -0,0 +1,390 @@
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
+ ---
@@ -0,0 +1,11 @@
1
+ # Payment rail limits for first builds
2
+
3
+ Use Cashu FakeWallet for the local paid demonstration. It is the only supported builder-facing local payment path in the admitted SDK release.
4
+
5
+ | Rail | First-build local proof | Current limit |
6
+ | --- | --- | --- |
7
+ | Cashu | Supported with a local Nutshell FakeWallet mint, disposable Postgres and builder/caller state. | Test value only; never deploy the test mint or key. Follow [Local Cashu paid test](local-cashu-test.md). |
8
+ | x402 | No builder-facing x402-only local verification. | Do not configure a mainnet endpoint as a substitute for a test. |
9
+ | Tempo | No supported builder-facing local test recipe. | The forthcoming Tempo testnet environment switches are not in the admitted SDK release. |
10
+
11
+ Payment configuration for a deployed DVM belongs to the deployment decision. A successful Cashu test proves the local Cashu path only; it does not validate another rail, production credentials, payout, or real-money settlement.