@paynodelabs/sdk-js 1.4.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -1
- package/dist/client.d.ts +6 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +145 -56
- package/dist/errors/index.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/middleware/x402.d.ts +2 -0
- package/dist/middleware/x402.d.ts.map +1 -1
- package/dist/middleware/x402.js +67 -37
- package/dist/types/x402.d.ts +83 -0
- package/dist/types/x402.d.ts.map +1 -0
- package/dist/types/x402.js +2 -0
- package/dist/utils/idempotency.d.ts +7 -0
- package/dist/utils/idempotency.d.ts.map +1 -1
- package/dist/utils/idempotency.js +7 -0
- package/dist/utils/verifier.d.ts +18 -1
- package/dist/utils/verifier.d.ts.map +1 -1
- package/dist/utils/verifier.js +139 -55
- package/dist/utils/webhook.js +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://docs.paynode.dev)
|
|
4
4
|
[](https://www.npmjs.com/package/@paynodelabs/sdk-js)
|
|
5
5
|
|
|
6
|
-
The official TypeScript/JavaScript SDK for the **PayNode Protocol**. PayNode is a stateless, non-custodial M2M payment gateway that standardizes the HTTP 402 "Payment Required" flow for AI Agents,
|
|
6
|
+
The official TypeScript/JavaScript SDK for the **PayNode Protocol (v3.1)**. PayNode is a stateless, non-custodial M2M payment gateway that standardizes the HTTP 402 "Payment Required" flow for AI Agents, with support for both on-chain receipts and off-chain signatures (EIP-3009).
|
|
7
7
|
|
|
8
8
|
## 📖 Read the Docs
|
|
9
9
|
|
|
@@ -33,6 +33,21 @@ async function main() {
|
|
|
33
33
|
main();
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
+
### Key Features (v2.0)
|
|
37
|
+
- **Zero-Wait Checkout**: API response speed drops from 5 seconds to **under 50ms** by using local signatures instead of waiting for on-chain inclusion.
|
|
38
|
+
- **Double-Spend Protection**:
|
|
39
|
+
- **L1 (Memory)**: High-speed local replay protection via `IdempotencyStore`.
|
|
40
|
+
- **L2 (RPC)**: Real-time on-chain `authorizationState` verification.
|
|
41
|
+
- **Empty-Wallet Proof**: Integrated `balanceOf` probes to block malicious agents using empty wallets to generate valid signatures.
|
|
42
|
+
- **EIP-3009 Support**: Sign payments off-chain using `TransferWithAuthorization`.
|
|
43
|
+
- **X402 V2 Protocol**: JSON-based handshake for structured agent interaction.
|
|
44
|
+
- **Dual Flow**: Automatic switch between V1 (on-chain receipts) and V2 (off-chain signatures).
|
|
45
|
+
|
|
46
|
+
## 🗺️ Roadmap
|
|
47
|
+
- **TRON Support**: USDT (TRC-20) payment integration.
|
|
48
|
+
- **Solana Support**: SPL USDC/USDT payment integration.
|
|
49
|
+
- **Cross-chain**: Universal settlement via bridges.
|
|
50
|
+
|
|
36
51
|
## 🚀 Run the Demo
|
|
37
52
|
|
|
38
53
|
The SDK includes a full merchant/agent demonstration in the `examples/` directory.
|
package/dist/client.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ExactEVMPayload } from './types/x402';
|
|
1
2
|
export interface RequestOptions extends RequestInit {
|
|
2
3
|
json?: any;
|
|
3
4
|
}
|
|
@@ -5,11 +6,14 @@ export declare class PayNodeAgentClient {
|
|
|
5
6
|
private wallet;
|
|
6
7
|
private provider;
|
|
7
8
|
private rpcUrls;
|
|
9
|
+
private maxRetries;
|
|
8
10
|
private ERC20_ABI;
|
|
9
11
|
private ROUTER_ABI;
|
|
10
|
-
constructor(privateKey: string, rpcUrls?: string | string[]);
|
|
12
|
+
constructor(privateKey: string, rpcUrls?: string | string[], maxRetries?: number);
|
|
13
|
+
private _fetchWithRetry;
|
|
11
14
|
requestGate(url: string, options?: RequestOptions): Promise<Response>;
|
|
12
|
-
private
|
|
15
|
+
private _handleX402V2;
|
|
16
|
+
signTransferWithAuthorization(tokenAddr: string, to: string, amount: bigint, validAfter: number, validBefore: number, nonce: string, extra?: Record<string, any>): Promise<ExactEVMPayload>;
|
|
13
17
|
pay(contractAddr: string, tokenAddr: string, merchantAddr: string, amount: bigint, orderId: string): Promise<string>;
|
|
14
18
|
payWithPermit(contractAddr: string, tokenAddr: string, merchantAddr: string, amount: bigint, orderId: string): Promise<string>;
|
|
15
19
|
signPermit(tokenAddr: string, spenderAddr: string, amount: bigint, deadlineSeconds?: number): Promise<{
|
package/dist/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,eAAe,EAEhB,MAAM,cAAc,CAAC;AAEtB,MAAM,WAAW,cAAe,SAAQ,WAAW;IACjD,IAAI,CAAC,EAAE,GAAG,CAAC;CACZ;AAID,qBAAa,kBAAkB;IAC7B,OAAO,CAAC,MAAM,CAAgB;IAC9B,OAAO,CAAC,QAAQ,CAA0B;IAC1C,OAAO,CAAC,OAAO,CAAW;IAC1B,OAAO,CAAC,UAAU,CAAS;IAE3B,OAAO,CAAC,SAAS,CAMf;IAEF,OAAO,CAAC,UAAU,CAGhB;gBAEU,UAAU,EAAE,MAAM,EAAE,OAAO,GAAE,MAAM,GAAG,MAAM,EAAkB,EAAE,UAAU,GAAE,MAAU;YAepF,eAAe;IAgCvB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,QAAQ,CAAC;YAiDjE,aAAa;IAqFrB,6BAA6B,CACjC,SAAS,EAAE,MAAM,EACjB,EAAE,EAAE,MAAM,EACV,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,MAAM,EACb,KAAK,GAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAM,GAC9B,OAAO,CAAC,eAAe,CAAC;IA6CrB,GAAG,CAAC,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAepH,aAAa,CAAC,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAwB9H,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,GAAE,MAAa;;;;;;CAwCxG"}
|
package/dist/client.js
CHANGED
|
@@ -4,10 +4,12 @@ exports.PayNodeAgentClient = void 0;
|
|
|
4
4
|
const ethers_1 = require("ethers");
|
|
5
5
|
const errors_1 = require("./errors");
|
|
6
6
|
const constants_1 = require("./constants");
|
|
7
|
+
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
|
|
7
8
|
class PayNodeAgentClient {
|
|
8
9
|
wallet;
|
|
9
10
|
provider;
|
|
10
11
|
rpcUrls;
|
|
12
|
+
maxRetries;
|
|
11
13
|
ERC20_ABI = [
|
|
12
14
|
"function approve(address spender, uint256 value) public returns (bool)",
|
|
13
15
|
"function allowance(address owner, address spender) public view returns (uint256)",
|
|
@@ -19,8 +21,9 @@ class PayNodeAgentClient {
|
|
|
19
21
|
"function pay(address token, address merchant, uint256 amount, bytes32 orderId) public",
|
|
20
22
|
"function payWithPermit(address payer, address token, address merchant, uint256 amount, bytes32 orderId, uint256 deadline, uint8 v, bytes32 r, bytes32 s) public"
|
|
21
23
|
];
|
|
22
|
-
constructor(privateKey, rpcUrls = constants_1.BASE_RPC_URLS) {
|
|
24
|
+
constructor(privateKey, rpcUrls = constants_1.BASE_RPC_URLS, maxRetries = 3) {
|
|
23
25
|
this.rpcUrls = Array.isArray(rpcUrls) ? rpcUrls : [rpcUrls];
|
|
26
|
+
this.maxRetries = maxRetries;
|
|
24
27
|
const configs = this.rpcUrls.map((url, index) => ({
|
|
25
28
|
provider: new ethers_1.ethers.JsonRpcProvider(url),
|
|
26
29
|
priority: index,
|
|
@@ -30,6 +33,33 @@ class PayNodeAgentClient {
|
|
|
30
33
|
this.provider = new ethers_1.ethers.FallbackProvider(configs);
|
|
31
34
|
this.wallet = new ethers_1.ethers.Wallet(privateKey, this.provider);
|
|
32
35
|
}
|
|
36
|
+
async _fetchWithRetry(url, options) {
|
|
37
|
+
let lastError = null;
|
|
38
|
+
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
|
|
39
|
+
try {
|
|
40
|
+
const response = await fetch(url, options);
|
|
41
|
+
if (!RETRYABLE_STATUS_CODES.has(response.status)) {
|
|
42
|
+
return response;
|
|
43
|
+
}
|
|
44
|
+
if (attempt < this.maxRetries - 1) {
|
|
45
|
+
const backoffMs = Math.pow(2, attempt) * 1000;
|
|
46
|
+
console.warn(`⚠️ [PayNode-JS] ${response.status} received. Retrying in ${backoffMs}ms...`);
|
|
47
|
+
await new Promise(resolve => setTimeout(resolve, backoffMs));
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
return response;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
lastError = error;
|
|
54
|
+
if (attempt < this.maxRetries - 1) {
|
|
55
|
+
const backoffMs = Math.pow(2, attempt) * 1000;
|
|
56
|
+
console.warn(`⚠️ [PayNode-JS] Request failed: ${error.message}. Retrying in ${backoffMs}ms...`);
|
|
57
|
+
await new Promise(resolve => setTimeout(resolve, backoffMs));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
throw lastError || new Error('Request failed after max retries');
|
|
62
|
+
}
|
|
33
63
|
async requestGate(url, options = {}) {
|
|
34
64
|
const fetchOptions = { ...options };
|
|
35
65
|
if (options.json && !fetchOptions.body) {
|
|
@@ -40,89 +70,148 @@ class PayNodeAgentClient {
|
|
|
40
70
|
};
|
|
41
71
|
}
|
|
42
72
|
try {
|
|
43
|
-
let response = await
|
|
73
|
+
let response = await this._fetchWithRetry(url, fetchOptions);
|
|
44
74
|
if (response.status === 402) {
|
|
45
|
-
console.log(`💡 [PayNode-JS] 402 Payment Required detected.
|
|
46
|
-
|
|
75
|
+
console.log(`💡 [PayNode-JS] 402 Payment Required detected. Analyzing protocol version...`);
|
|
76
|
+
const contentType = response.headers.get('content-type');
|
|
77
|
+
const b64Required = response.headers.get('X-402-Required');
|
|
78
|
+
const orderId = response.headers.get('X-402-Order-Id');
|
|
79
|
+
let body = null;
|
|
80
|
+
if (contentType && contentType.includes('application/json')) {
|
|
81
|
+
body = await response.clone().json();
|
|
82
|
+
}
|
|
83
|
+
else if (b64Required) {
|
|
84
|
+
try {
|
|
85
|
+
body = JSON.parse(Buffer.from(b64Required, 'base64').toString());
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
console.debug('⚠️ [PayNode-JS] Failed to parse X-402-Required header:', e);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (body && body.x402Version === 2) {
|
|
92
|
+
console.log(`🚀 [PayNode-JS] x402 v2 detected. Handling autonomous payment...`);
|
|
93
|
+
if (orderId)
|
|
94
|
+
body.orderId = orderId;
|
|
95
|
+
return await this._handleX402V2(url, fetchOptions, body);
|
|
96
|
+
}
|
|
97
|
+
throw new errors_1.PayNodeException(errors_1.ErrorCode.InternalError, "Unsupported or malformed 402 response");
|
|
47
98
|
}
|
|
48
99
|
return response;
|
|
49
100
|
}
|
|
50
101
|
catch (error) {
|
|
51
|
-
if (error instanceof errors_1.PayNodeException)
|
|
102
|
+
if (error instanceof errors_1.PayNodeException || error?.name === "PayNodeException")
|
|
52
103
|
throw error;
|
|
104
|
+
console.error(`❌ [PayNode-JS] Critical error in requestGate:`, error);
|
|
53
105
|
throw new errors_1.PayNodeException(errors_1.ErrorCode.RpcError, undefined, error);
|
|
54
106
|
}
|
|
55
107
|
}
|
|
56
|
-
async
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (!contractAddr || !merchantAddr || !amountStr || !tokenAddr || !orderIdStr) {
|
|
65
|
-
throw new errors_1.PayNodeException(errors_1.ErrorCode.InternalError, "Malformed 402 headers: missing metadata");
|
|
108
|
+
async _handleX402V2(url, options, requirements) {
|
|
109
|
+
const network = await this.provider.getNetwork();
|
|
110
|
+
const chainId = Number(network.chainId);
|
|
111
|
+
const caip2ChainId = `eip155:${chainId}`;
|
|
112
|
+
// Select suitable requirement
|
|
113
|
+
const requirement = requirements.accepts.find((req) => req.network === caip2ChainId);
|
|
114
|
+
if (!requirement) {
|
|
115
|
+
throw new errors_1.PayNodeException(errors_1.ErrorCode.InternalError, `No compatible payment requirement found for network ${caip2ChainId}`);
|
|
66
116
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
throw new errors_1.PayNodeException(errors_1.ErrorCode.InvalidReceipt, `Network mismatch: Current ${network.chainId}, Request ${chainIdStr}.`);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
console.log(`💡 [PayNode-JS] Payment request: ${amountStr} ${currency} to ${merchantAddr}`);
|
|
75
|
-
const amount = BigInt(amountStr);
|
|
76
|
-
// v1.3 Constraint: Min payment protection
|
|
77
|
-
if (amount < 1000n) {
|
|
78
|
-
throw new errors_1.PayNodeException(errors_1.ErrorCode.AmountTooLow);
|
|
117
|
+
const orderId = requirement.orderId || requirements.orderId || url;
|
|
118
|
+
// Dust limit check
|
|
119
|
+
if (BigInt(requirement.amount) < BigInt(constants_1.MIN_PAYMENT_AMOUNT)) {
|
|
120
|
+
throw new errors_1.PayNodeException(errors_1.ErrorCode.AmountTooLow, `Payment amount ${requirement.amount} is below the minimum dust limit`);
|
|
79
121
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
122
|
+
let payload;
|
|
123
|
+
if (requirement.type === 'eip3009') {
|
|
124
|
+
const validAfter = Math.floor(Date.now() / 1000) - 60;
|
|
125
|
+
const validBefore = Math.floor(Date.now() / 1000) + (requirement.maxTimeoutSeconds || 3600);
|
|
126
|
+
const nonce = ethers_1.ethers.hexlify(ethers_1.ethers.randomBytes(32));
|
|
127
|
+
const authorization = await this.signTransferWithAuthorization(requirement.asset, requirement.payTo, BigInt(requirement.amount), validAfter, validBefore, nonce, requirement.extra);
|
|
128
|
+
payload = {
|
|
129
|
+
version: "3.1",
|
|
130
|
+
type: 'eip3009',
|
|
131
|
+
orderId,
|
|
132
|
+
payload: authorization
|
|
133
|
+
};
|
|
85
134
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
tokenContract.allowance(this.wallet.address, contractAddr)
|
|
92
|
-
]);
|
|
93
|
-
if (balance < amount) {
|
|
94
|
-
throw new errors_1.PayNodeException(errors_1.ErrorCode.InsufficientFunds);
|
|
135
|
+
else {
|
|
136
|
+
// type: 'onchain' or fallback
|
|
137
|
+
const routerAddr = requirement.router;
|
|
138
|
+
if (!routerAddr) {
|
|
139
|
+
throw new errors_1.PayNodeException(errors_1.ErrorCode.InternalError, "On-chain payment required but no router address provided.");
|
|
95
140
|
}
|
|
96
|
-
|
|
141
|
+
console.log(`⚡ [PayNode-JS] Executing on-chain payment to ${requirement.payTo}...`);
|
|
142
|
+
const amount = BigInt(requirement.amount);
|
|
143
|
+
const tokenContract = new ethers_1.ethers.Contract(requirement.asset, this.ERC20_ABI, this.wallet);
|
|
144
|
+
const allowance = await tokenContract.allowance(this.wallet.address, routerAddr);
|
|
145
|
+
let txHash;
|
|
97
146
|
if (allowance >= amount) {
|
|
98
|
-
txHash = await this.pay(
|
|
147
|
+
txHash = await this.pay(routerAddr, requirement.asset, requirement.payTo, amount, orderId);
|
|
99
148
|
}
|
|
100
149
|
else {
|
|
101
|
-
|
|
102
|
-
txHash = await this.payWithPermit(contractAddr, tokenAddr, merchantAddr, amount, orderIdStr);
|
|
150
|
+
txHash = await this.payWithPermit(routerAddr, requirement.asset, requirement.payTo, amount, orderId);
|
|
103
151
|
}
|
|
152
|
+
payload = {
|
|
153
|
+
version: "3.1",
|
|
154
|
+
type: 'onchain',
|
|
155
|
+
orderId,
|
|
156
|
+
payload: { txHash }
|
|
157
|
+
};
|
|
104
158
|
}
|
|
105
|
-
|
|
106
|
-
if (error instanceof errors_1.PayNodeException)
|
|
107
|
-
throw error;
|
|
108
|
-
throw new errors_1.PayNodeException(errors_1.ErrorCode.TransactionFailed, undefined, error);
|
|
109
|
-
}
|
|
110
|
-
console.log(`✅ [PayNode-JS] Payment confirmed on-chain: ${txHash}`);
|
|
159
|
+
const b64Payload = Buffer.from(JSON.stringify(payload)).toString('base64');
|
|
111
160
|
const retryOptions = {
|
|
112
161
|
...options,
|
|
113
162
|
headers: {
|
|
114
163
|
...options.headers,
|
|
115
|
-
'
|
|
116
|
-
'
|
|
164
|
+
'Content-Type': 'application/json',
|
|
165
|
+
'X-402-Payload': b64Payload,
|
|
166
|
+
'X-402-Order-Id': orderId
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
return await this._fetchWithRetry(url, retryOptions);
|
|
170
|
+
}
|
|
171
|
+
async signTransferWithAuthorization(tokenAddr, to, amount, validAfter, validBefore, nonce, extra = {}) {
|
|
172
|
+
const network = await this.provider.getNetwork();
|
|
173
|
+
const domain = {
|
|
174
|
+
name: extra.name || "USD Coin",
|
|
175
|
+
version: extra.version || "2",
|
|
176
|
+
chainId: Number(network.chainId),
|
|
177
|
+
verifyingContract: tokenAddr
|
|
178
|
+
};
|
|
179
|
+
const types = {
|
|
180
|
+
TransferWithAuthorization: [
|
|
181
|
+
{ name: "from", type: "address" },
|
|
182
|
+
{ name: "to", type: "address" },
|
|
183
|
+
{ name: "value", type: "uint256" },
|
|
184
|
+
{ name: "validAfter", type: "uint256" },
|
|
185
|
+
{ name: "validBefore", type: "uint256" },
|
|
186
|
+
{ name: "nonce", type: "bytes32" }
|
|
187
|
+
]
|
|
188
|
+
};
|
|
189
|
+
const value = {
|
|
190
|
+
from: this.wallet.address,
|
|
191
|
+
to,
|
|
192
|
+
value: amount,
|
|
193
|
+
validAfter,
|
|
194
|
+
validBefore,
|
|
195
|
+
nonce
|
|
196
|
+
};
|
|
197
|
+
const signature = await this.wallet.signTypedData(domain, types, value);
|
|
198
|
+
return {
|
|
199
|
+
signature,
|
|
200
|
+
authorization: {
|
|
201
|
+
from: this.wallet.address,
|
|
202
|
+
to,
|
|
203
|
+
value: amount.toString(),
|
|
204
|
+
validAfter: validAfter.toString(),
|
|
205
|
+
validBefore: validBefore.toString(),
|
|
206
|
+
nonce
|
|
117
207
|
}
|
|
118
208
|
};
|
|
119
|
-
return await fetch(url, retryOptions);
|
|
120
209
|
}
|
|
121
210
|
async pay(contractAddr, tokenAddr, merchantAddr, amount, orderId) {
|
|
122
211
|
const router = new ethers_1.ethers.Contract(contractAddr, this.ROUTER_ABI, this.wallet);
|
|
123
212
|
const orderIdBytes = ethers_1.ethers.id(orderId);
|
|
124
213
|
const feeData = await this.provider.getFeeData();
|
|
125
|
-
const gasPrice = (feeData.gasPrice * 120n) / 100n;
|
|
214
|
+
const gasPrice = (feeData.gasPrice * 120n) / 100n;
|
|
126
215
|
const tx = await router.pay(tokenAddr, merchantAddr, amount, orderIdBytes, {
|
|
127
216
|
gasPrice,
|
|
128
217
|
gasLimit: 200000
|
|
@@ -150,7 +239,7 @@ class PayNodeAgentClient {
|
|
|
150
239
|
]);
|
|
151
240
|
const domain = {
|
|
152
241
|
name,
|
|
153
|
-
version: '1',
|
|
242
|
+
version: '1',
|
|
154
243
|
chainId: Number(network.chainId),
|
|
155
244
|
verifyingContract: tokenAddr
|
|
156
245
|
};
|
package/dist/errors/index.js
CHANGED
|
@@ -29,7 +29,7 @@ exports.ERROR_MESSAGES = {
|
|
|
29
29
|
"transaction_not_found": "Transaction not found on-chain.",
|
|
30
30
|
"wrong_contract": "Payment event was not emitted by the official PayNode contract.",
|
|
31
31
|
"order_mismatch": "OrderId in receipt does not match requested ID.",
|
|
32
|
-
"missing_receipt": "Please pay to PayNode contract and provide '
|
|
32
|
+
"missing_receipt": "Please pay to PayNode contract and provide 'X-402-Payload' header.",
|
|
33
33
|
};
|
|
34
34
|
class PayNodeException extends Error {
|
|
35
35
|
code;
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,mBAAmB,CAAC;AAClC,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAChC,cAAc,UAAU,CAAC;AACzB,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,mBAAmB,CAAC;AAClC,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAChC,cAAc,UAAU,CAAC;AACzB,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC;AAC5B,cAAc,cAAc,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,8 @@ export interface PayNodeMiddlewareOptions {
|
|
|
11
11
|
decimals?: number;
|
|
12
12
|
store?: IdempotencyStore;
|
|
13
13
|
generateOrderId?: (req: Request | any) => string;
|
|
14
|
+
description?: string;
|
|
15
|
+
maxTimeoutSeconds?: number;
|
|
14
16
|
}
|
|
15
17
|
export declare const x402Gate: (options: PayNodeMiddlewareOptions) => (req: Request | any, res: Response | any, next: NextFunction) => Promise<any>;
|
|
16
18
|
/** @deprecated Use x402Gate instead. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"x402.d.ts","sourceRoot":"","sources":["../../src/middleware/x402.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAG1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"x402.d.ts","sourceRoot":"","sources":["../../src/middleware/x402.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAG1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAcxD,MAAM,WAAW,wBAAwB;IACvC,eAAe,EAAE,MAAM,CAAC;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC5B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,gBAAgB,CAAC;IACzB,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,KAAK,MAAM,CAAC;IACjD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,eAAO,MAAM,QAAQ,GAAI,SAAS,wBAAwB,MAwB1C,KAAK,OAAO,GAAG,GAAG,EAAE,KAAK,QAAQ,GAAG,GAAG,EAAE,MAAM,YAAY,iBA6F1E,CAAC;AAEF,wCAAwC;AACxC,eAAO,MAAM,SAAS,YAxHY,wBAAwB,MAwB1C,KAAK,OAAO,GAAG,GAAG,EAAE,KAAK,QAAQ,GAAG,GAAG,EAAE,MAAM,YAAY,iBAgG1C,CAAC"}
|
package/dist/middleware/x402.js
CHANGED
|
@@ -35,50 +35,80 @@ const x402Gate = (options) => {
|
|
|
35
35
|
return req.headers[name.toLowerCase()] || req.headers[name];
|
|
36
36
|
return null;
|
|
37
37
|
};
|
|
38
|
-
const
|
|
39
|
-
let orderId = getHeader('
|
|
38
|
+
const v2PayloadHeader = getHeader('X-402-Payload');
|
|
39
|
+
let orderId = getHeader('X-402-Order-Id');
|
|
40
40
|
if (!orderId) {
|
|
41
41
|
orderId = (options.generateOrderId || defaultOrderIdGen)(req);
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
'x-paynode-chain-id': chainId.toString(),
|
|
52
|
-
'x-paynode-order-id': orderId
|
|
53
|
-
});
|
|
43
|
+
// Handle x402 v2 Unified Payload
|
|
44
|
+
let unifiedPayload = null;
|
|
45
|
+
if (v2PayloadHeader) {
|
|
46
|
+
try {
|
|
47
|
+
unifiedPayload = JSON.parse(Buffer.from(v2PayloadHeader, 'base64').toString());
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
console.error("❌ [PayNode-Middleware] Failed to decode X-402-Payload header:", e);
|
|
54
51
|
}
|
|
55
|
-
return res.status(402).json({
|
|
56
|
-
error: "Payment Required",
|
|
57
|
-
code: errors_1.ErrorCode.MissingReceipt,
|
|
58
|
-
message: "Please pay to PayNode contract and provide 'x-paynode-receipt' header.",
|
|
59
|
-
amount: options.price,
|
|
60
|
-
currency: currency
|
|
61
|
-
});
|
|
62
52
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
53
|
+
if (unifiedPayload) {
|
|
54
|
+
const result = await verifier.verify(unifiedPayload, {
|
|
55
|
+
merchantAddress: options.merchantAddress,
|
|
56
|
+
tokenAddress: tokenAddress,
|
|
57
|
+
amount: rawAmount.toString(),
|
|
58
|
+
orderId: orderId
|
|
59
|
+
}, unifiedPayload.type === 'eip3009' ? unifiedPayload.payload?.extra : {});
|
|
60
|
+
if (result.isValid) {
|
|
61
|
+
req.paynode = { unifiedPayload, orderId };
|
|
62
|
+
return next();
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
return res.status(403).json({
|
|
66
|
+
error: "Forbidden",
|
|
67
|
+
code: result.error?.code || errors_1.ErrorCode.InvalidReceipt,
|
|
68
|
+
message: result.error?.message || "Invalid X402 payment payload"
|
|
69
|
+
});
|
|
70
|
+
}
|
|
74
71
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
72
|
+
// No valid payment found, return 402 with X-402-Required
|
|
73
|
+
const v2Response = {
|
|
74
|
+
x402Version: 2,
|
|
75
|
+
error: "Payment Required by PayNode",
|
|
76
|
+
resource: {
|
|
77
|
+
url: req.protocol + '://' + req.get('host') + req.originalUrl,
|
|
78
|
+
description: options.description || "Protected Resource",
|
|
79
|
+
mimeType: req.header('accept') || "application/json"
|
|
80
|
+
},
|
|
81
|
+
accepts: [
|
|
82
|
+
{
|
|
83
|
+
scheme: "exact",
|
|
84
|
+
type: "eip3009",
|
|
85
|
+
network: `eip155:${chainId}`,
|
|
86
|
+
amount: rawAmount.toString(),
|
|
87
|
+
asset: tokenAddress,
|
|
88
|
+
payTo: options.merchantAddress,
|
|
89
|
+
maxTimeoutSeconds: options.maxTimeoutSeconds || 3600,
|
|
90
|
+
extra: {
|
|
91
|
+
name: currency,
|
|
92
|
+
version: "2"
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
scheme: "exact",
|
|
97
|
+
type: "onchain",
|
|
98
|
+
network: `eip155:${chainId}`,
|
|
99
|
+
amount: rawAmount.toString(),
|
|
100
|
+
asset: tokenAddress,
|
|
101
|
+
payTo: options.merchantAddress,
|
|
102
|
+
maxTimeoutSeconds: options.maxTimeoutSeconds || 3600,
|
|
103
|
+
router: contractAddress
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
};
|
|
107
|
+
const b64Required = Buffer.from(JSON.stringify(v2Response)).toString('base64');
|
|
108
|
+
if (res.set) {
|
|
109
|
+
res.set('X-402-Required', b64Required);
|
|
81
110
|
}
|
|
111
|
+
return res.status(402).json(v2Response);
|
|
82
112
|
};
|
|
83
113
|
};
|
|
84
114
|
exports.x402Gate = x402Gate;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export type X402Version = 2;
|
|
2
|
+
export interface ResourceInfo {
|
|
3
|
+
url: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
mimeType?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface PaymentRequirements {
|
|
8
|
+
scheme: string;
|
|
9
|
+
type?: "onchain" | "eip3009";
|
|
10
|
+
network: string;
|
|
11
|
+
amount: string;
|
|
12
|
+
asset: string;
|
|
13
|
+
payTo: string;
|
|
14
|
+
router?: string;
|
|
15
|
+
orderId?: string;
|
|
16
|
+
maxTimeoutSeconds: number;
|
|
17
|
+
extra?: Record<string, any>;
|
|
18
|
+
}
|
|
19
|
+
export interface Extension {
|
|
20
|
+
info: Record<string, any>;
|
|
21
|
+
schema: Record<string, any>;
|
|
22
|
+
}
|
|
23
|
+
export interface PaymentRequiredResponse {
|
|
24
|
+
x402Version: X402Version;
|
|
25
|
+
error?: string;
|
|
26
|
+
orderId?: string;
|
|
27
|
+
resource: ResourceInfo;
|
|
28
|
+
accepts: PaymentRequirements[];
|
|
29
|
+
extensions?: Record<string, Extension>;
|
|
30
|
+
}
|
|
31
|
+
export interface PaymentPayload {
|
|
32
|
+
x402Version: X402Version;
|
|
33
|
+
resource?: ResourceInfo;
|
|
34
|
+
accepted: PaymentRequirements;
|
|
35
|
+
payload: Record<string, any>;
|
|
36
|
+
extensions?: Record<string, any>;
|
|
37
|
+
}
|
|
38
|
+
export interface ExactEVMPayload {
|
|
39
|
+
signature: string;
|
|
40
|
+
authorization: {
|
|
41
|
+
from: string;
|
|
42
|
+
to: string;
|
|
43
|
+
value: string;
|
|
44
|
+
validAfter: string;
|
|
45
|
+
validBefore: string;
|
|
46
|
+
nonce: string;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export interface SettlementResponse {
|
|
50
|
+
success: boolean;
|
|
51
|
+
errorReason?: string;
|
|
52
|
+
payer?: string;
|
|
53
|
+
transaction: string;
|
|
54
|
+
network: string;
|
|
55
|
+
extensions?: Record<string, any>;
|
|
56
|
+
}
|
|
57
|
+
export interface VerifyResponse {
|
|
58
|
+
isValid: boolean;
|
|
59
|
+
invalidReason?: string;
|
|
60
|
+
payer?: string;
|
|
61
|
+
}
|
|
62
|
+
export interface UnifiedPaymentPayload {
|
|
63
|
+
version: "3.1";
|
|
64
|
+
type: "onchain" | "eip3009";
|
|
65
|
+
orderId: string;
|
|
66
|
+
payload: {
|
|
67
|
+
txHash?: string;
|
|
68
|
+
signature?: string;
|
|
69
|
+
authorization?: any;
|
|
70
|
+
} | ExactEVMPayload;
|
|
71
|
+
}
|
|
72
|
+
export interface SupportedKind {
|
|
73
|
+
x402Version: X402Version;
|
|
74
|
+
scheme: string;
|
|
75
|
+
network: string;
|
|
76
|
+
extra?: Record<string, any>;
|
|
77
|
+
}
|
|
78
|
+
export interface SupportedResponse {
|
|
79
|
+
kinds: SupportedKind[];
|
|
80
|
+
extensions: string[];
|
|
81
|
+
signers: Record<string, string[]>;
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=x402.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"x402.d.ts","sourceRoot":"","sources":["../../src/types/x402.ts"],"names":[],"mappings":"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"}
|
|
@@ -5,6 +5,11 @@ export interface IdempotencyStore {
|
|
|
5
5
|
* @returns true if the hash was newly added, false if it already exists.
|
|
6
6
|
*/
|
|
7
7
|
checkAndSet(txHash: string, ttlSeconds: number): Promise<boolean>;
|
|
8
|
+
/**
|
|
9
|
+
* Deletes a transaction hash from the store.
|
|
10
|
+
* Used for rolling back a lock if subsequent verification fails.
|
|
11
|
+
*/
|
|
12
|
+
delete(txHash: string): Promise<void>;
|
|
8
13
|
}
|
|
9
14
|
/**
|
|
10
15
|
* Default implementation for MVP.
|
|
@@ -15,6 +20,7 @@ export declare class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
15
20
|
private cache;
|
|
16
21
|
constructor();
|
|
17
22
|
checkAndSet(txHash: string, ttlSeconds: number): Promise<boolean>;
|
|
23
|
+
delete(txHash: string): Promise<void>;
|
|
18
24
|
private cleanup;
|
|
19
25
|
}
|
|
20
26
|
/**
|
|
@@ -26,5 +32,6 @@ export declare class RedisIdempotencyStore implements IdempotencyStore {
|
|
|
26
32
|
private prefix;
|
|
27
33
|
constructor(redisClient: Redis, prefix?: string);
|
|
28
34
|
checkAndSet(txHash: string, ttlSeconds: number): Promise<boolean>;
|
|
35
|
+
delete(txHash: string): Promise<void>;
|
|
29
36
|
}
|
|
30
37
|
//# sourceMappingURL=idempotency.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"idempotency.d.ts","sourceRoot":"","sources":["../../src/utils/idempotency.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAErC,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"idempotency.d.ts","sourceRoot":"","sources":["../../src/utils/idempotency.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAErC,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAElE;;;OAGG;IACH,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAED;;;;GAIG;AACH,qBAAa,sBAAuB,YAAW,gBAAgB;IAC7D,OAAO,CAAC,KAAK,CAAkC;;IAOzC,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAYjE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI3C,OAAO,CAAC,OAAO;CAQhB;AAED;;;GAGG;AACH,qBAAa,qBAAsB,YAAW,gBAAgB;IAC5D,OAAO,CAAC,KAAK,CAAQ;IACrB,OAAO,CAAC,MAAM,CAAS;gBAEX,WAAW,EAAE,KAAK,EAAE,MAAM,GAAE,MAAsB;IAKxD,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAMjE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAI5C"}
|
|
@@ -21,6 +21,9 @@ class MemoryIdempotencyStore {
|
|
|
21
21
|
this.cache.set(txHash, now + ttlSeconds);
|
|
22
22
|
return true;
|
|
23
23
|
}
|
|
24
|
+
async delete(txHash) {
|
|
25
|
+
this.cache.delete(txHash);
|
|
26
|
+
}
|
|
24
27
|
cleanup() {
|
|
25
28
|
const now = Math.floor(Date.now() / 1000);
|
|
26
29
|
for (const [key, expiry] of this.cache.entries()) {
|
|
@@ -47,5 +50,9 @@ class RedisIdempotencyStore {
|
|
|
47
50
|
const result = await this.redis.set(key, '1', 'EX', ttlSeconds, 'NX');
|
|
48
51
|
return result === 'OK';
|
|
49
52
|
}
|
|
53
|
+
async delete(txHash) {
|
|
54
|
+
const key = `${this.prefix}${txHash}`;
|
|
55
|
+
await this.redis.del(key);
|
|
56
|
+
}
|
|
50
57
|
}
|
|
51
58
|
exports.RedisIdempotencyStore = RedisIdempotencyStore;
|
package/dist/utils/verifier.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { PayNodeException } from '../errors';
|
|
2
2
|
import { IdempotencyStore } from './idempotency';
|
|
3
|
+
import { ExactEVMPayload, UnifiedPaymentPayload } from '../types/x402';
|
|
3
4
|
export interface PayNodeVerifierConfig {
|
|
4
5
|
rpcUrls: string | string[];
|
|
5
6
|
contractAddress: string;
|
|
@@ -21,7 +22,23 @@ export declare class PayNodeVerifier {
|
|
|
21
22
|
private store?;
|
|
22
23
|
private acceptedTokens?;
|
|
23
24
|
constructor(config: PayNodeVerifierConfig);
|
|
24
|
-
|
|
25
|
+
private static ROUTER_ABI;
|
|
26
|
+
verify(unifiedPayload: UnifiedPaymentPayload, expected: ExpectedPayment, extra?: any): Promise<{
|
|
27
|
+
isValid: boolean;
|
|
28
|
+
error?: PayNodeException;
|
|
29
|
+
}>;
|
|
30
|
+
verifyOnchainPayment(txHash: string, expected: any): Promise<{
|
|
31
|
+
isValid: boolean;
|
|
32
|
+
error?: PayNodeException;
|
|
33
|
+
}>;
|
|
34
|
+
/**
|
|
35
|
+
* 亚秒级离线签名验证 (V2 核心)
|
|
36
|
+
* 耗时: < 50ms (仅需一次 RPC Read)
|
|
37
|
+
*/
|
|
38
|
+
verifyTransferWithAuthorization(tokenAddr: string, payload: ExactEVMPayload, expected: {
|
|
39
|
+
to: string;
|
|
40
|
+
value: string | number | bigint;
|
|
41
|
+
}, extra?: Record<string, any>): Promise<{
|
|
25
42
|
isValid: boolean;
|
|
26
43
|
error?: PayNodeException;
|
|
27
44
|
}>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"verifier.d.ts","sourceRoot":"","sources":["../../src/utils/verifier.ts"],"names":[],"mappings":"AACA,OAAO,EAAa,gBAAgB,EAAE,MAAM,WAAW,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAA0B,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"verifier.d.ts","sourceRoot":"","sources":["../../src/utils/verifier.ts"],"names":[],"mappings":"AACA,OAAO,EAAa,gBAAgB,EAAE,MAAM,WAAW,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAA0B,MAAM,eAAe,CAAC;AAEzE,OAAO,EAAE,eAAe,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC;AAEvE,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC3B,eAAe,EAAE,MAAM,CAAC;IACxB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,gBAAgB,CAAC;IACzB,oGAAoG;IACpG,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,eAAe;IAC9B,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;IACjC,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,qBAAa,eAAe;IAC1B,OAAO,CAAC,QAAQ,CAAqC;IACrD,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,OAAO,CAAC,CAAS;IACzB,OAAO,CAAC,KAAK,CAAC,CAAmB;IACjC,OAAO,CAAC,cAAc,CAAC,CAAc;gBAEzB,MAAM,EAAE,qBAAqB;IAmCzC,OAAO,CAAC,MAAM,CAAC,UAAU,CAEvB;IAEI,MAAM,CACV,cAAc,EAAE,qBAAqB,EACrC,QAAQ,EAAE,eAAe,EACzB,KAAK,CAAC,EAAE,GAAG,GACV,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,gBAAgB,CAAA;KAAE,CAAC;IAkCpD,oBAAoB,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,gBAAgB,CAAA;KAAE,CAAC;IAiDlH;;;OAGG;IACG,+BAA+B,CACnC,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,eAAe,EACxB,QAAQ,EAAE;QACR,EAAE,EAAE,MAAM,CAAC;QACX,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;KACjC,EACD,KAAK,GAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAM,GAC9B,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,gBAAgB,CAAA;KAAE,CAAC;CA6F3D"}
|
package/dist/utils/verifier.js
CHANGED
|
@@ -5,10 +5,6 @@ const ethers_1 = require("ethers");
|
|
|
5
5
|
const errors_1 = require("../errors");
|
|
6
6
|
const idempotency_1 = require("./idempotency");
|
|
7
7
|
const constants_1 = require("../constants");
|
|
8
|
-
const PAYNODE_ABI = [
|
|
9
|
-
"event PaymentReceived(bytes32 indexed orderId, address indexed merchant, address indexed payer, address token, uint256 amount, uint256 fee, uint256 chainId)"
|
|
10
|
-
];
|
|
11
|
-
const iface = new ethers_1.Interface(PAYNODE_ABI);
|
|
12
8
|
class PayNodeVerifier {
|
|
13
9
|
provider;
|
|
14
10
|
contractAddress;
|
|
@@ -25,7 +21,7 @@ class PayNodeVerifier {
|
|
|
25
21
|
return {
|
|
26
22
|
provider: new ethers_1.JsonRpcProvider(url, config.chainId),
|
|
27
23
|
priority: i,
|
|
28
|
-
stallTimeout:
|
|
24
|
+
stallTimeout: 3000,
|
|
29
25
|
weight: 1
|
|
30
26
|
};
|
|
31
27
|
});
|
|
@@ -48,83 +44,171 @@ class PayNodeVerifier {
|
|
|
48
44
|
this.acceptedTokens = new Set(tokenList.map(t => t.toLowerCase()));
|
|
49
45
|
}
|
|
50
46
|
}
|
|
51
|
-
|
|
47
|
+
static ROUTER_ABI = [
|
|
48
|
+
"event PaymentReceived(bytes32 indexed orderId, address indexed merchant, address indexed payer, address token, uint256 amount, uint256 fee, uint256 chainId)"
|
|
49
|
+
];
|
|
50
|
+
async verify(unifiedPayload, expected, extra) {
|
|
52
51
|
try {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
const { type, payload, orderId } = unifiedPayload;
|
|
53
|
+
if (type === 'eip3009') {
|
|
54
|
+
const tokenAddr = expected.tokenAddress;
|
|
55
|
+
if (!tokenAddr) {
|
|
56
|
+
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.TokenNotAccepted, "tokenAddress is required for eip3009 verification") };
|
|
57
|
+
}
|
|
58
|
+
const actualPayload = payload;
|
|
59
|
+
return await this.verifyTransferWithAuthorization(tokenAddr, actualPayload, {
|
|
60
|
+
to: expected.merchantAddress,
|
|
61
|
+
value: expected.amount
|
|
62
|
+
}, extra);
|
|
57
63
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
64
|
+
else if (type === 'onchain') {
|
|
65
|
+
const { txHash } = payload;
|
|
66
|
+
if (!txHash) {
|
|
67
|
+
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.InvalidReceipt, "Missing txHash in onchain payload") };
|
|
68
|
+
}
|
|
69
|
+
return await this.verifyOnchainPayment(txHash, {
|
|
70
|
+
merchantAddress: expected.merchantAddress,
|
|
71
|
+
tokenAddress: expected.tokenAddress,
|
|
72
|
+
amount: expected.amount,
|
|
73
|
+
orderId: orderId
|
|
74
|
+
});
|
|
61
75
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (!receipt) {
|
|
65
|
-
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.InvalidReceipt) };
|
|
76
|
+
else {
|
|
77
|
+
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.InternalError, `Unsupported payload type: ${type}`) };
|
|
66
78
|
}
|
|
67
|
-
|
|
68
|
-
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
if (e instanceof errors_1.PayNodeException)
|
|
82
|
+
return { isValid: false, error: e };
|
|
83
|
+
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.InternalError, e.message) };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async verifyOnchainPayment(txHash, expected) {
|
|
87
|
+
try {
|
|
88
|
+
const receipt = await this.provider.getTransactionReceipt(txHash);
|
|
89
|
+
if (!receipt || receipt.status === 0) {
|
|
90
|
+
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.TransactionNotFound) };
|
|
69
91
|
}
|
|
70
|
-
|
|
71
|
-
|
|
92
|
+
const router = new ethers_1.ethers.Interface(PayNodeVerifier.ROUTER_ABI);
|
|
93
|
+
const targetOrderId = ethers_1.ethers.id(expected.orderId);
|
|
94
|
+
let validEventFound = false;
|
|
72
95
|
for (const log of receipt.logs) {
|
|
73
96
|
try {
|
|
74
|
-
|
|
75
|
-
if (log.address.toLowerCase() !== this.contractAddress.toLowerCase()) {
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
const parsed = iface.parseLog({ topics: log.topics, data: log.data });
|
|
97
|
+
const parsed = router.parseLog(log);
|
|
79
98
|
if (parsed && parsed.name === 'PaymentReceived') {
|
|
80
|
-
|
|
81
|
-
|
|
99
|
+
const { merchant, token, amount, orderId } = parsed.args;
|
|
100
|
+
if (merchant.toLowerCase() === expected.merchantAddress.toLowerCase() &&
|
|
101
|
+
token.toLowerCase() === expected.tokenAddress.toLowerCase() &&
|
|
102
|
+
BigInt(amount) >= BigInt(expected.amount) &&
|
|
103
|
+
orderId === targetOrderId) {
|
|
104
|
+
validEventFound = true;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
82
107
|
}
|
|
83
108
|
}
|
|
84
109
|
catch (e) {
|
|
85
|
-
|
|
110
|
+
// Skip
|
|
86
111
|
}
|
|
87
112
|
}
|
|
88
|
-
if (!
|
|
89
|
-
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.
|
|
113
|
+
if (!validEventFound) {
|
|
114
|
+
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.InvalidReceipt, "No matching PaymentReceived event found") };
|
|
90
115
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.OrderMismatch) };
|
|
116
|
+
if (this.store) {
|
|
117
|
+
const isNew = await this.store.checkAndSet(txHash, 86400);
|
|
118
|
+
if (!isNew) {
|
|
119
|
+
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.DuplicateTransaction) };
|
|
96
120
|
}
|
|
97
121
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
122
|
+
return { isValid: true };
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.RpcError, undefined, error) };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* 亚秒级离线签名验证 (V2 核心)
|
|
130
|
+
* 耗时: < 50ms (仅需一次 RPC Read)
|
|
131
|
+
*/
|
|
132
|
+
async verifyTransferWithAuthorization(tokenAddr, payload, expected, extra = {}) {
|
|
133
|
+
try {
|
|
134
|
+
const { signature, authorization } = payload;
|
|
135
|
+
const { from, to, value, validAfter, validBefore, nonce } = authorization;
|
|
136
|
+
const expectedValue = BigInt(expected.value);
|
|
137
|
+
const payloadValue = BigInt(value);
|
|
138
|
+
// 1. 基础字段与金额校验 (防粉尘攻击)
|
|
139
|
+
if (to.toLowerCase() !== expected.to.toLowerCase()) {
|
|
140
|
+
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.InvalidReceipt, "Recipient mismatch") };
|
|
101
141
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.InvalidReceipt, "Payment used unexpected token.") };
|
|
142
|
+
if (payloadValue < expectedValue) {
|
|
143
|
+
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.AmountTooLow) };
|
|
105
144
|
}
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
145
|
+
// 2. 时间窗口校验
|
|
146
|
+
const now = Math.floor(Date.now() / 1000);
|
|
147
|
+
if (now < Number(validAfter)) {
|
|
148
|
+
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.InvalidReceipt, "Authorization not yet valid") };
|
|
109
149
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (BigInt(args.chainId) !== expectedChainId) {
|
|
113
|
-
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.InvalidReceipt, "ChainId mismatch. Invalid network.") };
|
|
150
|
+
if (now > Number(validBefore)) {
|
|
151
|
+
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.InvalidReceipt, "Authorization expired") };
|
|
114
152
|
}
|
|
115
|
-
//
|
|
153
|
+
// 3. 密码学验签 (EIP-712 / EIP-3009) - 纯本地计算 0ms
|
|
154
|
+
const chainId = Number(this.chainId || (await this.provider.getNetwork()).chainId);
|
|
155
|
+
const domain = {
|
|
156
|
+
name: extra.name || "USD Coin",
|
|
157
|
+
version: extra.version || "2",
|
|
158
|
+
chainId,
|
|
159
|
+
verifyingContract: tokenAddr
|
|
160
|
+
};
|
|
161
|
+
const types = {
|
|
162
|
+
TransferWithAuthorization: [
|
|
163
|
+
{ name: "from", type: "address" },
|
|
164
|
+
{ name: "to", type: "address" },
|
|
165
|
+
{ name: "value", type: "uint256" },
|
|
166
|
+
{ name: "validAfter", type: "uint256" },
|
|
167
|
+
{ name: "validBefore", type: "uint256" },
|
|
168
|
+
{ name: "nonce", type: "bytes32" }
|
|
169
|
+
]
|
|
170
|
+
};
|
|
171
|
+
const recoveredAddress = ethers_1.ethers.verifyTypedData(domain, types, authorization, signature);
|
|
172
|
+
if (recoveredAddress.toLowerCase() !== from.toLowerCase()) {
|
|
173
|
+
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.InvalidReceipt, "Invalid signature: recovered address mismatch") };
|
|
174
|
+
}
|
|
175
|
+
// 4. 内存幂等性校验 (防高频重放)
|
|
116
176
|
if (this.store) {
|
|
117
|
-
const isNew = await this.store.checkAndSet(
|
|
177
|
+
const isNew = await this.store.checkAndSet(nonce, 86400); // 锁定 24 小时
|
|
118
178
|
if (!isNew) {
|
|
119
|
-
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.DuplicateTransaction) };
|
|
179
|
+
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.DuplicateTransaction, "Nonce already used in local memory") };
|
|
120
180
|
}
|
|
121
181
|
}
|
|
182
|
+
// ================= 核心补全:RPC 状态只读校验 (<50ms) =================
|
|
183
|
+
const tokenContract = new ethers_1.ethers.Contract(tokenAddr, [
|
|
184
|
+
"function balanceOf(address account) view returns (uint256)",
|
|
185
|
+
"function authorizationState(address authorizer, bytes32 nonce) view returns (bool)"
|
|
186
|
+
], this.provider);
|
|
187
|
+
// 并发执行 RPC 查询以追求极限速度
|
|
188
|
+
const [balance, isNonceUsedOnChain] = await Promise.all([
|
|
189
|
+
tokenContract.balanceOf(from).catch(() => 0n),
|
|
190
|
+
// Note: For mock tokens that don't support EIP-3009 view methods, this will fallback to false.
|
|
191
|
+
// We still have L1 protection (IdempotencyStore) to prevent immediate replays.
|
|
192
|
+
tokenContract.authorizationState(from, nonce).catch(() => false)
|
|
193
|
+
]);
|
|
194
|
+
// 5. 校验真实余额 (防止空钱包签署有效签名)
|
|
195
|
+
if (BigInt(balance) < payloadValue) {
|
|
196
|
+
// 如果验签失败,释放内存锁
|
|
197
|
+
if (this.store)
|
|
198
|
+
await this.store.delete(nonce);
|
|
199
|
+
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.InvalidReceipt, "Insufficient token balance") };
|
|
200
|
+
}
|
|
201
|
+
// 6. 校验链上 Nonce 状态 (防止该签名已被打包结算)
|
|
202
|
+
if (isNonceUsedOnChain) {
|
|
203
|
+
if (this.store)
|
|
204
|
+
await this.store.delete(nonce);
|
|
205
|
+
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.DuplicateTransaction, "Nonce already consumed on-chain") };
|
|
206
|
+
}
|
|
207
|
+
// =======================================================================
|
|
122
208
|
return { isValid: true };
|
|
123
209
|
}
|
|
124
210
|
catch (e) {
|
|
125
|
-
|
|
126
|
-
return { isValid: false, error: e };
|
|
127
|
-
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.InternalError, `An unexpected error occurred: ${e.message}`) };
|
|
211
|
+
return { isValid: false, error: new errors_1.PayNodeException(errors_1.ErrorCode.InternalError, e.message) };
|
|
128
212
|
}
|
|
129
213
|
}
|
|
130
214
|
}
|
package/dist/utils/webhook.js
CHANGED
|
@@ -129,7 +129,7 @@ class PayNodeWebhookNotifier {
|
|
|
129
129
|
this.lastBlock = currentBlock;
|
|
130
130
|
}
|
|
131
131
|
catch (error) {
|
|
132
|
-
console.error(
|
|
132
|
+
console.error(`❌ [PayNode Webhook] Poll error: ${error.message}`);
|
|
133
133
|
}
|
|
134
134
|
finally {
|
|
135
135
|
this.isProcessing = false;
|
|
@@ -187,13 +187,13 @@ class PayNodeWebhookNotifier {
|
|
|
187
187
|
this.config.onSuccess?.(event);
|
|
188
188
|
}
|
|
189
189
|
catch (error) {
|
|
190
|
-
console.error(
|
|
190
|
+
console.error(`⚠️ [PayNode Webhook] Delivery failed (attempt ${attempt}/${MAX_RETRIES}): ${error.message}`);
|
|
191
191
|
if (attempt < MAX_RETRIES) {
|
|
192
192
|
const backoffMs = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s
|
|
193
193
|
await new Promise(resolve => setTimeout(resolve, backoffMs));
|
|
194
194
|
return this.deliver(event, attempt + 1);
|
|
195
195
|
}
|
|
196
|
-
console.error(
|
|
196
|
+
console.error(`❌ [PayNode Webhook] Gave up on tx ${event.txHash} after ${MAX_RETRIES} attempts.`);
|
|
197
197
|
this.config.onError?.(error, event);
|
|
198
198
|
}
|
|
199
199
|
}
|