@dvmkit/dvmctl 0.0.0 → 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.
package/package.json CHANGED
@@ -1,18 +1,66 @@
1
1
  {
2
2
  "name": "@dvmkit/dvmctl",
3
- "version": "0.0.0",
4
- "description": "Bootstrap claim for the dvmctl builder command-line tool; install a release from the next or latest dist-tag.",
3
+ "version": "0.2.0-rc.2",
4
+ "description": "Builder CLI for creating, testing, and deploying Digital Vending Machines",
5
5
  "license": "Apache-2.0",
6
- "homepage": "https://dvmkit.ai",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/dvmkit/dvmctl.git"
9
+ },
7
10
  "publishConfig": {
8
11
  "access": "public",
9
- "registry": "https://registry.npmjs.org/",
10
- "tag": "bootstrap"
12
+ "provenance": false
13
+ },
14
+ "type": "module",
15
+ "bin": {
16
+ "dvmctl": "dist/dvmctl.js"
11
17
  },
12
18
  "files": [
19
+ "dist",
20
+ "skills",
21
+ "LICENSE",
22
+ "NOTICE",
13
23
  "README.md"
14
24
  ],
25
+ "exports": {
26
+ "./skill": "./skills/build-dvm/SKILL.md"
27
+ },
15
28
  "engines": {
16
29
  "node": ">=22"
30
+ },
31
+ "scripts": {
32
+ "build": "tsup --metafile && node scripts/check-bundled-notices.mjs",
33
+ "lint": "eslint . --max-warnings 0",
34
+ "typecheck": "tsc --noEmit",
35
+ "test": "node --test test/*.test.mjs",
36
+ "verify": "npm run build && npm run lint && npm run typecheck && npm test"
37
+ },
38
+ "dependencies": {
39
+ "@dvmkit/sdk": "0.1.4-rc.7"
40
+ },
41
+ "optionalDependencies": {
42
+ "pg": "8.20.0",
43
+ "tsx": "^4"
44
+ },
45
+ "devDependencies": {
46
+ "@cashu/cashu-ts": "4.10.2",
47
+ "@scure/bip39": "2.0.1",
48
+ "commander": "^14",
49
+ "mppx": "0.8.15",
50
+ "proper-lockfile": "4.1.2",
51
+ "viem": "2.55.11",
52
+ "ws": "8.21.0",
53
+ "@types/node": "25.3.4",
54
+ "@types/pg": "8.18.0",
55
+ "@types/proper-lockfile": "4.1.4",
56
+ "@types/ws": "8.18.1",
57
+ "pg": "8.20.0",
58
+ "tsup": "8.5.1",
59
+ "tsx": "4.21.0",
60
+ "typescript": "5.9.3",
61
+ "@eslint/js": "^10.0.1",
62
+ "eslint": "^10.0.2",
63
+ "globals": "^17.7.0",
64
+ "typescript-eslint": "^8.56.1"
17
65
  }
18
66
  }
