@wopr-network/platform-core 1.42.3 → 1.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.github/workflows/key-server-image.yml +35 -0
  2. package/Dockerfile.key-server +20 -0
  3. package/GATEWAY_BILLING_RESEARCH.md +430 -0
  4. package/biome.json +2 -9
  5. package/dist/billing/crypto/__tests__/key-server.test.js +240 -0
  6. package/dist/billing/crypto/btc/watcher.d.ts +2 -0
  7. package/dist/billing/crypto/btc/watcher.js +1 -1
  8. package/dist/billing/crypto/charge-store.d.ts +7 -1
  9. package/dist/billing/crypto/charge-store.js +7 -1
  10. package/dist/billing/crypto/client.d.ts +68 -30
  11. package/dist/billing/crypto/client.js +63 -46
  12. package/dist/billing/crypto/client.test.js +66 -83
  13. package/dist/billing/crypto/index.d.ts +8 -8
  14. package/dist/billing/crypto/index.js +4 -5
  15. package/dist/billing/crypto/key-server-entry.js +84 -0
  16. package/dist/billing/crypto/key-server-webhook.d.ts +33 -0
  17. package/dist/billing/crypto/key-server-webhook.js +73 -0
  18. package/dist/billing/crypto/key-server.d.ts +20 -0
  19. package/dist/billing/crypto/key-server.js +263 -0
  20. package/dist/billing/crypto/watcher-service.d.ts +33 -0
  21. package/dist/billing/crypto/watcher-service.js +295 -0
  22. package/dist/billing/index.js +1 -1
  23. package/dist/db/schema/crypto.d.ts +464 -2
  24. package/dist/db/schema/crypto.js +60 -6
  25. package/dist/monetization/crypto/__tests__/webhook.test.js +57 -92
  26. package/dist/monetization/crypto/index.d.ts +4 -4
  27. package/dist/monetization/crypto/index.js +2 -2
  28. package/dist/monetization/crypto/webhook.d.ts +13 -14
  29. package/dist/monetization/crypto/webhook.js +12 -83
  30. package/dist/monetization/index.d.ts +2 -2
  31. package/dist/monetization/index.js +1 -1
  32. package/drizzle/migrations/0014_crypto_key_server.sql +60 -0
  33. package/drizzle/migrations/0015_callback_url.sql +32 -0
  34. package/drizzle/migrations/meta/_journal.json +28 -0
  35. package/package.json +2 -1
  36. package/src/billing/crypto/__tests__/key-server.test.ts +262 -0
  37. package/src/billing/crypto/btc/watcher.ts +3 -1
  38. package/src/billing/crypto/charge-store.ts +13 -1
  39. package/src/billing/crypto/client.test.ts +70 -98
  40. package/src/billing/crypto/client.ts +118 -59
  41. package/src/billing/crypto/index.ts +19 -14
  42. package/src/billing/crypto/key-server-entry.ts +96 -0
  43. package/src/billing/crypto/key-server-webhook.ts +119 -0
  44. package/src/billing/crypto/key-server.ts +343 -0
  45. package/src/billing/crypto/watcher-service.ts +381 -0
  46. package/src/billing/index.ts +1 -1
  47. package/src/db/schema/crypto.ts +75 -6
  48. package/src/monetization/crypto/__tests__/webhook.test.ts +61 -104
  49. package/src/monetization/crypto/index.ts +9 -11
  50. package/src/monetization/crypto/webhook.ts +25 -99
  51. package/src/monetization/index.ts +3 -7
  52. package/dist/billing/crypto/checkout.d.ts +0 -18
  53. package/dist/billing/crypto/checkout.js +0 -35
  54. package/dist/billing/crypto/checkout.test.js +0 -71
  55. package/dist/billing/crypto/webhook.d.ts +0 -34
  56. package/dist/billing/crypto/webhook.js +0 -107
  57. package/dist/billing/crypto/webhook.test.js +0 -266
  58. package/src/billing/crypto/checkout.test.ts +0 -93
  59. package/src/billing/crypto/checkout.ts +0 -48
  60. package/src/billing/crypto/webhook.test.ts +0 -340
  61. package/src/billing/crypto/webhook.ts +0 -136
  62. /package/dist/billing/crypto/{checkout.test.d.ts → __tests__/key-server.test.d.ts} +0 -0
  63. /package/dist/billing/crypto/{webhook.test.d.ts → key-server-entry.d.ts} +0 -0
