@dvmkit/dvmctl 0.2.1-rc.2 → 0.3.1-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/README.md +2 -2
- package/dist/dvmctl.js +20 -19
- package/package.json +1 -1
- package/skills/build-dvm/SKILL.md +22 -8
- package/skills/build-dvm/references/local-cashu-test.md +54 -92
- package/skills/build-dvm/references/local-tempo-test.md +72 -0
- package/skills/build-dvm/references/local-x402-test.md +74 -0
- package/skills/build-dvm/references/payment-rails.md +7 -7
- package/skills/build-dvm/references/running-deployment.md +9 -9
package/package.json
CHANGED
|
@@ -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,
|
|
4
|
-
compatibility: Node.js 22+, npm, the invited `@dvmkit/dvmctl` release, and Docker
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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):
|
|
93
|
-
- [
|
|
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
|
|
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
|
|
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'
|
|
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 "$
|
|
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
|
-
|
|
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
|
|
61
|
-
|
|
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
|
|
64
|
-
dvmctl
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
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 -
|
|
75
|
-
|
|
76
|
-
|
|
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.
|
|
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/
|
|
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/
|
|
88
|
-
node -
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
if
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1
|
+
# Payment rails for first builds
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
5
|
+
| Rail | First-build proof | Test boundary |
|
|
6
6
|
| --- | --- | --- |
|
|
7
|
-
| Cashu |
|
|
8
|
-
| x402 |
|
|
9
|
-
| Tempo |
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 #
|
|
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
|
|
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.
|