wdk-payment-verifier 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/verifier.d.ts +102 -0
- package/dist/verifier.js +154 -0
- package/package.json +66 -0
- package/src/index.ts +10 -0
- package/src/verifier.spec.ts +111 -0
- package/src/verifier.ts +225 -0
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# wdk-payment-verifier
|
|
2
|
+
|
|
3
|
+
Server-side, **read-only** on-chain confirmation for self-custodial **WDK Pay**
|
|
4
|
+
payments. A payment is a plain ERC-20 transfer of the settlement token (USDt) to
|
|
5
|
+
the merchant; this library confirms one landed — `Transfer(token, to = merchant,
|
|
6
|
+
value ≥ due)` with N confirmations — over JSON-RPC. No keys, no custody: the
|
|
7
|
+
merchant only *watches* the chain. It's the Node/headless counterpart to the
|
|
8
|
+
WooCommerce plugin's PHP verifier.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install wdk-payment-verifier
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Verify a known transaction
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { PaymentVerifier } from 'wdk-payment-verifier'
|
|
18
|
+
|
|
19
|
+
const verifier = new PaymentVerifier({ rpcUrl: process.env.RPC_URL! })
|
|
20
|
+
|
|
21
|
+
const result = await verifier.verify(
|
|
22
|
+
{ tokenAddress: USDt, receivingAddress: merchant, amountBase: '19990000' }, // 19.99 USDt
|
|
23
|
+
txHash,
|
|
24
|
+
/* requiredConfirmations */ 5,
|
|
25
|
+
)
|
|
26
|
+
// result.status: 'confirmed' | 'pending' | 'failed'
|
|
27
|
+
if (result.status === 'confirmed') markOrderPaid(result)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Watch for an incoming payment (no hash yet)
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
const result = await verifier.watch(
|
|
34
|
+
{ tokenAddress: USDt, receivingAddress: merchant, amountBase: '19990000' },
|
|
35
|
+
{ requiredConfirmations: 5, timeoutMs: 15 * 60_000, pollIntervalMs: 5000 },
|
|
36
|
+
)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`watch` polls `eth_getLogs` for a matching Transfer to the recipient, then waits
|
|
40
|
+
for confirmations; it resolves `confirmed`/`failed`, or `pending` (reason
|
|
41
|
+
`timeout`) if the window elapses.
|
|
42
|
+
|
|
43
|
+
## Pluggable provider
|
|
44
|
+
|
|
45
|
+
Pass `rpcUrl` (an ethers `JsonRpcProvider` is created lazily), an existing
|
|
46
|
+
`provider`, or — for tests — any object implementing the minimal `EvmReadProvider`
|
|
47
|
+
interface (`getTransactionReceipt`, `getBlockNumber`, `getLogs`). Nothing is
|
|
48
|
+
hard-coded.
|
|
49
|
+
|
|
50
|
+
`PaymentConfirmation` carries `status`, `txHash`, `blockNumber`, `confirmations`,
|
|
51
|
+
`valueBase`, and `fromAddress` so your order system has everything to reconcile.
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
[MIT](https://github.com/plinkdev1/wdk-checkout-and-woocommerce-plugin/blob/main/LICENSE).
|
|
56
|
+
Built with [Tether WDK](https://docs.wallet.tether.io); not an official Tether product.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wdk-payment-verifier — server-side on-chain confirmation of self-custodial
|
|
3
|
+
* WDK Pay payments (the Node/headless counterpart to the WooCommerce PHP verifier).
|
|
4
|
+
*/
|
|
5
|
+
export { PaymentVerifier, matchTransfer, TRANSFER_TOPIC } from './verifier.js';
|
|
6
|
+
export type { VerifierIntent, PaymentStatus, PaymentConfirmation, EvmLog, EvmReceipt, EvmReadProvider, PaymentVerifierOptions, WatchOptions } from './verifier.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** keccak256("Transfer(address,address,uint256)") — the ERC-20 Transfer topic. */
|
|
2
|
+
export declare const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
|
3
|
+
/** The minimum a verifier needs to know about an order to confirm payment. */
|
|
4
|
+
export interface VerifierIntent {
|
|
5
|
+
/** Settlement token contract (the Transfer emitter), e.g. USDt. */
|
|
6
|
+
readonly tokenAddress: string;
|
|
7
|
+
/** Merchant receiving address (the Transfer `to`). */
|
|
8
|
+
readonly receivingAddress: string;
|
|
9
|
+
/** Minimum amount required, in token base units (decimal string). */
|
|
10
|
+
readonly amountBase: string;
|
|
11
|
+
}
|
|
12
|
+
export type PaymentStatus = 'confirmed' | 'pending' | 'failed';
|
|
13
|
+
/** The outcome of a verification. */
|
|
14
|
+
export interface PaymentConfirmation {
|
|
15
|
+
readonly status: PaymentStatus;
|
|
16
|
+
readonly txHash?: string;
|
|
17
|
+
readonly blockNumber?: number;
|
|
18
|
+
readonly confirmations?: number;
|
|
19
|
+
/** The matched transfer's value, base units. */
|
|
20
|
+
readonly valueBase?: string;
|
|
21
|
+
/** The payer (the Transfer `from`). */
|
|
22
|
+
readonly fromAddress?: string;
|
|
23
|
+
/** Unix ms when confirmed. */
|
|
24
|
+
readonly receivedAt?: number;
|
|
25
|
+
/** Machine-readable reason for pending/failed. */
|
|
26
|
+
readonly reason?: string;
|
|
27
|
+
}
|
|
28
|
+
/** A single log entry (subset of an eth_getLogs / receipt log). */
|
|
29
|
+
export interface EvmLog {
|
|
30
|
+
readonly address: string;
|
|
31
|
+
readonly topics: readonly string[];
|
|
32
|
+
readonly data: string;
|
|
33
|
+
readonly blockNumber?: number;
|
|
34
|
+
readonly transactionHash?: string;
|
|
35
|
+
}
|
|
36
|
+
/** A transaction receipt subset. */
|
|
37
|
+
export interface EvmReceipt {
|
|
38
|
+
readonly status?: number | null;
|
|
39
|
+
readonly blockNumber: number;
|
|
40
|
+
readonly logs: readonly EvmLog[];
|
|
41
|
+
readonly transactionHash?: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Minimal read-only provider the verifier needs. An ethers `JsonRpcProvider`
|
|
45
|
+
* satisfies it; tests pass a fake. (ethers receipts expose `logs` and a numeric
|
|
46
|
+
* `status`; `getLogs` returns the same log shape.)
|
|
47
|
+
*/
|
|
48
|
+
export interface EvmReadProvider {
|
|
49
|
+
getTransactionReceipt(txHash: string): Promise<EvmReceipt | null>;
|
|
50
|
+
getBlockNumber(): Promise<number>;
|
|
51
|
+
getLogs(filter: {
|
|
52
|
+
address?: string;
|
|
53
|
+
topics?: (string | null)[];
|
|
54
|
+
fromBlock?: number | string;
|
|
55
|
+
toBlock?: number | string;
|
|
56
|
+
}): Promise<readonly EvmLog[]>;
|
|
57
|
+
}
|
|
58
|
+
export interface PaymentVerifierOptions {
|
|
59
|
+
/** A ready provider (e.g. ethers JsonRpcProvider) — preferred for reuse. */
|
|
60
|
+
readonly provider?: EvmReadProvider;
|
|
61
|
+
/** Or a JSON-RPC URL; an ethers JsonRpcProvider is created lazily. */
|
|
62
|
+
readonly rpcUrl?: string;
|
|
63
|
+
}
|
|
64
|
+
export interface WatchOptions {
|
|
65
|
+
/** Confirmations required before `confirmed`. Default 1. */
|
|
66
|
+
readonly requiredConfirmations?: number;
|
|
67
|
+
/** Give up after this many ms (resolves `pending`/`timeout`). Default 15 min. */
|
|
68
|
+
readonly timeoutMs?: number;
|
|
69
|
+
/** Poll cadence. Default 5s. */
|
|
70
|
+
readonly pollIntervalMs?: number;
|
|
71
|
+
/** Block to scan logs from. Default: latest − 5000 (or 0). */
|
|
72
|
+
readonly fromBlock?: number;
|
|
73
|
+
/** Injectable sleep for deterministic tests. */
|
|
74
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
75
|
+
/** Injectable clock (ms) for deterministic tests. */
|
|
76
|
+
readonly now?: () => number;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Find a Transfer log in `logs` matching the intent (emitter = token,
|
|
80
|
+
* to = recipient, value ≥ amount). Returns the match or null.
|
|
81
|
+
*/
|
|
82
|
+
export declare function matchTransfer(logs: readonly EvmLog[], intent: VerifierIntent): {
|
|
83
|
+
value: bigint;
|
|
84
|
+
from: string;
|
|
85
|
+
log: EvmLog;
|
|
86
|
+
} | null;
|
|
87
|
+
export declare class PaymentVerifier {
|
|
88
|
+
#private;
|
|
89
|
+
constructor(opts: PaymentVerifierOptions);
|
|
90
|
+
/**
|
|
91
|
+
* Verify a specific transaction satisfies the intent. One-shot: checks the
|
|
92
|
+
* receipt for a matching Transfer and the current confirmation count.
|
|
93
|
+
*/
|
|
94
|
+
verify(intent: VerifierIntent, txHash: string, requiredConfirmations?: number): Promise<PaymentConfirmation>;
|
|
95
|
+
/**
|
|
96
|
+
* Watch for an incoming payment matching the intent (no tx hash known yet):
|
|
97
|
+
* polls `getLogs` for a Transfer to the recipient ≥ amount, then waits for
|
|
98
|
+
* confirmations. Resolves `confirmed`, or `pending` (reason `timeout`) if the
|
|
99
|
+
* window elapses.
|
|
100
|
+
*/
|
|
101
|
+
watch(intent: VerifierIntent, options?: WatchOptions): Promise<PaymentConfirmation>;
|
|
102
|
+
}
|
package/dist/verifier.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wdk-payment-verifier — server-side on-chain confirmation of WDK Pay payments.
|
|
3
|
+
*
|
|
4
|
+
* A payment is a plain ERC-20 transfer of the settlement token to the merchant.
|
|
5
|
+
* Verification = observe the chain (read-only JSON-RPC) and confirm a
|
|
6
|
+
* `Transfer(token, to = merchant, value ≥ due)` landed with enough
|
|
7
|
+
* confirmations. No keys, no custody — the merchant only watches.
|
|
8
|
+
*
|
|
9
|
+
* This is the standalone library counterpart to the WooCommerce plugin's PHP
|
|
10
|
+
* verifier, for Node back-ends / headless commerce. The RPC provider is
|
|
11
|
+
* injectable (pass an `rpcUrl`, an ethers provider, or any minimal provider for
|
|
12
|
+
* tests); nothing is hard-coded.
|
|
13
|
+
*/
|
|
14
|
+
import { getAddress } from 'ethers';
|
|
15
|
+
/** keccak256("Transfer(address,address,uint256)") — the ERC-20 Transfer topic. */
|
|
16
|
+
export const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';
|
|
17
|
+
/** A 32-byte topic word → checksummed address (last 20 bytes). */
|
|
18
|
+
function topicToAddress(topic) {
|
|
19
|
+
return getAddress('0x' + topic.slice(-40));
|
|
20
|
+
}
|
|
21
|
+
function eq(a, b) {
|
|
22
|
+
try {
|
|
23
|
+
return getAddress(a) === getAddress(b);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Find a Transfer log in `logs` matching the intent (emitter = token,
|
|
31
|
+
* to = recipient, value ≥ amount). Returns the match or null.
|
|
32
|
+
*/
|
|
33
|
+
export function matchTransfer(logs, intent) {
|
|
34
|
+
const need = BigInt(intent.amountBase);
|
|
35
|
+
for (const log of logs) {
|
|
36
|
+
if (!log.topics || log.topics.length < 3)
|
|
37
|
+
continue;
|
|
38
|
+
if (log.topics[0]?.toLowerCase() !== TRANSFER_TOPIC)
|
|
39
|
+
continue;
|
|
40
|
+
if (!eq(log.address, intent.tokenAddress))
|
|
41
|
+
continue;
|
|
42
|
+
if (!eq(topicToAddress(log.topics[2]), intent.receivingAddress))
|
|
43
|
+
continue;
|
|
44
|
+
let value;
|
|
45
|
+
try {
|
|
46
|
+
value = BigInt(log.data);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (value < need)
|
|
52
|
+
continue;
|
|
53
|
+
return { value, from: topicToAddress(log.topics[1]), log };
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
export class PaymentVerifier {
|
|
58
|
+
#provider;
|
|
59
|
+
#rpcUrl;
|
|
60
|
+
constructor(opts) {
|
|
61
|
+
if (!opts.provider && !opts.rpcUrl)
|
|
62
|
+
throw new Error('PaymentVerifier: pass `provider` or `rpcUrl`.');
|
|
63
|
+
this.#provider = opts.provider;
|
|
64
|
+
this.#rpcUrl = opts.rpcUrl;
|
|
65
|
+
}
|
|
66
|
+
async #getProvider() {
|
|
67
|
+
if (this.#provider)
|
|
68
|
+
return this.#provider;
|
|
69
|
+
const { JsonRpcProvider } = await import('ethers');
|
|
70
|
+
this.#provider = new JsonRpcProvider(this.#rpcUrl);
|
|
71
|
+
return this.#provider;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Verify a specific transaction satisfies the intent. One-shot: checks the
|
|
75
|
+
* receipt for a matching Transfer and the current confirmation count.
|
|
76
|
+
*/
|
|
77
|
+
async verify(intent, txHash, requiredConfirmations = 1) {
|
|
78
|
+
const provider = await this.#getProvider();
|
|
79
|
+
let receipt;
|
|
80
|
+
try {
|
|
81
|
+
receipt = await provider.getTransactionReceipt(txHash);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return { status: 'pending', txHash, reason: 'rpc_unreachable' };
|
|
85
|
+
}
|
|
86
|
+
if (!receipt)
|
|
87
|
+
return { status: 'pending', txHash, reason: 'not_mined' };
|
|
88
|
+
if (receipt.status === 0)
|
|
89
|
+
return { status: 'failed', txHash, reason: 'reverted' };
|
|
90
|
+
const match = matchTransfer(receipt.logs, intent);
|
|
91
|
+
if (!match)
|
|
92
|
+
return { status: 'failed', txHash, reason: 'no_matching_transfer' };
|
|
93
|
+
let head;
|
|
94
|
+
try {
|
|
95
|
+
head = await provider.getBlockNumber();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return { status: 'pending', txHash, valueBase: match.value.toString(), reason: 'rpc_unreachable' };
|
|
99
|
+
}
|
|
100
|
+
const confirmations = head - receipt.blockNumber + 1;
|
|
101
|
+
const base = {
|
|
102
|
+
txHash, blockNumber: receipt.blockNumber, confirmations: Math.max(0, confirmations),
|
|
103
|
+
valueBase: match.value.toString(), fromAddress: match.from
|
|
104
|
+
};
|
|
105
|
+
if (confirmations < requiredConfirmations)
|
|
106
|
+
return { status: 'pending', ...base, reason: 'awaiting_confirmations' };
|
|
107
|
+
return { status: 'confirmed', ...base, receivedAt: Date.now() };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Watch for an incoming payment matching the intent (no tx hash known yet):
|
|
111
|
+
* polls `getLogs` for a Transfer to the recipient ≥ amount, then waits for
|
|
112
|
+
* confirmations. Resolves `confirmed`, or `pending` (reason `timeout`) if the
|
|
113
|
+
* window elapses.
|
|
114
|
+
*/
|
|
115
|
+
async watch(intent, options = {}) {
|
|
116
|
+
const provider = await this.#getProvider();
|
|
117
|
+
const requiredConfirmations = options.requiredConfirmations ?? 1;
|
|
118
|
+
const timeoutMs = options.timeoutMs ?? 15 * 60 * 1000;
|
|
119
|
+
const pollIntervalMs = options.pollIntervalMs ?? 5000;
|
|
120
|
+
const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
121
|
+
const now = options.now ?? (() => Date.now());
|
|
122
|
+
const start = now();
|
|
123
|
+
let fromBlock = options.fromBlock;
|
|
124
|
+
if (fromBlock === undefined) {
|
|
125
|
+
try {
|
|
126
|
+
fromBlock = Math.max(0, (await provider.getBlockNumber()) - 5000);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
fromBlock = 0;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (;;) {
|
|
133
|
+
let logs = [];
|
|
134
|
+
try {
|
|
135
|
+
logs = await provider.getLogs({
|
|
136
|
+
address: intent.tokenAddress,
|
|
137
|
+
topics: [TRANSFER_TOPIC, null, '0x' + getAddress(intent.receivingAddress).slice(2).toLowerCase().padStart(64, '0')],
|
|
138
|
+
fromBlock,
|
|
139
|
+
toBlock: 'latest'
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
catch { /* transient — retry next tick */ }
|
|
143
|
+
const match = matchTransfer(logs, intent);
|
|
144
|
+
if (match && match.log.transactionHash) {
|
|
145
|
+
const confirmation = await this.verify(intent, match.log.transactionHash, requiredConfirmations);
|
|
146
|
+
if (confirmation.status === 'confirmed' || confirmation.status === 'failed')
|
|
147
|
+
return confirmation;
|
|
148
|
+
}
|
|
149
|
+
if (now() - start >= timeoutMs)
|
|
150
|
+
return { status: 'pending', reason: 'timeout' };
|
|
151
|
+
await sleep(pollIntervalMs);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wdk-payment-verifier",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Server-side on-chain confirmation for self-custodial WDK Pay payments — verify or watch for a USDt transfer to the merchant with N confirmations. Node/headless counterpart to the WooCommerce PHP verifier.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "WDK Pay contributors",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"wdk",
|
|
10
|
+
"tether",
|
|
11
|
+
"usdt",
|
|
12
|
+
"payment",
|
|
13
|
+
"verifier",
|
|
14
|
+
"on-chain",
|
|
15
|
+
"ecommerce",
|
|
16
|
+
"self-custodial",
|
|
17
|
+
"erc20",
|
|
18
|
+
"ethers"
|
|
19
|
+
],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/plinkdev1/wdk-checkout-and-woocommerce-plugin.git",
|
|
23
|
+
"directory": "packages/wdk-payment-verifier"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/plinkdev1/wdk-checkout-and-woocommerce-plugin/tree/main/packages/wdk-payment-verifier#readme",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/plinkdev1/wdk-checkout-and-woocommerce-plugin/issues"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public",
|
|
31
|
+
"registry": "https://registry.npmjs.org/"
|
|
32
|
+
},
|
|
33
|
+
"main": "./dist/index.js",
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"exports": {
|
|
36
|
+
".": {
|
|
37
|
+
"types": "./dist/index.d.ts",
|
|
38
|
+
"default": "./dist/index.js"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"dist",
|
|
43
|
+
"src"
|
|
44
|
+
],
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"prepublishOnly": "npm run build"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"ethers": "6.14.3"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"typescript": "5.5.4",
|
|
56
|
+
"vitest": "^3.2.6"
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=20"
|
|
60
|
+
},
|
|
61
|
+
"overrides": {
|
|
62
|
+
"vitest": "^3.2.6",
|
|
63
|
+
"vite": ">=6.4.3 <7",
|
|
64
|
+
"ws": ">=8.21.0"
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wdk-payment-verifier — server-side on-chain confirmation of self-custodial
|
|
3
|
+
* WDK Pay payments (the Node/headless counterpart to the WooCommerce PHP verifier).
|
|
4
|
+
*/
|
|
5
|
+
export { PaymentVerifier, matchTransfer, TRANSFER_TOPIC } from './verifier.js'
|
|
6
|
+
export type {
|
|
7
|
+
VerifierIntent, PaymentStatus, PaymentConfirmation,
|
|
8
|
+
EvmLog, EvmReceipt, EvmReadProvider,
|
|
9
|
+
PaymentVerifierOptions, WatchOptions
|
|
10
|
+
} from './verifier.js'
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the payment verifier: Transfer-log matching, one-shot verify
|
|
3
|
+
* (confirmed / pending / reverted / no-match), and watch (poll → confirm, timeout).
|
|
4
|
+
* A fake provider stands in for ethers so no network is touched.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
7
|
+
import { PaymentVerifier, matchTransfer, TRANSFER_TOPIC, type EvmLog, type EvmReadProvider, type VerifierIntent } from './verifier.js'
|
|
8
|
+
|
|
9
|
+
const TOKEN = '0xdAC17F958D2ee523a2206206994597C13D831ec7' // USDt
|
|
10
|
+
const MERCHANT = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8'
|
|
11
|
+
const PAYER = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'
|
|
12
|
+
const HASH = '0x' + 'ab'.repeat(32)
|
|
13
|
+
|
|
14
|
+
const intent: VerifierIntent = { tokenAddress: TOKEN, receivingAddress: MERCHANT, amountBase: '100000000' } // 100 USDt
|
|
15
|
+
|
|
16
|
+
function topicAddr (addr: string): string {
|
|
17
|
+
return '0x' + addr.slice(2).toLowerCase().padStart(64, '0')
|
|
18
|
+
}
|
|
19
|
+
function valueData (v: bigint): string {
|
|
20
|
+
return '0x' + v.toString(16).padStart(64, '0')
|
|
21
|
+
}
|
|
22
|
+
function transferLog (over: Partial<EvmLog> & { value?: bigint } = {}): EvmLog {
|
|
23
|
+
const value = over.value ?? 100_000_000n
|
|
24
|
+
return {
|
|
25
|
+
address: over.address ?? TOKEN,
|
|
26
|
+
topics: over.topics ?? [TRANSFER_TOPIC, topicAddr(PAYER), topicAddr(MERCHANT)],
|
|
27
|
+
data: over.data ?? valueData(value),
|
|
28
|
+
blockNumber: over.blockNumber ?? 100,
|
|
29
|
+
transactionHash: over.transactionHash ?? HASH
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function provider (over: Partial<EvmReadProvider> = {}): EvmReadProvider {
|
|
34
|
+
return {
|
|
35
|
+
getTransactionReceipt: vi.fn(async () => ({ status: 1, blockNumber: 100, logs: [transferLog()], transactionHash: HASH })),
|
|
36
|
+
getBlockNumber: vi.fn(async () => 105),
|
|
37
|
+
getLogs: vi.fn(async () => []),
|
|
38
|
+
...over
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('matchTransfer', () => {
|
|
43
|
+
it('matches a Transfer to the recipient with value ≥ amount', () => {
|
|
44
|
+
const m = matchTransfer([transferLog()], intent)
|
|
45
|
+
expect(m?.value).toBe(100_000_000n)
|
|
46
|
+
expect(m?.from.toLowerCase()).toBe(PAYER.toLowerCase())
|
|
47
|
+
})
|
|
48
|
+
it('rejects wrong token, wrong recipient, or short amount', () => {
|
|
49
|
+
expect(matchTransfer([transferLog({ address: '0x' + '11'.repeat(20) })], intent)).toBeNull()
|
|
50
|
+
expect(matchTransfer([transferLog({ topics: [TRANSFER_TOPIC, topicAddr(PAYER), topicAddr(PAYER)] })], intent)).toBeNull()
|
|
51
|
+
expect(matchTransfer([transferLog({ value: 99_999_999n })], intent)).toBeNull()
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('verify', () => {
|
|
56
|
+
it('confirms with enough confirmations', async () => {
|
|
57
|
+
const v = new PaymentVerifier({ provider: provider() })
|
|
58
|
+
const r = await v.verify(intent, HASH, 3)
|
|
59
|
+
expect(r.status).toBe('confirmed')
|
|
60
|
+
expect(r.confirmations).toBe(6) // 105 - 100 + 1
|
|
61
|
+
expect(r.valueBase).toBe('100000000')
|
|
62
|
+
expect(r.fromAddress?.toLowerCase()).toBe(PAYER.toLowerCase())
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('is pending while confirmations are insufficient', async () => {
|
|
66
|
+
const v = new PaymentVerifier({ provider: provider({ getBlockNumber: vi.fn(async () => 100) }) })
|
|
67
|
+
const r = await v.verify(intent, HASH, 5)
|
|
68
|
+
expect(r.status).toBe('pending')
|
|
69
|
+
expect(r.reason).toBe('awaiting_confirmations')
|
|
70
|
+
expect(r.confirmations).toBe(1)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('is pending when the tx is not mined yet', async () => {
|
|
74
|
+
const v = new PaymentVerifier({ provider: provider({ getTransactionReceipt: vi.fn(async () => null) }) })
|
|
75
|
+
expect((await v.verify(intent, HASH)).reason).toBe('not_mined')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('fails on a reverted tx', async () => {
|
|
79
|
+
const v = new PaymentVerifier({ provider: provider({ getTransactionReceipt: vi.fn(async () => ({ status: 0, blockNumber: 100, logs: [] })) }) })
|
|
80
|
+
expect((await v.verify(intent, HASH)).status).toBe('failed')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('fails when no matching transfer is present', async () => {
|
|
84
|
+
const v = new PaymentVerifier({ provider: provider({ getTransactionReceipt: vi.fn(async () => ({ status: 1, blockNumber: 100, logs: [transferLog({ value: 1n })] })) }) })
|
|
85
|
+
const r = await v.verify(intent, HASH)
|
|
86
|
+
expect(r.status).toBe('failed')
|
|
87
|
+
expect(r.reason).toBe('no_matching_transfer')
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('watch', () => {
|
|
92
|
+
it('polls getLogs, finds the transfer, and confirms', async () => {
|
|
93
|
+
const p = provider({ getLogs: vi.fn(async () => [transferLog()]) })
|
|
94
|
+
const v = new PaymentVerifier({ provider: p })
|
|
95
|
+
const r = await v.watch(intent, { requiredConfirmations: 3, fromBlock: 0, sleep: async () => {}, now: () => 0 })
|
|
96
|
+
expect(r.status).toBe('confirmed')
|
|
97
|
+
expect(p.getLogs).toHaveBeenCalled()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('resolves pending/timeout when nothing arrives', async () => {
|
|
101
|
+
let t = 0
|
|
102
|
+
const v = new PaymentVerifier({ provider: provider() }) // getLogs → []
|
|
103
|
+
const r = await v.watch(intent, { timeoutMs: 5, pollIntervalMs: 1, fromBlock: 0, sleep: async () => {}, now: () => (t += 10) })
|
|
104
|
+
expect(r.status).toBe('pending')
|
|
105
|
+
expect(r.reason).toBe('timeout')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('requires a provider or rpcUrl', () => {
|
|
109
|
+
expect(() => new PaymentVerifier({})).toThrow()
|
|
110
|
+
})
|
|
111
|
+
})
|
package/src/verifier.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wdk-payment-verifier — server-side on-chain confirmation of WDK Pay payments.
|
|
3
|
+
*
|
|
4
|
+
* A payment is a plain ERC-20 transfer of the settlement token to the merchant.
|
|
5
|
+
* Verification = observe the chain (read-only JSON-RPC) and confirm a
|
|
6
|
+
* `Transfer(token, to = merchant, value ≥ due)` landed with enough
|
|
7
|
+
* confirmations. No keys, no custody — the merchant only watches.
|
|
8
|
+
*
|
|
9
|
+
* This is the standalone library counterpart to the WooCommerce plugin's PHP
|
|
10
|
+
* verifier, for Node back-ends / headless commerce. The RPC provider is
|
|
11
|
+
* injectable (pass an `rpcUrl`, an ethers provider, or any minimal provider for
|
|
12
|
+
* tests); nothing is hard-coded.
|
|
13
|
+
*/
|
|
14
|
+
import { getAddress } from 'ethers'
|
|
15
|
+
|
|
16
|
+
/** keccak256("Transfer(address,address,uint256)") — the ERC-20 Transfer topic. */
|
|
17
|
+
export const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
|
|
18
|
+
|
|
19
|
+
/** The minimum a verifier needs to know about an order to confirm payment. */
|
|
20
|
+
export interface VerifierIntent {
|
|
21
|
+
/** Settlement token contract (the Transfer emitter), e.g. USDt. */
|
|
22
|
+
readonly tokenAddress: string
|
|
23
|
+
/** Merchant receiving address (the Transfer `to`). */
|
|
24
|
+
readonly receivingAddress: string
|
|
25
|
+
/** Minimum amount required, in token base units (decimal string). */
|
|
26
|
+
readonly amountBase: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type PaymentStatus = 'confirmed' | 'pending' | 'failed'
|
|
30
|
+
|
|
31
|
+
/** The outcome of a verification. */
|
|
32
|
+
export interface PaymentConfirmation {
|
|
33
|
+
readonly status: PaymentStatus
|
|
34
|
+
readonly txHash?: string
|
|
35
|
+
readonly blockNumber?: number
|
|
36
|
+
readonly confirmations?: number
|
|
37
|
+
/** The matched transfer's value, base units. */
|
|
38
|
+
readonly valueBase?: string
|
|
39
|
+
/** The payer (the Transfer `from`). */
|
|
40
|
+
readonly fromAddress?: string
|
|
41
|
+
/** Unix ms when confirmed. */
|
|
42
|
+
readonly receivedAt?: number
|
|
43
|
+
/** Machine-readable reason for pending/failed. */
|
|
44
|
+
readonly reason?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A single log entry (subset of an eth_getLogs / receipt log). */
|
|
48
|
+
export interface EvmLog {
|
|
49
|
+
readonly address: string
|
|
50
|
+
readonly topics: readonly string[]
|
|
51
|
+
readonly data: string
|
|
52
|
+
readonly blockNumber?: number
|
|
53
|
+
readonly transactionHash?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A transaction receipt subset. */
|
|
57
|
+
export interface EvmReceipt {
|
|
58
|
+
readonly status?: number | null
|
|
59
|
+
readonly blockNumber: number
|
|
60
|
+
readonly logs: readonly EvmLog[]
|
|
61
|
+
readonly transactionHash?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Minimal read-only provider the verifier needs. An ethers `JsonRpcProvider`
|
|
66
|
+
* satisfies it; tests pass a fake. (ethers receipts expose `logs` and a numeric
|
|
67
|
+
* `status`; `getLogs` returns the same log shape.)
|
|
68
|
+
*/
|
|
69
|
+
export interface EvmReadProvider {
|
|
70
|
+
getTransactionReceipt (txHash: string): Promise<EvmReceipt | null>
|
|
71
|
+
getBlockNumber (): Promise<number>
|
|
72
|
+
getLogs (filter: {
|
|
73
|
+
address?: string
|
|
74
|
+
topics?: (string | null)[]
|
|
75
|
+
fromBlock?: number | string
|
|
76
|
+
toBlock?: number | string
|
|
77
|
+
}): Promise<readonly EvmLog[]>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface PaymentVerifierOptions {
|
|
81
|
+
/** A ready provider (e.g. ethers JsonRpcProvider) — preferred for reuse. */
|
|
82
|
+
readonly provider?: EvmReadProvider
|
|
83
|
+
/** Or a JSON-RPC URL; an ethers JsonRpcProvider is created lazily. */
|
|
84
|
+
readonly rpcUrl?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface WatchOptions {
|
|
88
|
+
/** Confirmations required before `confirmed`. Default 1. */
|
|
89
|
+
readonly requiredConfirmations?: number
|
|
90
|
+
/** Give up after this many ms (resolves `pending`/`timeout`). Default 15 min. */
|
|
91
|
+
readonly timeoutMs?: number
|
|
92
|
+
/** Poll cadence. Default 5s. */
|
|
93
|
+
readonly pollIntervalMs?: number
|
|
94
|
+
/** Block to scan logs from. Default: latest − 5000 (or 0). */
|
|
95
|
+
readonly fromBlock?: number
|
|
96
|
+
/** Injectable sleep for deterministic tests. */
|
|
97
|
+
readonly sleep?: (ms: number) => Promise<void>
|
|
98
|
+
/** Injectable clock (ms) for deterministic tests. */
|
|
99
|
+
readonly now?: () => number
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A 32-byte topic word → checksummed address (last 20 bytes). */
|
|
103
|
+
function topicToAddress (topic: string): string {
|
|
104
|
+
return getAddress('0x' + topic.slice(-40))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function eq (a: string, b: string): boolean {
|
|
108
|
+
try { return getAddress(a) === getAddress(b) } catch { return false }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Find a Transfer log in `logs` matching the intent (emitter = token,
|
|
113
|
+
* to = recipient, value ≥ amount). Returns the match or null.
|
|
114
|
+
*/
|
|
115
|
+
export function matchTransfer (
|
|
116
|
+
logs: readonly EvmLog[],
|
|
117
|
+
intent: VerifierIntent
|
|
118
|
+
): { value: bigint, from: string, log: EvmLog } | null {
|
|
119
|
+
const need = BigInt(intent.amountBase)
|
|
120
|
+
for (const log of logs) {
|
|
121
|
+
if (!log.topics || log.topics.length < 3) continue
|
|
122
|
+
if (log.topics[0]?.toLowerCase() !== TRANSFER_TOPIC) continue
|
|
123
|
+
if (!eq(log.address, intent.tokenAddress)) continue
|
|
124
|
+
if (!eq(topicToAddress(log.topics[2]!), intent.receivingAddress)) continue
|
|
125
|
+
let value: bigint
|
|
126
|
+
try { value = BigInt(log.data) } catch { continue }
|
|
127
|
+
if (value < need) continue
|
|
128
|
+
return { value, from: topicToAddress(log.topics[1]!), log }
|
|
129
|
+
}
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export class PaymentVerifier {
|
|
134
|
+
#provider: EvmReadProvider | undefined
|
|
135
|
+
readonly #rpcUrl: string | undefined
|
|
136
|
+
|
|
137
|
+
constructor (opts: PaymentVerifierOptions) {
|
|
138
|
+
if (!opts.provider && !opts.rpcUrl) throw new Error('PaymentVerifier: pass `provider` or `rpcUrl`.')
|
|
139
|
+
this.#provider = opts.provider
|
|
140
|
+
this.#rpcUrl = opts.rpcUrl
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async #getProvider (): Promise<EvmReadProvider> {
|
|
144
|
+
if (this.#provider) return this.#provider
|
|
145
|
+
const { JsonRpcProvider } = await import('ethers')
|
|
146
|
+
this.#provider = new JsonRpcProvider(this.#rpcUrl) as unknown as EvmReadProvider
|
|
147
|
+
return this.#provider
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Verify a specific transaction satisfies the intent. One-shot: checks the
|
|
152
|
+
* receipt for a matching Transfer and the current confirmation count.
|
|
153
|
+
*/
|
|
154
|
+
async verify (
|
|
155
|
+
intent: VerifierIntent,
|
|
156
|
+
txHash: string,
|
|
157
|
+
requiredConfirmations = 1
|
|
158
|
+
): Promise<PaymentConfirmation> {
|
|
159
|
+
const provider = await this.#getProvider()
|
|
160
|
+
let receipt: EvmReceipt | null
|
|
161
|
+
try {
|
|
162
|
+
receipt = await provider.getTransactionReceipt(txHash)
|
|
163
|
+
} catch {
|
|
164
|
+
return { status: 'pending', txHash, reason: 'rpc_unreachable' }
|
|
165
|
+
}
|
|
166
|
+
if (!receipt) return { status: 'pending', txHash, reason: 'not_mined' }
|
|
167
|
+
if (receipt.status === 0) return { status: 'failed', txHash, reason: 'reverted' }
|
|
168
|
+
|
|
169
|
+
const match = matchTransfer(receipt.logs, intent)
|
|
170
|
+
if (!match) return { status: 'failed', txHash, reason: 'no_matching_transfer' }
|
|
171
|
+
|
|
172
|
+
let head: number
|
|
173
|
+
try { head = await provider.getBlockNumber() } catch {
|
|
174
|
+
return { status: 'pending', txHash, valueBase: match.value.toString(), reason: 'rpc_unreachable' }
|
|
175
|
+
}
|
|
176
|
+
const confirmations = head - receipt.blockNumber + 1
|
|
177
|
+
const base = {
|
|
178
|
+
txHash, blockNumber: receipt.blockNumber, confirmations: Math.max(0, confirmations),
|
|
179
|
+
valueBase: match.value.toString(), fromAddress: match.from
|
|
180
|
+
}
|
|
181
|
+
if (confirmations < requiredConfirmations) return { status: 'pending', ...base, reason: 'awaiting_confirmations' }
|
|
182
|
+
return { status: 'confirmed', ...base, receivedAt: Date.now() }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Watch for an incoming payment matching the intent (no tx hash known yet):
|
|
187
|
+
* polls `getLogs` for a Transfer to the recipient ≥ amount, then waits for
|
|
188
|
+
* confirmations. Resolves `confirmed`, or `pending` (reason `timeout`) if the
|
|
189
|
+
* window elapses.
|
|
190
|
+
*/
|
|
191
|
+
async watch (intent: VerifierIntent, options: WatchOptions = {}): Promise<PaymentConfirmation> {
|
|
192
|
+
const provider = await this.#getProvider()
|
|
193
|
+
const requiredConfirmations = options.requiredConfirmations ?? 1
|
|
194
|
+
const timeoutMs = options.timeoutMs ?? 15 * 60 * 1000
|
|
195
|
+
const pollIntervalMs = options.pollIntervalMs ?? 5000
|
|
196
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)))
|
|
197
|
+
const now = options.now ?? (() => Date.now())
|
|
198
|
+
|
|
199
|
+
const start = now()
|
|
200
|
+
let fromBlock = options.fromBlock
|
|
201
|
+
if (fromBlock === undefined) {
|
|
202
|
+
try { fromBlock = Math.max(0, (await provider.getBlockNumber()) - 5000) } catch { fromBlock = 0 }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for (;;) {
|
|
206
|
+
let logs: readonly EvmLog[] = []
|
|
207
|
+
try {
|
|
208
|
+
logs = await provider.getLogs({
|
|
209
|
+
address: intent.tokenAddress,
|
|
210
|
+
topics: [TRANSFER_TOPIC, null, '0x' + getAddress(intent.receivingAddress).slice(2).toLowerCase().padStart(64, '0')],
|
|
211
|
+
fromBlock,
|
|
212
|
+
toBlock: 'latest'
|
|
213
|
+
})
|
|
214
|
+
} catch { /* transient — retry next tick */ }
|
|
215
|
+
|
|
216
|
+
const match = matchTransfer(logs, intent)
|
|
217
|
+
if (match && match.log.transactionHash) {
|
|
218
|
+
const confirmation = await this.verify(intent, match.log.transactionHash, requiredConfirmations)
|
|
219
|
+
if (confirmation.status === 'confirmed' || confirmation.status === 'failed') return confirmation
|
|
220
|
+
}
|
|
221
|
+
if (now() - start >= timeoutMs) return { status: 'pending', reason: 'timeout' }
|
|
222
|
+
await sleep(pollIntervalMs)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|