@@ -0,0 +1,93 @@
1
+ ---
2
+ name: build-dvm
3
+ description: Build a first DVM with dvmkit. Use when asked to turn an idea into a paid HTTPS capability, scaffold or hand-write a DVM, implement a `configureDVM` handler, run it locally, test it with Cashu FakeWallet funds, or prepare it for deployment. Do not use for changing the dvmkit platform, SDK internals, or a caller wallet unrelated to testing a DVM.
4
+ compatibility: Node.js 22+, npm, the invited `@dvmkit/dvmctl` release, and Docker for the local paid Cashu test. The optional paid test also uses `@dvmkit/dvm-cli@0.2.0` with isolated builder and caller state.
5
+ allowed-tools: Bash Read Write Edit Glob Grep
6
+ ---
7
+
8
+ # Build a DVM
9
+
10
+ Use this process for a first build. Stop after each approval point; do not deploy, connect a wallet, or spend real funds until the builder explicitly asks.
11
+
12
+ ## 1. Install the CLI and this skill
13
+
14
+ Use the exact `@dvmkit/dvmctl` version named in the builder's invitation. Never use a floating tag. Install the CLI, then install the complete skill directory so its references remain available.
15
+
16
+ ```bash
17
+ npm install -g @dvmkit/dvmctl@<invited-version>
18
+ npm install --ignore-scripts --prefix "$HOME/.dvmkit" @dvmkit/dvmctl@<invited-version>
19
+ skill_dir="$(dirname "$(NODE_PATH="$HOME/.dvmkit/node_modules" node -p "require.resolve('@dvmkit/dvmctl/skill')")")"
20
+ ```
21
+
22
+ <!-- agent-mirror:verbatim start -->
23
+ For Claude Code, install the complete directory globally:
24
+
25
+ ```bash
26
+ mkdir -p "$HOME/.claude/skills/build-dvm"
27
+ cp -R "$skill_dir"/. "$HOME/.claude/skills/build-dvm/"
28
+ ```
29
+
30
+ For Codex in the current project, install the complete directory locally:
31
+
32
+ ```bash
33
+ mkdir -p .agents/skills/build-dvm
34
+ cp -R "$skill_dir"/. .agents/skills/build-dvm/
35
+ ```
36
+ <!-- agent-mirror:verbatim end -->
37
+
38
+ For another harness, copy the same complete directory to that harness's project-skill location. Confirm `dvmctl --version` reports the invited version before continuing.
39
+
40
+ Give the builder this canonical prompt after installation:
41
+
42
+ > Help me build a DVM with dvmkit. Read the installed `build-dvm` skill. Interview me about one capability, its input, output, example, fiat price, and name. Write a short brief and wait for my approval. Then build and test it locally, including the optional Cashu FakeWallet paid-call path with isolated caller state. Do not deploy or use real funds without my approval.
43
+
44
+ ## 2. Shape one capability
45
+
46
+ Ask only for the decisions that change the first version: input, result, one example, name, and a fiat price. Prefer one capability and one typed object input. Write this brief and get approval before editing:
47
+
48
+ ```text
49
+ Name:
50
+ Capability:
51
+ Input: example JSON and validation rules
52
+ Result: text or named artifact
53
+ Price: $0.01 (or free)
54
+ Paid-test cap: separately approved before test money is spent
55
+ Example: input → result
56
+ ```
57
+
58
+ The approved DVM price and the approved paid-test cap are different decisions. A $0.01 DVM can use a $0.02 per-test cap to allow whole-satoshi rounding, but ask for that cap if it is not already approved. Never raise a cap automatically. Do not choose a payment rail or cloud configuration during this interview. A local Cashu mint is test infrastructure, not the builder's production payment choice.
59
+
60
+ ## 3. Implement and test the free path
61
+
62
+ Scaffold when it helps; a hand-written Node 22 project with `@dvmkit/sdk` is equally supported.
63
+
64
+ ```bash
65
+ dvmctl create my-dvm
66
+ cd my-dvm
67
+ npm install
68
+ npm test
69
+ dvmctl dev handler.ts
70
+ ```
71
+
72
+ Keep `handler.ts` small: use `configureDVM`, a Zod `input` schema, a USD `price` string when paid, and `ctx.complete()` with a useful summary. Put provider keys in `.env`, never source code. Add a unit test using `createTestContext` before calling external services. Use the `/_dev` URL printed by `dvmctl dev` to show the builder the free result.
73
+
74
+ Read [the SDK reference](references/sdk-reference.md) for descriptor shapes, job-context methods, streaming, dynamic quotes, auth, persistence, custom routes, and deployment. Read [the Cashu test reference](references/local-cashu-test.md) before a paid test.
75
+
76
+ ## 4. Exercise a paid local call with test money
77
+
78
+ This is the only first-build paid path. It uses a local Cashu FakeWallet mint, which auto-settles test invoices and has no real value. Before starting, confirm a separate per-test dollar cap; approving the DVM's price alone is not payment approval. Then follow the complete [Local Cashu paid test](references/local-cashu-test.md). It isolates `DVMKIT_CONFIG_DIR`, `DVMCTL_CONFIG_DIR`, and `DVM_CONFIG_DIR`; uses self-hosted `dvmctl serve --dvm` with Postgres; and proves Cashu advertisement, an unpaid 402, the paid result, receipt, and balance decrease.
79
+
80
+ ## 5. Decide before deployment
81
+
82
+ After the local result and paid test work, show the approved brief, test result, and the exact files changed. Ask whether to prepare deployment. Only then follow the invitation's account and deployment instructions. Production rails, payout configuration, wallet connections, and real funds are separate decisions.
83
+
84
+ ## Reference routing
85
+
86
+ - [Configure and job context](references/configure-context.md): descriptor shapes, context methods, artifacts, cancellation, and costs.
87
+ - [Pricing and credit](references/pricing-credit.md): fiat pricing, quotes, and prepaid credit.
88
+ - [Patterns and auth](references/patterns-auth.md): multi-turn handlers, signed requests, refunds, receipts, and custom routes.
89
+ - [Testing](references/testing.md): schemas, unit contexts, and live-provider boundaries.
90
+ - [Running and deployment](references/running-deployment.md): dev server, host options, persistence, environment, and deployment.
91
+ - [Operating and feedback](references/operating-feedback.md): deployed-DVM operations, revenue, and caller feedback.
92
+ - [Local Cashu paid test](references/local-cashu-test.md): prerequisites, expected behavior, and failures.
93
+ - [Rail limits](references/payment-rails.md): what can and cannot be tested locally today.
@@ -0,0 +1,273 @@
1
+ ## Quickstart — scaffold with `dvmctl create`
2
+
3
+ ```bash
4
+ dvmctl create my-dvm # scaffolds ./my-dvm/
5
+ cd my-dvm
6
+ npm install
7
+ npm test # the scaffold ships a passing test
8
+ dvmctl dev handler.ts # http://localhost:3000, hot reload, /_dev test page
9
+ ```
10
+
11
+ > Zod ships with the SDK — `import { z } from "@dvmkit/sdk"` for typed `input:` schemas, no extra
12
+ > install. Using the SDK's own zod guarantees a builder schema and the SDK validate against one
13
+ > instance. Skip the schema and `ctx.input` is a raw string you parse yourself.
14
+
15
+ `dvmctl create <name>` writes a standalone npm project:
16
+
17
+ ```
18
+ my-dvm/
19
+ handler.ts # your DVM — a configureDVM(...) descriptor (the file you edit)
20
+ serve.ts # production entry point — calls serve(handler, {...})
21
+ handler.test.ts # vitest unit tests
22
+ package.json # depends on @dvmkit/sdk; scripts: test / dev / deploy
23
+ tsconfig.json
24
+ Dockerfile # two-stage Node 22 build (add system deps here)
25
+ .env.example # copy to .env for API keys / secrets
26
+ README.md
27
+ ```
28
+
29
+ The generated `handler.ts` is a one-capability echo stub. Replace its `onJob` body with your
30
+ logic; everything below is how to grow it.
31
+
32
+ > **Self-hosting instead of deploying to the platform?** Run `dvmctl init <handle> --self-hosted`
33
+ > once to generate a stable `canonical_dvm_id` and persist it to `~/.dvmkit/builder.json` (a
34
+ > platform deploy assigns this ID for you at deploy time). You then `serve(dvm)` on your own
35
+ > infrastructure.
36
+
37
+ **Imports** (the SDK ships three entry points):
38
+
39
+ ```typescript
40
+ import { configureDVM } from "@dvmkit/sdk"; // descriptor + types
41
+ import { serve, createDVMHost } from "@dvmkit/sdk/server"; // hosting
42
+ import { secp256k1Auth } from "@dvmkit/sdk/server"; // descriptor-level signed-request auth
43
+ import { createTestContext } from "@dvmkit/sdk/testing"; // unit tests
44
+ ```
45
+
46
+ ---
47
+
48
+ ## Quick example — single-capability DVM
49
+
50
+ `handler.ts`:
51
+
52
+ ```typescript
53
+ import { configureDVM, z } from "@dvmkit/sdk";
54
+
55
+ const inputSchema = z.object({ text: z.string() });
56
+
57
+ export function createUppercaseDVM() {
58
+ return configureDVM({
59
+ name: "uppercase",
60
+ description: "Convert text to uppercase.",
61
+ capability: "uppercase",
62
+ tags: ["text", "uppercase"],
63
+ input: inputSchema,
64
+ price: "$0.01",
65
+
66
+ async onJob(ctx) {
67
+ ctx.complete(ctx.input.text.toUpperCase());
68
+ },
69
+ });
70
+ }
71
+
72
+ export default createUppercaseDVM();
73
+ ```
74
+
75
+ `serve.ts`:
76
+
77
+ ```typescript
78
+ import { serve } from "@dvmkit/sdk/server";
79
+ import dvm from "./handler";
80
+
81
+ await serve(dvm);
82
+ ```
83
+
84
+ `serve(dvm)` reads all wiring (port, `DATABASE_URL`, `DVMKIT_CASHU_MINTS`, Tempo, x402, FX,
85
+ platform reporter) from env. Pass `opts` to override; reach for `createDVMHost` when you need
86
+ custom HTTP routes or to mount several DVMs on one host.
87
+
88
+ ---
89
+
90
+ ## configureDVM() reference
91
+
92
+ ```typescript
93
+ configureDVM({
94
+ // --- DVM-level (always at top level) ---
95
+ name: string,
96
+ description?: string,
97
+ tag?: string,
98
+ tags?: string[],
99
+ idleTimeout?: number, // seconds before auto-cancel (default: 3600)
100
+
101
+ auth?: DVMAuthScheme, // descriptor-level signed-request auth (secp256k1Auth({...}))
102
+
103
+ onBoot?(env): void | Promise<void>, // run once at server start
104
+ onShutdown?(): void | Promise<void>, // run once at server stop
105
+ routes?(app: Hono): void | Promise<void>, // DVM-scoped non-protocol routes
106
+
107
+ paymentMethods?: PaymentMethod[], // ["cashu" | "x402" | "tempo"]; auto-derived from configured rails
108
+
109
+ // --- Pick one of the two shapes below ---
110
+
111
+ // Flat single-capability shape (most DVMs):
112
+ capability?: string, // e.g. "synthesise" — names this one capability
113
+ // (auto-slugified from `name` if omitted)
114
+ input?: ZodSchema, // ctx.input is typed
115
+ state?: object, // ctx.state defaults, typed
116
+ price?: PriceValue, // "$0.05" — USD literal, the only form
117
+ onQuote?: QuoteConfig, // dynamic pricing
118
+ onJob(ctx): void | Promise<void>, // required
119
+ onResponse?(ctx, content): void | Promise<void>,
120
+ onPayment?(ctx, content): void | Promise<void>,
121
+ onApproval?(ctx, content): void | Promise<void>,
122
+ onCancel?(ctx, content): void | Promise<void>,
123
+ onMessage?(ctx, msg): void | Promise<void>,
124
+
125
+ // OR multi-capability block (mutually exclusive with flat shape):
126
+ capabilities?: Record<string, CapabilityConfig>,
127
+ });
128
+ ```
129
+
130
+ `configureDVM` returns a frozen `DVMDescriptor`. The flat shape desugars internally to a
131
+ one-entry `capabilities` map — `dvm.capabilities[<name>].onJob` works in either form (tests rely
132
+ on this).
133
+
134
+ ### Per-capability fields (multi-capability shape)
135
+
136
+ Each entry in `capabilities` accepts: `description`, `input`, `state`, `price`, `onQuote`,
137
+ `onJob` (required), and the same message hooks (`onResponse`, `onPayment`, `onApproval`,
138
+ `onCancel`, `onMessage`). DVM-level concerns (`name`, `description`, `tags`, `auth`,
139
+ `paymentMethods`, `routes`, lifecycle hooks) stay on the parent and are shared across every
140
+ capability.
141
+
142
+ ---
143
+
144
+ ## SDKJobContext reference
145
+
146
+ ### Identity (readonly)
147
+
148
+ | Property | Type | Description |
149
+ | ------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------- |
150
+ | `jobId` | `string` | Server-assigned job ID |
151
+ | `tags` | `string[]` | Tags from the DVM descriptor |
152
+ | `input` | `Input` | Job input (raw string, or parsed object when `input` schema is set) |
153
+ | `params` | `Record<string, string>` | Key-value parameters |
154
+ | `requesterId` | `string` | Opaque requester ID (derived from auth or "anonymous") |
155
+ | `paidMsats` | `number` | Total msats paid so far |
156
+ | `auth` | `{ pubkey, envelope } \| undefined` | Verified caller identity — present only when the descriptor declares `auth: secp256k1Auth(...)` |
157
+
158
+ ### Messaging
159
+
160
+ ```typescript
161
+ ctx.text(message: string): void
162
+ ctx.artifact(content: ArtifactContent): void
163
+ ctx.sendMessage(type: MessageType, content: object): void
164
+ ctx.progress(percentComplete: number, phase?: string, hint?: string): void
165
+ ```
166
+
167
+ `ArtifactContent`: `{ data: string, mime_type: string, name?: string, encoding?: "base64" }`
168
+
169
+ ### Flow control
170
+
171
+ ```typescript
172
+ ctx.prompt(id: string, text: string, opts?): Promise<ResponseContent>
173
+ ctx.requestPayment(
174
+ amount: number | { amount: number; currency: "usd" },
175
+ reason: string,
176
+ opts?: SDKPaymentRequestOpts,
177
+ ): Promise<PaymentContent>
178
+ ctx.working(estimateSeconds?: number, hint?: string): void
179
+ ctx.complete(summary: string): void // one-liner; artifacts carry the payload
180
+ ctx.fail(error: string, opts?: { refund?: boolean }): void
181
+ ctx.cost({ amount, currency }): void // your serving cost, in major units
182
+ ```
183
+
184
+ `requestPayment` takes msats (`number`) or a fiat envelope (`{ amount, currency: "usd" }`) — the
185
+ SDK converts to sats at send-time. The legacy `"$0.05"` string form was removed. On counter-based
186
+ auto-credit, `requestPayment` resolves synchronously when the upfront pool already
187
+ covers the amount and only suspends when the pool is exhausted. The caller sees whatever currency
188
+ you name; the ledger records the charge in the DVM's own `currency`, converted at the moment the
189
+ charge is made, so the anti-double-charge ceiling on the job's credit stays exact.
190
+
191
+ Pass `{ refund: true }` to `ctx.fail` to return Cashu proofs to the caller — typical when the
192
+ failure isn't operator fault (a network glitch, an upstream provider error). Don't refund on
193
+ operator-fault failures (misconfiguration, your own upload failing). See [Refund-on-failure
194
+ policy](patterns-auth.md#refund-on-failure-policy).
195
+
196
+ ### Cancellation — `ctx.signal`
197
+
198
+ ```typescript
199
+ ctx.signal: AbortSignal // aborts on caller cancel, idle timeout, stale sweep, supersession
200
+ ```
201
+
202
+ Cancel is **terminal**: after it, `ctx.complete` / `ctx.fail` / `ctx.working` and the message
203
+ emitters are no-ops that log a warning (a cancelled job can never come back as `completed`, and no
204
+ revenue is booked on it), and `ctx.prompt` / `ctx.requestPayment` reject instead of hanging. This
205
+ holds across machines — a terminal status is sticky in the job store and the runtime re-reads it
206
+ before persisting, so a handler on a machine that never saw the cancel still can't overwrite it.
207
+
208
+ ### Report serving costs
209
+
210
+ Use `ctx.cost({ amount, currency })` immediately after a paid provider call or other builder-side
211
+ expense. Amounts are major units, repeated calls add, and only the job total is rounded to
212
+ millionths. Every declaration in one job must use the same lowercase ISO currency; a mixed-currency
213
+ job reports no total rather than guessing an exchange rate.
214
+
215
+ No call means “unknown”; `{ amount: 0, currency: "usd" }` explicitly means free. Cost declarations
216
+ inside `ctx.step()` are cached with the result and replay exactly once when the body is skipped, so
217
+ declare the cost where it is incurred. Paid completions attach it to revenue, while free completions,
218
+ failures, and cancellations use the separate cost-only report and create no revenue row.
219
+
220
+ `ctx.fetch` already carries the signal — every request through it aborts on cancel, merged with any
221
+ per-request signal you pass. Thread `ctx.signal` into everything else that can be torn down
222
+ (provider SDK `signal` options, spawned processes, your own sleeps): that is where provider spend
223
+ actually stops.
224
+
225
+ ```typescript
226
+ async onJob(ctx) {
227
+ const audio = await client.tts.generate(text, { signal: ctx.signal });
228
+
229
+ for (const chunk of chunks) {
230
+ if (ctx.signal.aborted) return; // already terminal — don't call ctx.fail()
231
+ await process(chunk);
232
+ }
233
+
234
+ ctx.complete("Done");
235
+ }
236
+ ```
237
+
238
+ Return early on abort; the runtime has written the terminal status already. `onCancel` still fires if
239
+ you declare one — use it for cleanup (release a lock, delete a staged upload); the signal is raised
240
+ before the hook runs, so a slow cleanup can't hold the abort back.
241
+
242
+ **`onCancel` is cleanup only — it can't emit.** The job is terminal before the hook runs, so a
243
+ `ctx.text()` / `ctx.artifact()` / `ctx.complete()` inside it is ignored with a
244
+ `ctx.<op>() ignored — job already cancelled` warning. Emit partial results as you go, not on the way
245
+ out.
246
+
247
+ **Multi-machine DVMs are covered.** On a DVM with `max_machines > 1`, a cancel that lands on a machine
248
+ other than the one running the handler is committed to the store there; the machine running the
249
+ handler subscribes to its own jobs' notifications, so it picks the cancel up within a notification
250
+ round-trip — `ctx.signal` fires, `onCancel` runs on the machine holding the resources, the cancel
251
+ lands in the job's message log, and the handler's late writes are rejected. Same behaviour whichever
252
+ machine the cancel hits.
253
+
254
+ **Isolate tier.** An isolate-hosted handler runs in its own Machine, so it learns of a cancel by
255
+ polling its job's durable status on the platform (default every 2 s, `DVMKIT_ISOLATE_CANCEL_POLL_MS`).
256
+ The contract above is identical — `ctx.signal` aborts, `ctx.fetch` tears down, emitters no-op, yields
257
+ reject — just one poll tick behind the cancel.
258
+
259
+ ### Platform services
260
+
261
+ ```typescript
262
+ ctx.state: State // typed from defaults; auto-persisted on every yield
263
+ ctx.store: KVStore // cross-job async KV storage
264
+ ctx.fetch: typeof fetch // instrumented fetch — dvm.fetch spans + auto-aborts on cancel
265
+ ctx.env: Record<string, string> // environment variables (read at job time, not boot)
266
+ ctx.log: Logger // structured logging (.debug, .info, .warn, .error)
267
+ ctx.step<T>(id: string, fn: () => Promise<T>): Promise<T> // cached on yield
268
+ ```
269
+
270
+ Prefer `ctx.fetch` over the global `fetch` for outbound HTTP: calls show up in traces, and they
271
+ abort when the caller cancels the job.
272
+
273
+ ---
@@ -0,0 +1,141 @@
1
+ # Local Cashu paid test
2
+
3
+ Use this only after the builder has approved both the DVM price and a separate dollar cap for this one test. A $0.01 DVM commonly needs a $0.02 cap because Cashu payments settle in whole satoshis. Do not infer approval or raise the cap after a refusal. This one shell session owns its temporary state, server process, Postgres container, and (unless `CASHU_MINT_URL` is supplied) FakeWallet container. It never stops an externally supplied mint.
4
+
5
+ The handler must have the approved static USD price and produce a useful completion summary or `ctx.artifact`. Replace the variables with the approved brief. For a named artifact, specify its name and MIME type; for JSON, also specify expected keys from the approved example. Leave the name and MIME values empty only when the completion summary is the whole result. Use `--data` for an object input; a deliberately primitive string input uses `-i` in the final request instead.
6
+
7
+ ```bash
8
+ set -euo pipefail
9
+
10
+ DVM_HANDLE="my-dvm"
11
+ DVM_CAPABILITY="uppercase"
12
+ DVM_INPUT='{"text":"your approved example"}'
13
+ DVM_PAYMENT_CAP='$0.02' # explicit approval for this test, separate from the $0.01 DVM price
14
+ DVM_ARTIFACT_NAME="" # e.g. result.json; required when the handler emits an artifact
15
+ DVM_ARTIFACT_MIME="" # e.g. application/json
16
+ DVM_ARTIFACT_JSON_KEYS="" # comma-separated required keys, e.g. answer,language
17
+
18
+ test_root="$(mktemp -d "${TMPDIR:-/tmp}/dvmkit-cashu-paid.XXXXXX")"
19
+ export DVMKIT_CONFIG_DIR="$test_root/builder"
20
+ export DVMCTL_CONFIG_DIR="$test_root/dvmctl"
21
+ export DVM_CONFIG_DIR="$test_root/caller"
22
+ mkdir -p "$DVMKIT_CONFIG_DIR" "$DVMCTL_CONFIG_DIR" "$DVM_CONFIG_DIR"
23
+
24
+ postgres_name="dvmkit-cashu-postgres-$$"
25
+ postgres_id=""
26
+ mint_id=""
27
+ dvm_pid=""
28
+ cleanup() {
29
+ status=$?
30
+ trap - EXIT INT TERM
31
+ [ -z "$dvm_pid" ] || kill "$dvm_pid" 2>/dev/null || true
32
+ [ -z "$mint_id" ] || docker rm -f "$mint_id" >/dev/null 2>&1 || true
33
+ [ -z "$postgres_id" ] || docker rm -f "$postgres_id" >/dev/null 2>&1 || true
34
+ rm -rf "$test_root"
35
+ exit "$status"
36
+ }
37
+ trap cleanup EXIT
38
+ trap 'exit 130' INT TERM
39
+
40
+ docker run --rm -d --name "$postgres_name" -e POSTGRES_PASSWORD=paid-test \
41
+ -p 127.0.0.1::5432 postgres:17-alpine >/dev/null
42
+ postgres_id="$(docker container inspect --format '{{.Id}}' "$postgres_name")"
43
+ for _ in $(seq 1 30); do docker exec "$postgres_id" pg_isready -U postgres -d postgres >/dev/null 2>&1 && break; sleep 1; done
44
+ docker exec "$postgres_id" pg_isready -U postgres -d postgres >/dev/null
45
+ postgres_port="$(docker port "$postgres_id" 5432/tcp | sed -E 's/.*:([0-9]+)$/\1/')"
46
+ export DATABASE_URL="postgresql://postgres:paid-test@127.0.0.1:${postgres_port}/postgres"
47
+
48
+ if [ -n "${CASHU_MINT_URL:-}" ]; then
49
+ cashu_mint_url="$CASHU_MINT_URL"
50
+ else
51
+ mint_name="dvmkit-cashu-mint-$$"
52
+ docker run --rm -d --name "$mint_name" -p 127.0.0.1::3338 \
53
+ -e MINT_LISTEN_HOST=0.0.0.0 -e MINT_LISTEN_PORT=3338 \
54
+ -e MINT_BACKEND_BOLT11_SAT=FakeWallet \
55
+ -e MINT_PRIVATE_KEY=test-key-do-not-use-in-production \
56
+ cashubtc/nutshell:0.19.2 poetry run mint >/dev/null
57
+ mint_id="$(docker container inspect --format '{{.Id}}' "$mint_name")"
58
+ cashu_mint_url="http://127.0.0.1:$(docker port "$mint_id" 3338/tcp | sed -E 's/.*:([0-9]+)$/\1/')"
59
+ fi
60
+ for _ in $(seq 1 30); do curl -fsS "$cashu_mint_url/v1/info" >"$test_root/mint-info.json" && break; sleep 1; done
61
+ test -s "$test_root/mint-info.json"
62
+
63
+ dvmctl lock create
64
+ dvmctl identity create
65
+ dvmctl init "$DVM_HANDLE" --self-hosted
66
+ dvm_port="$(node -e 'require("node:net").createServer().listen(0, "127.0.0.1", function () { console.log(this.address().port); this.close(); })')"
67
+ DVMKIT_CASHU_MINTS="$cashu_mint_url" dvmctl serve handler.ts --dvm "$DVM_HANDLE" --port "$dvm_port" >"$test_root/dvm.log" 2>&1 &
68
+ dvm_pid=$!
69
+ dvm_endpoint="http://127.0.0.1:$dvm_port"
70
+ for _ in $(seq 1 30); do curl -fsS "$dvm_endpoint/v1/info" >"$test_root/info.json" && break; sleep 1; done
71
+ test -s "$test_root/info.json"
72
+ node -e 'const i=require(process.argv[1]); if (!i.payment?.methods?.includes("cashu")) throw new Error("/v1/info does not advertise cashu")' "$test_root/info.json"
73
+
74
+ node -e 'process.stdout.write(JSON.stringify({capability:process.argv[1],data:JSON.parse(process.argv[2])}))' "$DVM_CAPABILITY" "$DVM_INPUT" >"$test_root/unpaid-request.json"
75
+ unpaid_status="$(curl -sS -o "$test_root/unpaid.json" -w '%{http_code}' -H 'content-type: application/json' --data-binary @"$test_root/unpaid-request.json" "$dvm_endpoint/v1/job")"
76
+ test "$unpaid_status" = 402
77
+
78
+ npm install -g @dvmkit/dvm-cli@0.2.0
79
+ dvm init
80
+ dvm wallet init
81
+ dvm wallet mint-add "$cashu_mint_url"
82
+ dvm wallet fund '$1' --mint "$cashu_mint_url" --test-skip-lightning
83
+ dvm wallet show >"$test_root/balance-before.json"
84
+ dvm request --endpoint "$dvm_endpoint" --data "$DVM_INPUT" --rail cashu --mint "$cashu_mint_url" \
85
+ --budget "$DVM_PAYMENT_CAP" --auto-pay-below "$DVM_PAYMENT_CAP" \
86
+ --max-increment "$DVM_PAYMENT_CAP" --max-payments 1 </dev/null >"$test_root/paid.json"
87
+ dvm wallet show >"$test_root/balance-after.json"
88
+ node -e '
89
+ const before=require(process.argv[1]).total_sats;
90
+ const after=require(process.argv[2]).total_sats;
91
+ const paid=require(process.argv[3]);
92
+ if (!(typeof before === "number" && typeof after === "number" && after < before)) throw new Error("Cashu balance did not decrease");
93
+ if (!paid.jobId || paid.next_action !== null || paid.receipt_verified !== "verified") throw new Error("paid receipt is not verified");
94
+ if (paid.receipt?.outcome !== "completed" || paid.receipt?.paid?.rail !== "cashu" || !(paid.receipt.paid.msats > 0)) throw new Error("receipt does not prove a positive Cashu payment for a completed job");
95
+ if (!(paid.summary || paid.has_artifacts)) throw new Error("paid result has no completion output");
96
+ ' "$test_root/balance-before.json" "$test_root/balance-after.json" "$test_root/paid.json"
97
+ if [ -n "$DVM_ARTIFACT_NAME" ]; then
98
+ node -e 'if (!require(process.argv[1]).has_artifacts) throw new Error("expected artifact is missing")' "$test_root/paid.json"
99
+ test -n "$DVM_ARTIFACT_NAME" && test -n "$DVM_ARTIFACT_MIME"
100
+ case "$DVM_ARTIFACT_MIME" in application/json*) test -n "$DVM_ARTIFACT_JSON_KEYS" ;; esac
101
+ job_id="$(node -p 'require(process.argv[1]).jobId' "$test_root/paid.json")"
102
+ dvm messages "$job_id" --no-stream >"$test_root/artifacts.json"
103
+ cat "$test_root/artifacts.json"
104
+ node -e '
105
+ const m=require(process.argv[1]).messages.filter((m) => m.type === "artifact");
106
+ if (!m.length) throw new Error("artifact content is missing");
107
+ const [name, mime, keys]=process.argv.slice(2);
108
+ Promise.all(m.map(async ({content}) => {
109
+ let bytes;
110
+ if (typeof content?.data === "string" && content.data.length > 0) {
111
+ bytes=Buffer.from(content.data, content.encoding === "base64" ? "base64" : "utf8");
112
+ } else if (typeof content?.url === "string") {
113
+ const response=await fetch(content.url);
114
+ bytes=Buffer.from(await response.arrayBuffer());
115
+ if (!response.ok || bytes.byteLength === 0) throw new Error("artifact URL did not return content");
116
+ } else {
117
+ throw new Error("artifact content is missing");
118
+ }
119
+ const name=typeof content.name === "string" ? content.name :
120
+ content.name?._source === "provider" && typeof content.name.text === "string" ? content.name.text : undefined;
121
+ return {name, mime_type: content.mime_type, bytes_base64: bytes.toString("base64"), text: bytes.toString("utf8")};
122
+ })).then((artifacts) => {
123
+ const artifact=artifacts.find((a) => a.name === name && a.mime_type === mime);
124
+ if (!artifact) throw new Error("expected artifact name or MIME type is missing");
125
+ if (keys) { const value=JSON.parse(artifact.text); for (const key of keys.split(",")) if (!(key in value)) throw new Error(`artifact JSON is missing ${key}`); }
126
+ console.log(JSON.stringify({artifact}));
127
+ });' "$test_root/artifacts.json" "$DVM_ARTIFACT_NAME" "$DVM_ARTIFACT_MIME" "$DVM_ARTIFACT_JSON_KEYS" >"$test_root/artifact-content.json"
128
+ cat "$test_root/artifact-content.json"
129
+ fi
130
+ cat "$test_root/paid.json"
131
+ ```
132
+
133
+ The final JSON is the evidence artifact: it must show a terminal paid job and verified Cashu receipt, while the assertions prove Cashu was advertised, the same unauthenticated request returned 402, and the isolated balance fell. When the approved result is a named artifact, the recipe fetches URL content or decodes inline data, then checks its name, MIME type, and expected JSON keys. Add a stricter assertion for any deeper approved schema before counting the test as passed; the generic recipe cannot infer it. The trap deletes only the state and processes this session created. The FakeWallet key and funds are test-only; do not point this recipe at a production mint.
134
+
135
+ ## Failure routing
136
+
137
+ - `/v1/info` lacks `cashu`: do not retry with `dvmctl dev`. Confirm the three disposable configuration directories are exported, then rerun `lock create`, `identity create`, `init --self-hosted`, and `serve --dvm` in this session.
138
+ - The unpaid request is not 402: confirm the handler's approved price and capability/input values before spending test money.
139
+ - `fx_rate_unavailable` or `rate_unavailable`: the public BTC/USD rate provider could not answer. Wait for recovery before retrying, keeping the same approved dollar cap. A DVM-side test rate source does not configure the caller’s rate provider.
140
+ - The paid request exceeds the cap: stop and ask for a new explicit dollar cap. Do not increase any flag automatically.
141
+ - The mint cannot be reached: start the owned FakeWallet or correct `CASHU_MINT_URL`; an externally supplied mint remains outside this session's cleanup.