@wopr-network/platform-core 1.48.0 → 1.49.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.
Files changed (70) hide show
  1. package/dist/billing/crypto/__tests__/unified-checkout.test.d.ts +1 -0
  2. package/dist/billing/crypto/__tests__/unified-checkout.test.js +63 -0
  3. package/dist/billing/crypto/__tests__/watcher-service.test.d.ts +1 -0
  4. package/dist/billing/crypto/__tests__/watcher-service.test.js +174 -0
  5. package/dist/billing/crypto/__tests__/webhook-confirmations.test.d.ts +1 -0
  6. package/dist/billing/crypto/__tests__/webhook-confirmations.test.js +304 -0
  7. package/dist/billing/crypto/btc/__tests__/settler.test.js +1 -0
  8. package/dist/billing/crypto/btc/__tests__/watcher.test.d.ts +1 -0
  9. package/dist/billing/crypto/btc/__tests__/watcher.test.js +170 -0
  10. package/dist/billing/crypto/btc/types.d.ts +3 -1
  11. package/dist/billing/crypto/btc/watcher.d.ts +6 -1
  12. package/dist/billing/crypto/btc/watcher.js +20 -6
  13. package/dist/billing/crypto/charge-store.d.ts +27 -2
  14. package/dist/billing/crypto/charge-store.js +67 -1
  15. package/dist/billing/crypto/charge-store.test.js +180 -1
  16. package/dist/billing/crypto/client.d.ts +2 -0
  17. package/dist/billing/crypto/cursor-store.d.ts +10 -3
  18. package/dist/billing/crypto/cursor-store.js +21 -1
  19. package/dist/billing/crypto/evm/__tests__/eth-settler.test.js +2 -0
  20. package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +31 -4
  21. package/dist/billing/crypto/evm/__tests__/settler.test.js +2 -0
  22. package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.d.ts +1 -0
  23. package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.js +144 -0
  24. package/dist/billing/crypto/evm/__tests__/watcher.test.js +6 -2
  25. package/dist/billing/crypto/evm/eth-watcher.d.ts +11 -8
  26. package/dist/billing/crypto/evm/eth-watcher.js +27 -13
  27. package/dist/billing/crypto/evm/types.d.ts +5 -1
  28. package/dist/billing/crypto/evm/watcher.d.ts +9 -1
  29. package/dist/billing/crypto/evm/watcher.js +36 -13
  30. package/dist/billing/crypto/index.d.ts +3 -3
  31. package/dist/billing/crypto/index.js +1 -1
  32. package/dist/billing/crypto/key-server-webhook.d.ts +17 -4
  33. package/dist/billing/crypto/key-server-webhook.js +76 -15
  34. package/dist/billing/crypto/types.d.ts +16 -0
  35. package/dist/billing/crypto/unified-checkout.d.ts +8 -17
  36. package/dist/billing/crypto/unified-checkout.js +17 -131
  37. package/dist/billing/crypto/watcher-service.d.ts +22 -2
  38. package/dist/billing/crypto/watcher-service.js +71 -30
  39. package/dist/db/schema/crypto.d.ts +68 -0
  40. package/dist/db/schema/crypto.js +8 -0
  41. package/dist/monetization/crypto/__tests__/webhook.test.js +2 -1
  42. package/drizzle/migrations/0016_charge_progress_columns.sql +4 -0
  43. package/drizzle/migrations/meta/_journal.json +7 -0
  44. package/package.json +1 -1
  45. package/src/billing/crypto/__tests__/unified-checkout.test.ts +83 -0
  46. package/src/billing/crypto/__tests__/watcher-service.test.ts +242 -0
  47. package/src/billing/crypto/__tests__/webhook-confirmations.test.ts +367 -0
  48. package/src/billing/crypto/btc/__tests__/settler.test.ts +1 -0
  49. package/src/billing/crypto/btc/__tests__/watcher.test.ts +201 -0
  50. package/src/billing/crypto/btc/types.ts +3 -1
  51. package/src/billing/crypto/btc/watcher.ts +22 -6
  52. package/src/billing/crypto/charge-store.test.ts +204 -1
  53. package/src/billing/crypto/charge-store.ts +86 -2
  54. package/src/billing/crypto/client.ts +2 -0
  55. package/src/billing/crypto/cursor-store.ts +31 -3
  56. package/src/billing/crypto/evm/__tests__/eth-settler.test.ts +2 -0
  57. package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +31 -4
  58. package/src/billing/crypto/evm/__tests__/settler.test.ts +2 -0
  59. package/src/billing/crypto/evm/__tests__/watcher-confirmations.test.ts +176 -0
  60. package/src/billing/crypto/evm/__tests__/watcher.test.ts +6 -2
  61. package/src/billing/crypto/evm/eth-watcher.ts +34 -14
  62. package/src/billing/crypto/evm/types.ts +5 -1
  63. package/src/billing/crypto/evm/watcher.ts +39 -13
  64. package/src/billing/crypto/index.ts +12 -3
  65. package/src/billing/crypto/key-server-webhook.ts +92 -21
  66. package/src/billing/crypto/types.ts +18 -0
  67. package/src/billing/crypto/unified-checkout.ts +20 -179
  68. package/src/billing/crypto/watcher-service.ts +85 -32
  69. package/src/db/schema/crypto.ts +8 -0
  70. package/src/monetization/crypto/__tests__/webhook.test.ts +2 -1
