@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,367 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { KeyServerWebhookDeps, KeyServerWebhookPayload } from "../key-server-webhook.js";
3
+ import { handleKeyServerWebhook, normalizeStatus } from "../key-server-webhook.js";
4
+
5
+ function mockChargeStore(overrides: Record<string, unknown> = {}) {
6
+ return {
7
+ getByReferenceId: vi.fn().mockResolvedValue({
8
+ referenceId: "btc:bc1qtest",
9
+ tenantId: "t1",
10
+ amountUsdCents: 5000,
11
+ creditedAt: null,
12
+ chain: "bitcoin",
13
+ token: "BTC",
14
+ ...overrides,
15
+ }),
16
+ updateProgress: vi.fn().mockResolvedValue(undefined),
17
+ updateStatus: vi.fn().mockResolvedValue(undefined),
18
+ markCredited: vi.fn().mockResolvedValue(undefined),
19
+ get: vi.fn().mockResolvedValue(null),
20
+ create: vi.fn(),
21
+ isCredited: vi.fn(),
22
+ createStablecoinCharge: vi.fn(),
23
+ getByDepositAddress: vi.fn(),
24
+ getNextDerivationIndex: vi.fn(),
25
+ listActiveDepositAddresses: vi.fn(),
26
+ };
27
+ }
28
+
29
+ function mockLedger() {
30
+ return {
31
+ credit: vi.fn().mockResolvedValue({ id: "j1" }),
32
+ debit: vi.fn(),
33
+ balance: vi.fn(),
34
+ hasReferenceId: vi.fn().mockResolvedValue(false),
35
+ post: vi.fn(),
36
+ expiredCredits: vi.fn(),
37
+ };
38
+ }
39
+
40
+ function mockReplayGuard() {
41
+ return {
42
+ isDuplicate: vi.fn().mockResolvedValue(false),
43
+ markSeen: vi.fn().mockResolvedValue({ eventId: "", source: "", seenAt: 0 }),
44
+ purgeExpired: vi.fn(),
45
+ };
46
+ }
47
+
48
+ function makeDeps(overrides: Partial<KeyServerWebhookDeps> = {}): KeyServerWebhookDeps {
49
+ return {
50
+ chargeStore: mockChargeStore() as never,
51
+ creditLedger: mockLedger() as never,
52
+ replayGuard: mockReplayGuard() as never,
53
+ ...overrides,
54
+ };
55
+ }
56
+
57
+ describe("normalizeStatus", () => {
58
+ it("maps canonical statuses through unchanged", () => {
59
+ expect(normalizeStatus("confirmed")).toBe("confirmed");
60
+ expect(normalizeStatus("partial")).toBe("partial");
61
+ expect(normalizeStatus("expired")).toBe("expired");
62
+ expect(normalizeStatus("failed")).toBe("failed");
63
+ expect(normalizeStatus("pending")).toBe("pending");
64
+ });
65
+
66
+ it("maps legacy BTCPay statuses to canonical", () => {
67
+ expect(normalizeStatus("Settled")).toBe("confirmed");
68
+ expect(normalizeStatus("Processing")).toBe("partial");
69
+ expect(normalizeStatus("Expired")).toBe("expired");
70
+ expect(normalizeStatus("Invalid")).toBe("failed");
71
+ expect(normalizeStatus("New")).toBe("pending");
72
+ });
73
+
74
+ it("maps BTCPay event type strings to canonical", () => {
75
+ expect(normalizeStatus("InvoiceSettled")).toBe("confirmed");
76
+ expect(normalizeStatus("InvoiceProcessing")).toBe("partial");
77
+ expect(normalizeStatus("InvoiceReceivedPayment")).toBe("partial");
78
+ expect(normalizeStatus("InvoiceExpired")).toBe("expired");
79
+ expect(normalizeStatus("InvoiceInvalid")).toBe("failed");
80
+ expect(normalizeStatus("InvoiceCreated")).toBe("pending");
81
+ });
82
+
83
+ it("defaults unknown statuses to pending", () => {
84
+ expect(normalizeStatus("SomethingWeird")).toBe("pending");
85
+ expect(normalizeStatus("")).toBe("pending");
86
+ });
87
+ });
88
+
89
+ describe("handleKeyServerWebhook — confirmation tracking", () => {
90
+ it("calls updateProgress on partial payment (not just terminal)", async () => {
91
+ const chargeStore = mockChargeStore();
92
+ const deps = makeDeps({ chargeStore: chargeStore as never });
93
+
94
+ const payload: KeyServerWebhookPayload = {
95
+ chargeId: "btc:bc1qtest",
96
+ chain: "bitcoin",
97
+ address: "bc1qtest",
98
+ status: "partial",
99
+ amountReceivedCents: 2500,
100
+ confirmations: 2,
101
+ confirmationsRequired: 6,
102
+ txHash: "0xabc",
103
+ };
104
+
105
+ const result = await handleKeyServerWebhook(deps, payload);
106
+
107
+ expect(result.handled).toBe(true);
108
+ expect(result.status).toBe("partial");
109
+ expect(result.confirmations).toBe(2);
110
+ expect(result.confirmationsRequired).toBe(6);
111
+ expect(chargeStore.updateProgress).toHaveBeenCalledWith("btc:bc1qtest", {
112
+ status: "partial",
113
+ amountReceivedCents: 2500,
114
+ confirmations: 2,
115
+ confirmationsRequired: 6,
116
+ txHash: "0xabc",
117
+ });
118
+ });
119
+
120
+ it("calls updateProgress AND credits ledger on confirmed", async () => {
121
+ const chargeStore = mockChargeStore();
122
+ const ledger = mockLedger();
123
+ const deps = makeDeps({ chargeStore: chargeStore as never, creditLedger: ledger as never });
124
+
125
+ const payload: KeyServerWebhookPayload = {
126
+ chargeId: "btc:bc1qtest",
127
+ chain: "bitcoin",
128
+ address: "bc1qtest",
129
+ status: "confirmed",
130
+ amountReceivedCents: 5000,
131
+ confirmations: 6,
132
+ confirmationsRequired: 6,
133
+ txHash: "0xfinal",
134
+ };
135
+
136
+ const result = await handleKeyServerWebhook(deps, payload);
137
+
138
+ expect(result.handled).toBe(true);
139
+ expect(result.status).toBe("confirmed");
140
+ expect(result.creditedCents).toBe(5000);
141
+ expect(chargeStore.updateProgress).toHaveBeenCalledWith("btc:bc1qtest", {
142
+ status: "confirmed",
143
+ amountReceivedCents: 5000,
144
+ confirmations: 6,
145
+ confirmationsRequired: 6,
146
+ txHash: "0xfinal",
147
+ });
148
+ expect(ledger.credit).toHaveBeenCalledOnce();
149
+ expect(chargeStore.markCredited).toHaveBeenCalledWith("btc:bc1qtest");
150
+ });
151
+
152
+ it("does NOT credit ledger on partial status", async () => {
153
+ const ledger = mockLedger();
154
+ const deps = makeDeps({ creditLedger: ledger as never });
155
+
156
+ await handleKeyServerWebhook(deps, {
157
+ chargeId: "btc:bc1qtest",
158
+ chain: "bitcoin",
159
+ address: "bc1qtest",
160
+ status: "Processing",
161
+ amountReceivedCents: 2500,
162
+ confirmations: 1,
163
+ confirmationsRequired: 6,
164
+ });
165
+
166
+ expect(ledger.credit).not.toHaveBeenCalled();
167
+ });
168
+
169
+ it("does NOT credit ledger on expired status", async () => {
170
+ const ledger = mockLedger();
171
+ const deps = makeDeps({ creditLedger: ledger as never });
172
+
173
+ await handleKeyServerWebhook(deps, {
174
+ chargeId: "btc:bc1qtest",
175
+ chain: "bitcoin",
176
+ address: "bc1qtest",
177
+ status: "expired",
178
+ amountReceivedCents: 0,
179
+ confirmations: 0,
180
+ confirmationsRequired: 6,
181
+ });
182
+
183
+ expect(ledger.credit).not.toHaveBeenCalled();
184
+ });
185
+
186
+ it("normalizes legacy 'Settled' status to 'confirmed' and credits", async () => {
187
+ const chargeStore = mockChargeStore();
188
+ const ledger = mockLedger();
189
+ const deps = makeDeps({ chargeStore: chargeStore as never, creditLedger: ledger as never });
190
+
191
+ const result = await handleKeyServerWebhook(deps, {
192
+ chargeId: "btc:bc1qtest",
193
+ chain: "bitcoin",
194
+ address: "bc1qtest",
195
+ status: "Settled",
196
+ amountReceivedCents: 5000,
197
+ confirmations: 6,
198
+ confirmationsRequired: 6,
199
+ txHash: "0xlegacy",
200
+ });
201
+
202
+ expect(result.status).toBe("confirmed");
203
+ expect(ledger.credit).toHaveBeenCalledOnce();
204
+ expect(chargeStore.updateProgress).toHaveBeenCalledWith(
205
+ "btc:bc1qtest",
206
+ expect.objectContaining({ status: "confirmed" }),
207
+ );
208
+ });
209
+
210
+ it("deduplicates exact same chargeId + status + confirmations", async () => {
211
+ const replayGuard = mockReplayGuard();
212
+ replayGuard.isDuplicate.mockResolvedValue(true);
213
+ const deps = makeDeps({ replayGuard: replayGuard as never });
214
+
215
+ const result = await handleKeyServerWebhook(deps, {
216
+ chargeId: "btc:bc1qtest",
217
+ chain: "bitcoin",
218
+ address: "bc1qtest",
219
+ status: "partial",
220
+ confirmations: 2,
221
+ confirmationsRequired: 6,
222
+ });
223
+
224
+ expect(result.duplicate).toBe(true);
225
+ });
226
+
227
+ it("allows same charge with different confirmation counts through", async () => {
228
+ const replayGuard = mockReplayGuard();
229
+ const seenKeys = new Set<string>();
230
+ replayGuard.isDuplicate.mockImplementation(async (key: string) => seenKeys.has(key));
231
+ replayGuard.markSeen.mockImplementation(async (key: string) => {
232
+ seenKeys.add(key);
233
+ return { eventId: key, source: "crypto", seenAt: 0 };
234
+ });
235
+ const deps = makeDeps({ replayGuard: replayGuard as never });
236
+
237
+ const base = {
238
+ chargeId: "btc:bc1qtest",
239
+ chain: "bitcoin",
240
+ address: "bc1qtest",
241
+ status: "partial",
242
+ amountReceivedCents: 5000,
243
+ confirmationsRequired: 6,
244
+ };
245
+
246
+ const r1 = await handleKeyServerWebhook(deps, { ...base, confirmations: 1 });
247
+ const r2 = await handleKeyServerWebhook(deps, { ...base, confirmations: 2 });
248
+ const r3 = await handleKeyServerWebhook(deps, { ...base, confirmations: 1 }); // duplicate
249
+
250
+ expect(r1.handled).toBe(true);
251
+ expect(r1.duplicate).toBeUndefined();
252
+ expect(r2.handled).toBe(true);
253
+ expect(r2.duplicate).toBeUndefined();
254
+ expect(r3.duplicate).toBe(true);
255
+ });
256
+
257
+ it("supports deprecated amountUsdCents field as fallback", async () => {
258
+ const chargeStore = mockChargeStore();
259
+ const deps = makeDeps({ chargeStore: chargeStore as never });
260
+
261
+ await handleKeyServerWebhook(deps, {
262
+ chargeId: "btc:bc1qtest",
263
+ chain: "bitcoin",
264
+ address: "bc1qtest",
265
+ status: "partial",
266
+ amountUsdCents: 3000,
267
+ confirmations: 1,
268
+ confirmationsRequired: 6,
269
+ });
270
+
271
+ expect(chargeStore.updateProgress).toHaveBeenCalledWith(
272
+ "btc:bc1qtest",
273
+ expect.objectContaining({ amountReceivedCents: 3000 }),
274
+ );
275
+ });
276
+
277
+ it("prefers amountReceivedCents over deprecated amountUsdCents", async () => {
278
+ const chargeStore = mockChargeStore();
279
+ const deps = makeDeps({ chargeStore: chargeStore as never });
280
+
281
+ await handleKeyServerWebhook(deps, {
282
+ chargeId: "btc:bc1qtest",
283
+ chain: "bitcoin",
284
+ address: "bc1qtest",
285
+ status: "partial",
286
+ amountReceivedCents: 4000,
287
+ amountUsdCents: 3000,
288
+ confirmations: 1,
289
+ confirmationsRequired: 6,
290
+ });
291
+
292
+ expect(chargeStore.updateProgress).toHaveBeenCalledWith(
293
+ "btc:bc1qtest",
294
+ expect.objectContaining({ amountReceivedCents: 4000 }),
295
+ );
296
+ });
297
+
298
+ it("returns handled: false for unknown charges", async () => {
299
+ const chargeStore = mockChargeStore();
300
+ chargeStore.getByReferenceId.mockResolvedValue(null);
301
+ const deps = makeDeps({ chargeStore: chargeStore as never });
302
+
303
+ const result = await handleKeyServerWebhook(deps, {
304
+ chargeId: "unknown",
305
+ chain: "bitcoin",
306
+ address: "bc1qunknown",
307
+ status: "partial",
308
+ });
309
+
310
+ expect(result.handled).toBe(false);
311
+ });
312
+
313
+ it("defaults confirmations to 0 and confirmationsRequired to 1 when absent", async () => {
314
+ const chargeStore = mockChargeStore();
315
+ const deps = makeDeps({ chargeStore: chargeStore as never });
316
+
317
+ await handleKeyServerWebhook(deps, {
318
+ chargeId: "btc:bc1qtest",
319
+ chain: "bitcoin",
320
+ address: "bc1qtest",
321
+ status: "partial",
322
+ });
323
+
324
+ expect(chargeStore.updateProgress).toHaveBeenCalledWith(
325
+ "btc:bc1qtest",
326
+ expect.objectContaining({ confirmations: 0, confirmationsRequired: 1 }),
327
+ );
328
+ });
329
+
330
+ it("also calls legacy updateStatus for backward compat", async () => {
331
+ const chargeStore = mockChargeStore();
332
+ const deps = makeDeps({ chargeStore: chargeStore as never });
333
+
334
+ await handleKeyServerWebhook(deps, {
335
+ chargeId: "btc:bc1qtest",
336
+ chain: "bitcoin",
337
+ address: "bc1qtest",
338
+ status: "partial",
339
+ amountReceived: "25000",
340
+ });
341
+
342
+ expect(chargeStore.updateStatus).toHaveBeenCalledWith("btc:bc1qtest", "Processing", "BTC", "25000");
343
+ });
344
+
345
+ it("calls onCreditsPurchased on confirmed and returns reactivatedBots", async () => {
346
+ const chargeStore = mockChargeStore();
347
+ const ledger = mockLedger();
348
+ const onCreditsPurchased = vi.fn().mockResolvedValue(["bot-1", "bot-2"]);
349
+ const deps = makeDeps({
350
+ chargeStore: chargeStore as never,
351
+ creditLedger: ledger as never,
352
+ onCreditsPurchased,
353
+ });
354
+
355
+ const result = await handleKeyServerWebhook(deps, {
356
+ chargeId: "btc:bc1qtest",
357
+ chain: "bitcoin",
358
+ address: "bc1qtest",
359
+ status: "confirmed",
360
+ confirmations: 6,
361
+ confirmationsRequired: 6,
362
+ });
363
+
364
+ expect(onCreditsPurchased).toHaveBeenCalledWith("t1", ledger);
365
+ expect(result.reactivatedBots).toEqual(["bot-1", "bot-2"]);
366
+ });
367
+ });
@@ -8,6 +8,7 @@ const mockEvent: BtcPaymentEvent = {
8
8
  amountSats: 15000,
9
9
  amountUsdCents: 1000,
10
10
  confirmations: 6,
11
+ confirmationsRequired: 6,
11
12
  };
