@dvmkit/dvmctl 0.2.1-rc.2 → 0.3.0-rc.1

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@dvmkit/dvmctl",
3
- "version": "0.2.1-rc.2",
3
+ "version": "0.3.0-rc.1",
4
4
  "description": "Builder CLI for creating, testing, and deploying Digital Vending Machines",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -1,7 +1,7 @@
1
1
  ---
2
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.
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, verify Cashu, x402, or Tempo test payments, 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, `@dvmkit/sdk@0.1.5-rc.9`, and `@dvmkit/dvm-cli@0.3.0`. Docker is needed only when this skill starts its local Cashu FakeWallet mint or performs a container build.
5
5
  allowed-tools: Bash Read Write Edit Glob Grep
6
6
  ---
7
7
 
@@ -39,11 +39,11 @@ For another harness, copy the same complete directory to that harness's project-
39
39
 
40
40
  Give the builder this canonical prompt after installation:
41
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.
42
+ > Help me build a DVM with dvmkit. Read the installed `build-dvm` skill. Establish one capability, its name, typed input, output, example, fiat price, and a separate test-payment cap. Reuse any decisions and approval I already supplied; ask only for material choices that are still missing. Then implement it, run `dvmctl validate` and `dvmctl doctor`, and test it locally. Use only the approved test rail and cap, with isolated caller state. Do not deploy, connect a production wallet, or use real funds without my separate approval.
43
43
 
44
44
  ## 2. Shape one capability
45
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:
46
+ Ask only for missing decisions that change the first version: input, result, one example, name, and a fiat price. Prefer one capability and one typed object input. If the builder already supplied and approved a complete brief, reuse it instead of asking them to approve it again. Otherwise write this brief and get approval before editing:
47
47
 
48
48
  ```text
49
49
  Name:
@@ -61,26 +61,38 @@ The approved DVM price and the approved paid-test cap are different decisions. A
61
61
 
62
62
  Scaffold when it helps; a hand-written Node 22 project with `@dvmkit/sdk` is equally supported.
63
63
 
64
+ For a hand-written project, pin the admitted SDK exactly:
65
+
66
+ ```bash
67
+ npm install --save-exact @dvmkit/sdk@0.1.5-rc.9
68
+ ```
69
+
64
70
  ```bash
65
71
  dvmctl create my-dvm
66
72
  cd my-dvm
67
73
  npm install
68
74
  npm test
75
+ dvmctl validate
76
+ dvmctl doctor
69
77
  dvmctl dev handler.ts