@@ -2,14 +2,14 @@
2
2
  * Unit tests for the monetization crypto webhook handler.
3
3
  *
4
4
  * This handler is the WOPR-specific layer on top of the platform-core
5
- * billing/crypto webhook. Key differences from billing/crypto/webhook.ts:
5
+ * billing/crypto key-server webhook. Key differences from billing layer:
6
6
  * - Uses BotBilling.checkReactivation() instead of onCreditsPurchased callback
7
7
  * - Imports charge store / replay guard types from billing layer (relative)
8
8
  */
9
9
  import type { PGlite } from "@electric-sql/pglite";
10
10
  import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
11
11
  import { CryptoChargeRepository } from "../../../billing/crypto/charge-store.js";
12
- import type { CryptoWebhookPayload } from "../../../billing/crypto/types.js";
12
+ import type { CryptoWebhookPayload } from "../../../billing/crypto/index.js";
13
13
  import { DrizzleWebhookSeenRepository } from "../../../billing/drizzle-webhook-seen-repository.js";
14
14
  import { noOpReplayGuard } from "../../../billing/webhook-seen-repository.js";
15
15
  import { DrizzleLedger } from "../../../credits/ledger.js";
@@ -20,15 +20,12 @@ import { handleCryptoWebhook } from "../webhook.js";
20
20
 
21
21
  function makePayload(overrides: Partial<CryptoWebhookPayload> = {}): CryptoWebhookPayload {
22
22
  return {
23
- deliveryId: "del-001",
24
- webhookId: "whk-001",
25
- originalDeliveryId: "del-001",
26
- isRedelivery: false,
27
- type: "InvoiceSettled",
28
- timestamp: Date.now(),
29
- storeId: "store-test",
30
- invoiceId: "inv-test-001",
31
- metadata: { orderId: "order-001" },
23
+ chargeId: "chg-test-001",
24
+ chain: "bitcoin",
25
+ address: "bc1q-test-address",
26
+ amountUsdCents: 2500,
27
+ status: "confirmed",
28
+ txHash: "tx-abc123",
32
29
  ...overrides,
33
30
  };
34
31
  }
@@ -57,19 +54,18 @@ describe("handleCryptoWebhook (monetization layer)", () => {
57
54
  deps = { chargeStore, creditLedger, replayGuard: noOpReplayGuard };
58
55
 
59
56
  // Default test charge: $25 = 2500 cents
60
- await chargeStore.create("inv-test-001", "tenant-a", 2500);
57
+ await chargeStore.create("chg-test-001", "tenant-a", 2500);
61
58
  });
62
59
 
63
60
  // ---------------------------------------------------------------------------
64
- // InvoiceSettled — credits ledger
61
+ // confirmed — credits ledger
65
62
  // ---------------------------------------------------------------------------
66
63
 
