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,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ExplorerBackend = void 0;
|
|
4
|
+
class ExplorerBackend {
|
|
5
|
+
cfg;
|
|
6
|
+
constructor(cfg) {
|
|
7
|
+
this.cfg = cfg;
|
|
8
|
+
}
|
|
9
|
+
async getTransaction(txid) {
|
|
10
|
+
const fetchImpl = this.cfg.fetchImpl ?? fetch;
|
|
11
|
+
const url = `${this.cfg.baseUrl.replace(/\/$/, "")}/api/v2/tx/${encodeURIComponent(txid)}`;
|
|
12
|
+
const res = await fetchImpl(url, { headers: { accept: "application/json" } });
|
|
13
|
+
if (res.status === 404)
|
|
14
|
+
return null;
|
|
15
|
+
if (!res.ok)
|
|
16
|
+
throw new Error(`explorer ${url} returned ${res.status}`);
|
|
17
|
+
const tx = (await res.json());
|
|
18
|
+
return {
|
|
19
|
+
txid: tx.txid,
|
|
20
|
+
confirmations: tx.confirmations ?? 0,
|
|
21
|
+
outputs: tx.vout.map(mapVout),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
exports.ExplorerBackend = ExplorerBackend;
|
|
26
|
+
function mapVout(v) {
|
|
27
|
+
const opReturn = v.hex && v.hex.startsWith("6a") ? decodeOpReturnHex(v.hex) : null;
|
|
28
|
+
if (opReturn !== null) {
|
|
29
|
+
return { value: null, address: null, opReturnText: opReturn };
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
value: satsStringToPivString(v.value),
|
|
33
|
+
address: v.addresses && v.addresses[0] ? v.addresses[0] : null,
|
|
34
|
+
opReturnText: null,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function satsStringToPivString(sats) {
|
|
38
|
+
// BlockBook reports satoshi-equivalents as a decimal string.
|
|
39
|
+
const n = BigInt(sats);
|
|
40
|
+
const whole = n / 100000000n;
|
|
41
|
+
const frac = (n % 100000000n).toString().padStart(8, "0");
|
|
42
|
+
return `${whole}.${frac}`;
|
|
43
|
+
}
|
|
44
|
+
function decodeOpReturnHex(scriptHex) {
|
|
45
|
+
// scriptHex: "6a" + push opcode/length + data
|
|
46
|
+
// Common forms: 6a <1-75 length><data>, 6a 4c <length><data> (OP_PUSHDATA1), etc.
|
|
47
|
+
let i = 2;
|
|
48
|
+
let len;
|
|
49
|
+
const next = parseInt(scriptHex.slice(i, i + 2), 16);
|
|
50
|
+
if (Number.isNaN(next))
|
|
51
|
+
return null;
|
|
52
|
+
if (next <= 0x4b) {
|
|
53
|
+
len = next;
|
|
54
|
+
i += 2;
|
|
55
|
+
}
|
|
56
|
+
else if (next === 0x4c) {
|
|
57
|
+
len = parseInt(scriptHex.slice(i + 2, i + 4), 16);
|
|
58
|
+
i += 4;
|
|
59
|
+
}
|
|
60
|
+
else if (next === 0x4d) {
|
|
61
|
+
// little-endian uint16
|
|
62
|
+
const b0 = parseInt(scriptHex.slice(i + 2, i + 4), 16);
|
|
63
|
+
const b1 = parseInt(scriptHex.slice(i + 4, i + 6), 16);
|
|
64
|
+
len = b0 | (b1 << 8);
|
|
65
|
+
i += 6;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const dataHex = scriptHex.slice(i, i + len * 2);
|
|
71
|
+
if (dataHex.length !== len * 2)
|
|
72
|
+
return null;
|
|
73
|
+
try {
|
|
74
|
+
return Buffer.from(dataHex, "hex").toString("utf8");
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ShieldedTxInfo, TxInfo } from "../types";
|
|
2
|
+
export interface PivxBackend {
|
|
3
|
+
/** Returns the transaction, or null if not (yet) known to the backend. */
|
|
4
|
+
getTransaction(txid: string): Promise<TxInfo | null>;
|
|
5
|
+
/**
|
|
6
|
+
* Decode the shielded portion of a transaction using viewing keys held by
|
|
7
|
+
* this backend. Optional: explorer-only backends cannot implement this, since
|
|
8
|
+
* shielded outputs are encrypted on-chain.
|
|
9
|
+
*/
|
|
10
|
+
viewShieldedTransaction?(txid: string): Promise<ShieldedTxInfo | null>;
|
|
11
|
+
}
|
|
12
|
+
export { NodeRpcBackend } from "./node-rpc";
|
|
13
|
+
export type { NodeRpcConfig } from "./node-rpc";
|
|
14
|
+
export { ExplorerBackend } from "./explorer";
|
|
15
|
+
export type { ExplorerConfig } from "./explorer";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ExplorerBackend = exports.NodeRpcBackend = void 0;
|
|
4
|
+
var node_rpc_1 = require("./node-rpc");
|
|
5
|
+
Object.defineProperty(exports, "NodeRpcBackend", { enumerable: true, get: function () { return node_rpc_1.NodeRpcBackend; } });
|
|
6
|
+
var explorer_1 = require("./explorer");
|
|
7
|
+
Object.defineProperty(exports, "ExplorerBackend", { enumerable: true, get: function () { return explorer_1.ExplorerBackend; } });
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PivxBackend } from "./index";
|
|
2
|
+
import type { ShieldedTxInfo, TxInfo } from "../types";
|
|
3
|
+
export interface NodeRpcConfig {
|
|
4
|
+
/** e.g. http://127.0.0.1:51473 */
|
|
5
|
+
url: string;
|
|
6
|
+
username?: string;
|
|
7
|
+
password?: string;
|
|
8
|
+
/** Optional fetch override (for tests). */
|
|
9
|
+
fetchImpl?: typeof fetch;
|
|
10
|
+
}
|
|
11
|
+
export declare class NodeRpcBackend implements PivxBackend {
|
|
12
|
+
private readonly cfg;
|
|
13
|
+
constructor(cfg: NodeRpcConfig);
|
|
14
|
+
getTransaction(txid: string): Promise<TxInfo | null>;
|
|
15
|
+
viewShieldedTransaction(txid: string): Promise<ShieldedTxInfo | null>;
|
|
16
|
+
private call;
|
|
17
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NodeRpcBackend = void 0;
|
|
4
|
+
class NodeRpcBackend {
|
|
5
|
+
cfg;
|
|
6
|
+
constructor(cfg) {
|
|
7
|
+
this.cfg = cfg;
|
|
8
|
+
}
|
|
9
|
+
async getTransaction(txid) {
|
|
10
|
+
let raw;
|
|
11
|
+
try {
|
|
12
|
+
raw = await this.call("getrawtransaction", [txid, true]);
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
16
|
+
if (/No such mempool|No information available|-5/i.test(msg))
|
|
17
|
+
return null;
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
if (!raw)
|
|
21
|
+
return null;
|
|
22
|
+
const outputs = raw.vout.map((v) => {
|
|
23
|
+
const isOpReturn = v.scriptPubKey.asm?.startsWith("OP_RETURN");
|
|
24
|
+
if (isOpReturn) {
|
|
25
|
+
return {
|
|
26
|
+
value: null,
|
|
27
|
+
address: null,
|
|
28
|
+
opReturnText: decodeOpReturnAsm(v.scriptPubKey.asm),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const address = v.scriptPubKey.address ??
|
|
32
|
+
(v.scriptPubKey.addresses && v.scriptPubKey.addresses[0]) ??
|
|
33
|
+
null;
|
|
34
|
+
return {
|
|
35
|
+
value: typeof v.value === "number" ? v.value.toFixed(8) : String(v.value),
|
|
36
|
+
address,
|
|
37
|
+
opReturnText: null,
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
txid: raw.txid,
|
|
42
|
+
confirmations: raw.confirmations ?? 0,
|
|
43
|
+
outputs,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
async viewShieldedTransaction(txid) {
|
|
47
|
+
// viewshieldtransaction returns the decrypted outputs (requires viewing keys
|
|
48
|
+
// in the wallet) but no confirmations; getrawtransaction returns
|
|
49
|
+
// confirmations for any tx the node knows about. Fire both in parallel —
|
|
50
|
+
// they're independent and each is a network round-trip.
|
|
51
|
+
const [rawResult, chainResult] = await Promise.allSettled([
|
|
52
|
+
this.call("viewshieldtransaction", [txid]),
|
|
53
|
+
this.call("getrawtransaction", [txid, true]),
|
|
54
|
+
]);
|
|
55
|
+
if (rawResult.status === "rejected") {
|
|
56
|
+
const msg = rawResult.reason instanceof Error
|
|
57
|
+
? rawResult.reason.message
|
|
58
|
+
: String(rawResult.reason);
|
|
59
|
+
// -5: not in wallet (no viewing key for any output). Treat as "not visible".
|
|
60
|
+
// -1: txid not found. Either way the verifier should see "tx_not_found".
|
|
61
|
+
if (/No such mempool|No information available|-5|-1/i.test(msg))
|
|
62
|
+
return null;
|
|
63
|
+
throw rawResult.reason;
|
|
64
|
+
}
|
|
65
|
+
const raw = rawResult.value;
|
|
66
|
+
if (!raw)
|
|
67
|
+
return null;
|
|
68
|
+
let confirmations = 0;
|
|
69
|
+
if (chainResult.status === "fulfilled") {
|
|
70
|
+
confirmations = chainResult.value?.confirmations ?? 0;
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
const msg = chainResult.reason instanceof Error
|
|
74
|
+
? chainResult.reason.message
|
|
75
|
+
: String(chainResult.reason);
|
|
76
|
+
if (!/No such mempool|No information available|-5/i.test(msg))
|
|
77
|
+
throw chainResult.reason;
|
|
78
|
+
// tx not yet on the chain or in mempool — treat as 0 confirmations.
|
|
79
|
+
}
|
|
80
|
+
const shieldedOutputs = (raw.outputs ?? []).map((o) => ({
|
|
81
|
+
address: o.address,
|
|
82
|
+
value: typeof o.value === "number"
|
|
83
|
+
? o.value.toFixed(8)
|
|
84
|
+
: typeof o.valueSat === "number"
|
|
85
|
+
? satsNumToPivStr(o.valueSat)
|
|
86
|
+
: "0.00000000",
|
|
87
|
+
memoText: o.memoStr ?? decodeMemoHex(o.memo),
|
|
88
|
+
outgoing: o.outgoing === true,
|
|
89
|
+
}));
|
|
90
|
+
return {
|
|
91
|
+
txid: raw.txid,
|
|
92
|
+
confirmations,
|
|
93
|
+
shieldedOutputs,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
async call(method, params) {
|
|
97
|
+
const fetchImpl = this.cfg.fetchImpl ?? fetch;
|
|
98
|
+
const headers = { "content-type": "application/json" };
|
|
99
|
+
if (this.cfg.username || this.cfg.password) {
|
|
100
|
+
const token = Buffer.from(`${this.cfg.username ?? ""}:${this.cfg.password ?? ""}`).toString("base64");
|
|
101
|
+
headers["authorization"] = `Basic ${token}`;
|
|
102
|
+
}
|
|
103
|
+
const res = await fetchImpl(this.cfg.url, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers,
|
|
106
|
+
body: JSON.stringify({ jsonrpc: "1.0", id: "pivx402", method, params }),
|
|
107
|
+
});
|
|
108
|
+
const body = (await res.json());
|
|
109
|
+
if (body.error)
|
|
110
|
+
throw new Error(`pivxd rpc ${method}: ${body.error.message}`);
|
|
111
|
+
return body.result;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
exports.NodeRpcBackend = NodeRpcBackend;
|
|
115
|
+
function satsNumToPivStr(sats) {
|
|
116
|
+
const whole = Math.trunc(sats / 1e8);
|
|
117
|
+
const frac = (sats - whole * 1e8).toString().padStart(8, "0");
|
|
118
|
+
return `${whole}.${frac}`;
|
|
119
|
+
}
|
|
120
|
+
function decodeMemoHex(memo) {
|
|
121
|
+
if (!memo)
|
|
122
|
+
return null;
|
|
123
|
+
if (!/^[0-9a-fA-F]*$/.test(memo) || memo.length % 2 !== 0)
|
|
124
|
+
return null;
|
|
125
|
+
try {
|
|
126
|
+
// Sapling memos are 512 bytes padded with 0x00; strip trailing nulls.
|
|
127
|
+
const buf = Buffer.from(memo, "hex");
|
|
128
|
+
let end = buf.length;
|
|
129
|
+
while (end > 0 && buf[end - 1] === 0x00)
|
|
130
|
+
end--;
|
|
131
|
+
return buf.subarray(0, end).toString("utf8");
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function decodeOpReturnAsm(asm) {
|
|
138
|
+
// asm looks like "OP_RETURN <hex>" or "OP_RETURN -1234" for numeric pushes.
|
|
139
|
+
const rest = asm.slice("OP_RETURN".length).trim();
|
|
140
|
+
if (!rest)
|
|
141
|
+
return "";
|
|
142
|
+
// Take the first whitespace-delimited token; PIVX's OP_RETURN typically holds one push.
|
|
143
|
+
const token = rest.split(/\s+/)[0];
|
|
144
|
+
if (/^[0-9a-fA-F]+$/.test(token) && token.length % 2 === 0) {
|
|
145
|
+
try {
|
|
146
|
+
return Buffer.from(token, "hex").toString("utf8");
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { PaymentRequirement } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* A function that pays a payment requirement and returns the broadcast txid.
|
|
4
|
+
*
|
|
5
|
+
* Implementations are responsible for:
|
|
6
|
+
* - constructing a PIVX transaction that pays `req.maxAmountRequired` PIV to `req.payTo`
|
|
7
|
+
* - embedding `req.nonce` (as OP_RETURN for transparent, as the memo for shield)
|
|
8
|
+
* - broadcasting it
|
|
9
|
+
*
|
|
10
|
+
* AI agents typically supply a payer that calls into a wallet, custodian, or signer.
|
|
11
|
+
*/
|
|
12
|
+
export type Payer = (req: PaymentRequirement) => Promise<string>;
|
|
13
|
+
export interface PayAndFetchOptions {
|
|
14
|
+
/** Request init forwarded to fetch (method, headers, body, etc.). */
|
|
15
|
+
init?: RequestInit;
|
|
16
|
+
/** Optional fetch override (for tests or non-browser runtimes). */
|
|
17
|
+
fetchImpl?: typeof fetch;
|
|
18
|
+
/**
|
|
19
|
+
* Choose a payment requirement when the server advertises more than one.
|
|
20
|
+
* Default: pick the first.
|
|
21
|
+
*/
|
|
22
|
+
pickRequirement?: (accepts: PaymentRequirement[]) => PaymentRequirement;
|
|
23
|
+
}
|
|
24
|
+
export interface PayAndFetchResult {
|
|
25
|
+
response: Response;
|
|
26
|
+
requirement: PaymentRequirement;
|
|
27
|
+
txid: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Fetch a 402-gated URL, paying it through the supplied `payer` if required.
|
|
31
|
+
*
|
|
32
|
+
* Flow:
|
|
33
|
+
* 1. GET the URL.
|
|
34
|
+
* 2. If 200, return immediately (no payment needed).
|
|
35
|
+
* 3. If 402, decode the X-Payment-Required header, hand the requirement to
|
|
36
|
+
* the payer, and retry the request with X-Payment set to the proof.
|
|
37
|
+
*
|
|
38
|
+
* Any other status surfaces directly on the returned `response`.
|
|
39
|
+
*/
|
|
40
|
+
export declare function payAndFetch(url: string, payer: Payer, opts?: PayAndFetchOptions): Promise<PayAndFetchResult>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.payAndFetch = payAndFetch;
|
|
4
|
+
const headers_1 = require("./headers");
|
|
5
|
+
/**
|
|
6
|
+
* Fetch a 402-gated URL, paying it through the supplied `payer` if required.
|
|
7
|
+
*
|
|
8
|
+
* Flow:
|
|
9
|
+
* 1. GET the URL.
|
|
10
|
+
* 2. If 200, return immediately (no payment needed).
|
|
11
|
+
* 3. If 402, decode the X-Payment-Required header, hand the requirement to
|
|
12
|
+
* the payer, and retry the request with X-Payment set to the proof.
|
|
13
|
+
*
|
|
14
|
+
* Any other status surfaces directly on the returned `response`.
|
|
15
|
+
*/
|
|
16
|
+
async function payAndFetch(url, payer, opts = {}) {
|
|
17
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
18
|
+
const first = await fetchImpl(url, opts.init);
|
|
19
|
+
if (first.status !== 402) {
|
|
20
|
+
return { response: first, requirement: emptyRequirement(), txid: "" };
|
|
21
|
+
}
|
|
22
|
+
const header = first.headers.get(headers_1.HEADER_PAYMENT_REQUIRED.toLowerCase());
|
|
23
|
+
if (!header) {
|
|
24
|
+
throw new Error(`server returned 402 with no ${headers_1.HEADER_PAYMENT_REQUIRED} header`);
|
|
25
|
+
}
|
|
26
|
+
// Drain so the connection can be reused.
|
|
27
|
+
await first.arrayBuffer();
|
|
28
|
+
const envelope = (0, headers_1.decodeRequirements)(header);
|
|
29
|
+
if (!envelope.accepts.length) {
|
|
30
|
+
throw new Error("X-Payment-Required envelope has no accepted payment options");
|
|
31
|
+
}
|
|
32
|
+
const requirement = opts.pickRequirement
|
|
33
|
+
? opts.pickRequirement(envelope.accepts)
|
|
34
|
+
: envelope.accepts[0];
|
|
35
|
+
const txid = await payer(requirement);
|
|
36
|
+
if (!txid)
|
|
37
|
+
throw new Error("payer returned an empty txid");
|
|
38
|
+
const proof = {
|
|
39
|
+
x402Version: 1,
|
|
40
|
+
scheme: requirement.scheme,
|
|
41
|
+
network: requirement.network,
|
|
42
|
+
payload: { txid, nonce: requirement.nonce },
|
|
43
|
+
};
|
|
44
|
+
const init = {
|
|
45
|
+
...(opts.init ?? {}),
|
|
46
|
+
headers: {
|
|
47
|
+
...(opts.init?.headers ?? {}),
|
|
48
|
+
[headers_1.HEADER_PAYMENT]: (0, headers_1.encodeProof)(proof),
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
const response = await fetchImpl(url, init);
|
|
52
|
+
return { response, requirement, txid };
|
|
53
|
+
}
|
|
54
|
+
function emptyRequirement() {
|
|
55
|
+
return {
|
|
56
|
+
scheme: "pivx-transparent",
|
|
57
|
+
network: "pivx-mainnet",
|
|
58
|
+
asset: "PIV",
|
|
59
|
+
maxAmountRequired: "0",
|
|
60
|
+
payTo: "",
|
|
61
|
+
nonce: "",
|
|
62
|
+
minConfirmations: 0,
|
|
63
|
+
maxTimeoutSeconds: 0,
|
|
64
|
+
resource: "",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PaymentProof, PaymentRequiredEnvelope } from "./types";
|
|
2
|
+
export declare const HEADER_PAYMENT_REQUIRED = "X-Payment-Required";
|
|
3
|
+
export declare const HEADER_PAYMENT = "X-Payment";
|
|
4
|
+
/**
|
|
5
|
+
* Hard cap on the base64-encoded X-Payment header. A valid proof is ~200 bytes;
|
|
6
|
+
* 8KB leaves comfortable headroom while bounding what we'll decode and parse.
|
|
7
|
+
*/
|
|
8
|
+
export declare const MAX_PAYMENT_HEADER_BYTES: number;
|
|
9
|
+
export declare function encodeRequirements(env: PaymentRequiredEnvelope): string;
|
|
10
|
+
export declare function decodeRequirements(header: string): PaymentRequiredEnvelope;
|
|
11
|
+
export declare function encodeProof(proof: PaymentProof): string;
|
|
12
|
+
/**
|
|
13
|
+
* Decode and validate an X-Payment header. Throws on:
|
|
14
|
+
* - oversized input (> MAX_PAYMENT_HEADER_BYTES)
|
|
15
|
+
* - invalid JSON
|
|
16
|
+
* - wrong shape (missing/typed-wrong fields, unknown scheme/network)
|
|
17
|
+
*
|
|
18
|
+
* Callers should treat any throw as a 400-class client error.
|
|
19
|
+
*/
|
|
20
|
+
export declare function decodeProof(header: string): PaymentProof;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_PAYMENT_HEADER_BYTES = exports.HEADER_PAYMENT = exports.HEADER_PAYMENT_REQUIRED = void 0;
|
|
4
|
+
exports.encodeRequirements = encodeRequirements;
|
|
5
|
+
exports.decodeRequirements = decodeRequirements;
|
|
6
|
+
exports.encodeProof = encodeProof;
|
|
7
|
+
exports.decodeProof = decodeProof;
|
|
8
|
+
exports.HEADER_PAYMENT_REQUIRED = "X-Payment-Required";
|
|
9
|
+
exports.HEADER_PAYMENT = "X-Payment";
|
|
10
|
+
/**
|
|
11
|
+
* Hard cap on the base64-encoded X-Payment header. A valid proof is ~200 bytes;
|
|
12
|
+
* 8KB leaves comfortable headroom while bounding what we'll decode and parse.
|
|
13
|
+
*/
|
|
14
|
+
exports.MAX_PAYMENT_HEADER_BYTES = 8 * 1024;
|
|
15
|
+
function b64encode(json) {
|
|
16
|
+
return Buffer.from(JSON.stringify(json), "utf8").toString("base64");
|
|
17
|
+
}
|
|
18
|
+
function b64decode(value) {
|
|
19
|
+
return JSON.parse(Buffer.from(value, "base64").toString("utf8"));
|
|
20
|
+
}
|
|
21
|
+
function encodeRequirements(env) {
|
|
22
|
+
return b64encode(env);
|
|
23
|
+
}
|
|
24
|
+
function decodeRequirements(header) {
|
|
25
|
+
return b64decode(header);
|
|
26
|
+
}
|
|
27
|
+
function encodeProof(proof) {
|
|
28
|
+
return b64encode(proof);
|
|
29
|
+
}
|
|
30
|
+
const VALID_SCHEMES = new Set(["pivx-transparent", "pivx-shield"]);
|
|
31
|
+
const VALID_NETWORKS = new Set([
|
|
32
|
+
"pivx-mainnet",
|
|
33
|
+
"pivx-testnet",
|
|
34
|
+
"pivx-regtest",
|
|
35
|
+
]);
|
|
36
|
+
/**
|
|
37
|
+
* Decode and validate an X-Payment header. Throws on:
|
|
38
|
+
* - oversized input (> MAX_PAYMENT_HEADER_BYTES)
|
|
39
|
+
* - invalid JSON
|
|
40
|
+
* - wrong shape (missing/typed-wrong fields, unknown scheme/network)
|
|
41
|
+
*
|
|
42
|
+
* Callers should treat any throw as a 400-class client error.
|
|
43
|
+
*/
|
|
44
|
+
function decodeProof(header) {
|
|
45
|
+
if (header.length > exports.MAX_PAYMENT_HEADER_BYTES) {
|
|
46
|
+
throw new Error(`X-Payment header exceeds ${exports.MAX_PAYMENT_HEADER_BYTES} bytes`);
|
|
47
|
+
}
|
|
48
|
+
const parsed = b64decode(header);
|
|
49
|
+
if (!isPaymentProof(parsed)) {
|
|
50
|
+
throw new Error("malformed payment proof");
|
|
51
|
+
}
|
|
52
|
+
return parsed;
|
|
53
|
+
}
|
|
54
|
+
function isPaymentProof(v) {
|
|
55
|
+
if (!v || typeof v !== "object")
|
|
56
|
+
return false;
|
|
57
|
+
const p = v;
|
|
58
|
+
if (p.x402Version !== 1)
|
|
59
|
+
return false;
|
|
60
|
+
if (typeof p.scheme !== "string" || !VALID_SCHEMES.has(p.scheme))
|
|
61
|
+
return false;
|
|
62
|
+
if (typeof p.network !== "string" || !VALID_NETWORKS.has(p.network))
|
|
63
|
+
return false;
|
|
64
|
+
if (!p.payload || typeof p.payload !== "object")
|
|
65
|
+
return false;
|
|
66
|
+
const payload = p.payload;
|
|
67
|
+
// PIVX txids are 32-byte hashes: exactly 64 hex chars. Rejecting anything
|
|
68
|
+
// else here keeps malformed ids from reaching the backend RPC/explorer.
|
|
69
|
+
if (typeof payload.txid !== "string" || !/^[0-9a-fA-F]{64}$/.test(payload.txid))
|
|
70
|
+
return false;
|
|
71
|
+
if (typeof payload.nonce !== "string" || payload.nonce.length === 0)
|
|
72
|
+
return false;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./types"), exports);
|
|
18
|
+
__exportStar(require("./headers"), exports);
|
|
19
|
+
__exportStar(require("./amount"), exports);
|
|
20
|
+
__exportStar(require("./nonce-store"), exports);
|
|
21
|
+
__exportStar(require("./verifier"), exports);
|
|
22
|
+
__exportStar(require("./middleware"), exports);
|
|
23
|
+
__exportStar(require("./backends"), exports);
|
|
24
|
+
__exportStar(require("./client"), exports);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Request, RequestHandler } from "express";
|
|
2
|
+
import { type NonceStore } from "./nonce-store";
|
|
3
|
+
import type { PivxBackend } from "./backends";
|
|
4
|
+
import type { Network, Scheme } from "./types";
|
|
5
|
+
export interface PriceConfig {
|
|
6
|
+
/** Decimal PIV, e.g. "0.01". */
|
|
7
|
+
amount: string;
|
|
8
|
+
/** Receiving PIVX address. */
|
|
9
|
+
payTo: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Per-request price, returned either synchronously or asynchronously.
|
|
14
|
+
* Use the async form when the price depends on a database/external lookup.
|
|
15
|
+
*/
|
|
16
|
+
export type PriceResolver = (req: Request) => PriceConfig | Promise<PriceConfig>;
|
|
17
|
+
export interface MiddlewareOptions {
|
|
18
|
+
backend: PivxBackend;
|
|
19
|
+
network: Network;
|
|
20
|
+
scheme?: Scheme;
|
|
21
|
+
minConfirmations?: number;
|
|
22
|
+
maxTimeoutSeconds?: number;
|
|
23
|
+
/** Static price, or a per-request function (may be async). */
|
|
24
|
+
price: PriceConfig | PriceResolver;
|
|
25
|
+
/**
|
|
26
|
+
* Replay-protection store. Defaults to a per-middleware in-memory store.
|
|
27
|
+
*
|
|
28
|
+
* IMPORTANT: if you mount pivx402() on multiple routes that share the same
|
|
29
|
+
* payTo, scheme, and price, you MUST pass the *same* nonceStore instance to
|
|
30
|
+
* each — otherwise a single broadcast tx can be redeemed against every route.
|
|
31
|
+
* For multi-process deployments, supply a shared backing store (e.g. Redis).
|
|
32
|
+
*/
|
|
33
|
+
nonceStore?: NonceStore;
|
|
34
|
+
}
|
|
35
|
+
/** Data the middleware attaches to a successful request before next(). */
|
|
36
|
+
export interface Pivx402RequestContext {
|
|
37
|
+
txid: string;
|
|
38
|
+
nonce: string;
|
|
39
|
+
scheme: Scheme;
|
|
40
|
+
network: Network;
|
|
41
|
+
amount: string;
|
|
42
|
+
payTo: string;
|
|
43
|
+
}
|
|
44
|
+
declare module "express-serve-static-core" {
|
|
45
|
+
interface Request {
|
|
46
|
+
/** Set by the pivx402 middleware after a successful payment verification. */
|
|
47
|
+
pivx402?: Pivx402RequestContext;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export declare function pivx402(opts: MiddlewareOptions): RequestHandler;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.pivx402 = pivx402;
|
|
4
|
+
const headers_1 = require("./headers");
|
|
5
|
+
const nonce_store_1 = require("./nonce-store");
|
|
6
|
+
const verifier_1 = require("./verifier");
|
|
7
|
+
function pivx402(opts) {
|
|
8
|
+
const scheme = opts.scheme ?? "pivx-transparent";
|
|
9
|
+
const nonceStore = opts.nonceStore ?? new nonce_store_1.InMemoryNonceStore();
|
|
10
|
+
const verifier = new verifier_1.Verifier({ backend: opts.backend, nonceStore });
|
|
11
|
+
return async (req, res, next) => {
|
|
12
|
+
const price = await (typeof opts.price === "function" ? opts.price(req) : opts.price);
|
|
13
|
+
const header = req.header(headers_1.HEADER_PAYMENT);
|
|
14
|
+
if (!header) {
|
|
15
|
+
return sendPaymentRequired(res, {
|
|
16
|
+
scheme,
|
|
17
|
+
network: opts.network,
|
|
18
|
+
asset: "PIV",
|
|
19
|
+
maxAmountRequired: price.amount,
|
|
20
|
+
payTo: price.payTo,
|
|
21
|
+
nonce: (0, nonce_store_1.randomNonce)(),
|
|
22
|
+
minConfirmations: opts.minConfirmations ?? 1,
|
|
23
|
+
maxTimeoutSeconds: opts.maxTimeoutSeconds ?? 600,
|
|
24
|
+
resource: req.originalUrl,
|
|
25
|
+
description: price.description,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
let proof;
|
|
29
|
+
try {
|
|
30
|
+
proof = (0, headers_1.decodeProof)(header);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
return res.status(400).json({ error: "malformed_payment_header" });
|
|
34
|
+
}
|
|
35
|
+
// Reconstruct the requirement from the proof's nonce + current price.
|
|
36
|
+
// The nonce came from a prior 402 we issued; the client echoes it back.
|
|
37
|
+
const requirement = {
|
|
38
|
+
scheme,
|
|
39
|
+
network: opts.network,
|
|
40
|
+
asset: "PIV",
|
|
41
|
+
maxAmountRequired: price.amount,
|
|
42
|
+
payTo: price.payTo,
|
|
43
|
+
nonce: proof.payload.nonce,
|
|
44
|
+
minConfirmations: opts.minConfirmations ?? 1,
|
|
45
|
+
maxTimeoutSeconds: opts.maxTimeoutSeconds ?? 600,
|
|
46
|
+
resource: req.originalUrl,
|
|
47
|
+
description: price.description,
|
|
48
|
+
};
|
|
49
|
+
let result;
|
|
50
|
+
try {
|
|
51
|
+
result = await verifier.verify(requirement, proof);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
// Backend RPC/explorer failures shouldn't crash the process. Surface as
|
|
55
|
+
// a retryable 402 so the client (e.g. pay-cli) treats it like an upstream
|
|
56
|
+
// hiccup and reissues the proof. Don't leak the error string to the wire.
|
|
57
|
+
// eslint-disable-next-line no-console
|
|
58
|
+
console.error("[pivx402] backend error during verify:", err);
|
|
59
|
+
return sendPaymentRequired(res, { ...requirement, nonce: (0, nonce_store_1.randomNonce)() }, "tx_not_found: backend temporarily unavailable");
|
|
60
|
+
}
|
|
61
|
+
if (!result.ok) {
|
|
62
|
+
return sendPaymentRequired(res, { ...requirement, nonce: (0, nonce_store_1.randomNonce)() }, `${result.reason}${result.details ? `: ${result.details}` : ""}`);
|
|
63
|
+
}
|
|
64
|
+
req.pivx402 = {
|
|
65
|
+
txid: proof.payload.txid,
|
|
66
|
+
nonce: requirement.nonce,
|
|
67
|
+
scheme: requirement.scheme,
|
|
68
|
+
network: requirement.network,
|
|
69
|
+
amount: requirement.maxAmountRequired,
|
|
70
|
+
payTo: requirement.payTo,
|
|
71
|
+
};
|
|
72
|
+
return next();
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function sendPaymentRequired(res, req, error) {
|
|
76
|
+
res
|
|
77
|
+
.status(402)
|
|
78
|
+
.setHeader(headers_1.HEADER_PAYMENT_REQUIRED, (0, headers_1.encodeRequirements)({ x402Version: 1, accepts: [req], error }))
|
|
79
|
+
.json({
|
|
80
|
+
x402Version: 1,
|
|
81
|
+
accepts: [req],
|
|
82
|
+
...(error ? { error } : {}),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface NonceStore {
|
|
2
|
+
/** Returns true if claimed by this caller; false if already used. */
|
|
3
|
+
claim(nonce: string): Promise<boolean>;
|
|
4
|
+
}
|
|
5
|
+
/** In-memory store. Fine for a single process; swap for Redis in production. */
|
|
6
|
+
export declare class InMemoryNonceStore implements NonceStore {
|
|
7
|
+
private readonly ttlMs;
|
|
8
|
+
private readonly used;
|
|
9
|
+
private lastSweep;
|
|
10
|
+
/** Minimum gap between sweeps. Sweep is O(n); don't run it on every claim. */
|
|
11
|
+
private static readonly SWEEP_INTERVAL_MS;
|
|
12
|
+
constructor(ttlMs?: number);
|
|
13
|
+
claim(nonce: string): Promise<boolean>;
|
|
14
|
+
private maybeSweep;
|
|
15
|
+
}
|
|
16
|
+
export declare function randomNonce(): string;
|