70
78
  ```
71
79
 
72
80
  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
81
 
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.
82
+ Read [the SDK reference](references/sdk-reference.md) for descriptor shapes, job-context methods, streaming, dynamic quotes, auth, persistence, custom routes, and deployment. `dvmctl validate` loads the real handler and checks its capability, schema, example, price, and tags. The `dev` banner states whether payments are skipped or really verified and prints the first caller command.
75
83
 
76
84
  ## 4. Exercise a paid local call with test money
77
85
 
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.
86
+ Use the rail and cap in the approved brief. If either is missing, ask before moving value. Testnet and FakeWallet funds still need the separate cap; production funds always need separate approval. Follow the complete recipe for [Cashu](references/local-cashu-test.md), [x402 on Base Sepolia](references/local-x402-test.md), or [Tempo Moderato](references/local-tempo-test.md). Every recipe isolates `DVM_CONFIG_DIR`, runs `dvmctl dev` without Postgres, proves the advertised test rail and unpaid 402, makes one capped paid call with closed stdin, and verifies the stored receipt. Never label a mainnet rail as test money because the server is in dev mode.
79
87
 
80
88
  ## 5. Decide before deployment
81
89
 
82
90
  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
91
 
92
+ Run `dvmctl deploy --dry-run` before a real deploy; Docker is required for a container dry-run. A first real deploy needs a builder signing identity. In a noninteractive run, use `dvmctl deploy --create-identity`; in a terminal, `dvmctl deploy` offers to create the missing identity. Neither route prints the signing secret.
93
+
94
+ Cashu accumulator operation also needs the separate recovery mnemonic created by `dvmctl lock create --human`. That command prints 12 recovery words only in human mode. Pause while the builder writes them down and require their explicit confirmation before continuing. Never infer that the backup happened, capture the words in a log, or replace an existing mnemonic without the builder's explicit instruction.
95
+
84
96
  ## Reference routing
85
97
 
86
98
  - [Configure and job context](references/configure-context.md): descriptor shapes, context methods, artifacts, cancellation, and costs.
@@ -89,5 +101,7 @@ After the local result and paid test work, show the approved brief, test result,
89
101
  - [Testing](references/testing.md): schemas, unit contexts, and live-provider boundaries.
90
102
  - [Running and deployment](references/running-deployment.md): dev server, host options, persistence, environment, and deployment.
91
103
  - [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.
104
+ - [Local Cashu paid test](references/local-cashu-test.md): FakeWallet or approved hosted-mint recipe.
105
+ - [Local x402 paid test](references/local-x402-test.md): Base Sepolia USDC recipe.
106
+ - [Local Tempo paid test](references/local-tempo-test.md): Moderato pathUSD recipe.
107
+ - [Payment rails](references/payment-rails.md): test boundaries and production separation.
@@ -1,28 +1,20 @@
1
1
  # Local Cashu paid test
2
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.
3
+ Use this after the builder approves the DVM price and a separate cap for one test payment. A `$0.01` price is fixed in dollars; the Cashu sats vary with the server's live quote. Derive the debit from the 402 and signed receipt. Never assume the server reads the caller's BTC-rate cache.
4
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.
5
+ The Docker-free default is the canonical hosted FakeWallet mint at `https://dvmkit-testmint.fly.dev`; override it only with another approved `CASHU_MINT_URL`. Set `CASHU_LOCAL_MINT=1` to opt into the owned Nutshell container fallback. Docker is needed only for that explicit fallback; `dvmctl dev` itself is Docker-free and needs no Postgres.
6
+
7
+ Set the values from the approved brief. Object inputs use `--data`; keep stdin closed so the caller cannot wait on the terminal.
6
8
 
