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/AGENTS.md +261 -0
- package/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +632 -0
- package/dist/src/amount.d.ts +2 -0
- package/dist/src/amount.js +24 -0
- package/dist/src/backends/explorer.d.ts +15 -0
- package/dist/src/backends/explorer.js +79 -0
- package/dist/src/backends/index.d.ts +15 -0
- package/dist/src/backends/index.js +7 -0
- package/dist/src/backends/node-rpc.d.ts +17 -0
- package/dist/src/backends/node-rpc.js +153 -0
- package/dist/src/client.d.ts +40 -0
- package/dist/src/client.js +66 -0
- package/dist/src/headers.d.ts +20 -0
- package/dist/src/headers.js +74 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +24 -0
- package/dist/src/middleware.d.ts +50 -0
- package/dist/src/middleware.js +84 -0
- package/dist/src/nonce-store.d.ts +16 -0
- package/dist/src/nonce-store.js +40 -0
- package/dist/src/types.d.ts +75 -0
- package/dist/src/types.js +2 -0
- package/dist/src/verifier.d.ts +14 -0
- package/dist/src/verifier.js +110 -0
- package/package.json +65 -0
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,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
|
+
}
|