@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.
- package/dist/billing/crypto/__tests__/unified-checkout.test.d.ts +1 -0
- package/dist/billing/crypto/__tests__/unified-checkout.test.js +63 -0
- package/dist/billing/crypto/__tests__/watcher-service.test.d.ts +1 -0
- package/dist/billing/crypto/__tests__/watcher-service.test.js +174 -0
- package/dist/billing/crypto/__tests__/webhook-confirmations.test.d.ts +1 -0
- package/dist/billing/crypto/__tests__/webhook-confirmations.test.js +304 -0
- package/dist/billing/crypto/btc/__tests__/settler.test.js +1 -0
- package/dist/billing/crypto/btc/__tests__/watcher.test.d.ts +1 -0
- package/dist/billing/crypto/btc/__tests__/watcher.test.js +170 -0
- package/dist/billing/crypto/btc/types.d.ts +3 -1
- package/dist/billing/crypto/btc/watcher.d.ts +6 -1
- package/dist/billing/crypto/btc/watcher.js +20 -6
- package/dist/billing/crypto/charge-store.d.ts +27 -2
- package/dist/billing/crypto/charge-store.js +67 -1
- package/dist/billing/crypto/charge-store.test.js +180 -1
- package/dist/billing/crypto/client.d.ts +2 -0
- package/dist/billing/crypto/cursor-store.d.ts +10 -3
- package/dist/billing/crypto/cursor-store.js +21 -1
- package/dist/billing/crypto/evm/__tests__/eth-settler.test.js +2 -0
- package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +31 -4
- package/dist/billing/crypto/evm/__tests__/settler.test.js +2 -0
- package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.d.ts +1 -0
- package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.js +144 -0
- package/dist/billing/crypto/evm/__tests__/watcher.test.js +6 -2
- package/dist/billing/crypto/evm/eth-watcher.d.ts +11 -8
- package/dist/billing/crypto/evm/eth-watcher.js +27 -13
- package/dist/billing/crypto/evm/types.d.ts +5 -1
- package/dist/billing/crypto/evm/watcher.d.ts +9 -1
- package/dist/billing/crypto/evm/watcher.js +36 -13
- package/dist/billing/crypto/index.d.ts +3 -3
- package/dist/billing/crypto/index.js +1 -1
- package/dist/billing/crypto/key-server-webhook.d.ts +17 -4
- package/dist/billing/crypto/key-server-webhook.js +76 -15
- package/dist/billing/crypto/types.d.ts +16 -0
- package/dist/billing/crypto/unified-checkout.d.ts +8 -17
- package/dist/billing/crypto/unified-checkout.js +17 -131
- package/dist/billing/crypto/watcher-service.d.ts +22 -2
- package/dist/billing/crypto/watcher-service.js +71 -30
- package/dist/db/schema/crypto.d.ts +68 -0
- package/dist/db/schema/crypto.js +8 -0
- package/dist/monetization/crypto/__tests__/webhook.test.js +2 -1
- package/drizzle/migrations/0016_charge_progress_columns.sql +4 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/billing/crypto/__tests__/unified-checkout.test.ts +83 -0
- package/src/billing/crypto/__tests__/watcher-service.test.ts +242 -0
- package/src/billing/crypto/__tests__/webhook-confirmations.test.ts +367 -0
- package/src/billing/crypto/btc/__tests__/settler.test.ts +1 -0
- package/src/billing/crypto/btc/__tests__/watcher.test.ts +201 -0
- package/src/billing/crypto/btc/types.ts +3 -1
- package/src/billing/crypto/btc/watcher.ts +22 -6
- package/src/billing/crypto/charge-store.test.ts +204 -1
- package/src/billing/crypto/charge-store.ts +86 -2
- package/src/billing/crypto/client.ts +2 -0
- package/src/billing/crypto/cursor-store.ts +31 -3
- package/src/billing/crypto/evm/__tests__/eth-settler.test.ts +2 -0
- package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +31 -4
- package/src/billing/crypto/evm/__tests__/settler.test.ts +2 -0
- package/src/billing/crypto/evm/__tests__/watcher-confirmations.test.ts +176 -0
- package/src/billing/crypto/evm/__tests__/watcher.test.ts +6 -2
- package/src/billing/crypto/evm/eth-watcher.ts +34 -14
- package/src/billing/crypto/evm/types.ts +5 -1
- package/src/billing/crypto/evm/watcher.ts +39 -13
- package/src/billing/crypto/index.ts +12 -3
- package/src/billing/crypto/key-server-webhook.ts +92 -21
- package/src/billing/crypto/types.ts +18 -0
- package/src/billing/crypto/unified-checkout.ts +20 -179
- package/src/billing/crypto/watcher-service.ts +85 -32
- package/src/db/schema/crypto.ts +8 -0
- 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
|
+
});
|
|
@@ -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({ priceCents: 6_500_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
|
|
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. */
|
|
@@ -74,12 +74,18 @@ export class BtcWatcher {
|
|
|
74
74
|
this.addresses.add(address);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Poll for payments to watched addresses, including unconfirmed txs.
|
|
79
|
+
*
|
|
80
|
+
* Fires onPayment on every confirmation increment (0, 1, 2, ... threshold).
|
|
81
|
+
* Only marks a tx as fully processed once it reaches the confirmation threshold.
|
|
82
|
+
*/
|
|
78
83
|
async poll(): Promise<void> {
|
|
79
84
|
if (this.addresses.size === 0) return;
|
|
80
85
|
|
|
86
|
+
// Poll with minconf=0 to see unconfirmed txs
|
|
81
87
|
const received = (await this.rpc("listreceivedbyaddress", [
|
|
82
|
-
|
|
88
|
+
0, // minconf=0: see ALL txs including unconfirmed
|
|
83
89
|
false, // include_empty
|
|
84
90
|
true, // include_watchonly
|
|
85
91
|
])) as ReceivedByAddress[];
|
|
@@ -90,7 +96,7 @@ export class BtcWatcher {
|
|
|
90
96
|
if (!this.addresses.has(entry.address)) continue;
|
|
91
97
|
|
|
92
98
|
for (const txid of entry.txids) {
|
|
93
|
-
// Skip
|
|
99
|
+
// Skip fully-processed txids (already reached threshold, persisted to DB)
|
|
94
100
|
if (await this.cursorStore.hasProcessedTx(this.watcherId, txid)) continue;
|
|
95
101
|
|
|
96
102
|
// Get transaction details for the exact amount sent to this address
|
|
@@ -102,8 +108,11 @@ export class BtcWatcher {
|
|
|
102
108
|
const detail = tx.details.find((d) => d.address === entry.address && d.category === "receive");
|
|
103
109
|
if (!detail) continue;
|
|
104
110
|
|
|
111
|
+
// Check if confirmations have increased since last seen
|
|
112
|
+
const lastSeen = await this.cursorStore.getConfirmationCount(this.watcherId, txid);
|
|
113
|
+
if (lastSeen !== null && tx.confirmations <= lastSeen) continue; // No change
|
|
114
|
+
|
|
105
115
|
const amountSats = Math.round(detail.amount * 100_000_000);
|
|
106
|
-
// priceCents is cents per 1 BTC. detail.amount is in BTC.
|
|
107
116
|
const amountUsdCents = Math.round((amountSats * priceCents) / 100_000_000);
|
|
108
117
|
|
|
109
118
|
const event: BtcPaymentEvent = {
|
|
@@ -112,11 +121,18 @@ export class BtcWatcher {
|
|
|
112
121
|
amountSats,
|
|
113
122
|
amountUsdCents,
|
|
114
123
|
confirmations: tx.confirmations,
|
|
124
|
+
confirmationsRequired: this.minConfirmations,
|
|
115
125
|
};
|
|
116
126
|
|
|
117
127
|
await this.onPayment(event);
|
|
118
|
-
|
|
119
|
-
|
|
128
|
+
|
|
129
|
+
// Persist confirmation count
|
|
130
|
+
await this.cursorStore.saveConfirmationCount(this.watcherId, txid, tx.confirmations);
|
|
131
|
+
|
|
132
|
+
// Mark as fully processed once we reach the threshold
|
|
133
|
+
if (tx.confirmations >= this.minConfirmations) {
|
|
134
|
+
await this.cursorStore.markProcessedTx(this.watcherId, txid);
|
|
135
|
+
}
|
|
120
136
|
}
|
|
121
137
|
}
|
|
122
138
|
}
|