12
13
 
13
14
  describe("settleBtcPayment", () => {
@@ -0,0 +1,201 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { BtcWatcher } from "../watcher.js";
3
+
4
+ function makeCursorStore() {
5
+ const processed = new Set<string>();
6
+ const confirmationCounts = new Map<string, number>();
7
+ return {
8
+ get: vi.fn().mockResolvedValue(null),
9
+ save: vi.fn().mockResolvedValue(undefined),
10
+ hasProcessedTx: vi.fn().mockImplementation(async (_: string, txId: string) => processed.has(txId)),
11
+ markProcessedTx: vi.fn().mockImplementation(async (_: string, txId: string) => {
12
+ processed.add(txId);
13
+ }),
14
+ getConfirmationCount: vi
15
+ .fn()
16
+ .mockImplementation(async (_: string, txId: string) => confirmationCounts.get(txId) ?? null),
17
+ saveConfirmationCount: vi.fn().mockImplementation(async (_: string, txId: string, count: number) => {
18
+ confirmationCounts.set(txId, count);
19
+ }),
20
+ _processed: processed,
21
+ _confirmationCounts: confirmationCounts,
22
+ };
23
+ }
24
+
25
+ function makeOracle() {
26
+ return { getPrice: vi.fn().mockResolvedValue({ priceMicros: 65_000_000_000 }) };
27
+ }
28
+
29
+ describe("BtcWatcher — intermediate confirmations", () => {
30
+ it("fires onPayment at 0 confirmations when tx first detected", async () => {
31
+ const events: Array<{ confirmations: number; confirmationsRequired: number }> = [];
32
+ const cursorStore = makeCursorStore();
33
+ const rpc = vi
34
+ .fn()
35
+ .mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.0005, confirmations: 0, txids: ["tx1"] }])
36
+ .mockResolvedValueOnce({
37
+ details: [{ address: "bc1qtest", amount: 0.0005, category: "receive" }],
38
+ confirmations: 0,
39
+ });
40
+
41
+ const watcher = new BtcWatcher({
42
+ config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 3 },
43
+ rpcCall: rpc,
44
+ watchedAddresses: ["bc1qtest"],
45
+ oracle: makeOracle(),
46
+ cursorStore,
47
+ onPayment: (evt) => {
48
+ events.push(evt);
49
+ },
50
+ });
51
+
52
+ await watcher.poll();
53
+
54
+ expect(events).toHaveLength(1);
55
+ expect(events[0].confirmations).toBe(0);
56
+ expect(events[0].confirmationsRequired).toBe(3);
57
+ });
58
+
59
+ it("fires onPayment on each confirmation increment", async () => {
60
+ const events: Array<{ confirmations: number }> = [];
61
+ const cursorStore = makeCursorStore();
62
+ cursorStore._confirmationCounts.set("tx1", 1);
63
+
64
+ const rpc = vi
65
+ .fn()
66
+ .mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.0005, confirmations: 2, txids: ["tx1"] }])
67
+ .mockResolvedValueOnce({
68
+ details: [{ address: "bc1qtest", amount: 0.0005, category: "receive" }],
69
+ confirmations: 2,
70
+ });
71
+
72
+ const watcher = new BtcWatcher({
73
+ config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 3 },
74
+ rpcCall: rpc,
75
+ watchedAddresses: ["bc1qtest"],
76
+ oracle: makeOracle(),
77
+ cursorStore,
78
+ onPayment: (evt) => {
79
+ events.push(evt);
80
+ },
81
+ });
82
+
83
+ await watcher.poll();
84
+
85
+ expect(events).toHaveLength(1);
86
+ expect(events[0].confirmations).toBe(2);
87
+ });
88
+
89
+ it("does not fire when confirmation count unchanged", async () => {
90
+ const events: Array<{ confirmations: number }> = [];
91
+ const cursorStore = makeCursorStore();
92
+ cursorStore._confirmationCounts.set("tx1", 2);
93
+
94
+ const rpc = vi
95
+ .fn()
96
+ .mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.0005, confirmations: 2, txids: ["tx1"] }])
97
+ .mockResolvedValueOnce({
98
+ details: [{ address: "bc1qtest", amount: 0.0005, category: "receive" }],
99
+ confirmations: 2,
100
+ });
101
+
102
+ const watcher = new BtcWatcher({
103
+ config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 3 },
104
+ rpcCall: rpc,
105
+ watchedAddresses: ["bc1qtest"],
106
+ oracle: makeOracle(),
107
+ cursorStore,
108
+ onPayment: (evt) => {
109
+ events.push(evt);
110
+ },
111
+ });
112
+
113
+ await watcher.poll();
114
+
115
+ expect(events).toHaveLength(0);
116
+ });
117
+
118
+ it("marks tx as processed once confirmations reach threshold", async () => {
119
+ const events: Array<{ confirmations: number }> = [];
120
+ const cursorStore = makeCursorStore();
121
+ cursorStore._confirmationCounts.set("tx1", 2);
122
+
123
+ const rpc = vi
124
+ .fn()
125
+ .mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.0005, confirmations: 3, txids: ["tx1"] }])
126
+ .mockResolvedValueOnce({
127
+ details: [{ address: "bc1qtest", amount: 0.0005, category: "receive" }],
128
+ confirmations: 3,
129
+ });
130
+
131
+ const watcher = new BtcWatcher({
132
+ config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 3 },
133
+ rpcCall: rpc,
134
+ watchedAddresses: ["bc1qtest"],
135
+ oracle: makeOracle(),
136
+ cursorStore,
137
+ onPayment: (evt) => {
138
+ events.push(evt);
139
+ },
140
+ });
141
+
142
+ await watcher.poll();
143
+
144
+ expect(events).toHaveLength(1);
145
+ expect(events[0].confirmations).toBe(3);
146
+ expect(cursorStore.markProcessedTx).toHaveBeenCalledWith(expect.any(String), "tx1");
147
+ });
148
+
149
+ it("skips fully-processed txids", async () => {
150
+ const events: unknown[] = [];
151
+ const cursorStore = makeCursorStore();
152
+ cursorStore._processed.add("tx1");
153
+
154
+ const rpc = vi
155
+ .fn()
156
+ .mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.0005, confirmations: 6, txids: ["tx1"] }]);
157
+
158
+ const watcher = new BtcWatcher({
159
+ config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 3 },
160
+ rpcCall: rpc,
161
+ watchedAddresses: ["bc1qtest"],
162
+ oracle: makeOracle(),
163
+ cursorStore,
164
+ onPayment: (evt) => {
165
+ events.push(evt);
166
+ },
167
+ });
168
+
169
+ await watcher.poll();
170
+
171
+ expect(events).toHaveLength(0);
172
+ });
173
+
174
+ it("includes confirmationsRequired in event", async () => {
175
+ const events: Array<{ confirmationsRequired: number }> = [];
176
+ const cursorStore = makeCursorStore();
177
+
178
+ const rpc = vi
179
+ .fn()
180
+ .mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.001, confirmations: 0, txids: ["txNew"] }])
181
+ .mockResolvedValueOnce({
182
+ details: [{ address: "bc1qtest", amount: 0.001, category: "receive" }],
183
+ confirmations: 0,
184
+ });
185
+
186
+ const watcher = new BtcWatcher({
187
+ config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 6 },
188
+ rpcCall: rpc,
189
+ watchedAddresses: ["bc1qtest"],
190
+ oracle: makeOracle(),
191
+ cursorStore,
192
+ onPayment: (evt) => {
193
+ events.push(evt);
194
+ },
195
+ });
196
+
197
+ await watcher.poll();
198
+
199
+ expect(events[0].confirmationsRequired).toBe(6);
200
+ });
201
+ });
@@ -1,4 +1,4 @@
1
- /** BTC payment event emitted when a deposit is confirmed. */
1
+ /** BTC payment event emitted on each confirmation increment. */
2
2
  export interface BtcPaymentEvent {
3
3
  readonly address: string;
4
4
  readonly txid: string;
@@ -7,6 +7,8 @@ export interface BtcPaymentEvent {
7
7
  /** USD cents equivalent (integer). */
8
8
  readonly amountUsdCents: number;
9
9
  readonly confirmations: number;
10
+ /** Required confirmations for this chain (from config). */
11
+ readonly confirmationsRequired: number;
10
12
  }
11
13
 
12
14
  /** Options for creating a BTC checkout. */
@@ -1,4 +1,5 @@
1
1
  import type { IWatcherCursorStore } from "../cursor-store.js";
2
+ import { nativeToCents } from "../oracle/convert.js";
2
3
  import type { IPriceOracle } from "../oracle/types.js";
3
4
  import type { BitcoindConfig, BtcPaymentEvent } from "./types.js";
4
5
 
@@ -74,23 +75,29 @@ export class BtcWatcher {
74
75
  this.addresses.add(address);
75
76
  }
76
77
 
77
- /** Poll for confirmed payments to watched addresses. */
78
+ /**
79
+ * Poll for payments to watched addresses, including unconfirmed txs.
80
+ *
81
+ * Fires onPayment on every confirmation increment (0, 1, 2, ... threshold).
82
+ * Only marks a tx as fully processed once it reaches the confirmation threshold.
83
+ */
78
84
  async poll(): Promise<void> {
79
85
  if (this.addresses.size === 0) return;
80
86
 
87
+ // Poll with minconf=0 to see unconfirmed txs
81
88
  const received = (await this.rpc("listreceivedbyaddress", [
82
- this.minConfirmations,
89
+ 0, // minconf=0: see ALL txs including unconfirmed
83
90
  false, // include_empty
84
91
  true, // include_watchonly
85
92
  ])) as ReceivedByAddress[];
86
93
 
87
- const { priceCents } = await this.oracle.getPrice("BTC");
94
+ const { priceMicros } = await this.oracle.getPrice("BTC");
88
95
 
89
96
  for (const entry of received) {
90
97
  if (!this.addresses.has(entry.address)) continue;
91
98
 
92
99
  for (const txid of entry.txids) {
93
- // Skip already-processed txids (persisted to DB, survives restart)
100
+ // Skip fully-processed txids (already reached threshold, persisted to DB)
94
101
  if (await this.cursorStore.hasProcessedTx(this.watcherId, txid)) continue;
95
102
 
96
103
  // Get transaction details for the exact amount sent to this address
@@ -102,9 +109,13 @@ export class BtcWatcher {
102
109
  const detail = tx.details.find((d) => d.address === entry.address && d.category === "receive");
103
110
  if (!detail) continue;
104
111
 
112
+ // Check if confirmations have increased since last seen
113
+ const lastSeen = await this.cursorStore.getConfirmationCount(this.watcherId, txid);
114
+ if (lastSeen !== null && tx.confirmations <= lastSeen) continue; // No change
115
+
105
116
  const amountSats = Math.round(detail.amount * 100_000_000);
106
- // priceCents is cents per 1 BTC. detail.amount is in BTC.
107
- const amountUsdCents = Math.round((amountSats * priceCents) / 100_000_000);
117
+ // priceMicros is microdollars per 1 BTC. Convert sats→USD cents via nativeToCents.
118
+ const amountUsdCents = nativeToCents(BigInt(amountSats), priceMicros, 8);
108
119
 
109
120
  const event: BtcPaymentEvent = {
110
121
  address: entry.address,
@@ -112,11 +123,18 @@ export class BtcWatcher {
112
123
  amountSats,
113
124
  amountUsdCents,
114
125
  confirmations: tx.confirmations,
126
+ confirmationsRequired: this.minConfirmations,
115
127
  };
116
128
 
117
129
  await this.onPayment(event);
118
- // Persist AFTER successful onPayment — survives restart, no unbounded memory
119
- await this.cursorStore.markProcessedTx(this.watcherId, txid);
130
+
131
+ // Persist confirmation count
132
+ await this.cursorStore.saveConfirmationCount(this.watcherId, txid, tx.confirmations);
133
+
134
+ // Mark as fully processed once we reach the threshold
135
+ if (tx.confirmations >= this.minConfirmations) {
136
+ await this.cursorStore.markProcessedTx(this.watcherId, txid);
137
+ }
120
138
  }
121
139
  }
122
140
  }