@wopr-network/platform-core 1.48.0 → 1.49.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dist/billing/crypto/__tests__/key-server.test.js +1 -1
  2. package/dist/billing/crypto/__tests__/unified-checkout.test.d.ts +1 -0
  3. package/dist/billing/crypto/__tests__/unified-checkout.test.js +63 -0
  4. package/dist/billing/crypto/__tests__/watcher-service.test.d.ts +1 -0
  5. package/dist/billing/crypto/__tests__/watcher-service.test.js +174 -0
  6. package/dist/billing/crypto/__tests__/webhook-confirmations.test.d.ts +1 -0
  7. package/dist/billing/crypto/__tests__/webhook-confirmations.test.js +304 -0
  8. package/dist/billing/crypto/btc/__tests__/settler.test.js +1 -0
  9. package/dist/billing/crypto/btc/__tests__/watcher.test.d.ts +1 -0
  10. package/dist/billing/crypto/btc/__tests__/watcher.test.js +170 -0
  11. package/dist/billing/crypto/btc/types.d.ts +3 -1
  12. package/dist/billing/crypto/btc/watcher.d.ts +6 -1
  13. package/dist/billing/crypto/btc/watcher.js +24 -8
  14. package/dist/billing/crypto/charge-store.d.ts +27 -2
  15. package/dist/billing/crypto/charge-store.js +67 -1
  16. package/dist/billing/crypto/charge-store.test.js +180 -1
  17. package/dist/billing/crypto/client.d.ts +2 -0
  18. package/dist/billing/crypto/cursor-store.d.ts +10 -3
  19. package/dist/billing/crypto/cursor-store.js +21 -1
  20. package/dist/billing/crypto/evm/__tests__/eth-checkout.test.js +3 -3
  21. package/dist/billing/crypto/evm/__tests__/eth-settler.test.js +2 -0
  22. package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +33 -6
  23. package/dist/billing/crypto/evm/__tests__/settler.test.js +2 -0
  24. package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.d.ts +1 -0
  25. package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.js +144 -0
  26. package/dist/billing/crypto/evm/__tests__/watcher.test.js +6 -2
  27. package/dist/billing/crypto/evm/eth-checkout.d.ts +2 -2
  28. package/dist/billing/crypto/evm/eth-checkout.js +3 -3
  29. package/dist/billing/crypto/evm/eth-watcher.d.ts +11 -8
  30. package/dist/billing/crypto/evm/eth-watcher.js +29 -15
  31. package/dist/billing/crypto/evm/types.d.ts +5 -1
  32. package/dist/billing/crypto/evm/watcher.d.ts +9 -1
  33. package/dist/billing/crypto/evm/watcher.js +36 -13
  34. package/dist/billing/crypto/index.d.ts +3 -3
  35. package/dist/billing/crypto/index.js +1 -1
  36. package/dist/billing/crypto/key-server-entry.js +7 -2
  37. package/dist/billing/crypto/key-server-webhook.d.ts +17 -4
  38. package/dist/billing/crypto/key-server-webhook.js +76 -15
  39. package/dist/billing/crypto/key-server.js +18 -7
  40. package/dist/billing/crypto/oracle/__tests__/chainlink.test.js +4 -4
  41. package/dist/billing/crypto/oracle/__tests__/coingecko.test.d.ts +1 -0
  42. package/dist/billing/crypto/oracle/__tests__/coingecko.test.js +65 -0
  43. package/dist/billing/crypto/oracle/__tests__/composite.test.d.ts +1 -0
  44. package/dist/billing/crypto/oracle/__tests__/composite.test.js +48 -0
  45. package/dist/billing/crypto/oracle/__tests__/convert.test.js +27 -17
  46. package/dist/billing/crypto/oracle/__tests__/fixed.test.js +5 -5
  47. package/dist/billing/crypto/oracle/chainlink.d.ts +2 -2
  48. package/dist/billing/crypto/oracle/chainlink.js +11 -10
  49. package/dist/billing/crypto/oracle/coingecko.d.ts +22 -0
  50. package/dist/billing/crypto/oracle/coingecko.js +67 -0
  51. package/dist/billing/crypto/oracle/composite.d.ts +14 -0
  52. package/dist/billing/crypto/oracle/composite.js +34 -0
  53. package/dist/billing/crypto/oracle/convert.d.ts +17 -7
  54. package/dist/billing/crypto/oracle/convert.js +26 -13
  55. package/dist/billing/crypto/oracle/fixed.d.ts +2 -2
  56. package/dist/billing/crypto/oracle/fixed.js +9 -7
  57. package/dist/billing/crypto/oracle/index.d.ts +4 -0
  58. package/dist/billing/crypto/oracle/index.js +3 -0
  59. package/dist/billing/crypto/oracle/types.d.ts +12 -3
  60. package/dist/billing/crypto/oracle/types.js +7 -1
  61. package/dist/billing/crypto/types.d.ts +16 -0
  62. package/dist/billing/crypto/unified-checkout.d.ts +10 -19
  63. package/dist/billing/crypto/unified-checkout.js +17 -131
  64. package/dist/billing/crypto/watcher-service.d.ts +22 -2
  65. package/dist/billing/crypto/watcher-service.js +71 -30
  66. package/dist/db/schema/crypto.d.ts +68 -0
  67. package/dist/db/schema/crypto.js +8 -0
  68. package/dist/monetization/crypto/__tests__/webhook.test.js +2 -1
  69. package/drizzle/migrations/0016_charge_progress_columns.sql +4 -0
  70. package/drizzle/migrations/meta/_journal.json +7 -0
  71. package/package.json +1 -1
  72. package/src/billing/crypto/__tests__/key-server.test.ts +1 -1
  73. package/src/billing/crypto/__tests__/unified-checkout.test.ts +83 -0
  74. package/src/billing/crypto/__tests__/watcher-service.test.ts +242 -0
  75. package/src/billing/crypto/__tests__/webhook-confirmations.test.ts +367 -0
  76. package/src/billing/crypto/btc/__tests__/settler.test.ts +1 -0
  77. package/src/billing/crypto/btc/__tests__/watcher.test.ts +201 -0
  78. package/src/billing/crypto/btc/types.ts +3 -1
  79. package/src/billing/crypto/btc/watcher.ts +26 -8
  80. package/src/billing/crypto/charge-store.test.ts +204 -1
  81. package/src/billing/crypto/charge-store.ts +86 -2
  82. package/src/billing/crypto/client.ts +2 -0
  83. package/src/billing/crypto/cursor-store.ts +31 -3
  84. package/src/billing/crypto/evm/__tests__/eth-checkout.test.ts +3 -3
  85. package/src/billing/crypto/evm/__tests__/eth-settler.test.ts +2 -0
  86. package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +33 -6
  87. package/src/billing/crypto/evm/__tests__/settler.test.ts +2 -0
  88. package/src/billing/crypto/evm/__tests__/watcher-confirmations.test.ts +176 -0
  89. package/src/billing/crypto/evm/__tests__/watcher.test.ts +6 -2
  90. package/src/billing/crypto/evm/eth-checkout.ts +5 -5
  91. package/src/billing/crypto/evm/eth-watcher.ts +36 -16
  92. package/src/billing/crypto/evm/types.ts +5 -1
  93. package/src/billing/crypto/evm/watcher.ts +39 -13
  94. package/src/billing/crypto/index.ts +12 -3
  95. package/src/billing/crypto/key-server-entry.ts +7 -2
  96. package/src/billing/crypto/key-server-webhook.ts +92 -21
  97. package/src/billing/crypto/key-server.ts +17 -7
  98. package/src/billing/crypto/oracle/__tests__/chainlink.test.ts +4 -4
  99. package/src/billing/crypto/oracle/__tests__/coingecko.test.ts +75 -0
  100. package/src/billing/crypto/oracle/__tests__/composite.test.ts +61 -0
  101. package/src/billing/crypto/oracle/__tests__/convert.test.ts +29 -17
  102. package/src/billing/crypto/oracle/__tests__/fixed.test.ts +5 -5
  103. package/src/billing/crypto/oracle/chainlink.ts +11 -10
  104. package/src/billing/crypto/oracle/coingecko.ts +92 -0
  105. package/src/billing/crypto/oracle/composite.ts +35 -0
  106. package/src/billing/crypto/oracle/convert.ts +28 -13
  107. package/src/billing/crypto/oracle/fixed.ts +9 -7
  108. package/src/billing/crypto/oracle/index.ts +4 -0
  109. package/src/billing/crypto/oracle/types.ts +16 -3
  110. package/src/billing/crypto/types.ts +18 -0
  111. package/src/billing/crypto/unified-checkout.ts +22 -181
  112. package/src/billing/crypto/watcher-service.ts +85 -32
  113. package/src/db/schema/crypto.ts +8 -0
  114. package/src/monetization/crypto/__tests__/webhook.test.ts +2 -1
