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
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InMemoryNonceStore = void 0;
|
|
4
|
+
exports.randomNonce = randomNonce;
|
|
5
|
+
/** In-memory store. Fine for a single process; swap for Redis in production. */
|
|
6
|
+
class InMemoryNonceStore {
|
|
7
|
+
ttlMs;
|
|
8
|
+
used = new Map();
|
|
9
|
+
lastSweep = 0;
|
|
10
|
+
/** Minimum gap between sweeps. Sweep is O(n); don't run it on every claim. */
|
|
11
|
+
static SWEEP_INTERVAL_MS = 60_000;
|
|
12
|
+
constructor(ttlMs = 24 * 60 * 60 * 1000) {
|
|
13
|
+
this.ttlMs = ttlMs;
|
|
14
|
+
}
|
|
15
|
+
async claim(nonce) {
|
|
16
|
+
this.maybeSweep();
|
|
17
|
+
if (this.used.has(nonce))
|
|
18
|
+
return false;
|
|
19
|
+
this.used.set(nonce, Date.now());
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
maybeSweep() {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
if (now - this.lastSweep < InMemoryNonceStore.SWEEP_INTERVAL_MS)
|
|
25
|
+
return;
|
|
26
|
+
this.lastSweep = now;
|
|
27
|
+
const cutoff = now - this.ttlMs;
|
|
28
|
+
for (const [k, t] of this.used)
|
|
29
|
+
if (t < cutoff)
|
|
30
|
+
this.used.delete(k);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
exports.InMemoryNonceStore = InMemoryNonceStore;
|
|
34
|
+
function randomNonce() {
|
|
35
|
+
// 16 bytes hex -> 32 chars. Fits in a single OP_RETURN push easily.
|
|
36
|
+
const bytes = new Uint8Array(16);
|
|
37
|
+
// crypto.getRandomValues is available in Node 19+ via the global crypto object.
|
|
38
|
+
crypto.getRandomValues(bytes);
|
|
39
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
40
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export type Network = "pivx-mainnet" | "pivx-testnet" | "pivx-regtest";
|
|
2
|
+
export type Scheme = "pivx-transparent" | "pivx-shield";
|
|
3
|
+
export interface PaymentRequirement {
|
|
4
|
+
scheme: Scheme;
|
|
5
|
+
network: Network;
|
|
6
|
+
asset: "PIV";
|
|
7
|
+
/** Decimal string, e.g. "0.01". */
|
|
8
|
+
maxAmountRequired: string;
|
|
9
|
+
/** Destination PIVX address. */
|
|
10
|
+
payTo: string;
|
|
11
|
+
/** Server-generated nonce. Client MUST embed this in an OP_RETURN. */
|
|
12
|
+
nonce: string;
|
|
13
|
+
/** Minimum confirmations before the payment is accepted. 0 = mempool ok. */
|
|
14
|
+
minConfirmations: number;
|
|
15
|
+
/** Seconds until the requirement expires. */
|
|
16
|
+
maxTimeoutSeconds: number;
|
|
17
|
+
/** The resource being paid for, e.g. "/api/weather". */
|
|
18
|
+
resource: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface PaymentRequiredEnvelope {
|
|
22
|
+
x402Version: 1;
|
|
23
|
+
accepts: PaymentRequirement[];
|
|
24
|
+
error?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface PaymentProof {
|
|
27
|
+
x402Version: 1;
|
|
28
|
+
scheme: Scheme;
|
|
29
|
+
network: Network;
|
|
30
|
+
payload: ProofPayload;
|
|
31
|
+
}
|
|
32
|
+
/** Same shape for transparent and shielded — txid is public either way. */
|
|
33
|
+
export interface ProofPayload {
|
|
34
|
+
/** Broadcast txid of the payment. */
|
|
35
|
+
txid: string;
|
|
36
|
+
/** Echoed back from the requirement so the server can match. */
|
|
37
|
+
nonce: string;
|
|
38
|
+
}
|
|
39
|
+
export type TransparentProofPayload = ProofPayload;
|
|
40
|
+
export type ShieldedProofPayload = ProofPayload;
|
|
41
|
+
export interface TxOutput {
|
|
42
|
+
/** Amount in PIV as a decimal string. null for OP_RETURN. */
|
|
43
|
+
value: string | null;
|
|
44
|
+
/** Recipient address, or null for non-address outputs (OP_RETURN, multisig, etc.). */
|
|
45
|
+
address: string | null;
|
|
46
|
+
/** Decoded OP_RETURN data as a UTF-8 string, or null if not an OP_RETURN. */
|
|
47
|
+
opReturnText: string | null;
|
|
48
|
+
}
|
|
49
|
+
export interface TxInfo {
|
|
50
|
+
txid: string;
|
|
51
|
+
confirmations: number;
|
|
52
|
+
outputs: TxOutput[];
|
|
53
|
+
}
|
|
54
|
+
/** A shielded (Sapling) output visible to the holder of the relevant viewing key. */
|
|
55
|
+
export interface ShieldedOutput {
|
|
56
|
+
/** Shielded address (ps1... on mainnet). */
|
|
57
|
+
address: string;
|
|
58
|
+
/** Amount in PIV as a decimal string. */
|
|
59
|
+
value: string;
|
|
60
|
+
/** Decoded memo as UTF-8 text. null if memo is not valid UTF-8. */
|
|
61
|
+
memoText: string | null;
|
|
62
|
+
/** True if this output is one we sent (visible via outgoing viewing key). */
|
|
63
|
+
outgoing: boolean;
|
|
64
|
+
}
|
|
65
|
+
export interface ShieldedTxInfo {
|
|
66
|
+
txid: string;
|
|
67
|
+
confirmations: number;
|
|
68
|
+
shieldedOutputs: ShieldedOutput[];
|
|
69
|
+
}
|
|
70
|
+
export interface VerificationResult {
|
|
71
|
+
ok: boolean;
|
|
72
|
+
/** Set when ok=false. Stable codes for clients to react to. */
|
|
73
|
+
reason?: "tx_not_found" | "insufficient_confirmations" | "wrong_recipient" | "insufficient_amount" | "missing_nonce" | "nonce_replayed" | "scheme_unsupported" | "network_mismatch" | "shielded_backend_unavailable";
|
|
74
|
+
details?: string;
|
|
75
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PivxBackend } from "./backends";
|
|
2
|
+
import type { NonceStore } from "./nonce-store";
|
|
3
|
+
import type { PaymentProof, PaymentRequirement, VerificationResult } from "./types";
|
|
4
|
+
export interface VerifierOptions {
|
|
5
|
+
backend: PivxBackend;
|
|
6
|
+
nonceStore: NonceStore;
|
|
7
|
+
}
|
|
8
|
+
export declare class Verifier {
|
|
9
|
+
private readonly opts;
|
|
10
|
+
constructor(opts: VerifierOptions);
|
|
11
|
+
verify(requirement: PaymentRequirement, proof: PaymentProof): Promise<VerificationResult>;
|
|
12
|
+
private verifyTransparent;
|
|
13
|
+
private verifyShielded;
|
|
14
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Verifier = void 0;
|
|
4
|
+
const amount_1 = require("./amount");
|
|
5
|
+
class Verifier {
|
|
6
|
+
opts;
|
|
7
|
+
constructor(opts) {
|
|
8
|
+
this.opts = opts;
|
|
9
|
+
}
|
|
10
|
+
async verify(requirement, proof) {
|
|
11
|
+
if (proof.scheme !== requirement.scheme) {
|
|
12
|
+
return { ok: false, reason: "scheme_unsupported", details: `proof scheme ${proof.scheme} != ${requirement.scheme}` };
|
|
13
|
+
}
|
|
14
|
+
if (proof.network !== requirement.network) {
|
|
15
|
+
return { ok: false, reason: "network_mismatch" };
|
|
16
|
+
}
|
|
17
|
+
if (proof.payload.nonce !== requirement.nonce) {
|
|
18
|
+
return { ok: false, reason: "missing_nonce", details: "proof nonce does not match requirement" };
|
|
19
|
+
}
|
|
20
|
+
let onChain;
|
|
21
|
+
switch (requirement.scheme) {
|
|
22
|
+
case "pivx-transparent":
|
|
23
|
+
onChain = await this.verifyTransparent(requirement, proof);
|
|
24
|
+
break;
|
|
25
|
+
case "pivx-shield":
|
|
26
|
+
onChain = await this.verifyShielded(requirement, proof);
|
|
27
|
+
break;
|
|
28
|
+
default:
|
|
29
|
+
return assertNever(requirement.scheme);
|
|
30
|
+
}
|
|
31
|
+
if (!onChain.ok)
|
|
32
|
+
return onChain;
|
|
33
|
+
// Replay protection: claim the nonce only once, after all other checks pass.
|
|
34
|
+
const claimed = await this.opts.nonceStore.claim(requirement.nonce);
|
|
35
|
+
if (!claimed)
|
|
36
|
+
return { ok: false, reason: "nonce_replayed" };
|
|
37
|
+
return { ok: true };
|
|
38
|
+
}
|
|
39
|
+
async verifyTransparent(requirement, proof) {
|
|
40
|
+
const tx = await this.opts.backend.getTransaction(proof.payload.txid);
|
|
41
|
+
if (!tx)
|
|
42
|
+
return { ok: false, reason: "tx_not_found" };
|
|
43
|
+
if (tx.confirmations < requirement.minConfirmations) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
reason: "insufficient_confirmations",
|
|
47
|
+
details: `have ${tx.confirmations}, need ${requirement.minConfirmations}`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const required = (0, amount_1.pivToSats)(requirement.maxAmountRequired);
|
|
51
|
+
const paid = tx.outputs
|
|
52
|
+
.filter((o) => o.address === requirement.payTo && o.value !== null)
|
|
53
|
+
.reduce((acc, o) => acc + (0, amount_1.pivToSats)(o.value), 0n);
|
|
54
|
+
if (paid === 0n)
|
|
55
|
+
return { ok: false, reason: "wrong_recipient" };
|
|
56
|
+
if (paid < required) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
reason: "insufficient_amount",
|
|
60
|
+
details: `paid ${paid} sats, need ${required} sats`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const hasNonce = tx.outputs.some((o) => o.opReturnText === requirement.nonce);
|
|
64
|
+
if (!hasNonce)
|
|
65
|
+
return { ok: false, reason: "missing_nonce", details: "OP_RETURN with nonce not found" };
|
|
66
|
+
return { ok: true };
|
|
67
|
+
}
|
|
68
|
+
async verifyShielded(requirement, proof) {
|
|
69
|
+
if (!this.opts.backend.viewShieldedTransaction) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
reason: "shielded_backend_unavailable",
|
|
73
|
+
details: "configured backend cannot decrypt shielded outputs",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const tx = await this.opts.backend.viewShieldedTransaction(proof.payload.txid);
|
|
77
|
+
if (!tx)
|
|
78
|
+
return { ok: false, reason: "tx_not_found" };
|
|
79
|
+
if (tx.confirmations < requirement.minConfirmations) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
reason: "insufficient_confirmations",
|
|
83
|
+
details: `have ${tx.confirmations}, need ${requirement.minConfirmations}`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
// Only consider incoming (non-outgoing) outputs to our shielded address.
|
|
87
|
+
const incoming = tx.shieldedOutputs.filter((o) => !o.outgoing && o.address === requirement.payTo);
|
|
88
|
+
if (incoming.length === 0)
|
|
89
|
+
return { ok: false, reason: "wrong_recipient" };
|
|
90
|
+
const required = (0, amount_1.pivToSats)(requirement.maxAmountRequired);
|
|
91
|
+
// Match an output whose memo carries the nonce; aggregate its value.
|
|
92
|
+
const matching = incoming.filter((o) => o.memoText === requirement.nonce);
|
|
93
|
+
if (matching.length === 0) {
|
|
94
|
+
return { ok: false, reason: "missing_nonce", details: "no shielded output with the nonce in its memo" };
|
|
95
|
+
}
|
|
96
|
+
const paid = matching.reduce((acc, o) => acc + (0, amount_1.pivToSats)(o.value), 0n);
|
|
97
|
+
if (paid < required) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
reason: "insufficient_amount",
|
|
101
|
+
details: `paid ${paid} sats, need ${required} sats`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return { ok: true };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
exports.Verifier = Verifier;
|
|
108
|
+
function assertNever(x) {
|
|
109
|
+
throw new Error(`unreachable scheme: ${String(x)}`);
|
|
110
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pivx-402",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "HTTP 402 Payment Required middleware for PIVX — pay-per-request APIs with transparent or shielded PIV.",
|
|
5
|
+
"main": "dist/src/index.js",
|
|
6
|
+
"types": "dist/src/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/src/index.d.ts",
|
|
10
|
+
"default": "./dist/src/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist/src",
|
|
15
|
+
"README.md",
|
|
16
|
+
"AGENTS.md",
|
|
17
|
+
"CHANGELOG.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.build.json",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
24
|
+
"demo:server": "tsx demo/server.ts",
|
|
25
|
+
"demo:cat": "tsx demo/cat.ts",
|
|
26
|
+
"demo:client": "tsx demo/client.ts",
|
|
27
|
+
"demo:pay": "tsx demo/pay-cli.ts",
|
|
28
|
+
"test": "node --test --import=tsx test/*.test.ts"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"pivx",
|
|
32
|
+
"x402",
|
|
33
|
+
"http-402",
|
|
34
|
+
"payment-required",
|
|
35
|
+
"micropayments",
|
|
36
|
+
"express",
|
|
37
|
+
"middleware",
|
|
38
|
+
"cryptocurrency",
|
|
39
|
+
"ai-agents",
|
|
40
|
+
"agentic-payments",
|
|
41
|
+
"shield",
|
|
42
|
+
"sapling"
|
|
43
|
+
],
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=20"
|
|
47
|
+
},
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/Luke-Larsen/402-PIVX.git"
|
|
51
|
+
},
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/Luke-Larsen/402-PIVX/issues"
|
|
54
|
+
},
|
|
55
|
+
"homepage": "https://github.com/Luke-Larsen/402-PIVX#readme",
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"express": "^4.19.0"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@types/express": "^4.17.21",
|
|
61
|
+
"@types/node": "^20.11.0",
|
|
62
|
+
"tsx": "^4.7.0",
|
|
63
|
+
"typescript": "^5.4.0"
|
|
64
|
+
}
|
|
65
|
+
}
|