7
9
  ```bash
8
10
  set -euo pipefail
9
-
10
- DVM_HANDLE="my-dvm"
11
11
  DVM_CAPABILITY="uppercase"
12
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
13
+ DVM_PAYMENT_CAP='$0.02'
17
14
 
18
15
  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
16
  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=""
17
+ mkdir -p "$DVM_CONFIG_DIR"
26
18
  mint_id=""
27
19
  dvm_pid=""
28
20
  cleanup() {
@@ -30,24 +22,13 @@ cleanup() {
30
22
  trap - EXIT INT TERM
31
23
  [ -z "$dvm_pid" ] || kill "$dvm_pid" 2>/dev/null || true
32
24
  [ -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
25
  rm -rf "$test_root"
35
26
  exit "$status"
36
27
  }
37
28
  trap cleanup EXIT
38
29
  trap 'exit 130' INT TERM
39
30
 
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
31
+ if [ "${CASHU_LOCAL_MINT:-0}" = "1" ]; then
51
32
  mint_name="dvmkit-cashu-mint-$$"
52
33
  docker run --rm -d --name "$mint_name" -p 127.0.0.1::3338 \
53
34
  -e MINT_LISTEN_HOST=0.0.0.0 -e MINT_LISTEN_PORT=3338 \
@@ -56,86 +37,67 @@ else
56
37
  cashubtc/nutshell:0.19.2 poetry run mint >/dev/null
57
38
  mint_id="$(docker container inspect --format '{{.Id}}' "$mint_name")"
58
39
  cashu_mint_url="http://127.0.0.1:$(docker port "$mint_id" 3338/tcp | sed -E 's/.*:([0-9]+)$/\1/')"
40
+ else
41
+ cashu_mint_url="${CASHU_MINT_URL:-https://dvmkit-testmint.fly.dev}"
59
42
  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"
43
+ for _ in $(seq 1 30); do
44
+ node -e 'fetch(process.argv[1]+"/v1/info",{signal:AbortSignal.timeout(2000)}).then(r=>{if(!r.ok)throw Error(`HTTP ${r.status}`)}).catch(()=>process.exit(1))' "${cashu_mint_url%/}" && break
45
+ sleep 1
46
+ done
47
+ node -e 'fetch(process.argv[1]+"/v1/info",{signal:AbortSignal.timeout(2000)}).then(r=>{if(!r.ok)throw Error(`HTTP ${r.status}`)}).catch(e=>{console.error(e.message);process.exit(1)})' "${cashu_mint_url%/}"
62
48
 
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 &
49
+ dvmctl validate
50
+ dvmctl doctor
51
+ dvm_port="$(node -e 'require("node:net").createServer().listen(0,"127.0.0.1",function(){console.log(this.address().port);this.close()})')"
52
+ DVMKIT_CASHU_MINTS="$cashu_mint_url" dvmctl dev handler.ts --port "$dvm_port" >"$test_root/dvm.log" 2>&1 &
68
53
  dvm_pid=$!
69
54
  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"
55
+ for _ in $(seq 1 30); do
56
+ node -e 'fetch(process.argv[1]+"/v1/info",{signal:AbortSignal.timeout(2000)}).then(async r=>{if(!r.ok)throw Error(`HTTP ${r.status}`);require("node:fs").writeFileSync(process.argv[2],await r.text())}).catch(()=>process.exit(1))' "$dvm_endpoint" "$test_root/info.json" && break
57
+ sleep 1
58
+ done
59
+ node -e 'const i=require(process.argv[1]);if(!i.payment?.methods?.includes("cashu"))throw Error("cashu is not advertised")' "$test_root/info.json"
73
60
 
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
61
+ node --input-type=module - "$dvm_endpoint" "$DVM_CAPABILITY" "$DVM_INPUT" "$test_root/unpaid.json" <<'NODE'
62
+ import { writeFileSync } from "node:fs";
63
+ const [endpoint, capability, input, output] = process.argv.slice(2);
64
+ const response = await fetch(`${endpoint}/v1/job`, {
65
+ method: "POST", headers: { "content-type": "application/json" },
66
+ body: JSON.stringify({ capability, data: JSON.parse(input) }),
67
+ signal: AbortSignal.timeout(5000),
68
+ });
69
+ const body = await response.json();
70
+ if (response.status !== 402 || body.error !== "payment_required" || !Number.isSafeInteger(body.required_msats) || body.required_msats <= 0 || body.required_msats % 1000 !== 0) {
71
+ throw new Error(`expected a valid Cashu 402, got ${response.status} ${JSON.stringify(body)}`);
72
+ }
73
+ writeFileSync(output, JSON.stringify(body));
74
+ NODE
77
75
 
78
- npm install -g @dvmkit/dvm-cli@0.2.0
76
+ npm install -g @dvmkit/dvm-cli@0.3.0
79
77
  dvm init
80
78
  dvm wallet init
81
79
  dvm wallet mint-add "$cashu_mint_url"
82
80
  dvm wallet fund '$1' --mint "$cashu_mint_url" --test-skip-lightning
83
- dvm wallet show >"$test_root/balance-before.json"
81
+ dvm wallet show >"$test_root/before.json"
84
82
  dvm request --endpoint "$dvm_endpoint" --data "$DVM_INPUT" --rail cashu --mint "$cashu_mint_url" \
85
83
  --budget "$DVM_PAYMENT_CAP" --auto-pay-below "$DVM_PAYMENT_CAP" \
86
84
  --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
85
+ dvm wallet show >"$test_root/after.json"
86
+ job_id="$(node -p 'require(process.argv[1]).jobId' "$test_root/paid.json")"
87
+ dvm receipts verify "$job_id" >"$test_root/verified.json"
88
+ node - "$test_root/unpaid.json" "$test_root/before.json" "$test_root/after.json" "$test_root/paid.json" "$test_root/verified.json" <<'NODE'
89
+ const [challengePath, beforePath, afterPath, paidPath, verifiedPath] = process.argv.slice(2);
90
+ const challenge=require(challengePath), before=require(beforePath), after=require(afterPath), paid=require(paidPath), verified=require(verifiedPath);
91
+ const validReceiptStatuses = new Set(["verified", "verified_unattested"]);
92
+ const expectedSats=challenge.required_msats/1000;
93
+ if (before.total_sats-after.total_sats !== expectedSats) throw Error(`Cashu debit was ${before.total_sats-after.total_sats}, quote was ${expectedSats} sats`);
94
+ if (!paid.jobId || paid.next_action !== null || !validReceiptStatuses.has(paid.receipt_verified)) throw Error("paid result is not terminal with a valid receipt signature");
95
+ if (paid.receipt?.outcome !== "completed" || paid.receipt?.paid?.rail !== "cashu" || paid.receipt.paid.msats !== challenge.required_msats) throw Error("receipt is not bound to the 402 quote");
96
+ if (verified.job_id !== paid.jobId || !validReceiptStatuses.has(verified.receipt_verified)) throw Error("stored receipt signature did not verify");
97
+ NODE
130
98
  cat "$test_root/paid.json"
131
99
  ```