67
- describe("InvoiceSettled", () => {
64
+ describe("confirmed status", () => {
68
65
  it("credits the ledger with the USD amount in cents", async () => {
69
- const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
66
+ const result = await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
70
67
 
71
68
  expect(result.handled).toBe(true);
72
- expect(result.status).toBe("Settled");
73
69
  expect(result.tenant).toBe("tenant-a");
74
70
  expect(result.creditedCents).toBe(2500);
75
71
 
@@ -77,33 +73,33 @@ describe("handleCryptoWebhook (monetization layer)", () => {
77
73
  expect(balance.toCents()).toBe(2500);
78
74
  });
79
75
 
80
- it("marks the charge as credited after settlement", async () => {
81
- await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
82
- expect(await chargeStore.isCredited("inv-test-001")).toBe(true);
76
+ it("marks the charge as credited after confirmation", async () => {
77
+ await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
78
+ expect(await chargeStore.isCredited("chg-test-001")).toBe(true);
83
79
  });
84
80
 
85
81
  it("uses crypto: prefix on reference ID in ledger entry", async () => {
86
- await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
82
+ await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
87
83
 
88
84
  const history = await creditLedger.history("tenant-a");
89
85
  expect(history).toHaveLength(1);
90
- expect(history[0].referenceId).toBe("crypto:inv-test-001");
86
+ expect(history[0].referenceId).toBe("crypto:chg-test-001");
91
87
  expect(history[0].entryType).toBe("purchase");
92
88
  });
93
89
 
94
90
  it("records fundingSource as crypto", async () => {
95
- await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
91
+ await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
96
92
 
97
93
  const history = await creditLedger.history("tenant-a");
98
94
  expect(history[0].metadata?.fundingSource).toBe("crypto");
99
95
  });
100
96
 
101
- it("is idempotent — second InvoiceSettled does not double-credit", async () => {
102
- await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
103
- const result2 = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
97
+ it("is idempotent — second confirmed does not double-credit", async () => {
98
+ await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
99
+ const result2 = await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
104
100
 
105
101
  expect(result2.handled).toBe(true);
106
- expect(result2.creditedCents).toBe(0);
102
+ expect(result2.creditedCents).toBeUndefined();
107
103
 
108
104
  // Balance is still $25, not $50
109
105
  const balance = await creditLedger.balance("tenant-a");
@@ -112,12 +108,12 @@ describe("handleCryptoWebhook (monetization layer)", () => {
112
108
  });
113
109
 
114
110
  // ---------------------------------------------------------------------------
115
- // Non-settlement event types — no ledger credit
111
+ // Non-confirmed statuses — no ledger credit
116
112
  // ---------------------------------------------------------------------------
117
113
 
118
- describe("InvoiceProcessing", () => {
114
+ describe("pending status", () => {
119
115
  it("does NOT credit the ledger", async () => {
120
- const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceProcessing" }));
116
+ const result = await handleCryptoWebhook(deps, makePayload({ status: "pending" }));
121
117
 
122
118
  expect(result.handled).toBe(true);
123
119
  expect(result.tenant).toBe("tenant-a");
@@ -126,9 +122,9 @@ describe("handleCryptoWebhook (monetization layer)", () => {
126
122
  });
127
123
  });
128
124
 
129
- describe("InvoiceCreated", () => {
125
+ describe("expired status", () => {
130
126
  it("does NOT credit the ledger", async () => {
131
- const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceCreated" }));
127
+ const result = await handleCryptoWebhook(deps, makePayload({ status: "expired" }));
132
128
 
133
129
  expect(result.handled).toBe(true);
134
130
  expect(result.creditedCents).toBeUndefined();
@@ -136,19 +132,9 @@ describe("handleCryptoWebhook (monetization layer)", () => {
136
132
  });
137
133
  });
138
134
 
139
- describe("InvoiceExpired", () => {
135
+ describe("failed status", () => {
140
136
  it("does NOT credit the ledger", async () => {
141
- const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceExpired" }));
142
-
143
- expect(result.handled).toBe(true);
144
- expect(result.creditedCents).toBeUndefined();
145
- expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
146
- });
147
- });
148
-
149
- describe("InvoiceInvalid", () => {
150
- it("does NOT credit the ledger", async () => {
151
- const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceInvalid" }));
137
+ const result = await handleCryptoWebhook(deps, makePayload({ status: "failed" }));
152
138
 
153
139
  expect(result.handled).toBe(true);
154
140
  expect(result.creditedCents).toBeUndefined();
@@ -157,53 +143,35 @@ describe("handleCryptoWebhook (monetization layer)", () => {
157
143
  });
158
144
 
159
145
  // ---------------------------------------------------------------------------
160
- // Unknown invoiceId — returns handled:false
146
+ // Unknown chargeId — returns handled:false
161
147
  // ---------------------------------------------------------------------------
162
148
 
163
149
  describe("missing charge", () => {
164
- it("returns handled:false when invoiceId is not in the charge store", async () => {
165
- const result = await handleCryptoWebhook(deps, makePayload({ invoiceId: "inv-unknown-999" }));
150
+ it("returns handled:false when chargeId is not in the charge store", async () => {
151
+ const result = await handleCryptoWebhook(deps, makePayload({ chargeId: "chg-unknown-999" }));
166
152
 
167
153
  expect(result.handled).toBe(false);
168
154
  expect(result.tenant).toBeUndefined();
169
155
  });
170
156
  });
171
157
 
172
- // ---------------------------------------------------------------------------
173
- // Status mapping for all known event types
174
- // ---------------------------------------------------------------------------
175
-
176
- describe("status mapping", () => {
177
- it.each([
178
- ["InvoiceCreated", "New"],
179
- ["InvoiceProcessing", "Processing"],
180
- ["InvoiceReceivedPayment", "Processing"],
181
- ["InvoiceSettled", "Settled"],
182
- ["InvoicePaymentSettled", "Settled"],
183
- ["InvoiceExpired", "Expired"],
184
- ["InvoiceInvalid", "Invalid"],
185
- ] as const)("maps %s event to %s status", async (eventType, expectedStatus) => {
186
- const result = await handleCryptoWebhook(deps, makePayload({ type: eventType }));
187
- expect(result.status).toBe(expectedStatus);
188
- });
189
-
190
- it("throws on unknown event types", async () => {
191
- await expect(handleCryptoWebhook(deps, makePayload({ type: "InvoiceSomeUnknownEvent" }))).rejects.toThrow(
192
- "Unknown BTCPay event type: InvoiceSomeUnknownEvent",
193
- );
194
- });
195
- });
196
-
197
158
  // ---------------------------------------------------------------------------
198
159
  // Charge store status updates
199
160
  // ---------------------------------------------------------------------------
200
161
 
201
162
  describe("charge store updates", () => {
202
163
  it("updates charge status on every webhook call", async () => {
203
- await handleCryptoWebhook(deps, makePayload({ type: "InvoiceProcessing" }));
164
+ await handleCryptoWebhook(deps, makePayload({ status: "partial" }));
204
165
 
205
- const charge = await chargeStore.getByReferenceId("inv-test-001");
206
- expect(charge?.status).toBe("Processing");
166
+ const charge = await chargeStore.getByReferenceId("chg-test-001");
167
+ expect(charge?.status).toBe("partial");
168
+ });
169
+
170
+ it("settles charge when status is confirmed", async () => {
171
+ await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
172
+
173
+ const charge = await chargeStore.getByReferenceId("chg-test-001");
174
+ expect(charge?.status).toBe("Settled");
207
175
  });
208
176
  });
209
177
 
@@ -212,16 +180,16 @@ describe("handleCryptoWebhook (monetization layer)", () => {
212
180
  // ---------------------------------------------------------------------------
213
181
 
214
182
  describe("replay guard", () => {
215
- it("blocks duplicate invoiceId + event type combinations", async () => {
183
+ it("blocks duplicate chargeId via ks: dedupe key", async () => {
216
184
  const replayGuard = new DrizzleWebhookSeenRepository(db);
217
185
  const depsWithGuard: CryptoWebhookDeps = { ...deps, replayGuard };
218
186
 
219
- const first = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
187
+ const first = await handleCryptoWebhook(depsWithGuard, makePayload({ status: "confirmed" }));
220
188
  expect(first.handled).toBe(true);
221
189
  expect(first.creditedCents).toBe(2500);
222
190
  expect(first.duplicate).toBeUndefined();
223
191
 
224
- const second = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
192
+ const second = await handleCryptoWebhook(depsWithGuard, makePayload({ status: "confirmed" }));
225
193
  expect(second.handled).toBe(true);
226
194
  expect(second.duplicate).toBe(true);
227
195
  expect(second.creditedCents).toBeUndefined();
@@ -229,17 +197,6 @@ describe("handleCryptoWebhook (monetization layer)", () => {
229
197
  // Balance is still $25, not $50
230
198
  expect((await creditLedger.balance("tenant-a")).toCents()).toBe(2500);
231
199
  });
232
-
233
- it("same invoice with a different event type is not blocked", async () => {
234
- const replayGuard = new DrizzleWebhookSeenRepository(db);
235
- const depsWithGuard: CryptoWebhookDeps = { ...deps, replayGuard };
236
-
237
- await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceProcessing" }));
238
- const result = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
239
-
240
- expect(result.duplicate).toBeUndefined();
241
- expect(result.creditedCents).toBe(2500);
242
- });
243
200
  });
244
201
 
245
202
  // ---------------------------------------------------------------------------
@@ -247,13 +204,13 @@ describe("handleCryptoWebhook (monetization layer)", () => {
247
204
  // ---------------------------------------------------------------------------
248
205
 
249
206
  describe("BotBilling reactivation", () => {
250
- it("calls botBilling.checkReactivation on InvoiceSettled and returns reactivatedBots", async () => {
207
+ it("calls botBilling.checkReactivation on confirmed and returns reactivatedBots", async () => {
251
208
  const mockBotBilling = {
252
209
  checkReactivation: vi.fn().mockResolvedValue(["bot-1", "bot-2"]),
253
210
  } as unknown as BotBilling;
254
211
  const depsWithBots: CryptoWebhookDeps = { ...deps, botBilling: mockBotBilling };
255
212
 
256
- const result = await handleCryptoWebhook(depsWithBots, makePayload({ type: "InvoiceSettled" }));
213
+ const result = await handleCryptoWebhook(depsWithBots, makePayload({ status: "confirmed" }));
257
214
 
258
215
  expect(mockBotBilling.checkReactivation).toHaveBeenCalledWith("tenant-a", creditLedger);
259
216
  expect(result.reactivatedBots).toEqual(["bot-1", "bot-2"]);
@@ -265,18 +222,18 @@ describe("handleCryptoWebhook (monetization layer)", () => {
265
222
  } as unknown as BotBilling;
266
223
  const depsWithBots: CryptoWebhookDeps = { ...deps, botBilling: mockBotBilling };
267
224
 
268
- const result = await handleCryptoWebhook(depsWithBots, makePayload({ type: "InvoiceSettled" }));
225
+ const result = await handleCryptoWebhook(depsWithBots, makePayload({ status: "confirmed" }));
269
226
 
270
227
  expect(result.reactivatedBots).toBeUndefined();
271
228
  });
272
229
 
273
- it("does NOT call botBilling on non-settled events", async () => {
230
+ it("does NOT call botBilling on non-confirmed statuses", async () => {
274
231
  const mockBotBilling = {
275
232
  checkReactivation: vi.fn().mockResolvedValue(["bot-1"]),
276
233
  } as unknown as BotBilling;
277
234
  const depsWithBots: CryptoWebhookDeps = { ...deps, botBilling: mockBotBilling };
278
235
 
279
- await handleCryptoWebhook(depsWithBots, makePayload({ type: "InvoiceProcessing" }));
236
+ await handleCryptoWebhook(depsWithBots, makePayload({ status: "pending" }));
280
237
 
281
238
  expect(mockBotBilling.checkReactivation).not.toHaveBeenCalled();
282
239
  });
@@ -287,18 +244,18 @@ describe("handleCryptoWebhook (monetization layer)", () => {
287
244
  } as unknown as BotBilling;
288
245
  const depsWithBots: CryptoWebhookDeps = { ...deps, botBilling: mockBotBilling };
289
246
 
290
- // First settlement — should call reactivation
291
- await handleCryptoWebhook(depsWithBots, makePayload({ type: "InvoiceSettled" }));
247
+ // First confirmation — should call reactivation
248
+ await handleCryptoWebhook(depsWithBots, makePayload({ status: "confirmed" }));
292
249
  expect(mockBotBilling.checkReactivation).toHaveBeenCalledTimes(1);
293
250
 
294
- // Second settlement — charge already credited, should NOT call reactivation again
295
- await handleCryptoWebhook(depsWithBots, makePayload({ type: "InvoiceSettled" }));
251
+ // Second confirmation — charge already credited, should NOT call reactivation again
252
+ await handleCryptoWebhook(depsWithBots, makePayload({ status: "confirmed" }));
296
253
  expect(mockBotBilling.checkReactivation).toHaveBeenCalledTimes(1);
297
254
  });
298
255
 
299
256
  it("operates correctly when botBilling is not provided", async () => {
300
257
  // No botBilling dependency — should complete without error
301
- const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
258
+ const result = await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
302
259
 
303
260
  expect(result.handled).toBe(true);
304
261
  expect(result.creditedCents).toBe(2500);
@@ -311,16 +268,16 @@ describe("handleCryptoWebhook (monetization layer)", () => {
311
268
  // ---------------------------------------------------------------------------
312
269
 
313
270
  describe("multiple tenants", () => {
314
- it("processes invoices for different tenants independently", async () => {
315
- await chargeStore.create("inv-b-001", "tenant-b", 5000);
316
- await chargeStore.create("inv-c-001", "tenant-c", 1500);
271
+ it("processes charges for different tenants independently", async () => {
272
+ await chargeStore.create("chg-b-001", "tenant-b", 5000);
273
+ await chargeStore.create("chg-c-001", "tenant-c", 1500);
317
274
 
318
- await handleCryptoWebhook(deps, makePayload({ invoiceId: "inv-b-001", type: "InvoiceSettled" }));
319
- await handleCryptoWebhook(deps, makePayload({ invoiceId: "inv-c-001", type: "InvoiceSettled" }));
275
+ await handleCryptoWebhook(deps, makePayload({ chargeId: "chg-b-001", status: "confirmed" }));
276
+ await handleCryptoWebhook(deps, makePayload({ chargeId: "chg-c-001", status: "confirmed" }));
320
277
 
321
278
  expect((await creditLedger.balance("tenant-b")).toCents()).toBe(5000);
322
279
  expect((await creditLedger.balance("tenant-c")).toCents()).toBe(1500);
323
- // Original tenant-a was not settled in this test
280
+ // Original tenant-a was not confirmed in this test
324
281
  expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
325
282
  });
326
283
  });
@@ -1,23 +1,21 @@
1
- // Re-export everything from the billing/crypto module.
1
+ // Re-export from billing/crypto module.
2
2
  export type {
3
- CryptoBillingConfig,
4
3
  CryptoChargeRecord,
5
- CryptoCheckoutOpts,
6
4
  CryptoConfig,
7
5
  CryptoPaymentState,
6
+ CryptoWebhookDeps,
8
7
  CryptoWebhookPayload,
9
8
  CryptoWebhookResult,
10
9
  ICryptoChargeRepository,
11
- } from "@wopr-network/platform-core/billing";
10
+ } from "../../billing/crypto/index.js";
12
11
  export {
13
- BTCPayClient,
14
12
  CryptoChargeRepository,
15
- createCryptoCheckout,
13
+ CryptoServiceClient,
16
14
  DrizzleCryptoChargeRepository,
15
+ handleCryptoWebhook,
16
+ handleKeyServerWebhook,
17
17
  loadCryptoConfig,
18
18
  MIN_PAYMENT_USD,
19
- mapBtcPayEventToStatus,
20
- verifyCryptoWebhookSignature,
21
- } from "@wopr-network/platform-core/billing";
22
- export type { CryptoWebhookDeps } from "./webhook.js";
23
- export { handleCryptoWebhook } from "./webhook.js";
19
+ } from "../../billing/crypto/index.js";
20
+ export type { CryptoWebhookDeps as WoprCryptoWebhookDeps } from "./webhook.js";
21
+ export { handleCryptoWebhook as handleWoprCryptoWebhook } from "./webhook.js";
@@ -1,12 +1,7 @@
1
- import type {
2
- CryptoWebhookPayload,
3
- CryptoWebhookResult,
4
- ICryptoChargeRepository,
5
- IWebhookSeenRepository,
6
- } from "@wopr-network/platform-core/billing";
7
- import { mapBtcPayEventToStatus } from "@wopr-network/platform-core/billing";
8
- import type { ILedger } from "@wopr-network/platform-core/credits";
9
- import { Credit } from "@wopr-network/platform-core/credits";
1
+ import type { CryptoWebhookPayload, ICryptoChargeRepository } from "../../billing/crypto/index.js";
2
+ import { handleKeyServerWebhook } from "../../billing/crypto/key-server-webhook.js";
3
+ import type { IWebhookSeenRepository } from "../../billing/webhook-seen-repository.js";
4
+ import type { ILedger } from "../../credits/ledger.js";
10
5
  import type { BotBilling } from "../credits/bot-billing.js";
11
6
 
12
7
  export interface CryptoWebhookDeps {
@@ -17,99 +12,30 @@ export interface CryptoWebhookDeps {
17
12
  }
18
13
 
19
14
  /**
20
- * Process a BTCPay Server webhook event (WOPR-specific version).
15
+ * Process a crypto payment webhook from the key server (WOPR-specific version).
21
16
  *
22
- * Only credits the ledger on InvoiceSettled.
23
- * Uses botBilling.checkReactivation for WOPR bot suspension recovery.
24
- *
25
- * Idempotency strategy (matches Stripe webhook pattern):
26
- * Primary: `creditLedger.hasReferenceId("crypto:<invoiceId>")` — atomic,
27
- * checked inside the ledger's serialized transaction.
28
- * Secondary: `chargeStore.markCredited()` — advisory flag for queries.
29
- *
30
- * CRITICAL: charge.amountUsdCents is in USD cents (integer).
31
- * Credit.fromCents() converts cents → nanodollars for the ledger.
17
+ * Delegates to handleKeyServerWebhook() for charge lookup, ledger crediting,
18
+ * and idempotency. Adds WOPR-specific bot reactivation via botBilling.
32
19
  */
33
20
  export async function handleCryptoWebhook(
34
21
  deps: CryptoWebhookDeps,
35
22
  payload: CryptoWebhookPayload,
36
- ): Promise<CryptoWebhookResult> {
37
- const { chargeStore, creditLedger } = deps;
38
-
39
- // Replay guard FIRST: deduplicate by invoiceId + event type.
40
- // Must run before mapBtcPayEventToStatus() — unknown event types throw,
41
- // and BTCPay retries webhooks on failure. Without this ordering, an unknown
42
- // event type causes an infinite retry loop.
43
- const dedupeKey = `${payload.invoiceId}:${payload.type}`;
44
- if (await deps.replayGuard.isDuplicate(dedupeKey, "crypto")) {
45
- return { handled: true, status: "New", duplicate: true };
46
- }
47
-
48
- // Map BTCPay event type to a CryptoPaymentState (throws on unknown types).
49
- const status = mapBtcPayEventToStatus(payload.type);
50
-
51
- // Look up the charge record to find the tenant.
52
- const charge = await chargeStore.getByReferenceId(payload.invoiceId);
53
- if (!charge) {
54
- return { handled: false, status };
55
- }
56
-
57
- // Update charge status regardless of event type.
58
- await chargeStore.updateStatus(payload.invoiceId, status);
59
-
60
- let result: CryptoWebhookResult;
61
-
62
- if (payload.type === "InvoiceSettled") {
63
- // Idempotency: use ledger referenceId check (same pattern as Stripe webhook).
64
- // This is atomic — the referenceId is checked inside the ledger's serialized
65
- // transaction, eliminating the TOCTOU race of isCredited() + creditLedger().
66
- const creditRef = `crypto:${payload.invoiceId}`;
67
- if (await creditLedger.hasReferenceId(creditRef)) {
68
- result = {
69
- handled: true,
70
- status,
71
- tenant: charge.tenantId,
72
- creditedCents: 0,
73
- };
74
- } else {
75
- // Credit the original USD amount requested (not the crypto amount).
76
- // charge.amountUsdCents is in USD cents (integer).
77
- // Credit.fromCents() converts to nanodollars for the ledger.
78
- const creditCents = charge.amountUsdCents;
79
-
80
- await creditLedger.credit(charge.tenantId, Credit.fromCents(creditCents), "purchase", {
81
- description: `Crypto credit purchase via BTCPay (invoice: ${payload.invoiceId})`,
82
- referenceId: creditRef,
83
- fundingSource: "crypto",
84
- });
85
-
86
- // Mark credited (advisory — primary idempotency is the ledger referenceId above).
87
- await chargeStore.markCredited(payload.invoiceId);
88
-
89
- // Reactivate suspended bots (same as Stripe webhook).
90
- let reactivatedBots: string[] | undefined;
91
- if (deps.botBilling) {
92
- reactivatedBots = await deps.botBilling.checkReactivation(charge.tenantId, creditLedger);
93
- if (reactivatedBots.length === 0) reactivatedBots = undefined;
94
- }
95
-
96
- result = {
97
- handled: true,
98
- status,
99
- tenant: charge.tenantId,
100
- creditedCents: creditCents,
101
- reactivatedBots,
102
- };
103
- }
104
- } else {
105
- // New, Processing, Expired, Invalid — just track status.
106
- result = {
107
- handled: true,
108
- status,
109
- tenant: charge.tenantId,
110
- };
111
- }
112
-
113
- await deps.replayGuard.markSeen(dedupeKey, "crypto");
114
- return result;
23
+ ): Promise<{
24
+ handled: boolean;
25
+ duplicate?: boolean;
26
+ tenant?: string;
27
+ creditedCents?: number;
28
+ reactivatedBots?: string[];
29
+ }> {
30
+ return handleKeyServerWebhook(
31
+ {
32
+ chargeStore: deps.chargeStore,
33
+ creditLedger: deps.creditLedger,
34
+ replayGuard: deps.replayGuard,
35
+ onCreditsPurchased: deps.botBilling
36
+ ? (tenantId, ledger) => deps.botBilling?.checkReactivation(tenantId, ledger) ?? Promise.resolve([])
37
+ : undefined,
38
+ },
39
+ payload,
40
+ );
115
41
  }
@@ -158,10 +158,8 @@ export {
158
158
  SIGNUP_GRANT,
159
159
  SUSPENSION_GRACE_DAYS,
160
160
  } from "./credits/index.js";
161
- // Crypto payments (BTCPay Server)
161
+ // Crypto payments (key server — native BTC/EVM watchers, no BTCPay)
162
162
  export type {
163
- CryptoBillingConfig,
164
- CryptoCheckoutOpts,
165
163
  CryptoConfig,
166
164
  CryptoPaymentState,
167
165
  CryptoWebhookDeps,
@@ -169,15 +167,13 @@ export type {
169
167
  CryptoWebhookResult,
170
168
  } from "./crypto/index.js";
171
169
  export {
172
- BTCPayClient,
173
170
  CryptoChargeRepository,
174
- createCryptoCheckout,
171
+ CryptoServiceClient,
175
172
  DrizzleCryptoChargeRepository,
176
173
  handleCryptoWebhook,
174
+ handleKeyServerWebhook,
177
175
  loadCryptoConfig,
178
176
  MIN_PAYMENT_USD,
179
- mapBtcPayEventToStatus,
180
- verifyCryptoWebhookSignature,
181
177
  } from "./crypto/index.js";
182
178
  // Feature gating middleware (WOP-384 — replaced tier gates with balance gates)
183
179
  export {
@@ -1,18 +0,0 @@
1
- import type { ICryptoChargeRepository } from "./charge-store.js";
2
- import type { BTCPayClient } from "./client.js";
3
- import type { CryptoCheckoutOpts } from "./types.js";
4
- /** Minimum payment amount in USD. */
5
- export declare const MIN_PAYMENT_USD = 10;
6
- /**
7
- * Create a BTCPay invoice and store the charge record.
8
- *
9
- * Returns the BTCPay-hosted checkout page URL and invoice ID.
10
- * The user is redirected to checkoutLink to complete the crypto payment.
11
- *
12
- * NOTE: amountUsd is converted to cents (integer) for the charge store.
13
- * The charge store holds USD cents, NOT nanodollars.
14
- */
15
- export declare function createCryptoCheckout(client: BTCPayClient, chargeStore: ICryptoChargeRepository, opts: CryptoCheckoutOpts): Promise<{
16
- referenceId: string;
17
- url: string;
18
- }>;
@@ -1,35 +0,0 @@
1
- import crypto from "node:crypto";
2
- import { Credit } from "../../credits/credit.js";
3
- /** Minimum payment amount in USD. */
4
- export const MIN_PAYMENT_USD = 10;
5
- /**
6
- * Create a BTCPay invoice and store the charge record.
7
- *
8
- * Returns the BTCPay-hosted checkout page URL and invoice ID.
9
- * The user is redirected to checkoutLink to complete the crypto payment.
10
- *
11
- * NOTE: amountUsd is converted to cents (integer) for the charge store.
12
- * The charge store holds USD cents, NOT nanodollars.
13
- */
14
- export async function createCryptoCheckout(client, chargeStore, opts) {
15
- if (opts.amountUsd < MIN_PAYMENT_USD) {
16
- throw new Error(`Minimum payment amount is $${MIN_PAYMENT_USD}`);
17
- }
18
- const orderId = `crypto:${opts.tenant}:${crypto.randomUUID()}`;
19
- const invoice = await client.createInvoice({
20
- amountUsd: opts.amountUsd,
21
- orderId,
22
- buyerEmail: `${opts.tenant}@${process.env.PLATFORM_DOMAIN ?? "wopr.bot"}`,
23
- });
24
- // Store the charge record for webhook correlation.
25
- // amountUsdCents = USD * 100 (cents, NOT nanodollars).
26
- // Credit.fromDollars() handles the float → integer boundary safely via Math.round
27
- // on the nanodollar scale, then toCentsRounded() converts back to integer cents.
28
- // This avoids direct floating-point multiplication for the cents conversion.
29
- const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
30
- await chargeStore.create(invoice.id, opts.tenant, amountUsdCents);
31
- return {
32
- referenceId: invoice.id,
33
- url: invoice.checkoutLink,
34
- };
35
- }