@@ -9,6 +9,8 @@ function makeEvent(overrides = {}) {
9
9
  amountUsdCents: 5000,
10
10
  txHash: "0xabc123",
11
11
  blockNumber: 100,
12
+ confirmations: 1,
13
+ confirmationsRequired: 1,
12
14
  ...overrides,
13
15
  };
14
16
  }
@@ -7,8 +7,9 @@ const mockOracle = { getPrice: vi.fn().mockResolvedValue({ priceCents: 350_000,
7
7
  describe("EthWatcher", () => {
8
8
  it("detects native ETH transfer to watched address", async () => {
9
9
  const onPayment = vi.fn();
10
+ // latest = 0xa (10), fromBlock = 10 → scans exactly block 10
10
11
  const rpc = makeRpc({
11
- eth_blockNumber: "0xb",
12
+ eth_blockNumber: "0xa",
12
13
  eth_getBlockByNumber: {
13
14
  transactions: [
14
15
  {
@@ -36,6 +37,8 @@ describe("EthWatcher", () => {
36
37
  expect(event.valueWei).toBe("1000000000000000000");
37
38
  expect(event.amountUsdCents).toBe(350_000); // 1 ETH × $3,500
38
39
  expect(event.txHash).toBe("0xabc");
40
+ expect(event.confirmations).toBe(0);
41
+ expect(event.confirmationsRequired).toBe(1);
39
42
  });
40
43
  it("skips transactions not to watched addresses", async () => {
41
44
  const onPayment = vi.fn();
@@ -77,6 +80,19 @@ describe("EthWatcher", () => {
77
80
  });
78
81
  it("does not double-process same txid", async () => {
79
82
  const onPayment = vi.fn();
83
+ const confirmations = new Map();
84
+ const cursorStore = {
85
+ get: vi.fn().mockResolvedValue(null),
86
+ save: vi.fn().mockResolvedValue(undefined),
87
+ hasProcessedTx: vi.fn().mockResolvedValue(false),
88
+ markProcessedTx: vi.fn().mockResolvedValue(undefined),
89
+ getConfirmationCount: vi
90
+ .fn()
91
+ .mockImplementation(async (_, txId) => confirmations.get(txId) ?? null),
92
+ saveConfirmationCount: vi.fn().mockImplementation(async (_, txId, count) => {
93
+ confirmations.set(txId, count);
94
+ }),
95
+ };
80
96
  const rpc = makeRpc({
81
97
  eth_blockNumber: "0xb",
82
98
  eth_getBlockByNumber: {
@@ -90,9 +106,10 @@ describe("EthWatcher", () => {
90
106
  fromBlock: 10,
91
107
  onPayment,
92
108
  watchedAddresses: ["0xDeposit"],
109
+ cursorStore,
93
110
  });
94
111
  await watcher.poll();
95
- // Reset cursor to re-scan same block
112
+ // Second poll same block, same confirmations → no duplicate emission
96
113
  await watcher.poll();
97
114
  expect(onPayment).toHaveBeenCalledOnce();
98
115
  });
@@ -112,8 +129,17 @@ describe("EthWatcher", () => {
112
129
  });
113
130
  it("does not mark txid as processed if onPayment throws", async () => {
114
131
  const onPayment = vi.fn().mockRejectedValueOnce(new Error("db fail")).mockResolvedValueOnce(undefined);
132
+ const cursorStore = {
133
+ get: vi.fn().mockResolvedValue(null),
134
+ save: vi.fn().mockResolvedValue(undefined),
135
+ hasProcessedTx: vi.fn().mockResolvedValue(false),
136
+ markProcessedTx: vi.fn().mockResolvedValue(undefined),
137
+ getConfirmationCount: vi.fn().mockResolvedValue(null),
138
+ saveConfirmationCount: vi.fn().mockResolvedValue(undefined),
139
+ };
140
+ // latest = 0xa (10) = fromBlock → exactly one block to scan
115
141
  const rpc = makeRpc({
116
- eth_blockNumber: "0xb",
142
+ eth_blockNumber: "0xa",
117
143
  eth_getBlockByNumber: {
118
144
  transactions: [{ hash: "0xabc", from: "0xa", to: "0xdeposit", value: "0xDE0B6B3A7640000", blockNumber: "0xa" }],
119
145
  },
@@ -125,9 +151,10 @@ describe("EthWatcher", () => {
125
151
  fromBlock: 10,
126
152
  onPayment,
127
153
  watchedAddresses: ["0xDeposit"],
154
+ cursorStore,
128
155
  });
129
156
  await expect(watcher.poll()).rejects.toThrow("db fail");
130
- // Retry — should process the same tx again since it wasn't marked
157
+ // Retry — should process the same tx again since confirmationCount wasn't saved (error before save)
131
158
  await watcher.poll();
132
159
  expect(onPayment).toHaveBeenCalledTimes(2);
133
160
  });
@@ -10,6 +10,8 @@ const mockEvent = {
10
10
  txHash: "0xtx123",
11
11
  blockNumber: 100,
12
12
  logIndex: 0,
13
+ confirmations: 1,
14
+ confirmationsRequired: 1,
13
15
  };
14
16
  describe("settleEvmPayment", () => {
15
17
  it("credits ledger when charge found and not yet credited", async () => {
@@ -0,0 +1,144 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { EvmWatcher } from "../watcher.js";
3
+ const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
4
+ function mockTransferLog(to, amount, blockNumber) {
5
+ return {
6
+ address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
7
+ topics: [
8
+ TRANSFER_TOPIC,
9
+ `0x${"00".repeat(12)}${"ab".repeat(20)}`,
10
+ `0x${"00".repeat(12)}${to.slice(2).toLowerCase()}`,
11
+ ],
12
+ data: `0x${amount.toString(16).padStart(64, "0")}`,
13
+ blockNumber: `0x${blockNumber.toString(16)}`,
14
+ transactionHash: `0x${"ff".repeat(32)}`,
15
+ logIndex: "0x0",
16
+ };
17
+ }
18
+ function makeCursorStore() {
19
+ const cursors = new Map();
20
+ return {
21
+ get: vi.fn().mockImplementation(async (id) => cursors.get(id) ?? null),
22
+ save: vi.fn().mockImplementation(async (id, val) => {
23
+ cursors.set(id, val);
24
+ }),
25
+ hasProcessedTx: vi.fn().mockResolvedValue(false),
26
+ markProcessedTx: vi.fn().mockResolvedValue(undefined),
27
+ getConfirmationCount: vi.fn().mockResolvedValue(null),
28
+ saveConfirmationCount: vi.fn().mockResolvedValue(undefined),
29
+ };
30
+ }
31
+ describe("EvmWatcher — intermediate confirmations", () => {
32
+ it("emits events with confirmation count", async () => {
33
+ const toAddr = `0x${"cc".repeat(20)}`;
34
+ const events = [];
35
+ // Base has confirmations: 1. Latest block is 105. Log at block 103 -> 2 confirmations.
36
+ const mockRpc = vi
37
+ .fn()
38
+ .mockResolvedValueOnce(`0x${(105).toString(16)}`) // eth_blockNumber
39
+ .mockResolvedValueOnce([mockTransferLog(toAddr, 10000000n, 103)]); // eth_getLogs
40
+ const watcher = new EvmWatcher({
41
+ chain: "base",
42
+ token: "USDC",
43
+ rpcCall: mockRpc,
44
+ fromBlock: 100,
45
+ watchedAddresses: [toAddr],
46
+ cursorStore: makeCursorStore(),
47
+ onPayment: (evt) => {
48
+ events.push(evt);
49
+ },
50
+ });
51
+ await watcher.poll();
52
+ expect(events).toHaveLength(1);
53
+ expect(events[0].confirmations).toBe(2); // 105 - 103
54
+ expect(events[0].confirmationsRequired).toBe(1); // Base chain config
55
+ });
56
+ it("skips event when confirmation count unchanged", async () => {
57
+ const toAddr = `0x${"cc".repeat(20)}`;
58
+ const events = [];
59
+ const cursorStore = makeCursorStore();
60
+ cursorStore.getConfirmationCount.mockResolvedValue(2);
61
+ const mockRpc = vi
62
+ .fn()
63
+ .mockResolvedValueOnce(`0x${(105).toString(16)}`)
64
+ .mockResolvedValueOnce([mockTransferLog(toAddr, 10000000n, 103)]);
65
+ const watcher = new EvmWatcher({
66
+ chain: "base",
67
+ token: "USDC",
68
+ rpcCall: mockRpc,
69
+ fromBlock: 100,
70
+ watchedAddresses: [toAddr],
71
+ cursorStore,
72
+ onPayment: (evt) => {
73
+ events.push(evt);
74
+ },
75
+ });
76
+ await watcher.poll();
77
+ expect(events).toHaveLength(0);
78
+ });
79
+ it("re-emits when confirmations increase", async () => {
80
+ const toAddr = `0x${"cc".repeat(20)}`;
81
+ const events = [];
82
+ const cursorStore = makeCursorStore();
83
+ cursorStore.getConfirmationCount.mockResolvedValue(1);
84
+ const mockRpc = vi
85
+ .fn()
86
+ .mockResolvedValueOnce(`0x${(105).toString(16)}`)
87
+ .mockResolvedValueOnce([mockTransferLog(toAddr, 10000000n, 103)]);
88
+ const watcher = new EvmWatcher({
89
+ chain: "base",
90
+ token: "USDC",
91
+ rpcCall: mockRpc,
92
+ fromBlock: 100,
93
+ watchedAddresses: [toAddr],
94
+ cursorStore,
95
+ onPayment: (evt) => {
96
+ events.push(evt);
97
+ },
98
+ });
99
+ await watcher.poll();
100
+ expect(events).toHaveLength(1);
101
+ expect(events[0].confirmations).toBe(2);
102
+ });
103
+ it("includes confirmationsRequired from chain config", async () => {
104
+ const toAddr = `0x${"cc".repeat(20)}`;
105
+ const events = [];
106
+ const mockRpc = vi
107
+ .fn()
108
+ .mockResolvedValueOnce(`0x${(110).toString(16)}`)
109
+ .mockResolvedValueOnce([mockTransferLog(toAddr, 10000000n, 105)]);
110
+ const watcher = new EvmWatcher({
111
+ chain: "base",
112
+ token: "USDC",
113
+ rpcCall: mockRpc,
114
+ fromBlock: 100,
115
+ watchedAddresses: [toAddr],
116
+ cursorStore: makeCursorStore(),
117
+ onPayment: (evt) => {
118
+ events.push(evt);
119
+ },
120
+ });
121
+ await watcher.poll();
122
+ expect(events).toHaveLength(1);
123
+ expect(events[0].confirmationsRequired).toBe(1); // Base chain config
124
+ });
125
+ it("saves confirmation count after emitting", async () => {
126
+ const toAddr = `0x${"cc".repeat(20)}`;
127
+ const cursorStore = makeCursorStore();
128
+ const mockRpc = vi
129
+ .fn()
130
+ .mockResolvedValueOnce(`0x${(105).toString(16)}`)
131
+ .mockResolvedValueOnce([mockTransferLog(toAddr, 10000000n, 103)]);
132
+ const watcher = new EvmWatcher({
133
+ chain: "base",
134
+ token: "USDC",
135
+ rpcCall: mockRpc,
136
+ fromBlock: 100,
137
+ watchedAddresses: [toAddr],
138
+ cursorStore,
139
+ onPayment: () => { },
140
+ });
141
+ await watcher.poll();
142
+ expect(cursorStore.saveConfirmationCount).toHaveBeenCalledWith(expect.any(String), expect.stringContaining("0x"), 2);
143
+ });
144
+ });
@@ -56,7 +56,12 @@ describe("EvmWatcher", () => {
56
56
  });
57
57
  it("skips blocks not yet confirmed", async () => {
58
58
  const events = [];
59
- const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(50).toString(16)}`);
59
+ // latest = 50, cursor = 50 → latest < cursor is false, but range is empty (50..50)
60
+ // With intermediate confirmations, we still scan the range but find no logs
61
+ const mockRpc = vi
62
+ .fn()
63
+ .mockResolvedValueOnce(`0x${(50).toString(16)}`) // eth_blockNumber
64
+ .mockResolvedValueOnce([]); // eth_getLogs (empty)
60
65
  const watcher = new EvmWatcher({
61
66
  chain: "base",
62
67
  token: "USDC",
@@ -69,7 +74,6 @@ describe("EvmWatcher", () => {
69
74
  });
70
75
  await watcher.poll();
71
76
  expect(events).toHaveLength(0);
72
- expect(mockRpc).toHaveBeenCalledTimes(1);
73
77
  });
74
78
  it("processes multiple logs in one poll", async () => {
75
79
  const addr1 = `0x${"aa".repeat(20)}`;
@@ -2,7 +2,7 @@ import type { IWatcherCursorStore } from "../cursor-store.js";
2
2
  import type { IPriceOracle } from "../oracle/types.js";
3
3
  import type { EvmChain } from "./types.js";
4
4
  type RpcCall = (method: string, params: unknown[]) => Promise<unknown>;
5
- /** Event emitted when a native ETH deposit is detected and confirmed. */
5
+ /** Event emitted on each confirmation increment for a native ETH deposit. */
6
6
  export interface EthPaymentEvent {
7
7
  readonly chain: EvmChain;
8
8
  readonly from: string;
@@ -13,6 +13,10 @@ export interface EthPaymentEvent {
13
13
  readonly amountUsdCents: number;
14
14
  readonly txHash: string;
15
15
  readonly blockNumber: number;
16
+ /** Current confirmation count (latest block - tx block). */
17
+ readonly confirmations: number;
18
+ /** Required confirmations for this chain. */
19
+ readonly confirmationsRequired: number;
16
20
  }
17
21
  export interface EthWatcherOpts {
18
22
  chain: EvmChain;
@@ -30,9 +34,9 @@ export interface EthWatcherOpts {
30
34
  * this scans blocks for transactions where `to` matches a watched deposit
31
35
  * address and `value > 0`.
32
36
  *
33
- * Processes one block at a time and persists cursor after each block.
34
- * On restart, resumes from the last committed cursor — no replay, no
35
- * unbounded in-memory state.
37
+ * Scans up to latest block (not just confirmed) to detect pending txs.
38
+ * Emits events on each confirmation increment. Only advances cursor
39
+ * past fully-confirmed blocks.
36
40
  */
37
41
  export declare class EthWatcher {
38
42
  private _cursor;
@@ -50,11 +54,10 @@ export declare class EthWatcher {
50
54
  setWatchedAddresses(addresses: string[]): void;
51
55
  get cursor(): number;
52
56
  /**
53
- * Poll for new native ETH transfers to watched addresses.
57
+ * Poll for native ETH transfers to watched addresses, including unconfirmed blocks.
54
58
  *
55
- * Processes one block at a time. After each block is fully processed,
56
- * the cursor is persisted to the DB. If onPayment fails mid-block,
57
- * the cursor hasn't advanced — the entire block is retried on next poll.
59
+ * Scans from cursor to latest block. Emits events with current confirmation count.
60
+ * Re-emits on each confirmation increment. Only advances cursor past fully-confirmed blocks.
58
61
  */
59
62
  poll(): Promise<void>;
60
63
  }
@@ -7,9 +7,9 @@ import { getChainConfig } from "./config.js";
7
7
  * this scans blocks for transactions where `to` matches a watched deposit
8
8
  * address and `value > 0`.
9
9
  *
10
- * Processes one block at a time and persists cursor after each block.
11
- * On restart, resumes from the last committed cursor — no replay, no
12
- * unbounded in-memory state.
10
+ * Scans up to latest block (not just confirmed) to detect pending txs.
11
+ * Emits events on each confirmation increment. Only advances cursor
12
+ * past fully-confirmed blocks.
13
13
  */
14
14
  export class EthWatcher {
15
15
  _cursor;
@@ -47,11 +47,10 @@ export class EthWatcher {
47
47
  return this._cursor;
48
48
  }
49
49
  /**
50
- * Poll for new native ETH transfers to watched addresses.
50
+ * Poll for native ETH transfers to watched addresses, including unconfirmed blocks.
51
51
  *
52
- * Processes one block at a time. After each block is fully processed,
53
- * the cursor is persisted to the DB. If onPayment fails mid-block,
54
- * the cursor hasn't advanced — the entire block is retried on next poll.
52
+ * Scans from cursor to latest block. Emits events with current confirmation count.
53
+ * Re-emits on each confirmation increment. Only advances cursor past fully-confirmed blocks.
55
54
  */
56
55
  async poll() {
57
56
  if (this._watchedAddresses.size === 0)
@@ -59,13 +58,15 @@ export class EthWatcher {
59
58
  const latestHex = (await this.rpc("eth_blockNumber", []));
60
59
  const latest = Number.parseInt(latestHex, 16);
61
60
  const confirmed = latest - this.confirmations;
62
- if (confirmed < this._cursor)
61
+ if (latest < this._cursor)
63
62
  return;
64
63
  const { priceCents } = await this.oracle.getPrice("ETH");
65
- for (let blockNum = this._cursor; blockNum <= confirmed; blockNum++) {
64
+ // Scan up to latest (not just confirmed) to detect pending txs
65
+ for (let blockNum = this._cursor; blockNum <= latest; blockNum++) {
66
66
  const block = (await this.rpc("eth_getBlockByNumber", [`0x${blockNum.toString(16)}`, true]));
67
67
  if (!block)
68
68
  continue;
69
+ const confs = latest - blockNum;
69
70
  for (const tx of block.transactions) {
70
71
  if (!tx.to)
71
72
  continue;
@@ -75,6 +76,12 @@ export class EthWatcher {
75
76
  const valueWei = BigInt(tx.value);
76
77
  if (valueWei === 0n)
77
78
  continue;
79
+ // Skip if we already emitted at this confirmation count
80
+ if (this.cursorStore) {
81
+ const lastConf = await this.cursorStore.getConfirmationCount(this.watcherId, tx.hash);
82
+ if (lastConf !== null && confs <= lastConf)
83
+ continue;
84
+ }
78
85
  const amountUsdCents = nativeToCents(valueWei, priceCents, 18);
79
86
  const event = {
80
87
  chain: this.chain,
@@ -84,13 +91,20 @@ export class EthWatcher {
84
91
  amountUsdCents,
85
92
  txHash: tx.hash,
86
93
  blockNumber: blockNum,
94
+ confirmations: confs,
95
+ confirmationsRequired: this.confirmations,
87
96
  };
88
97
  await this.onPayment(event);
98
+ if (this.cursorStore) {
99
+ await this.cursorStore.saveConfirmationCount(this.watcherId, tx.hash, confs);
100
+ }
89
101
  }
90
- // Block fully processed — persist cursor so we never re-scan it.
91
- this._cursor = blockNum + 1;
92
- if (this.cursorStore) {
93
- await this.cursorStore.save(this.watcherId, this._cursor);
102
+ // Only advance cursor past fully-confirmed blocks
103
+ if (blockNum <= confirmed) {
104
+ this._cursor = blockNum + 1;
105
+ if (this.cursorStore) {
106
+ await this.cursorStore.save(this.watcherId, this._cursor);
107
+ }
94
108
  }
95
109
  }
96
110
  }
@@ -17,7 +17,7 @@ export interface TokenConfig {
17
17
  readonly contractAddress: `0x${string}`;
18
18
  readonly decimals: number;
19
19
  }
20
- /** Event emitted when a Transfer is detected and confirmed. */
20
+ /** Event emitted on each confirmation increment for a Transfer. */
21
21
  export interface EvmPaymentEvent {
22
22
  readonly chain: EvmChain;
23
23
  readonly token: StablecoinToken;
@@ -30,6 +30,10 @@ export interface EvmPaymentEvent {
30
30
  readonly txHash: string;
31
31
  readonly blockNumber: number;
32
32
  readonly logIndex: number;
33
+ /** Current confirmation count (latest block - tx block). */
34
+ readonly confirmations: number;
35
+ /** Required confirmations for this chain. */
36
+ readonly confirmationsRequired: number;
33
37
  }
34
38
  /** Options for creating a stablecoin checkout. */
35
39
  export interface StablecoinCheckoutOpts {
@@ -29,7 +29,15 @@ export declare class EvmWatcher {
29
29
  /** Update the set of watched deposit addresses (e.g. after a new checkout). */
30
30
  setWatchedAddresses(addresses: string[]): void;
31
31
  get cursor(): number;
32
- /** Poll for new Transfer events. Call on an interval. */
32
+ /**
33
+ * Poll for Transfer events, including pending (unconfirmed) blocks.
34
+ *
35
+ * Two-phase scan:
36
+ * 1. Scan cursor..latest for new/updated txs, emit with current confirmation count
37
+ * 2. Re-check pending txs automatically since cursor doesn't advance past unconfirmed blocks
38
+ *
39
+ * Cursor only advances past fully-confirmed blocks.
40
+ */
33
41
  poll(): Promise<void>;
34
42
  }
35
43
  /** Create an RPC caller for a given URL (plain JSON-RPC over fetch). */
@@ -42,31 +42,37 @@ export class EvmWatcher {
42
42
  get cursor() {
43
43
  return this._cursor;
44
44
  }
45
- /** Poll for new Transfer events. Call on an interval. */
45
+ /**
46
+ * Poll for Transfer events, including pending (unconfirmed) blocks.
47
+ *
48
+ * Two-phase scan:
49
+ * 1. Scan cursor..latest for new/updated txs, emit with current confirmation count
50
+ * 2. Re-check pending txs automatically since cursor doesn't advance past unconfirmed blocks
51
+ *
52
+ * Cursor only advances past fully-confirmed blocks.
53
+ */
46
54
  async poll() {
47
55
  if (this._watchedAddresses.length === 0)
48
56
  return; // nothing to watch
49
57
  const latestHex = (await this.rpc("eth_blockNumber", []));
50
58
  const latest = Number.parseInt(latestHex, 16);
51
59
  const confirmed = latest - this.confirmations;
52
- if (confirmed < this._cursor)
60
+ if (latest < this._cursor)
53
61
  return;
54
62
  // Filter by topic[2] (to address) when watched addresses are set.
55
- // This avoids fetching ALL USDC transfers on the chain (millions/day on Base).
56
- // topic[2] values are 32-byte zero-padded: 0x000000000000000000000000<address>
57
63
  const toFilter = this._watchedAddresses.length > 0
58
64
  ? this._watchedAddresses.map((a) => `0x000000000000000000000000${a.slice(2)}`)
59
65
  : null;
66
+ // Scan from cursor to latest (not just confirmed) to detect pending txs
60
67
  const logs = (await this.rpc("eth_getLogs", [
61
68
  {
62
69
  address: this.contractAddress,
63
70
  topics: [TRANSFER_TOPIC, null, toFilter],
64
71
  fromBlock: `0x${this._cursor.toString(16)}`,
65
- toBlock: `0x${confirmed.toString(16)}`,
72
+ toBlock: `0x${latest.toString(16)}`,
66
73
  },
67
74
  ]));
68
- // Group logs by block for incremental cursor checkpointing.
69
- // If onPayment fails mid-batch, only the current block is replayed on next poll.
75
+ // Group logs by block
70
76
  const logsByBlock = new Map();
71
77
  for (const log of logs) {
72
78
  const bn = Number.parseInt(log.blockNumber, 16);
@@ -76,10 +82,18 @@ export class EvmWatcher {
76
82
  else
77
83
  logsByBlock.set(bn, [log]);
78
84
  }
79
- // Process blocks in order, checkpoint after each.
85
+ // Process all blocks (including unconfirmed), emit with confirmation count
80
86
  const blockNums = [...logsByBlock.keys()].sort((a, b) => a - b);
81
87
  for (const blockNum of blockNums) {
88
+ const confs = latest - blockNum;
82
89
  for (const log of logsByBlock.get(blockNum) ?? []) {
90
+ const txKey = `${log.transactionHash}:${log.logIndex}`;
91
+ // Skip if we already emitted at this confirmation count
92
+ if (this.cursorStore) {
93
+ const lastConf = await this.cursorStore.getConfirmationCount(this.watcherId, txKey);
94
+ if (lastConf !== null && confs <= lastConf)
95
+ continue;
96
+ }
83
97
  const to = `0x${log.topics[2].slice(26)}`.toLowerCase();
84
98
  const from = `0x${log.topics[1].slice(26)}`.toLowerCase();
85
99
  const rawAmount = BigInt(log.data);
@@ -94,16 +108,25 @@ export class EvmWatcher {
94
108
  txHash: log.transactionHash,
95
109
  blockNumber: blockNum,
96
110
  logIndex: Number.parseInt(log.logIndex, 16),
111
+ confirmations: confs,
112
+ confirmationsRequired: this.confirmations,
97
113
  };
98
114
  await this.onPayment(event);
115
+ // Track confirmation count
116
+ if (this.cursorStore) {
117
+ await this.cursorStore.saveConfirmationCount(this.watcherId, txKey, confs);
118
+ }
99
119
  }
100
- this._cursor = blockNum + 1;
101
- if (this.cursorStore) {
102
- await this.cursorStore.save(this.watcherId, this._cursor);
120
+ // Only advance cursor past fully-confirmed blocks
121
+ if (blockNum <= confirmed) {
122
+ this._cursor = blockNum + 1;
123
+ if (this.cursorStore) {
124
+ await this.cursorStore.save(this.watcherId, this._cursor);
125
+ }
103
126
  }
104
127
  }
105
- // Advance cursor even if no logs were found in the range.
106
- if (blockNums.length === 0) {
128
+ // Advance cursor if no logs found but confirmed blocks exist
129
+ if (blockNums.length === 0 && confirmed >= this._cursor) {
107
130
  this._cursor = confirmed + 1;
108
131
  if (this.cursorStore) {
109
132
  await this.cursorStore.save(this.watcherId, this._cursor);
@@ -1,5 +1,5 @@
1
1
  export * from "./btc/index.js";
2
- export type { CryptoChargeRecord, CryptoDepositChargeInput, ICryptoChargeRepository } from "./charge-store.js";
2
+ export type { CryptoChargeProgressUpdate, CryptoChargeRecord, CryptoDepositChargeInput, ICryptoChargeRepository, } from "./charge-store.js";
3
3
  export { CryptoChargeRepository, DrizzleCryptoChargeRepository } from "./charge-store.js";
4
4
  export type { ChainInfo, ChargeStatus, CreateChargeResult, CryptoConfig, CryptoServiceConfig, DeriveAddressResult, } from "./client.js";
5
5
  export { CryptoServiceClient, loadCryptoConfig } from "./client.js";
@@ -9,10 +9,10 @@ export * from "./evm/index.js";
9
9
  export type { KeyServerDeps } from "./key-server.js";
10
10
  export { createKeyServerApp } from "./key-server.js";
11
11
  export type { KeyServerWebhookDeps as CryptoWebhookDeps, KeyServerWebhookPayload as CryptoWebhookPayload, KeyServerWebhookResult as CryptoWebhookResult, } from "./key-server-webhook.js";
12
- export { handleKeyServerWebhook, handleKeyServerWebhook as handleCryptoWebhook } from "./key-server-webhook.js";
12
+ export { handleKeyServerWebhook, handleKeyServerWebhook as handleCryptoWebhook, normalizeStatus, } from "./key-server-webhook.js";
13
13
  export * from "./oracle/index.js";
14
14
  export type { IPaymentMethodStore, PaymentMethodRecord } from "./payment-method-store.js";
15
15
  export { DrizzlePaymentMethodStore } from "./payment-method-store.js";
16
- export type { CryptoPaymentState } from "./types.js";
16
+ export type { CryptoCharge, CryptoChargeStatus, CryptoPaymentState } from "./types.js";
17
17
  export type { UnifiedCheckoutDeps, UnifiedCheckoutResult } from "./unified-checkout.js";
18
18
  export { createUnifiedCheckout, MIN_CHECKOUT_USD as MIN_PAYMENT_USD, MIN_CHECKOUT_USD } from "./unified-checkout.js";
@@ -4,7 +4,7 @@ export { CryptoServiceClient, loadCryptoConfig } from "./client.js";
4
4
  export { DrizzleWatcherCursorStore } from "./cursor-store.js";
5
5
  export * from "./evm/index.js";
6
6
  export { createKeyServerApp } from "./key-server.js";
7
- export { handleKeyServerWebhook, handleKeyServerWebhook as handleCryptoWebhook } from "./key-server-webhook.js";
7
+ export { handleKeyServerWebhook, handleKeyServerWebhook as handleCryptoWebhook, normalizeStatus, } from "./key-server-webhook.js";
8
8
  export * from "./oracle/index.js";
9
9
  export { DrizzlePaymentMethodStore } from "./payment-method-store.js";
10
10
  export { createUnifiedCheckout, MIN_CHECKOUT_USD as MIN_PAYMENT_USD, MIN_CHECKOUT_USD } from "./unified-checkout.js";
@@ -1,15 +1,19 @@
1
1
  import type { ILedger } from "../../credits/ledger.js";
2
2
  import type { IWebhookSeenRepository } from "../webhook-seen-repository.js";
3
3
  import type { ICryptoChargeRepository } from "./charge-store.js";
4
+ import type { CryptoChargeStatus } from "./types.js";
4
5
  export interface KeyServerWebhookPayload {
5
6
  chargeId: string;
6
7
  chain: string;
7
8
  address: string;
8
- amountUsdCents: number;
9
+ /** @deprecated Use amountReceivedCents instead. Kept for one release cycle. */
10
+ amountUsdCents?: number;
11
+ amountReceivedCents?: number;
9
12
  status: string;
10
13
  txHash?: string;
11
14
  amountReceived?: string;
12
15
  confirmations?: number;
16
+ confirmationsRequired?: number;
13
17
  }
14
18
  export interface KeyServerWebhookDeps {
15
19
  chargeStore: ICryptoChargeRepository;
@@ -23,11 +27,20 @@ export interface KeyServerWebhookResult {
23
27
  tenant?: string;
24
28
  creditedCents?: number;
25
29
  reactivatedBots?: string[];
30
+ status?: CryptoChargeStatus;
31
+ confirmations?: number;
32
+ confirmationsRequired?: number;
26
33
  }
27
34
  /**
28
- * Process a payment confirmation from the crypto key server.
35
+ * Map legacy/watcher status strings to canonical CryptoChargeStatus.
36
+ * Accepts both old BTCPay-style ("Settled", "Processing") and new canonical ("confirmed", "partial").
37
+ */
38
+ export declare function normalizeStatus(raw: string): CryptoChargeStatus;
39
+ /**
40
+ * Process a payment webhook from the crypto key server.
29
41
  *
30
- * Credits the ledger when status is "confirmed".
31
- * Idempotency: ledger referenceId + replay guard (same pattern as Stripe handler).
42
+ * Idempotency: deduplicate by chargeId + status + confirmations so that
43
+ * multiple progress updates (0→1→2→...→6 confirmations) each get through,
44
+ * but exact duplicates are rejected.
32
45
  */
33
46
  export declare function handleKeyServerWebhook(deps: KeyServerWebhookDeps, payload: KeyServerWebhookPayload): Promise<KeyServerWebhookResult>;