132
100
 
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
101
+ The observed hosted-mint acceptance for a `$0.01` call quoted `12000` msats and debited `12` test sats. That is evidence from that rate snapshot, not a constant for future runs.
136
102
 
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.
103
+ Stop on any cap refusal and ask for a new explicit cap. Do not raise flags automatically. A failed mint probe names the exact mint that must recover or be corrected.
@@ -0,0 +1,72 @@
1
+ # Local Tempo paid test on Moderato
2
+
3
+ Use this only after the builder approves a test-payment cap. Moderato is Tempo's public testnet (chain 42431); `dvm wallet fund --rail tempo` uses its official programmatic faucet. These tokens have no real value. Never substitute Tempo mainnet.
4
+
5
+ ```bash
6
+ set -euo pipefail
7
+ DVM_CAPABILITY="uppercase"
8
+ DVM_INPUT='{"text":"your approved example"}'
9
+ DVM_PAYMENT_CAP='$0.02'
10
+
11
+ test_root="$(mktemp -d "${TMPDIR:-/tmp}/dvmkit-tempo-paid.XXXXXX")"
12
+ export DVM_CONFIG_DIR="$test_root/caller"
13
+ mkdir -p "$DVM_CONFIG_DIR"
14
+ dvm_pid=""
15
+ cleanup() { status=$?; trap - EXIT INT TERM; [ -z "$dvm_pid" ] || kill "$dvm_pid" 2>/dev/null || true; rm -rf "$test_root"; exit "$status"; }
16
+ trap cleanup EXIT
17
+ trap 'exit 130' INT TERM
18
+
19
+ node --input-type=module <<'NODE'
20
+ const response=await fetch("https://rpc.moderato.tempo.xyz",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({jsonrpc:"2.0",id:1,method:"eth_chainId",params:[]}),signal:AbortSignal.timeout(5000)});
21
+ const body=await response.json();
22
+ if(!response.ok || body.result!=="0xa5bf") throw Error(`Moderato RPC mismatch: ${response.status} ${JSON.stringify(body)}`);
23
+ NODE
24
+
25
+ recipient="0x$(node -e 'process.stdout.write(require("node:crypto").randomBytes(20).toString("hex"))')"
26
+ tempo_secret="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))')"
27
+ dvmctl validate
28
+ dvmctl doctor
29
+ dvm_port="$(node -e 'require("node:net").createServer().listen(0,"127.0.0.1",function(){console.log(this.address().port);this.close()})')"
30
+ env DVMKIT_TEMPO_RECIPIENT="$recipient" DVMKIT_TEMPO_SECRET_KEY="$tempo_secret" \
31
+ DVMKIT_TEMPO_TESTNET=true DVMKIT_TEMPO_RPC_URL="https://rpc.moderato.tempo.xyz" \
32
+ dvmctl dev handler.ts --port "$dvm_port" >"$test_root/dvm.log" 2>&1 &
33
+ dvm_pid=$!
34
+ dvm_endpoint="http://127.0.0.1:$dvm_port"
35
+ for _ in $(seq 1 30); do
36
+ node -e 'fetch(process.argv[1]+"/v1/info",{signal:AbortSignal.timeout(2000)}).then(async r=>{if(!r.ok)throw Error(`HTTP ${r.status}`);require("node:fs").writeFileSync(process.argv[2],await r.text())}).catch(()=>process.exit(1))' "$dvm_endpoint" "$test_root/info.json" && break
37
+ sleep 1
38
+ done
39
+ node -e 'const i=require(process.argv[1]);if(i.tempo?.chain_id!==42431)throw Error("Moderato is not advertised")' "$test_root/info.json"
40
+
41
+ node --input-type=module - "$dvm_endpoint" "$DVM_CAPABILITY" "$DVM_INPUT" "$test_root/unpaid.json" <<'NODE'
42
+ import { writeFileSync } from "node:fs";
43
+ const [endpoint, capability, input, output] = process.argv.slice(2);
44
+ const response=await fetch(`${endpoint}/v1/job`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({capability,data:JSON.parse(input)}),signal:AbortSignal.timeout(5000)});
45
+ const body=await response.json();
46
+ if(response.status!==402 || body.error!=="payment_required" || !Number.isSafeInteger(body.required_msats) || body.required_msats<=0) throw Error(`expected valid 402, got ${response.status} ${JSON.stringify(body)}`);
47
+ writeFileSync(output,JSON.stringify(body));
48
+ NODE
49
+
50
+ npm install -g @dvmkit/dvm-cli@0.3.0
51
+ dvm init
52
+ dvm wallet tempo-connect --network moderato
53
+ dvm wallet fund '$1' --rail tempo
54
+ dvm wallet balance >"$test_root/balance.json"
55
+ dvm request --endpoint "$dvm_endpoint" --data "$DVM_INPUT" --rail tempo \
56
+ --budget "$DVM_PAYMENT_CAP" --auto-pay-below "$DVM_PAYMENT_CAP" \
57
+ --max-increment "$DVM_PAYMENT_CAP" --max-payments 1 </dev/null >"$test_root/paid.json"
58
+ job_id="$(node -p 'require(process.argv[1]).jobId' "$test_root/paid.json")"
59
+ dvm receipts verify "$job_id" >"$test_root/verified.json"
60
+ node - "$test_root/unpaid.json" "$test_root/paid.json" "$test_root/verified.json" <<'NODE'
61
+ const [challengePath,paidPath,verifiedPath]=process.argv.slice(2);const challenge=require(challengePath),paid=require(paidPath),verified=require(verifiedPath);
62
+ const validReceiptStatuses=new Set(["verified","verified_unattested"]);
63
+ if(!paid.jobId || paid.next_action!==null || !validReceiptStatuses.has(paid.receipt_verified)) throw Error("paid result is not terminal with a valid receipt signature");
64
+ const receipt=paid.receipt;
65
+ if(receipt?.outcome!=="completed" || receipt?.paid?.rail!=="tempo" || receipt.paid.msats!==challenge.required_msats) throw Error("receipt is not quote-bound Tempo");
66
+ if(receipt.paid.native_amount!==10000 || receipt.paid.native_asset!=="usdc") throw Error("$0.01 must settle as exactly 10000 pathUSD micro-units");
67
+ if(verified.job_id!==paid.jobId || !validReceiptStatuses.has(verified.receipt_verified)) throw Error("stored receipt signature did not verify");
68
+ NODE
69
+ cat "$test_root/paid.json"
70
+ ```
71
+
72
+ Stop if the Moderato chain check, faucet, balance, or cap fails. Do not switch to mainnet or fund a production wallet as a workaround.
@@ -0,0 +1,74 @@
1
+ # Local x402 paid test on Base Sepolia
2
+
3
+ Use this only after the builder approves a test-payment cap and supplies a Base Sepolia wallet containing at least the approved amount in test USDC. Base Sepolia has no unattended public faucet. Never substitute Base mainnet or a production key.
4
+
5
+ ```bash
6
+ set -euo pipefail
7
+ DVM_CAPABILITY="uppercase"
8
+ DVM_INPUT='{"text":"your approved example"}'
9
+ DVM_PAYMENT_CAP='$0.02'
10
+ X402_TESTNET_KEY_FILE="/absolute/path/to/base-sepolia-test-key"
11
+
12
+ test_root="$(mktemp -d "${TMPDIR:-/tmp}/dvmkit-x402-paid.XXXXXX")"
13
+ export DVM_CONFIG_DIR="$test_root/caller"
14
+ mkdir -p "$DVM_CONFIG_DIR"
15
+ dvm_pid=""
16
+ cleanup() { status=$?; trap - EXIT INT TERM; [ -z "$dvm_pid" ] || kill "$dvm_pid" 2>/dev/null || true; rm -rf "$test_root"; exit "$status"; }
17
+ trap cleanup EXIT
18
+ trap 'exit 130' INT TERM
19
+
20
+ test -f "$X402_TESTNET_KEY_FILE"
21
+ node --input-type=module <<'NODE'
22
+ const rpc = await fetch("https://sepolia.base.org", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({jsonrpc:"2.0",id:1,method:"eth_chainId",params:[]}), signal: AbortSignal.timeout(5000) });
23
+ const body = await rpc.json();
24
+ if (!rpc.ok || body.result !== "0x14a34") throw new Error(`Base Sepolia RPC mismatch: ${rpc.status} ${JSON.stringify(body)}`);
25
+ const facilitator = await fetch("https://x402.org/facilitator/supported", { signal: AbortSignal.timeout(5000) });
26
+ if (!facilitator.ok) throw new Error(`x402 facilitator HTTP ${facilitator.status}`);
27
+ NODE
28
+
29
+ recipient="0x$(node -e 'process.stdout.write(require("node:crypto").randomBytes(20).toString("hex"))')"
30
+ dvmctl validate
31
+ dvmctl doctor
32
+ dvm_port="$(node -e 'require("node:net").createServer().listen(0,"127.0.0.1",function(){console.log(this.address().port);this.close()})')"
33
+ env DVMKIT_X402_PAY_TO="$recipient" DVMKIT_X402_NETWORK="eip155:84532" \
34
+ DVMKIT_X402_FACILITATOR="https://x402.org/facilitator" \
35
+ dvmctl dev handler.ts --port "$dvm_port" >"$test_root/dvm.log" 2>&1 &
36
+ dvm_pid=$!
37
+ dvm_endpoint="http://127.0.0.1:$dvm_port"
38
+ for _ in $(seq 1 30); do
39
+ node -e 'fetch(process.argv[1]+"/v1/info",{signal:AbortSignal.timeout(2000)}).then(async r=>{if(!r.ok)throw Error(`HTTP ${r.status}`);require("node:fs").writeFileSync(process.argv[2],await r.text())}).catch(()=>process.exit(1))' "$dvm_endpoint" "$test_root/info.json" && break
40
+ sleep 1
41
+ done
42
+ node -e 'const i=require(process.argv[1]);if(i.x402?.network!=="eip155:84532")throw Error("Base Sepolia is not advertised")' "$test_root/info.json"
43
+
44
+ node --input-type=module - "$dvm_endpoint" "$DVM_CAPABILITY" "$DVM_INPUT" "$test_root/unpaid.json" <<'NODE'
45
+ import { writeFileSync } from "node:fs";
46
+ const [endpoint, capability, input, output] = process.argv.slice(2);
47
+ const response = await fetch(`${endpoint}/v1/job`, { method:"POST", headers:{"content-type":"application/json"}, body:JSON.stringify({capability,data:JSON.parse(input)}), signal:AbortSignal.timeout(5000) });
48
+ const body=await response.json();
49
+ if(response.status!==402 || body.error!=="payment_required" || !Number.isSafeInteger(body.required_msats) || body.required_msats<=0) throw Error(`expected valid 402, got ${response.status} ${JSON.stringify(body)}`);
50
+ writeFileSync(output,JSON.stringify(body));
51
+ NODE
52
+
53
+ npm install -g @dvmkit/dvm-cli@0.3.0
54
+ dvm init
55
+ dvm wallet x402-connect --key-file "$X402_TESTNET_KEY_FILE" --network "eip155:84532"
56
+ dvm wallet balance >"$test_root/balance.json"
57
+ dvm request --endpoint "$dvm_endpoint" --data "$DVM_INPUT" --rail x402 \
58
+ --budget "$DVM_PAYMENT_CAP" --auto-pay-below "$DVM_PAYMENT_CAP" \
59
+ --max-increment "$DVM_PAYMENT_CAP" --max-payments 1 </dev/null >"$test_root/paid.json"
60
+ job_id="$(node -p 'require(process.argv[1]).jobId' "$test_root/paid.json")"
61
+ dvm receipts verify "$job_id" >"$test_root/verified.json"
62
+ node - "$test_root/unpaid.json" "$test_root/paid.json" "$test_root/verified.json" <<'NODE'
63
+ const [challengePath,paidPath,verifiedPath]=process.argv.slice(2);const challenge=require(challengePath),paid=require(paidPath),verified=require(verifiedPath);
64
+ const validReceiptStatuses=new Set(["verified","verified_unattested"]);
65
+ if(!paid.jobId || paid.next_action!==null || !validReceiptStatuses.has(paid.receipt_verified)) throw Error("paid result is not terminal with a valid receipt signature");
66
+ const receipt=paid.receipt;
67
+ if(receipt?.outcome!=="completed" || receipt?.paid?.rail!=="x402" || receipt.paid.msats!==challenge.required_msats) throw Error("receipt is not quote-bound x402");
68
+ if(receipt.paid.native_amount!==10000 || receipt.paid.native_asset!=="usdc") throw Error("$0.01 must settle as exactly 10000 micro-USDC");
69
+ if(verified.job_id!==paid.jobId || !validReceiptStatuses.has(verified.receipt_verified)) throw Error("stored receipt signature did not verify");
70
+ NODE
71
+ cat "$test_root/paid.json"
72
+ ```
73
+
74
+ Stop on a missing balance, RPC/facilitator failure, or cap refusal. Do not replace the network, key, or cap without new approval.
@@ -1,11 +1,11 @@
1
- # Payment rail limits for first builds
1
+ # Payment rails for first builds
2
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.
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
4
 
