@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.
- package/dist/billing/crypto/__tests__/key-server.test.js +1 -1
- 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 +24 -8
- 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-checkout.test.js +3 -3
- package/dist/billing/crypto/evm/__tests__/eth-settler.test.js +2 -0
- package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +33 -6
- 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-checkout.d.ts +2 -2
- package/dist/billing/crypto/evm/eth-checkout.js +3 -3
- package/dist/billing/crypto/evm/eth-watcher.d.ts +11 -8
- package/dist/billing/crypto/evm/eth-watcher.js +29 -15
- 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-entry.js +7 -2
- 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/key-server.js +18 -7
- package/dist/billing/crypto/oracle/__tests__/chainlink.test.js +4 -4
- package/dist/billing/crypto/oracle/__tests__/coingecko.test.d.ts +1 -0
- package/dist/billing/crypto/oracle/__tests__/coingecko.test.js +65 -0
- package/dist/billing/crypto/oracle/__tests__/composite.test.d.ts +1 -0
- package/dist/billing/crypto/oracle/__tests__/composite.test.js +48 -0
- package/dist/billing/crypto/oracle/__tests__/convert.test.js +27 -17
- package/dist/billing/crypto/oracle/__tests__/fixed.test.js +5 -5
- package/dist/billing/crypto/oracle/chainlink.d.ts +2 -2
- package/dist/billing/crypto/oracle/chainlink.js +11 -10
- package/dist/billing/crypto/oracle/coingecko.d.ts +22 -0
- package/dist/billing/crypto/oracle/coingecko.js +67 -0
- package/dist/billing/crypto/oracle/composite.d.ts +14 -0
- package/dist/billing/crypto/oracle/composite.js +34 -0
- package/dist/billing/crypto/oracle/convert.d.ts +17 -7
- package/dist/billing/crypto/oracle/convert.js +26 -13
- package/dist/billing/crypto/oracle/fixed.d.ts +2 -2
- package/dist/billing/crypto/oracle/fixed.js +9 -7
- package/dist/billing/crypto/oracle/index.d.ts +4 -0
- package/dist/billing/crypto/oracle/index.js +3 -0
- package/dist/billing/crypto/oracle/types.d.ts +12 -3
- package/dist/billing/crypto/oracle/types.js +7 -1
- package/dist/billing/crypto/types.d.ts +16 -0
- package/dist/billing/crypto/unified-checkout.d.ts +10 -19
- 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__/key-server.test.ts +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 +26 -8
- 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-checkout.test.ts +3 -3
- package/src/billing/crypto/evm/__tests__/eth-settler.test.ts +2 -0
- package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +33 -6
- 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-checkout.ts +5 -5
- package/src/billing/crypto/evm/eth-watcher.ts +36 -16
- 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-entry.ts +7 -2
- package/src/billing/crypto/key-server-webhook.ts +92 -21
- package/src/billing/crypto/key-server.ts +17 -7
- package/src/billing/crypto/oracle/__tests__/chainlink.test.ts +4 -4
- package/src/billing/crypto/oracle/__tests__/coingecko.test.ts +75 -0
- package/src/billing/crypto/oracle/__tests__/composite.test.ts +61 -0
- package/src/billing/crypto/oracle/__tests__/convert.test.ts +29 -17
- package/src/billing/crypto/oracle/__tests__/fixed.test.ts +5 -5
- package/src/billing/crypto/oracle/chainlink.ts +11 -10
- package/src/billing/crypto/oracle/coingecko.ts +92 -0
- package/src/billing/crypto/oracle/composite.ts +35 -0
- package/src/billing/crypto/oracle/convert.ts +28 -13
- package/src/billing/crypto/oracle/fixed.ts +9 -7
- package/src/billing/crypto/oracle/index.ts +4 -0
- package/src/billing/crypto/oracle/types.ts +16 -3
- package/src/billing/crypto/types.ts +18 -0
- package/src/billing/crypto/unified-checkout.ts +22 -181
- 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({ 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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
//
|
|
107
|
-
const amountUsdCents =
|
|
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
|
-
|
|
119
|
-
|
|
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
|
}
|