pivx-402 1.0.0

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 ADDED
@@ -0,0 +1,632 @@
1
+ # pivx-402
2
+
3
+ **HTTP 402 Payment Required middleware for PIVX.** Gate any Express route
4
+ behind a real PIV payment (transparent or shielded) in a few lines of
5
+ code. Humans, scripts, and AI agents all pay the same way: there's no
6
+ account system.
7
+
8
+ Live demo: **https://pivx402.computingcache.com/cat** (one SVG cat per 0.0001 PIV)
9
+
10
+ Pick the section that matches what you're doing:
11
+
12
+ - **[A. Run a paywall](#a-run-a-paywall)** if you want to gate one or more
13
+ of your own HTTP endpoints behind a PIVX payment.
14
+ - **[B. Build a bot that pays paywalls](#b-build-a-bot-that-pays-paywalls)**
15
+ if you want to call somebody else's 402 endpoint, sign and broadcast the
16
+ payment, and retry with the proof.
17
+
18
+ Both halves share the same protocol envelope and error codes. The
19
+ [API reference](#api-reference) and [Protocol cheat sheet](#protocol-cheat-sheet)
20
+ are at the bottom. [Gotchas](#gotchas-we-actually-hit) lists the things
21
+ that cost us hours getting the live demo running.
22
+
23
+ ---
24
+
25
+ ## Why HTTP 402?
26
+
27
+ `402 Payment Required` is the long-reserved HTTP status code for "this
28
+ resource costs money." x402 is the convention for actually using it. This
29
+ package implements the x402 handshake on **PIVX**, with two schemes:
30
+
31
+ - **`pivx-transparent`**: normal `D...` addresses; the nonce travels as an
32
+ `OP_RETURN` output. Any block explorer can verify it.
33
+ - **`pivx-shield`**: Sapling `ps1...` addresses; the nonce travels as the
34
+ shielded **memo**. Amount, sender, recipient, and memo are all encrypted
35
+ on-chain. Requires a `pivxd` node holding the receiver's viewing key.
36
+
37
+ The middleware, the client code, and the proof envelope are identical for
38
+ both. The backend switches between transparent verification and shielded
39
+ decryption under the hood.
40
+
41
+ ---
42
+
43
+ # A. Run a paywall
44
+
45
+ You want incoming requests to your API/endpoint to pay you PIVX before the
46
+ response is served.
47
+
48
+ ## 1. Pick where you'll verify payments from
49
+
50
+ | Backend | What it needs | When to use |
51
+ | ----------------- | -------------------------------------------------- | ---------------------------------------------------------------------- |
52
+ | `ExplorerBackend` | An HTTPS URL to a BlockBook-compatible PIVX explorer | Easiest. No node to maintain. Transparent-scheme only. Trusts the explorer. |
53
+ | `NodeRpcBackend` | A locally-reachable `pivxd` JSON-RPC + creds | Full control. Required for **shielded** payments. ~20 GB disk, real RAM. |
54
+
55
+ Public PIVX BlockBook explorers come and go. The current default is
56
+ **`https://explorer.duddino.com`**. Override `PIVX_EXPLORER_URL` if that
57
+ one goes down and point at any other BlockBook-compatible PIVX mirror, or
58
+ run your own.
59
+
60
+ ## 2. Install
61
+
62
+ ```bash
63
+ npm install pivx-402
64
+ ```
65
+
66
+ Node ≥ 20. Works with Express 4. (Express 5 untested; should be a small fix
67
+ if it bites you.)
68
+
69
+ ## 3. Wire it up
70
+
71
+ ```ts
72
+ import express from "express";
73
+ import { pivx402, ExplorerBackend } from "pivx-402";
74
+
75
+ const app = express();
76
+
77
+ app.get(
78
+ "/api/paid-thing",
79
+ pivx402({
80
+ backend: new ExplorerBackend({ baseUrl: "https://explorer.duddino.com" }),
81
+ network: "pivx-mainnet",
82
+ minConfirmations: 1,
83
+ price: {
84
+ amount: "0.0001", // decimal PIV
85
+ payTo: "D...your address", // mainnet PIVX address you control
86
+ description: "what they're buying",
87
+ },
88
+ }),
89
+ (req, res) => {
90
+ // req.pivx402 contains { txid, nonce, amount, payTo, scheme, network }
91
+ // for receipts, audit logs, download tokens, rate-limiting, etc.
92
+ res.json({ ok: true, paidWith: req.pivx402!.txid });
93
+ },
94
+ );
95
+
96
+ app.listen(4403);
97
+ ```
98
+
99
+ That's the whole paywall. The first `GET /api/paid-thing` returns `402
100
+ Payment Required` with a machine-readable `X-Payment-Required` header.
101
+ The caller pays the quoted PIV to your address, embedding a
102
+ server-issued nonce as an `OP_RETURN`, then retries with `X-Payment:
103
+ <proof>`. The middleware verifies the on-chain transaction through your
104
+ backend and runs the handler.
105
+
106
+ ## 4. Pick price + confirmations sensibly
107
+
108
+ ```ts
109
+ // Static price for a single SKU:
110
+ price: { amount: "0.001", payTo: "...", description: "..." }
111
+
112
+ // Per-request price (sync or async):
113
+ price: async (req) => ({
114
+ amount: await priceFor(req.params.sku),
115
+ payTo: process.env.PIVX_PAY_TO!,
116
+ })
117
+ ```
118
+
119
+ | `minConfirmations` | When to use |
120
+ | ------------------ | ------------------------------------------------------------------------ |
121
+ | `0` | Cheap requests (<<$0.10 of PIV). Accepts mempool. Trades safety for UX. |
122
+ | `1` (default) | Sensible default. PIVX block time ≈ 1 min. |
123
+ | `6+` | Larger amounts; you don't want a re-org to lose your money. |
124
+
125
+ ## 5. Replace `InMemoryNonceStore` in production
126
+
127
+ The default nonce store is in-memory. That means:
128
+ - Lost on restart (already-paid nonces become replayable).
129
+ - Doesn't work across multiple app instances.
130
+ - **Each `pivx402()` call gets its own store by default.** If two routes share
131
+ the same `payTo` + price + scheme, you MUST pass the *same* `nonceStore`
132
+ instance to both — otherwise a single broadcast tx will be redeemable on
133
+ each route independently.
134
+
135
+ Implement `NonceStore` against Redis or Postgres:
136
+
137
+ ```ts
138
+ class RedisNonceStore implements NonceStore {
139
+ constructor(private readonly redis: Redis) {}
140
+ async claim(nonce: string): Promise<boolean> {
141
+ // SET NX guarantees a single winner per nonce.
142
+ const r = await this.redis.set(`pivx402:nonce:${nonce}`, "1", "NX", "EX", 86400);
143
+ return r === "OK";
144
+ }
145
+ }
146
+
147
+ pivx402({ ..., nonceStore: new RedisNonceStore(myRedis) });
148
+ ```
149
+
150
+ ## 6. Deploy
151
+
152
+ A Dockerfile + `docker-compose.yml` are included for the cat-demo flavor.
153
+ Adapt for your own server:
154
+
155
+ ```yaml
156
+ services:
157
+ paywall:
158
+ build: .
159
+ restart: unless-stopped
160
+ env_file: .env
161
+ ports:
162
+ - "127.0.0.1:4403:4403" # apache/nginx reverse-proxies in front
163
+ ```
164
+
165
+ A `.env.example` is included with the env vars demo/cat.ts reads.
166
+
167
+ ---
168
+
169
+ # B. Build a bot that pays paywalls
170
+
171
+ You want to programmatically pay 402-gated endpoints from an AI agent,
172
+ scraper, batch process, or anything else that runs without a user
173
+ sitting in front of it.
174
+
175
+ ## 1. Install
176
+
177
+ ```bash
178
+ npm install pivx-402
179
+ ```
180
+
181
+ You'll also need a way to broadcast PIVX transactions. Options, easiest
182
+ first:
183
+
184
+ | Signer | What you'll need |
185
+ | ---------------------------------- | ------------------------------------------------------ |
186
+ | **Local `pivxd` + JSON-RPC** | The full PIVX daemon, ~20 GB disk, RPC creds. Most reliable. |
187
+ | **`pivx-cli` shell from your bot** | Just the binaries; convenient if you already run `pivxd` for other reasons. |
188
+ | **A custodial/hosted wallet API** | Whatever HTTPS API your wallet provider exposes. |
189
+
190
+ The library doesn't care which one you use. You give it a `payer`
191
+ function whose only job is: given a payment requirement, return a
192
+ broadcast txid.
193
+
194
+ ## 2. The 5-line version
195
+
196
+ ```ts
197
+ import { payAndFetch, type Payer } from "pivx-402";
198
+
199
+ const payer: Payer = async (req) => {
200
+ // req.scheme, req.payTo, req.maxAmountRequired, req.nonce, req.network
201
+ return await myWallet.sendWithOpReturn(req.payTo, req.maxAmountRequired, req.nonce);
202
+ };
203
+
204
+ const { response } = await payAndFetch("https://api.example.com/paid-thing", payer);
205
+ const data = await response.json();
206
+ ```
207
+
208
+ `payAndFetch` does the dance:
209
+ 1. GETs the URL.
210
+ 2. If 200, returns immediately.
211
+ 3. If 402, decodes `X-Payment-Required`, hands the requirement to your
212
+ `payer`, retries with `X-Payment` set to the proof.
213
+ 4. Anything else surfaces on `response` for you to handle.
214
+
215
+ ## 3. A working `Payer` against a local `pivxd`
216
+
217
+ This is the recipe we use in `demo/pay-cli.ts` and in the live agent run
218
+ that paid https://pivx402.computingcache.com/cat:
219
+
220
+ ```ts
221
+ import { execFileSync } from "node:child_process";
222
+ import { Buffer } from "node:buffer";
223
+ import type { Payer } from "pivx-402";
224
+
225
+ const PIVX_CLI = "/path/to/pivx-cli";
226
+ const PIVX_TX = "/path/to/pivx-tx";
227
+ const DATADIR = "/path/to/.pivx";
228
+
229
+ const cli = (...args: string[]) =>
230
+ execFileSync(PIVX_CLI, ["-datadir=" + DATADIR, ...args], { encoding: "utf8" }).trim();
231
+ const ptx = (...args: string[]) =>
232
+ execFileSync(PIVX_TX, args, { encoding: "utf8" }).trim();
233
+
234
+ export const pivxdPayer: Payer = async (req) => {
235
+ // 1. Build a tx that pays the requirement (no OP_RETURN yet).
236
+ // PIVX 5.x createrawtransaction does NOT accept the {"data":hex} shorthand,
237
+ // so we splice the OP_RETURN in afterward with pivx-tx.
238
+ const raw = cli("createrawtransaction", "[]",
239
+ JSON.stringify({ [req.payTo]: Number(req.maxAmountRequired) }));
240
+
241
+ // 2. Let the wallet add inputs + change. feeRate 0.0005 PIV/kB leaves headroom
242
+ // for the OP_RETURN we're about to splice in (fundrawtransaction can't see it yet).
243
+ const funded = JSON.parse(cli("fundrawtransaction", raw,
244
+ JSON.stringify({ feeRate: 0.0005 }))).hex;
245
+
246
+ // 3. Splice the nonce as an OP_RETURN output.
247
+ const withOpReturn = ptx(funded, `outscript=0:OP_RETURN '${req.nonce}'`);
248
+
249
+ // 4. Sign and broadcast.
250
+ const signed = JSON.parse(cli("signrawtransaction", withOpReturn));
251
+ if (!signed.complete) throw new Error("sign incomplete");
252
+ return cli("sendrawtransaction", signed.hex);
253
+ };
254
+ ```
255
+
256
+ That's the entire signer. The middleware on the other end reads the
257
+ `OP_RETURN`, matches it to its issued nonce, and serves the resource.
258
+
259
+ ## 4. Failure modes and what your bot should do
260
+
261
+ When verification fails the server returns 402 again with an `error` field
262
+ on the envelope. Every reason is a stable string:
263
+
264
+ | `error` | Cause | Bot's next action |
265
+ | ----------------------------- | ---------------------------------------- | -------------------------------------------------------- |
266
+ | `tx_not_found` | Propagation delay; or wrong network | Wait a few seconds, re-submit the same proof. |
267
+ | `insufficient_confirmations` | Broadcast but not yet deep enough | Wait, re-submit. **Don't re-pay.** |
268
+ | `insufficient_amount` | Paid less than `maxAmountRequired` | Re-pay with at least the quoted amount on a new nonce. |
269
+ | `wrong_recipient` | Wrong `payTo` | Re-pay to the right address on a new nonce. |
270
+ | `missing_nonce` | Forgot the `OP_RETURN` / memo | Re-pay including the nonce. Don't reuse the old txid. |
271
+ | `nonce_replayed` | This nonce was already spent | Re-issue (`GET` again to get a fresh nonce), re-pay. |
272
+ | `scheme_unsupported` | Used wrong scheme | Switch scheme. |
273
+ | `network_mismatch` | Wrong network | Switch network on the wallet / endpoint. |
274
+ | `shielded_backend_unavailable`| Server misconfigured for shield | Fall back to transparent if the server lists both. |
275
+ | `malformed_payment_header` | Bad base64 / JSON in `X-Payment` | Fix your encoding. |
276
+
277
+ `payAndFetch` itself just returns the `response`; the polling+retry logic
278
+ lives in your code. See `demo/pay-cli.ts` for a working retry loop that
279
+ handles propagation delay.
280
+
281
+ ## 5. Spending guardrails for autonomous agents
282
+
283
+ Agents that spend real money should at least enforce:
284
+
285
+ ```ts
286
+ import { payAndFetch, pivToSats, type Payer } from "pivx-402";
287
+
288
+ const MAX_PER_CALL = pivToSats("0.001");
289
+ const MAX_PER_SESSION = pivToSats("0.05");
290
+ const ALLOWED_HOSTS = new Set(["pivx402.computingcache.com"]);
291
+ let spent = 0n;
292
+
293
+ const guardedPayer: Payer = async (req) => {
294
+ const need = pivToSats(req.maxAmountRequired);
295
+ if (need > MAX_PER_CALL) throw new Error("over per-call cap");
296
+ if (spent + need > MAX_PER_SESSION) throw new Error("over session cap");
297
+ const txid = await rawPayer(req);
298
+ spent += need;
299
+ return txid;
300
+ };
301
+ ```
302
+
303
+ See [AGENTS.md](./AGENTS.md) for more on the agent integration story
304
+ (consumer + builder paths, decision tree, prompt-friendly quick-reference card).
305
+
306
+ ---
307
+
308
+ # C. Shielded payments (pivx-shield)
309
+
310
+ Use this when on-chain observers should not see the amount, sender, recipient,
311
+ or memo. The protocol envelope is unchanged from transparent; only the
312
+ underlying tx and verification differ.
313
+
314
+ ## Server requirements
315
+
316
+ Shielded verification *requires* `NodeRpcBackend`. An explorer cannot decrypt
317
+ shielded outputs. The `pivxd` you point at must hold the **viewing key** for
318
+ the receiving `ps1...` address — otherwise `viewshieldtransaction` will return
319
+ no outputs and every payment will look like `wrong_recipient`.
320
+
321
+ The simplest way: generate the receiving address on the same `pivxd` that
322
+ verifies payments — the spending key (which you don't strictly need on the
323
+ server) implies the viewing key.
324
+
325
+ ```bash
326
+ # On the server's pivxd:
327
+ pivx-cli getnewshieldaddress
328
+ # -> ps1lxmel...xn3ckqgrne60fxl6f
329
+ ```
330
+
331
+ In production you should generate the address once, export its viewing key
332
+ (`exportsaplingviewingkey`), keep the spending key off the server, and import
333
+ only the viewing key on the verifying node (`importsaplingviewingkey`).
334
+
335
+ ## 1. Configure the route
336
+
337
+ ```ts
338
+ app.get(
339
+ "/cat",
340
+ pivx402({
341
+ backend: new NodeRpcBackend({
342
+ url: process.env.PIVX_RPC_URL!, // http://127.0.0.1:51473
343
+ username: process.env.PIVX_RPC_USER,
344
+ password: process.env.PIVX_RPC_PASSWORD,
345
+ }),
346
+ network: "pivx-mainnet",
347
+ scheme: "pivx-shield", // <- the only change vs transparent
348
+ minConfirmations: 1,
349
+ price: {
350
+ amount: "0.0001",
351
+ payTo: "ps1...your shield address", // <- ps1, not D
352
+ description: "private cat picture",
353
+ },
354
+ }),
355
+ (_req, res) => res.send(theThing),
356
+ );
357
+ ```
358
+
359
+ The cat demo (`demo/cat.ts`) already reads `SCHEME` and `PIVX_PAY_TO` from env,
360
+ so you can start the same demo in shielded mode by setting both.
361
+
362
+ ## 2. Fund the client's shield pool
363
+
364
+ Shielded to shield sends spend from the wallet's shielded balance, not its transparent
365
+ balance. A fresh wallet has 0 shielded; you have to move PIV into the pool
366
+ first.
367
+
368
+ ```bash
369
+ # Generate a shield address you control (or reuse an existing one):
370
+ SHIELD=$(pivx-cli getnewshieldaddress)
371
+
372
+ # Move some transparent PIV into the shield pool. Use a few × the per-call
373
+ # price so you can pay multiple times before topping up again (see fee note
374
+ # below).
375
+ pivx-cli shieldsendmany "from_transparent" \
376
+ "$(printf '[{"address":"%s","amount":0.1}]' "$SHIELD")" 1
377
+ ```
378
+
379
+ Shielded transactions cost more **~0.01–0.014 PIV in fees**
380
+
381
+ ## 3. Pay the route
382
+
383
+ `demo/pay-cli.ts` auto-routes to the shielded path when the requirement says
384
+ `pivx-shield`. The only thing it needs from you is access to the same `pivxd`
385
+ that holds the spending key for your shield address.
386
+
387
+ ```bash
388
+ PIVX_BIN_DIR=/path/to/pivx-bin \
389
+ PIVX_DATADIR=/path/to/.pivx \
390
+ PIVX_NETWORK=mainnet \
391
+ npx tsx demo/pay-cli.ts -v --out /tmp/cat.svg https://your-server/cat
392
+ ```
393
+
394
+ By default it spends from any shielded note in the wallet
395
+ (`shieldsendmany from_shield`). To spend straight from transparent (shielding
396
+ + paying in one tx, no pool needed) set `PIVX_SHIELD_FROM=from_transparent`.
397
+ That trades pool-management headache for a higher per-tx fee.
398
+
399
+ ## 4. How a shielded payment is verified
400
+
401
+ The middleware does, on each retry:
402
+
403
+ 1. `viewshieldtransaction <txid>` — pivxd decrypts every shielded output it
404
+ has a viewing key for. The receiving `ps1...` address must be one of them,
405
+ or the tx looks empty.
406
+ 2. `getrawtransaction <txid> true` — fetch confirmations (the shield RPC
407
+ doesn't return them).
408
+ 3. Match an *incoming* output whose `address` equals `payTo` and whose memo
409
+ (`memoStr` or hex-decoded `memo`) equals the issued nonce.
410
+ 4. Sum incoming-to-payTo-with-nonce values; require `>= maxAmountRequired`.
411
+ 5. Burn the nonce in the `NonceStore`.
412
+
413
+ `outgoing` outputs (e.g. shielded-change to ourselves) are ignored — only
414
+ notes the receiver could not have produced count.
415
+
416
+ ## 5. Cross-scheme: which combinations actually work
417
+
418
+ The endpoint's scheme determines what the `payTo` looks like and what proof is
419
+ needed. The payer's *source* of funds (transparent UTXOs vs. shielded notes)
420
+ is a separate decision, and not every combination is reachable in one tx:
421
+
422
+ | Payer's source | Endpoint scheme | One tx? | How |
423
+ | -------------- | ------------------ | :-----: | ------------------------------------------------------------ |
424
+ | transparent | `pivx-transparent` | ✓ | `pay-cli` default: `createrawtransaction` + OP_RETURN splice |
425
+ | transparent | `pivx-shield` | ✓ | `pay-cli` with `PIVX_SHIELD_FROM=from_transparent` |
426
+ | shield | `pivx-shield` | ✓ | `pay-cli` default for shield scheme: `from_shield` |
427
+ | shield | `pivx-transparent` | **✗** | **Impossible in one tx — no OP_RETURN in Sapling sends.** |
428
+
429
+ The last row is the only one that doesn't work. PIVX's `shieldsendmany` (and `rawshieldsendmany`)
430
+ accept only `{address, amount, memo}` outputs; the `memo` field only applies
431
+ to shielded recipients, and there's no escape hatch to add an OP_RETURN
432
+ output to a Sapling tx. So a shield→`D...` payment will broadcast fine but
433
+ land with no nonce — the verifier returns
434
+ `missing_nonce: OP_RETURN with nonce not found`, and a retry won't help.
435
+
436
+ If your payer wallet only has shielded balance and the endpoint is
437
+ transparent, you must de-shield first:
438
+
439
+ ```bash
440
+ # Move enough to a transparent address you control, including budget for the
441
+ # subsequent transparent fee.
442
+ pivx-cli shieldsendmany "from_shield" \
443
+ '[{"address":"D<your own>", "amount":0.001}]' 1
444
+ # Wait for confirmation, then pay normally with pay-cli.
445
+ ```
446
+
447
+ `pay-cli` will print a warning if you set `PIVX_SHIELD_FROM` while paying a
448
+ transparent endpoint and then ignore it (paying from your transparent UTXOs
449
+ instead). Shielded funds for a transparent endpoint always need a 2-tx flow.
450
+
451
+ ---
452
+
453
+ # API reference
454
+
455
+ ### `pivx402(opts) → express.RequestHandler`
456
+
457
+ | Option | Type | Required | Description |
458
+ | ------------------- | ------------------------------------------------------ | -------- | -------------------------------------------------------------------------------------------- |
459
+ | `backend` | `PivxBackend` | yes | `NodeRpcBackend` or `ExplorerBackend` (or your own). |
460
+ | `network` | `"pivx-mainnet" \| "pivx-testnet" \| "pivx-regtest"` | yes | Network label echoed back to clients. |
461
+ | `scheme` | `"pivx-transparent" \| "pivx-shield"` | no | Default: `"pivx-transparent"`. |
462
+ | `minConfirmations` | `number` | no | Default 1. `0` accepts mempool. |
463
+ | `maxTimeoutSeconds` | `number` | no | Default 600. |
464
+ | `price` | `PriceConfig \| (req) => PriceConfig \| Promise<PriceConfig>` | yes | Static or per-request price. |
465
+ | `nonceStore` | `NonceStore` | no | Default `InMemoryNonceStore` (swap for Redis in production). |
466
+
467
+ ### Backends
468
+
469
+ ```ts
470
+ import { NodeRpcBackend, ExplorerBackend } from "pivx-402";
471
+
472
+ // pivxd JSON-RPC (required for shielded verification).
473
+ new NodeRpcBackend({ url: "http://127.0.0.1:51473", username: "u", password: "p" });
474
+
475
+ // BlockBook-compatible explorer (transparent only).
476
+ new ExplorerBackend({ baseUrl: "https://explorer.duddino.com" });
477
+ ```
478
+
479
+ Implement your own by satisfying `PivxBackend`:
480
+
481
+ ```ts
482
+ interface PivxBackend {
483
+ getTransaction(txid: string): Promise<TxInfo | null>;
484
+ viewShieldedTransaction?(txid: string): Promise<ShieldedTxInfo | null>;
485
+ }
486
+ ```
487
+
488
+ ### Client helper
489
+
490
+ ```ts
491
+ import { payAndFetch, type Payer } from "pivx-402";
492
+ const { response, requirement, txid } = await payAndFetch(url, payer);
493
+ ```
494
+
495
+ ### Headers
496
+
497
+ | Header | Direction | Carries |
498
+ | --------------------- | ------------------ | ---------------------------------------------------- |
499
+ | `X-Payment-Required` | server → client | base64(`PaymentRequiredEnvelope`) on 402 responses |
500
+ | `X-Payment` | client → server | base64(`PaymentProof`) on retries |
501
+
502
+ Both headers carry the same JSON in the response body as well.
503
+
504
+ ---
505
+
506
+ # Protocol cheat sheet
507
+
508
+ ```
509
+ GET /resource
510
+ → 402 + X-Payment-Required: base64({ x402Version: 1, accepts: [requirement] })
511
+
512
+ requirement = {
513
+ scheme: "pivx-transparent" | "pivx-shield",
514
+ network: "pivx-mainnet" | "pivx-testnet" | "pivx-regtest",
515
+ asset: "PIV",
516
+ maxAmountRequired: "0.0001", // decimal PIV string
517
+ payTo: "D...", // ps1... for shielded
518
+ nonce: "<32-hex-chars>",
519
+ minConfirmations: number,
520
+ maxTimeoutSeconds: number,
521
+ resource: "/resource",
522
+ description?: string,
523
+ }
524
+
525
+ Pay: send >= maxAmountRequired PIV to payTo,
526
+ embed nonce in OP_RETURN (transparent) or memo (shielded).
527
+
528
+ GET /resource
529
+ X-Payment: base64({
530
+ x402Version: 1,
531
+ scheme, network,
532
+ payload: { txid, nonce }
533
+ })
534
+ → 200 + resource body
535
+ → 402 + { error: <reason> } if verification failed
536
+ ```
537
+
538
+ ---
539
+
540
+ # Some things to note
541
+
542
+ 1. **`createrawtransaction` in PIVX 5.x does NOT accept the `{"data": hex}`
543
+ shorthand for OP_RETURN.** You must build the tx with only the recipient output, `fundrawtransaction`
544
+ it, then splice the `OP_RETURN` in via `pivx-tx outscript=N:OP_RETURN '<nonce>'`,
545
+ then sign. `demo/pay-cli.ts` shows the working pattern.
546
+
547
+ 2. **`fundrawtransaction` doesn't know about the OP_RETURN you're about to
548
+ splice.** It sizes the fee based on the tx *before* the OP_RETURN gets
549
+ added, so the broadcast fails with `insufficient fee: X < Y`. Bump
550
+ `feeRate` to ~`0.0005 PIV/kB` to leave headroom for the ~40-byte
551
+ OP_RETURN; the absolute fee is still negligible.
552
+
553
+ 3. **Public BlockBook mirrors are not durable.** One went away on us
554
+ mid-demo. Always have a fallback configured, and keep
555
+ `PIVX_EXPLORER_URL` overridable at deploy time.
556
+
557
+ 4. **You cannot pay a transparent (`D...`) endpoint from shielded funds in
558
+ one tx.** PIVX's `shieldsendmany`/`rawshieldsendmany` accept only
559
+ `{address, amount, memo}` outputs, and `memo` is shielded-recipient-only
560
+ — there's no OP_RETURN escape hatch in a Sapling send. A shield→`D...`
561
+ tx will broadcast but carry no nonce; the verifier returns
562
+ `missing_nonce` and retries won't help. Fix: de-shield to your own
563
+ transparent address first (`shieldsendmany from_shield "D<self>" ...`),
564
+ wait one confirmation, then pay normally. See the source/scheme matrix
565
+ in the "Shielded payments" section. The reverse, transparent funds
566
+ paying a `ps1...` endpoint, is one-tx fine
567
+ (`PIVX_SHIELD_FROM=from_transparent`).
568
+
569
+ ---
570
+
571
+ # Production deployment
572
+
573
+ - **Use a real `pivxd`** if you can: explorer backends are a third-party
574
+ trust assumption, and shielded verification *requires* viewing keys
575
+ you'd never hand to a public explorer.
576
+ - **Replace `InMemoryNonceStore`.** Per-process, lost on restart. Use
577
+ Redis/Postgres.
578
+ - **Lock down the RPC port.** `pivxd`'s JSON-RPC has no rate limiting;
579
+ bind to localhost and front it with your app.
580
+ - **Mind dust limits.** PIVX rejects sends below ~0.0001 PIV in practice.
581
+ - **`MIN_CONFIRMATIONS` is a knob, not a default.** 0 for cheap calls, 6+
582
+ for anything you'd be sad to lose to a re-org.
583
+ - **Payment proofs are public and unauthenticated — guard against
584
+ front-running.** A transparent proof is just `(txid, nonce)`, and *both*
585
+ values are public: the nonce travels in the on-chain `OP_RETURN`. Anyone
586
+ watching the mempool or chain can read a payer's transaction and submit the
587
+ same `(txid, nonce)` first, claiming the nonce and receiving the resource;
588
+ the real payer then gets `nonce_replayed`. The server is still paid, but the
589
+ paying client can be denied while an observer gets the resource for free.
590
+ This is inherent to the bare x402 scheme — the proof carries no signature
591
+ binding it to the payer. Mitigations:
592
+ - Keep `minConfirmations >= 1` so a proof can't be redeemed straight from the
593
+ mempool; treat `0` as best-effort, low-value only.
594
+ - For anything high-value, require client authentication on the request
595
+ (API key, signed header) in addition to the payment, so the entitlement is
596
+ bound to a known caller rather than to whoever submits the proof first.
597
+ - Shielded (`pivx-shield`) payments narrow this: the memo-borne nonce is only
598
+ visible to the holder of the viewing key, not to a public observer.
599
+
600
+ ---
601
+
602
+ # Repo layout
603
+
604
+ ```
605
+ src/
606
+ index.ts # public exports
607
+ middleware.ts # express middleware (pivx402)
608
+ verifier.ts # transparent + shielded verification
609
+ backends/ # NodeRpcBackend, ExplorerBackend, PivxBackend interface
610
+ headers.ts # X-Payment / X-Payment-Required encoding
611
+ client.ts # payAndFetch helper for callers
612
+ nonce-store.ts # in-memory NonceStore (swap for Redis)
613
+ amount.ts # PIV ↔ satoshi conversion
614
+ types.ts
615
+ demo/
616
+ cat.ts # the cat SaaS (/cat -> 402 -> SVG)
617
+ server.ts # weather demo
618
+ client.ts # interactive payer (pivx-cli command shown)
619
+ pay-cli.ts # one-shot payer that drives pivx-cli + pivx-tx
620
+ test/
621
+ *.test.ts # 20 tests: transparent + shield verification, client
622
+ install.sh # downloads pivxd, runs regtest, starts the cat demo
623
+ Dockerfile # production-style container for the cat demo
624
+ docker-compose.yml # local + server-side compose
625
+ AGENTS.md # AI-agent integration guide
626
+ ```
627
+
628
+ ---
629
+
630
+ # License
631
+
632
+ MIT. See [LICENSE](./LICENSE).
@@ -0,0 +1,2 @@
1
+ export declare function pivToSats(piv: string): bigint;
2
+ export declare function satsToPiv(sats: bigint): string;
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pivToSats = pivToSats;
4
+ exports.satsToPiv = satsToPiv;
5
+ const PIV_DECIMALS = 8;
6
+ function pivToSats(piv) {
7
+ if (!/^\d+(\.\d+)?$/.test(piv)) {
8
+ throw new Error(`invalid PIV amount: ${piv}`);
9
+ }
10
+ const [whole, frac = ""] = piv.split(".");
11
+ if (frac.length > PIV_DECIMALS) {
12
+ throw new Error(`PIV amount has more than ${PIV_DECIMALS} decimals: ${piv}`);
13
+ }
14
+ const fracPadded = (frac + "0".repeat(PIV_DECIMALS)).slice(0, PIV_DECIMALS);
15
+ return BigInt(whole) * 100000000n + BigInt(fracPadded);
16
+ }
17
+ function satsToPiv(sats) {
18
+ const neg = sats < 0n;
19
+ const abs = neg ? -sats : sats;
20
+ const whole = abs / 100000000n;
21
+ const frac = (abs % 100000000n).toString().padStart(PIV_DECIMALS, "0").replace(/0+$/, "");
22
+ const out = frac ? `${whole}.${frac}` : `${whole}`;
23
+ return neg ? `-${out}` : out;
24
+ }
@@ -0,0 +1,15 @@
1
+ import type { PivxBackend } from "./index";
2
+ import type { TxInfo } from "../types";
3
+ export interface ExplorerConfig {
4
+ /**
5
+ * Base URL of a BlockBook-compatible PIVX explorer.
6
+ * Example: https://explorer.duddino.com (no trailing slash).
7
+ */
8
+ baseUrl: string;
9
+ fetchImpl?: typeof fetch;
10
+ }
11
+ export declare class ExplorerBackend implements PivxBackend {
12
+ private readonly cfg;
13
+ constructor(cfg: ExplorerConfig);
14
+ getTransaction(txid: string): Promise<TxInfo | null>;
15
+ }