5
- | Rail | First-build local proof | Current limit |
5
+ | Rail | First-build proof | Test boundary |
6
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. |
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
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.
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.
@@ -11,13 +11,13 @@ Port resolution: `--port` > `.env PORT` > `process.env.PORT` > `3000`.
11
11
 
12
12
  Handler resolution: default export > `dvm` named export > first `DVMDescriptor` export.
13
13
 
14
- The dev server auto-credits payments — upfront and mid-job alike — **only while no mints are
15
- configured**, so a priced handler runs with no wallet in sight. A mint alone starts Cashu accumulator
16
- mode but does not supply its lock pubkey, durable DVM ID, or Postgres store, so it does not advertise
17
- Cashu. Use the complete [Local Cashu paid test](local-cashu-test.md) for the self-hosted `dvmctl serve
18
- --dvm` path. `dvmctl dev` itself serves a test page at `/_dev` for
19
- interactive testing, whose auto-approve button satisfies a mid-job ask with a proofless `dev_auto`
20
- flag no non-dev server admits. Drive it from another terminal with the `dvm` CLI:
14
+ With no configured rail, dev mode skips payment verification and says so in its banner. Configure
15
+ Cashu, x402, or Tempo and the same server performs real rail verification with disposable in-process
16
+ storage; its banner names every rail, verification state, and whether the SDK can prove the funds are
17
+ test funds. The paid-completion observer prints each settled job and a deduplicated session total.
18
+ `/_dev` can satisfy an interactive ask with the synthetic `dev_auto` flag, which no non-dev server
19
+ admits; that shortcut applies only to the test page, never to caller CLI payments. Follow the
20
+ rail-specific paid-test references for bounded calls. Drive a no-rail server from another terminal:
21
21
 
