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