@@ -0,0 +1,176 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { EvmWatcher } from "../watcher.js";
3
+
4
+ const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
5
+
6
+ function mockTransferLog(to: string, amount: bigint, blockNumber: number) {
7
+ return {
8
+ address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
9
+ topics: [
10
+ TRANSFER_TOPIC,
11
+ `0x${"00".repeat(12)}${"ab".repeat(20)}`,
12
+ `0x${"00".repeat(12)}${to.slice(2).toLowerCase()}`,
13
+ ],
14
+ data: `0x${amount.toString(16).padStart(64, "0")}`,
15
+ blockNumber: `0x${blockNumber.toString(16)}`,
16
+ transactionHash: `0x${"ff".repeat(32)}`,
17
+ logIndex: "0x0",
18
+ };
19
+ }
20
+
21
+ function makeCursorStore() {
22
+ const cursors = new Map<string, number>();
23
+ return {
24
+ get: vi.fn().mockImplementation(async (id: string) => cursors.get(id) ?? null),
25
+ save: vi.fn().mockImplementation(async (id: string, val: number) => {
26
+ cursors.set(id, val);
27
+ }),
28
+ hasProcessedTx: vi.fn().mockResolvedValue(false),
29
+ markProcessedTx: vi.fn().mockResolvedValue(undefined),
30
+ getConfirmationCount: vi.fn().mockResolvedValue(null),
31
+ saveConfirmationCount: vi.fn().mockResolvedValue(undefined),
32
+ };
33
+ }
34
+
35
+ describe("EvmWatcher — intermediate confirmations", () => {
36
+ it("emits events with confirmation count", async () => {
37
+ const toAddr = `0x${"cc".repeat(20)}`;
38
+ const events: Array<{ confirmations: number; confirmationsRequired: number }> = [];
39
+
40
+ // Base has confirmations: 1. Latest block is 105. Log at block 103 -> 2 confirmations.
41
+ const mockRpc = vi
42
+ .fn()
43
+ .mockResolvedValueOnce(`0x${(105).toString(16)}`) // eth_blockNumber
44
+ .mockResolvedValueOnce([mockTransferLog(toAddr, 10_000_000n, 103)]); // eth_getLogs
45
+
46
+ const watcher = new EvmWatcher({
47
+ chain: "base",
48
+ token: "USDC",
49
+ rpcCall: mockRpc,
50
+ fromBlock: 100,
51
+ watchedAddresses: [toAddr],
52
+ cursorStore: makeCursorStore(),
53
+ onPayment: (evt) => {
54
+ events.push(evt);
55
+ },
56
+ });
57
+
58
+ await watcher.poll();
59
+
60
+ expect(events).toHaveLength(1);
61
+ expect(events[0].confirmations).toBe(2); // 105 - 103
62
+ expect(events[0].confirmationsRequired).toBe(1); // Base chain config
63
+ });
64
+
65
+ it("skips event when confirmation count unchanged", async () => {
66
+ const toAddr = `0x${"cc".repeat(20)}`;
67
+ const events: Array<{ confirmations: number }> = [];
68
+ const cursorStore = makeCursorStore();
69
+ cursorStore.getConfirmationCount.mockResolvedValue(2);
70
+
71
+ const mockRpc = vi
72
+ .fn()
73
+ .mockResolvedValueOnce(`0x${(105).toString(16)}`)
74
+ .mockResolvedValueOnce([mockTransferLog(toAddr, 10_000_000n, 103)]);
75
+
76
+ const watcher = new EvmWatcher({
77
+ chain: "base",
78
+ token: "USDC",
79
+ rpcCall: mockRpc,
80
+ fromBlock: 100,
81
+ watchedAddresses: [toAddr],
82
+ cursorStore,
83
+ onPayment: (evt) => {
84
+ events.push(evt);
85
+ },
86
+ });
87
+
88
+ await watcher.poll();
89
+
90
+ expect(events).toHaveLength(0);
91
+ });
92
+
93
+ it("re-emits when confirmations increase", async () => {
94
+ const toAddr = `0x${"cc".repeat(20)}`;
95
+ const events: Array<{ confirmations: number }> = [];
96
+ const cursorStore = makeCursorStore();
97
+ cursorStore.getConfirmationCount.mockResolvedValue(1);
98
+
99
+ const mockRpc = vi
100
+ .fn()
101
+ .mockResolvedValueOnce(`0x${(105).toString(16)}`)
102
+ .mockResolvedValueOnce([mockTransferLog(toAddr, 10_000_000n, 103)]);
103
+
104
+ const watcher = new EvmWatcher({
105
+ chain: "base",
106
+ token: "USDC",
107
+ rpcCall: mockRpc,
108
+ fromBlock: 100,
109
+ watchedAddresses: [toAddr],
110
+ cursorStore,
111
+ onPayment: (evt) => {
112
+ events.push(evt);
113
+ },
114
+ });
115
+
116
+ await watcher.poll();
117
+
118
+ expect(events).toHaveLength(1);
119
+ expect(events[0].confirmations).toBe(2);
120
+ });
121
+
122
+ it("includes confirmationsRequired from chain config", async () => {
123
+ const toAddr = `0x${"cc".repeat(20)}`;
124
+ const events: Array<{ confirmationsRequired: number }> = [];
125
+
126
+ const mockRpc = vi
127
+ .fn()
128
+ .mockResolvedValueOnce(`0x${(110).toString(16)}`)
129
+ .mockResolvedValueOnce([mockTransferLog(toAddr, 10_000_000n, 105)]);
130
+
131
+ const watcher = new EvmWatcher({
132
+ chain: "base",
133
+ token: "USDC",
134
+ rpcCall: mockRpc,
135
+ fromBlock: 100,
136
+ watchedAddresses: [toAddr],
137
+ cursorStore: makeCursorStore(),
138
+ onPayment: (evt) => {
139
+ events.push(evt);
140
+ },
141
+ });
142
+
143
+ await watcher.poll();
144
+
145
+ expect(events).toHaveLength(1);
146
+ expect(events[0].confirmationsRequired).toBe(1); // Base chain config
147
+ });
148
+
149
+ it("saves confirmation count after emitting", async () => {
150
+ const toAddr = `0x${"cc".repeat(20)}`;
151
+ const cursorStore = makeCursorStore();
152
+
153
+ const mockRpc = vi
154
+ .fn()
155
+ .mockResolvedValueOnce(`0x${(105).toString(16)}`)
156
+ .mockResolvedValueOnce([mockTransferLog(toAddr, 10_000_000n, 103)]);
157
+
158
+ const watcher = new EvmWatcher({
159
+ chain: "base",
160
+ token: "USDC",
161
+ rpcCall: mockRpc,
162
+ fromBlock: 100,
163
+ watchedAddresses: [toAddr],
164
+ cursorStore,
165
+ onPayment: () => {},
166
+ });
167
+
168
+ await watcher.poll();
169
+
170
+ expect(cursorStore.saveConfirmationCount).toHaveBeenCalledWith(
171
+ expect.any(String),
172
+ expect.stringContaining("0x"),
173
+ 2,
174
+ );
175
+ });
176
+ });
@@ -66,7 +66,12 @@ describe("EvmWatcher", () => {
66
66
 
67
67
  it("skips blocks not yet confirmed", async () => {
68
68
  const events: unknown[] = [];
69
- const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(50).toString(16)}`);
69
+ // latest = 50, cursor = 50 → latest < cursor is false, but range is empty (50..50)
70
+ // With intermediate confirmations, we still scan the range but find no logs
71
+ const mockRpc = vi
72
+ .fn()
73
+ .mockResolvedValueOnce(`0x${(50).toString(16)}`) // eth_blockNumber
74
+ .mockResolvedValueOnce([]); // eth_getLogs (empty)
70
75
 
71
76
  const watcher = new EvmWatcher({
72
77
  chain: "base",
@@ -81,7 +86,6 @@ describe("EvmWatcher", () => {
81
86
 
82
87
  await watcher.poll();
83
88
  expect(events).toHaveLength(0);
84
- expect(mockRpc).toHaveBeenCalledTimes(1);
85
89
  });
86
90
 
87
91
  it("processes multiple logs in one poll", async () => {
@@ -24,8 +24,8 @@ export interface EthCheckoutResult {
24
24
  amountUsd: number;
25
25
  /** Expected ETH amount in wei (BigInt as string). */
26
26
  expectedWei: string;
27
- /** ETH price in USD cents at checkout time. */
28
- priceCents: number;
27
+ /** ETH price in microdollars at checkout time (10^-6 USD). */
28
+ priceMicros: number;
29
29
  chain: EvmChain;
30
30
  referenceId: string;
31
31
  }
@@ -45,8 +45,8 @@ export async function createEthCheckout(deps: EthCheckoutDeps, opts: EthCheckout
45
45
  }
46
46
 
47
47
  const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
48
- const { priceCents } = await deps.oracle.getPrice("ETH");
49
- const expectedWei = centsToNative(amountUsdCents, priceCents, 18);
48
+ const { priceMicros } = await deps.oracle.getPrice("ETH");
49
+ const expectedWei = centsToNative(amountUsdCents, priceMicros, 18);
50
50
  const maxRetries = 3;
51
51
 
52
52
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
@@ -69,7 +69,7 @@ export async function createEthCheckout(deps: EthCheckoutDeps, opts: EthCheckout
69
69
  depositAddress,
70
70
  amountUsd: opts.amountUsd,
71
71
  expectedWei: expectedWei.toString(),
72
- priceCents,
72
+ priceMicros,
73
73
  chain: opts.chain,
74
74
  referenceId,
75
75
  };
@@ -6,7 +6,7 @@ import type { EvmChain } from "./types.js";
6
6
 
7
7
  type RpcCall = (method: string, params: unknown[]) => Promise<unknown>;
8
8
 
9
- /** Event emitted when a native ETH deposit is detected and confirmed. */
9
+ /** Event emitted on each confirmation increment for a native ETH deposit. */
10
10
  export interface EthPaymentEvent {
11
11
  readonly chain: EvmChain;
12
12
  readonly from: string;
@@ -17,6 +17,10 @@ export interface EthPaymentEvent {
17
17
  readonly amountUsdCents: number;
18
18
  readonly txHash: string;
19
19
  readonly blockNumber: number;
20
+ /** Current confirmation count (latest block - tx block). */
21
+ readonly confirmations: number;
22
+ /** Required confirmations for this chain. */
23
+ readonly confirmationsRequired: number;
20
24
  }
21
25
 
22
26
  export interface EthWatcherOpts {
@@ -44,9 +48,9 @@ interface RpcTransaction {
44
48
  * this scans blocks for transactions where `to` matches a watched deposit
45
49
  * address and `value > 0`.
46
50
  *
47
- * Processes one block at a time and persists cursor after each block.
48
- * On restart, resumes from the last committed cursor — no replay, no
49
- * unbounded in-memory state.
51
+ * Scans up to latest block (not just confirmed) to detect pending txs.
52
+ * Emits events on each confirmation increment. Only advances cursor
53
+ * past fully-confirmed blocks.
50
54
  */
51
55
  export class EthWatcher {
52
56
  private _cursor: number;
@@ -87,11 +91,10 @@ export class EthWatcher {
87
91
  }
88
92
 
89
93
  /**
90
- * Poll for new native ETH transfers to watched addresses.
94
+ * Poll for native ETH transfers to watched addresses, including unconfirmed blocks.
91
95
  *
92
- * Processes one block at a time. After each block is fully processed,
93
- * the cursor is persisted to the DB. If onPayment fails mid-block,
94
- * the cursor hasn't advanced — the entire block is retried on next poll.
96
+ * Scans from cursor to latest block. Emits events with current confirmation count.
97
+ * Re-emits on each confirmation increment. Only advances cursor past fully-confirmed blocks.
95
98
  */
96
99
  async poll(): Promise<void> {
97
100
  if (this._watchedAddresses.size === 0) return;
@@ -100,17 +103,20 @@ export class EthWatcher {
100
103
  const latest = Number.parseInt(latestHex, 16);
101
104
  const confirmed = latest - this.confirmations;
102
105
 
103
- if (confirmed < this._cursor) return;
106
+ if (latest < this._cursor) return;
104
107
 
105
- const { priceCents } = await this.oracle.getPrice("ETH");
108
+ const { priceMicros } = await this.oracle.getPrice("ETH");
106
109
 
107
- for (let blockNum = this._cursor; blockNum <= confirmed; blockNum++) {
110
+ // Scan up to latest (not just confirmed) to detect pending txs
111
+ for (let blockNum = this._cursor; blockNum <= latest; blockNum++) {
108
112
  const block = (await this.rpc("eth_getBlockByNumber", [`0x${blockNum.toString(16)}`, true])) as {
109
113
  transactions: RpcTransaction[];
110
114
  } | null;
111
115
 
112
116
  if (!block) continue;
113
117
 
118
+ const confs = latest - blockNum;
119
+
114
120
  for (const tx of block.transactions) {
115
121
  if (!tx.to) continue;
116
122
  const to = tx.to.toLowerCase();
@@ -119,7 +125,13 @@ export class EthWatcher {
119
125
  const valueWei = BigInt(tx.value);
120
126
  if (valueWei === 0n) continue;
121
127
 
122
- const amountUsdCents = nativeToCents(valueWei, priceCents, 18);
128
+ // Skip if we already emitted at this confirmation count
129
+ if (this.cursorStore) {
130
+ const lastConf = await this.cursorStore.getConfirmationCount(this.watcherId, tx.hash);
131
+ if (lastConf !== null && confs <= lastConf) continue;
132
+ }
133
+
134
+ const amountUsdCents = nativeToCents(valueWei, priceMicros, 18);
123
135
 
124
136
  const event: EthPaymentEvent = {
125
137
  chain: this.chain,
@@ -129,15 +141,23 @@ export class EthWatcher {
129
141
  amountUsdCents,
130
142
  txHash: tx.hash,
131
143
  blockNumber: blockNum,
144
+ confirmations: confs,
145
+ confirmationsRequired: this.confirmations,
132
146
  };
133
147
 
134
148
  await this.onPayment(event);
149
+
150
+ if (this.cursorStore) {
151
+ await this.cursorStore.saveConfirmationCount(this.watcherId, tx.hash, confs);
152
+ }
135
153
  }
136
154
 
137
- // Block fully processed — persist cursor so we never re-scan it.
138
- this._cursor = blockNum + 1;
139
- if (this.cursorStore) {
140
- await this.cursorStore.save(this.watcherId, this._cursor);
155
+ // Only advance cursor past fully-confirmed blocks
156
+ if (blockNum <= confirmed) {
157
+ this._cursor = blockNum + 1;
158
+ if (this.cursorStore) {
159
+ await this.cursorStore.save(this.watcherId, this._cursor);
160
+ }
141
161
  }
142
162
  }
143
163
  }
@@ -21,7 +21,7 @@ export interface TokenConfig {
21
21
  readonly decimals: number;
22
22
  }
23
23
 
24
- /** Event emitted when a Transfer is detected and confirmed. */
24
+ /** Event emitted on each confirmation increment for a Transfer. */
25
25
  export interface EvmPaymentEvent {
26
26
  readonly chain: EvmChain;
27
27
  readonly token: StablecoinToken;
@@ -34,6 +34,10 @@ export interface EvmPaymentEvent {
34
34
  readonly txHash: string;
35
35
  readonly blockNumber: number;
36
36
  readonly logIndex: number;
37
+ /** Current confirmation count (latest block - tx block). */
38
+ readonly confirmations: number;
39
+ /** Required confirmations for this chain. */
40
+ readonly confirmationsRequired: number;
37
41
  }
38
42
 
39
43
  /** Options for creating a stablecoin checkout. */
@@ -72,7 +72,15 @@ export class EvmWatcher {
72
72
  return this._cursor;
73
73
  }
74
74
 
75
- /** Poll for new Transfer events. Call on an interval. */
75
+ /**
76
+ * Poll for Transfer events, including pending (unconfirmed) blocks.
77
+ *
78
+ * Two-phase scan:
79
+ * 1. Scan cursor..latest for new/updated txs, emit with current confirmation count
80
+ * 2. Re-check pending txs automatically since cursor doesn't advance past unconfirmed blocks
81
+ *
82
+ * Cursor only advances past fully-confirmed blocks.
83
+ */
76
84
  async poll(): Promise<void> {
77
85
  if (this._watchedAddresses.length === 0) return; // nothing to watch
78
86
 
@@ -80,27 +88,25 @@ export class EvmWatcher {
80
88
  const latest = Number.parseInt(latestHex, 16);
81
89
  const confirmed = latest - this.confirmations;
82
90
 
83
- if (confirmed < this._cursor) return;
91
+ if (latest < this._cursor) return;
84
92
 
85
93
  // Filter by topic[2] (to address) when watched addresses are set.
86
- // This avoids fetching ALL USDC transfers on the chain (millions/day on Base).
87
- // topic[2] values are 32-byte zero-padded: 0x000000000000000000000000<address>
88
94
  const toFilter =
89
95
  this._watchedAddresses.length > 0
90
96
  ? this._watchedAddresses.map((a) => `0x000000000000000000000000${a.slice(2)}`)
91
97
  : null;
92
98
 
99
+ // Scan from cursor to latest (not just confirmed) to detect pending txs
93
100
  const logs = (await this.rpc("eth_getLogs", [
94
101
  {
95
102
  address: this.contractAddress,
96
103
  topics: [TRANSFER_TOPIC, null, toFilter],
97
104
  fromBlock: `0x${this._cursor.toString(16)}`,
98
- toBlock: `0x${confirmed.toString(16)}`,
105
+ toBlock: `0x${latest.toString(16)}`,
99
106
  },
100
107
  ])) as RpcLog[];
101
108
 
102
- // Group logs by block for incremental cursor checkpointing.
103
- // If onPayment fails mid-batch, only the current block is replayed on next poll.
109
+ // Group logs by block
104
110
  const logsByBlock = new Map<number, RpcLog[]>();
105
111
  for (const log of logs) {
106
112
  const bn = Number.parseInt(log.blockNumber, 16);
@@ -109,10 +115,20 @@ export class EvmWatcher {
109
115
  else logsByBlock.set(bn, [log]);
110
116
  }
111
117
 
112
- // Process blocks in order, checkpoint after each.
118
+ // Process all blocks (including unconfirmed), emit with confirmation count
113
119
  const blockNums = [...logsByBlock.keys()].sort((a, b) => a - b);
114
120
  for (const blockNum of blockNums) {
121
+ const confs = latest - blockNum;
122
+
115
123
  for (const log of logsByBlock.get(blockNum) ?? []) {
124
+ const txKey = `${log.transactionHash}:${log.logIndex}`;
125
+
126
+ // Skip if we already emitted at this confirmation count
127
+ if (this.cursorStore) {
128
+ const lastConf = await this.cursorStore.getConfirmationCount(this.watcherId, txKey);
129
+ if (lastConf !== null && confs <= lastConf) continue;
130
+ }
131
+
116
132
  const to = `0x${log.topics[2].slice(26)}`.toLowerCase();
117
133
  const from = `0x${log.topics[1].slice(26)}`.toLowerCase();
118
134
  const rawAmount = BigInt(log.data);
@@ -128,19 +144,29 @@ export class EvmWatcher {
128
144
  txHash: log.transactionHash,
129
145
  blockNumber: blockNum,
130
146
  logIndex: Number.parseInt(log.logIndex, 16),
147
+ confirmations: confs,
148
+ confirmationsRequired: this.confirmations,
131
149
  };
132
150
 
133
151
  await this.onPayment(event);
152
+
153
+ // Track confirmation count
154
+ if (this.cursorStore) {
155
+ await this.cursorStore.saveConfirmationCount(this.watcherId, txKey, confs);
156
+ }
134
157
  }
135
158
 
136
- this._cursor = blockNum + 1;
137
- if (this.cursorStore) {
138
- await this.cursorStore.save(this.watcherId, this._cursor);
159
+ // Only advance cursor past fully-confirmed blocks
160
+ if (blockNum <= confirmed) {
161
+ this._cursor = blockNum + 1;
162
+ if (this.cursorStore) {
163
+ await this.cursorStore.save(this.watcherId, this._cursor);
164
+ }
139
165
  }
140
166
  }
141
167
 
142
- // Advance cursor even if no logs were found in the range.
143
- if (blockNums.length === 0) {
168
+ // Advance cursor if no logs found but confirmed blocks exist
169
+ if (blockNums.length === 0 && confirmed >= this._cursor) {
144
170
  this._cursor = confirmed + 1;
145
171
  if (this.cursorStore) {
146
172
  await this.cursorStore.save(this.watcherId, this._cursor);
@@ -1,5 +1,10 @@
1
1
  export * from "./btc/index.js";
2
- export type { CryptoChargeRecord, CryptoDepositChargeInput, ICryptoChargeRepository } from "./charge-store.js";
2
+ export type {
3
+ CryptoChargeProgressUpdate,
4
+ CryptoChargeRecord,
5
+ CryptoDepositChargeInput,
6
+ ICryptoChargeRepository,
7
+ } from "./charge-store.js";
3
8
  export { CryptoChargeRepository, DrizzleCryptoChargeRepository } from "./charge-store.js";
4
9
  export type {
5
10
  ChainInfo,
@@ -20,10 +25,14 @@ export type {
20
25
  KeyServerWebhookPayload as CryptoWebhookPayload,
21
26
  KeyServerWebhookResult as CryptoWebhookResult,
22
27
  } from "./key-server-webhook.js";
23
- export { handleKeyServerWebhook, handleKeyServerWebhook as handleCryptoWebhook } from "./key-server-webhook.js";
28
+ export {
29
+ handleKeyServerWebhook,
30
+ handleKeyServerWebhook as handleCryptoWebhook,
31
+ normalizeStatus,
32
+ } from "./key-server-webhook.js";
24
33
  export * from "./oracle/index.js";
25
34
  export type { IPaymentMethodStore, PaymentMethodRecord } from "./payment-method-store.js";
26
35
  export { DrizzlePaymentMethodStore } from "./payment-method-store.js";
27
- export type { CryptoPaymentState } from "./types.js";
36
+ export type { CryptoCharge, CryptoChargeStatus, CryptoPaymentState } from "./types.js";
28
37
  export type { UnifiedCheckoutDeps, UnifiedCheckoutResult } from "./unified-checkout.js";
29
38
  export { createUnifiedCheckout, MIN_CHECKOUT_USD as MIN_PAYMENT_USD, MIN_CHECKOUT_USD } from "./unified-checkout.js";
@@ -17,6 +17,8 @@ import { DrizzleWatcherCursorStore } from "./cursor-store.js";
17
17
  import { createRpcCaller } from "./evm/watcher.js";
18
18
  import { createKeyServerApp } from "./key-server.js";
19
19
  import { ChainlinkOracle } from "./oracle/chainlink.js";
20
+ import { CoinGeckoOracle } from "./oracle/coingecko.js";
21
+ import { CompositeOracle } from "./oracle/composite.js";
20
22
  import { FixedPriceOracle } from "./oracle/fixed.js";
21
23
  import { DrizzlePaymentMethodStore } from "./payment-method-store.js";
22
24
  import { startWatchers } from "./watcher-service.js";
@@ -48,10 +50,13 @@ async function main(): Promise<void> {
48
50
  const chargeStore = new DrizzleCryptoChargeRepository(db);
49
51
  const methodStore = new DrizzlePaymentMethodStore(db);
50
52
 
51
- // Chainlink on-chain oracle for volatile assets (BTC, ETH).
52
- const oracle = BASE_RPC_URL
53
+ // Composite oracle: Chainlink on-chain (BTC, ETH on Base) + CoinGecko fallback (DOGE, LTC, etc.)
54
+ // Every volatile asset needs reliable USD pricing — the ledger credits nanodollars.
55
+ const chainlink = BASE_RPC_URL
53
56
  ? new ChainlinkOracle({ rpcCall: createRpcCaller(BASE_RPC_URL) })
54
57
  : new FixedPriceOracle();
58
+ const coingecko = new CoinGeckoOracle();
59
+ const oracle = new CompositeOracle(chainlink, coingecko);
55
60
 
56
61
  const app = createKeyServerApp({
57
62
  db,