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 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.
@@ -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,5 @@
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';
@@ -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
+ }
@@ -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
+ })
@@ -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
+ }