22
22
  ```bash
23
23
  dvm request --endpoint http://localhost:3000 -i "input" --human
@@ -133,12 +133,12 @@ the scaffold already lists it.
133
133
  The scaffold ships a Dockerfile and wires `npm run deploy` → `dvmctl deploy`. The cloud path:
134
134
 
135
135
  ```bash
136
- dvmctl validate # fast local check: slug, package.json, vmConfig — JSON out, no Docker
136
+ dvmctl validate # load handler + validate capability/schema/example/price/tags and package metadata
137
137
  dvmctl deploy # build container, push, deploy to the dvmkit platform
138
138
  dvmctl deploy --dry-run # full validation incl. a real `docker build` (slow, cold) — no provisioning
139
139
  ```
140
140
 
141
- Use `dvmctl validate` for fast iteration; it's instant and emits `{"status":"ok", ...}`.
141
+ Use `dvmctl validate` for fast iteration; it emits `{"status":"ok", ...}` with agent-facing display and hint fields.
142
142
  `dvmctl deploy --dry-run` adds a real container build on top, so it's slower and needs Docker.
143
143
 
144
144
  On dvmkit Cloud, the active org owns each deployed DVM. Its treasury holds payout configuration — Tempo recipient, Base/x402 address, Cashu mints, and lock pubkey — rather than the DVM descriptor or builder profile. The lock pubkey propagates from org to DVM to SDK; see the three-level lock pubkey model.