@wopr-network/platform-core 1.43.0 → 1.44.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/.env.example +20 -0
  2. package/dist/billing/crypto/__tests__/key-server.test.js +16 -1
  3. package/dist/billing/crypto/btc/watcher.d.ts +2 -0
  4. package/dist/billing/crypto/btc/watcher.js +1 -1
  5. package/dist/billing/crypto/charge-store.d.ts +7 -1
  6. package/dist/billing/crypto/charge-store.js +7 -1
  7. package/dist/billing/crypto/client.d.ts +0 -26
  8. package/dist/billing/crypto/client.js +0 -13
  9. package/dist/billing/crypto/client.test.js +1 -11
  10. package/dist/billing/crypto/index.d.ts +5 -7
  11. package/dist/billing/crypto/index.js +3 -5
  12. package/dist/billing/crypto/key-server-entry.js +43 -2
  13. package/dist/billing/crypto/key-server-webhook.d.ts +33 -0
  14. package/dist/billing/crypto/key-server-webhook.js +73 -0
  15. package/dist/billing/crypto/key-server.d.ts +2 -0
  16. package/dist/billing/crypto/key-server.js +25 -1
  17. package/dist/billing/crypto/watcher-service.d.ts +33 -0
  18. package/dist/billing/crypto/watcher-service.js +295 -0
  19. package/dist/billing/index.js +1 -1
  20. package/dist/db/schema/crypto.d.ts +217 -2
  21. package/dist/db/schema/crypto.js +25 -2
  22. package/dist/monetization/crypto/__tests__/webhook.test.js +57 -92
  23. package/dist/monetization/crypto/index.d.ts +4 -4
  24. package/dist/monetization/crypto/index.js +2 -2
  25. package/dist/monetization/crypto/webhook.d.ts +13 -14
  26. package/dist/monetization/crypto/webhook.js +12 -83
  27. package/dist/monetization/index.d.ts +2 -2
  28. package/dist/monetization/index.js +1 -1
  29. package/drizzle/migrations/0015_callback_url.sql +32 -0
  30. package/drizzle/migrations/meta/_journal.json +7 -0
  31. package/package.json +1 -1
  32. package/src/billing/crypto/__tests__/key-server.test.ts +16 -1
  33. package/src/billing/crypto/btc/watcher.ts +3 -1
  34. package/src/billing/crypto/charge-store.ts +13 -1
  35. package/src/billing/crypto/client.test.ts +1 -13
  36. package/src/billing/crypto/client.ts +0 -21
  37. package/src/billing/crypto/index.ts +9 -13
  38. package/src/billing/crypto/key-server-entry.ts +46 -2
  39. package/src/billing/crypto/key-server-webhook.ts +119 -0
  40. package/src/billing/crypto/key-server.ts +29 -1
  41. package/src/billing/crypto/watcher-service.ts +381 -0
  42. package/src/billing/index.ts +1 -1
  43. package/src/db/schema/crypto.ts +30 -2
  44. package/src/monetization/crypto/__tests__/webhook.test.ts +61 -104
  45. package/src/monetization/crypto/index.ts +9 -11
  46. package/src/monetization/crypto/webhook.ts +25 -99
  47. package/src/monetization/index.ts +3 -7
  48. package/dist/billing/crypto/checkout.d.ts +0 -18
  49. package/dist/billing/crypto/checkout.js +0 -35
  50. package/dist/billing/crypto/checkout.test.d.ts +0 -1
  51. package/dist/billing/crypto/checkout.test.js +0 -71
  52. package/dist/billing/crypto/webhook.d.ts +0 -34
  53. package/dist/billing/crypto/webhook.js +0 -107
  54. package/dist/billing/crypto/webhook.test.d.ts +0 -1
  55. package/dist/billing/crypto/webhook.test.js +0 -266
  56. package/src/billing/crypto/checkout.test.ts +0 -93
  57. package/src/billing/crypto/checkout.ts +0 -48
  58. package/src/billing/crypto/webhook.test.ts +0 -340
  59. package/src/billing/crypto/webhook.ts +0 -136
@@ -7,15 +7,12 @@ import { createTestDb, truncateAllTables } from "../../../test/db.js";
7
7
  import { handleCryptoWebhook } from "../webhook.js";
8
8
  function makePayload(overrides = {}) {
9
9
  return {
10
- deliveryId: "del-001",
11
- webhookId: "whk-001",
12
- originalDeliveryId: "del-001",
13
- isRedelivery: false,
14
- type: "InvoiceSettled",
15
- timestamp: Date.now(),
16
- storeId: "store-test",
17
- invoiceId: "inv-test-001",
18
- metadata: { orderId: "order-001" },
10
+ chargeId: "chg-test-001",
11
+ chain: "bitcoin",
12
+ address: "bc1q-test-address",
13
+ amountUsdCents: 2500,
14
+ status: "confirmed",
15
+ txHash: "tx-abc123",
19
16
  ...overrides,
20
17
  };
21
18
  }
@@ -38,160 +35,128 @@ describe("handleCryptoWebhook (monetization layer)", () => {
38
35
  await creditLedger.seedSystemAccounts();
39
36
  deps = { chargeStore, creditLedger, replayGuard: noOpReplayGuard };
40
37
  // Default test charge: $25 = 2500 cents
41
- await chargeStore.create("inv-test-001", "tenant-a", 2500);
38
+ await chargeStore.create("chg-test-001", "tenant-a", 2500);
42
39
  });
43
40
  // ---------------------------------------------------------------------------
44
- // InvoiceSettled — credits ledger
41
+ // confirmed — credits ledger
45
42
  // ---------------------------------------------------------------------------
46
- describe("InvoiceSettled", () => {
43
+ describe("confirmed status", () => {
47
44
  it("credits the ledger with the USD amount in cents", async () => {
48
- const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
45
+ const result = await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
49
46
  expect(result.handled).toBe(true);
50
- expect(result.status).toBe("Settled");
51
47
  expect(result.tenant).toBe("tenant-a");
52
48
  expect(result.creditedCents).toBe(2500);
53
49
  const balance = await creditLedger.balance("tenant-a");
54
50
  expect(balance.toCents()).toBe(2500);
55
51
  });
56
- it("marks the charge as credited after settlement", async () => {
57
- await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
58
- expect(await chargeStore.isCredited("inv-test-001")).toBe(true);
52
+ it("marks the charge as credited after confirmation", async () => {
53
+ await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
54
+ expect(await chargeStore.isCredited("chg-test-001")).toBe(true);
59
55
  });
60
56
  it("uses crypto: prefix on reference ID in ledger entry", async () => {
61
- await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
57
+ await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
62
58
  const history = await creditLedger.history("tenant-a");
63
59
  expect(history).toHaveLength(1);
64
- expect(history[0].referenceId).toBe("crypto:inv-test-001");
60
+ expect(history[0].referenceId).toBe("crypto:chg-test-001");
65
61
  expect(history[0].entryType).toBe("purchase");
66
62
  });
67
63
  it("records fundingSource as crypto", async () => {
68
- await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
64
+ await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
69
65
  const history = await creditLedger.history("tenant-a");
70
66
  expect(history[0].metadata?.fundingSource).toBe("crypto");
71
67
  });
72
- it("is idempotent — second InvoiceSettled does not double-credit", async () => {
73
- await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
74
- const result2 = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
68
+ it("is idempotent — second confirmed does not double-credit", async () => {
69
+ await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
70
+ const result2 = await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
75
71
  expect(result2.handled).toBe(true);
76
- expect(result2.creditedCents).toBe(0);
72
+ expect(result2.creditedCents).toBeUndefined();
77
73
  // Balance is still $25, not $50
78
74
  const balance = await creditLedger.balance("tenant-a");
79
75
  expect(balance.toCents()).toBe(2500);
80
76
  });
81
77
  });
82
78
  // ---------------------------------------------------------------------------
83
- // Non-settlement event types — no ledger credit
79
+ // Non-confirmed statuses — no ledger credit
84
80
  // ---------------------------------------------------------------------------
85
- describe("InvoiceProcessing", () => {
81
+ describe("pending status", () => {
86
82
  it("does NOT credit the ledger", async () => {
87
- const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceProcessing" }));
83
+ const result = await handleCryptoWebhook(deps, makePayload({ status: "pending" }));
88
84
  expect(result.handled).toBe(true);
89
85
  expect(result.tenant).toBe("tenant-a");
90
86
  expect(result.creditedCents).toBeUndefined();
91
87
  expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
92
88
  });
93
89
  });
94
- describe("InvoiceCreated", () => {
90
+ describe("expired status", () => {
95
91
  it("does NOT credit the ledger", async () => {
96
- const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceCreated" }));
92
+ const result = await handleCryptoWebhook(deps, makePayload({ status: "expired" }));
97
93
  expect(result.handled).toBe(true);
98
94
  expect(result.creditedCents).toBeUndefined();
99
95
  expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
100
96
  });
101
97
  });
102
- describe("InvoiceExpired", () => {
98
+ describe("failed status", () => {
103
99
  it("does NOT credit the ledger", async () => {
104
- const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceExpired" }));
105
- expect(result.handled).toBe(true);
106
- expect(result.creditedCents).toBeUndefined();
107
- expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
108
- });
109
- });
110
- describe("InvoiceInvalid", () => {
111
- it("does NOT credit the ledger", async () => {
112
- const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceInvalid" }));
100
+ const result = await handleCryptoWebhook(deps, makePayload({ status: "failed" }));
113
101
  expect(result.handled).toBe(true);
114
102
  expect(result.creditedCents).toBeUndefined();
115
103
  expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
116
104
  });
117
105
  });
118
106
  // ---------------------------------------------------------------------------
119
- // Unknown invoiceId — returns handled:false
107
+ // Unknown chargeId — returns handled:false
120
108
  // ---------------------------------------------------------------------------
121
109
  describe("missing charge", () => {
122
- it("returns handled:false when invoiceId is not in the charge store", async () => {
123
- const result = await handleCryptoWebhook(deps, makePayload({ invoiceId: "inv-unknown-999" }));
110
+ it("returns handled:false when chargeId is not in the charge store", async () => {
111
+ const result = await handleCryptoWebhook(deps, makePayload({ chargeId: "chg-unknown-999" }));
124
112
  expect(result.handled).toBe(false);
125
113
  expect(result.tenant).toBeUndefined();
126
114
  });
127
115
  });
128
116
  // ---------------------------------------------------------------------------
129
- // Status mapping for all known event types
130
- // ---------------------------------------------------------------------------
131
- describe("status mapping", () => {
132
- it.each([
133
- ["InvoiceCreated", "New"],
134
- ["InvoiceProcessing", "Processing"],
135
- ["InvoiceReceivedPayment", "Processing"],
136
- ["InvoiceSettled", "Settled"],
137
- ["InvoicePaymentSettled", "Settled"],
138
- ["InvoiceExpired", "Expired"],
139
- ["InvoiceInvalid", "Invalid"],
140
- ])("maps %s event to %s status", async (eventType, expectedStatus) => {
141
- const result = await handleCryptoWebhook(deps, makePayload({ type: eventType }));
142
- expect(result.status).toBe(expectedStatus);
143
- });
144
- it("throws on unknown event types", async () => {
145
- await expect(handleCryptoWebhook(deps, makePayload({ type: "InvoiceSomeUnknownEvent" }))).rejects.toThrow("Unknown BTCPay event type: InvoiceSomeUnknownEvent");
146
- });
147
- });
148
- // ---------------------------------------------------------------------------
149
117
  // Charge store status updates
150
118
  // ---------------------------------------------------------------------------
151
119
  describe("charge store updates", () => {
152
120
  it("updates charge status on every webhook call", async () => {
153
- await handleCryptoWebhook(deps, makePayload({ type: "InvoiceProcessing" }));
154
- const charge = await chargeStore.getByReferenceId("inv-test-001");
155
- expect(charge?.status).toBe("Processing");
121
+ await handleCryptoWebhook(deps, makePayload({ status: "partial" }));
122
+ const charge = await chargeStore.getByReferenceId("chg-test-001");
123
+ expect(charge?.status).toBe("partial");
124
+ });
125
+ it("settles charge when status is confirmed", async () => {
126
+ await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
127
+ const charge = await chargeStore.getByReferenceId("chg-test-001");
128
+ expect(charge?.status).toBe("Settled");
156
129
  });
157
130
  });
158
131
  // ---------------------------------------------------------------------------
159
132
  // Replay guard / idempotency
160
133
  // ---------------------------------------------------------------------------
161
134
  describe("replay guard", () => {
162
- it("blocks duplicate invoiceId + event type combinations", async () => {
135
+ it("blocks duplicate chargeId via ks: dedupe key", async () => {
163
136
  const replayGuard = new DrizzleWebhookSeenRepository(db);
164
137
  const depsWithGuard = { ...deps, replayGuard };
165
- const first = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
138
+ const first = await handleCryptoWebhook(depsWithGuard, makePayload({ status: "confirmed" }));
166
139
  expect(first.handled).toBe(true);
167
140
  expect(first.creditedCents).toBe(2500);
168
141
  expect(first.duplicate).toBeUndefined();
169
- const second = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
142
+ const second = await handleCryptoWebhook(depsWithGuard, makePayload({ status: "confirmed" }));
170
143
  expect(second.handled).toBe(true);
171
144
  expect(second.duplicate).toBe(true);
172
145
  expect(second.creditedCents).toBeUndefined();
173
146
  // Balance is still $25, not $50
174
147
  expect((await creditLedger.balance("tenant-a")).toCents()).toBe(2500);
175
148
  });
176
- it("same invoice with a different event type is not blocked", async () => {
177
- const replayGuard = new DrizzleWebhookSeenRepository(db);
178
- const depsWithGuard = { ...deps, replayGuard };
179
- await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceProcessing" }));
180
- const result = await handleCryptoWebhook(depsWithGuard, makePayload({ type: "InvoiceSettled" }));
181
- expect(result.duplicate).toBeUndefined();
182
- expect(result.creditedCents).toBe(2500);
183
- });
184
149
  });
185
150
  // ---------------------------------------------------------------------------
186
151
  // BotBilling reactivation — WOPR-specific behaviour
187
152
  // ---------------------------------------------------------------------------
188
153
  describe("BotBilling reactivation", () => {
189
- it("calls botBilling.checkReactivation on InvoiceSettled and returns reactivatedBots", async () => {
154
+ it("calls botBilling.checkReactivation on confirmed and returns reactivatedBots", async () => {
190
155
  const mockBotBilling = {
191
156
  checkReactivation: vi.fn().mockResolvedValue(["bot-1", "bot-2"]),
192
157
  };
193
158
  const depsWithBots = { ...deps, botBilling: mockBotBilling };
194
- const result = await handleCryptoWebhook(depsWithBots, makePayload({ type: "InvoiceSettled" }));
159
+ const result = await handleCryptoWebhook(depsWithBots, makePayload({ status: "confirmed" }));
195
160
  expect(mockBotBilling.checkReactivation).toHaveBeenCalledWith("tenant-a", creditLedger);
196
161
  expect(result.reactivatedBots).toEqual(["bot-1", "bot-2"]);
197
162
  });
@@ -200,15 +165,15 @@ describe("handleCryptoWebhook (monetization layer)", () => {
200
165
  checkReactivation: vi.fn().mockResolvedValue([]),
201
166
  };
202
167
  const depsWithBots = { ...deps, botBilling: mockBotBilling };
203
- const result = await handleCryptoWebhook(depsWithBots, makePayload({ type: "InvoiceSettled" }));
168
+ const result = await handleCryptoWebhook(depsWithBots, makePayload({ status: "confirmed" }));
204
169
  expect(result.reactivatedBots).toBeUndefined();
205
170
  });
206
- it("does NOT call botBilling on non-settled events", async () => {
171
+ it("does NOT call botBilling on non-confirmed statuses", async () => {
207
172
  const mockBotBilling = {
208
173
  checkReactivation: vi.fn().mockResolvedValue(["bot-1"]),
209
174
  };
210
175
  const depsWithBots = { ...deps, botBilling: mockBotBilling };
211
- await handleCryptoWebhook(depsWithBots, makePayload({ type: "InvoiceProcessing" }));
176
+ await handleCryptoWebhook(depsWithBots, makePayload({ status: "pending" }));
212
177
  expect(mockBotBilling.checkReactivation).not.toHaveBeenCalled();
213
178
  });
214
179
  it("does NOT call botBilling when charge is already credited (idempotency path)", async () => {
@@ -216,16 +181,16 @@ describe("handleCryptoWebhook (monetization layer)", () => {
216
181
  checkReactivation: vi.fn().mockResolvedValue(["bot-1"]),
217
182
  };
218
183
  const depsWithBots = { ...deps, botBilling: mockBotBilling };
219
- // First settlement — should call reactivation
220
- await handleCryptoWebhook(depsWithBots, makePayload({ type: "InvoiceSettled" }));
184
+ // First confirmation — should call reactivation
185
+ await handleCryptoWebhook(depsWithBots, makePayload({ status: "confirmed" }));
221
186
  expect(mockBotBilling.checkReactivation).toHaveBeenCalledTimes(1);
222
- // Second settlement — charge already credited, should NOT call reactivation again
223
- await handleCryptoWebhook(depsWithBots, makePayload({ type: "InvoiceSettled" }));
187
+ // Second confirmation — charge already credited, should NOT call reactivation again
188
+ await handleCryptoWebhook(depsWithBots, makePayload({ status: "confirmed" }));
224
189
  expect(mockBotBilling.checkReactivation).toHaveBeenCalledTimes(1);
225
190
  });
226
191
  it("operates correctly when botBilling is not provided", async () => {
227
192
  // No botBilling dependency — should complete without error
228
- const result = await handleCryptoWebhook(deps, makePayload({ type: "InvoiceSettled" }));
193
+ const result = await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
229
194
  expect(result.handled).toBe(true);
230
195
  expect(result.creditedCents).toBe(2500);
231
196
  expect(result.reactivatedBots).toBeUndefined();
@@ -235,14 +200,14 @@ describe("handleCryptoWebhook (monetization layer)", () => {
235
200
  // Multiple tenants — independent processing
236
201
  // ---------------------------------------------------------------------------
237
202
  describe("multiple tenants", () => {
238
- it("processes invoices for different tenants independently", async () => {
239
- await chargeStore.create("inv-b-001", "tenant-b", 5000);
240
- await chargeStore.create("inv-c-001", "tenant-c", 1500);
241
- await handleCryptoWebhook(deps, makePayload({ invoiceId: "inv-b-001", type: "InvoiceSettled" }));
242
- await handleCryptoWebhook(deps, makePayload({ invoiceId: "inv-c-001", type: "InvoiceSettled" }));
203
+ it("processes charges for different tenants independently", async () => {
204
+ await chargeStore.create("chg-b-001", "tenant-b", 5000);
205
+ await chargeStore.create("chg-c-001", "tenant-c", 1500);
206
+ await handleCryptoWebhook(deps, makePayload({ chargeId: "chg-b-001", status: "confirmed" }));
207
+ await handleCryptoWebhook(deps, makePayload({ chargeId: "chg-c-001", status: "confirmed" }));
243
208
  expect((await creditLedger.balance("tenant-b")).toCents()).toBe(5000);
244
209
  expect((await creditLedger.balance("tenant-c")).toCents()).toBe(1500);
245
- // Original tenant-a was not settled in this test
210
+ // Original tenant-a was not confirmed in this test
246
211
  expect((await creditLedger.balance("tenant-a")).toCents()).toBe(0);
247
212
  });
248
213
  });
@@ -1,4 +1,4 @@
1
- export type { CryptoBillingConfig, CryptoChargeRecord, CryptoCheckoutOpts, CryptoConfig, CryptoPaymentState, CryptoWebhookPayload, CryptoWebhookResult, ICryptoChargeRepository, } from "@wopr-network/platform-core/billing";
2
- export { BTCPayClient, CryptoChargeRepository, createCryptoCheckout, DrizzleCryptoChargeRepository, loadCryptoConfig, MIN_PAYMENT_USD, mapBtcPayEventToStatus, verifyCryptoWebhookSignature, } from "@wopr-network/platform-core/billing";
3
- export type { CryptoWebhookDeps } from "./webhook.js";
4
- export { handleCryptoWebhook } from "./webhook.js";
1
+ export type { CryptoChargeRecord, CryptoConfig, CryptoPaymentState, CryptoWebhookDeps, CryptoWebhookPayload, CryptoWebhookResult, ICryptoChargeRepository, } from "../../billing/crypto/index.js";
2
+ export { CryptoChargeRepository, CryptoServiceClient, DrizzleCryptoChargeRepository, handleCryptoWebhook, handleKeyServerWebhook, loadCryptoConfig, MIN_PAYMENT_USD, } from "../../billing/crypto/index.js";
3
+ export type { CryptoWebhookDeps as WoprCryptoWebhookDeps } from "./webhook.js";
4
+ export { handleCryptoWebhook as handleWoprCryptoWebhook } from "./webhook.js";
@@ -1,2 +1,2 @@
1
- export { BTCPayClient, CryptoChargeRepository, createCryptoCheckout, DrizzleCryptoChargeRepository, loadCryptoConfig, MIN_PAYMENT_USD, mapBtcPayEventToStatus, verifyCryptoWebhookSignature, } from "@wopr-network/platform-core/billing";
2
- export { handleCryptoWebhook } from "./webhook.js";
1
+ export { CryptoChargeRepository, CryptoServiceClient, DrizzleCryptoChargeRepository, handleCryptoWebhook, handleKeyServerWebhook, loadCryptoConfig, MIN_PAYMENT_USD, } from "../../billing/crypto/index.js";
2
+ export { handleCryptoWebhook as handleWoprCryptoWebhook } from "./webhook.js";
@@ -1,5 +1,6 @@
1
- import type { CryptoWebhookPayload, CryptoWebhookResult, ICryptoChargeRepository, IWebhookSeenRepository } from "@wopr-network/platform-core/billing";
2
- import type { ILedger } from "@wopr-network/platform-core/credits";
1
+ import type { CryptoWebhookPayload, ICryptoChargeRepository } from "../../billing/crypto/index.js";
2
+ import type { IWebhookSeenRepository } from "../../billing/webhook-seen-repository.js";
3
+ import type { ILedger } from "../../credits/ledger.js";
3
4
  import type { BotBilling } from "../credits/bot-billing.js";
4
5
  export interface CryptoWebhookDeps {
5
6
  chargeStore: ICryptoChargeRepository;
@@ -8,17 +9,15 @@ export interface CryptoWebhookDeps {
8
9
  replayGuard: IWebhookSeenRepository;
9
10
  }
10
11
  /**
11
- * Process a BTCPay Server webhook event (WOPR-specific version).
12
+ * Process a crypto payment webhook from the key server (WOPR-specific version).
12
13
  *
13
- * Only credits the ledger on InvoiceSettled.
14
- * Uses botBilling.checkReactivation for WOPR bot suspension recovery.
15
- *
16
- * Idempotency strategy (matches Stripe webhook pattern):
17
- * Primary: `creditLedger.hasReferenceId("crypto:<invoiceId>")` — atomic,
18
- * checked inside the ledger's serialized transaction.
19
- * Secondary: `chargeStore.markCredited()` — advisory flag for queries.
20
- *
21
- * CRITICAL: charge.amountUsdCents is in USD cents (integer).
22
- * Credit.fromCents() converts cents → nanodollars for the ledger.
14
+ * Delegates to handleKeyServerWebhook() for charge lookup, ledger crediting,
15
+ * and idempotency. Adds WOPR-specific bot reactivation via botBilling.
23
16
  */
24
- export declare function handleCryptoWebhook(deps: CryptoWebhookDeps, payload: CryptoWebhookPayload): Promise<CryptoWebhookResult>;
17
+ export declare function handleCryptoWebhook(deps: CryptoWebhookDeps, payload: CryptoWebhookPayload): Promise<{
18
+ handled: boolean;
19
+ duplicate?: boolean;
20
+ tenant?: string;
21
+ creditedCents?: number;
22
+ reactivatedBots?: string[];
23
+ }>;
@@ -1,88 +1,17 @@
1
- import { mapBtcPayEventToStatus } from "@wopr-network/platform-core/billing";
2
- import { Credit } from "@wopr-network/platform-core/credits";
1
+ import { handleKeyServerWebhook } from "../../billing/crypto/key-server-webhook.js";
3
2
  /**
4
- * Process a BTCPay Server webhook event (WOPR-specific version).
3
+ * Process a crypto payment webhook from the key server (WOPR-specific version).
5
4
  *
6
- * Only credits the ledger on InvoiceSettled.
7
- * Uses botBilling.checkReactivation for WOPR bot suspension recovery.
8
- *
9
- * Idempotency strategy (matches Stripe webhook pattern):
10
- * Primary: `creditLedger.hasReferenceId("crypto:<invoiceId>")` — atomic,
11
- * checked inside the ledger's serialized transaction.
12
- * Secondary: `chargeStore.markCredited()` — advisory flag for queries.
13
- *
14
- * CRITICAL: charge.amountUsdCents is in USD cents (integer).
15
- * Credit.fromCents() converts cents → nanodollars for the ledger.
5
+ * Delegates to handleKeyServerWebhook() for charge lookup, ledger crediting,
6
+ * and idempotency. Adds WOPR-specific bot reactivation via botBilling.
16
7
  */
17
8
  export async function handleCryptoWebhook(deps, payload) {
18
- const { chargeStore, creditLedger } = deps;
19
- // Replay guard FIRST: deduplicate by invoiceId + event type.
20
- // Must run before mapBtcPayEventToStatus() — unknown event types throw,
21
- // and BTCPay retries webhooks on failure. Without this ordering, an unknown
22
- // event type causes an infinite retry loop.
23
- const dedupeKey = `${payload.invoiceId}:${payload.type}`;
24
- if (await deps.replayGuard.isDuplicate(dedupeKey, "crypto")) {
25
- return { handled: true, status: "New", duplicate: true };
26
- }
27
- // Map BTCPay event type to a CryptoPaymentState (throws on unknown types).
28
- const status = mapBtcPayEventToStatus(payload.type);
29
- // Look up the charge record to find the tenant.
30
- const charge = await chargeStore.getByReferenceId(payload.invoiceId);
31
- if (!charge) {
32
- return { handled: false, status };
33
- }
34
- // Update charge status regardless of event type.
35
- await chargeStore.updateStatus(payload.invoiceId, status);
36
- let result;
37
- if (payload.type === "InvoiceSettled") {
38
- // Idempotency: use ledger referenceId check (same pattern as Stripe webhook).
39
- // This is atomic — the referenceId is checked inside the ledger's serialized
40
- // transaction, eliminating the TOCTOU race of isCredited() + creditLedger().
41
- const creditRef = `crypto:${payload.invoiceId}`;
42
- if (await creditLedger.hasReferenceId(creditRef)) {
43
- result = {
44
- handled: true,
45
- status,
46
- tenant: charge.tenantId,
47
- creditedCents: 0,
48
- };
49
- }
50
- else {
51
- // Credit the original USD amount requested (not the crypto amount).
52
- // charge.amountUsdCents is in USD cents (integer).
53
- // Credit.fromCents() converts to nanodollars for the ledger.
54
- const creditCents = charge.amountUsdCents;
55
- await creditLedger.credit(charge.tenantId, Credit.fromCents(creditCents), "purchase", {
56
- description: `Crypto credit purchase via BTCPay (invoice: ${payload.invoiceId})`,
57
- referenceId: creditRef,
58
- fundingSource: "crypto",
59
- });
60
- // Mark credited (advisory — primary idempotency is the ledger referenceId above).
61
- await chargeStore.markCredited(payload.invoiceId);
62
- // Reactivate suspended bots (same as Stripe webhook).
63
- let reactivatedBots;
64
- if (deps.botBilling) {
65
- reactivatedBots = await deps.botBilling.checkReactivation(charge.tenantId, creditLedger);
66
- if (reactivatedBots.length === 0)
67
- reactivatedBots = undefined;
68
- }
69
- result = {
70
- handled: true,
71
- status,
72
- tenant: charge.tenantId,
73
- creditedCents: creditCents,
74
- reactivatedBots,
75
- };
76
- }
77
- }
78
- else {
79
- // New, Processing, Expired, Invalid — just track status.
80
- result = {
81
- handled: true,
82
- status,
83
- tenant: charge.tenantId,
84
- };
85
- }
86
- await deps.replayGuard.markSeen(dedupeKey, "crypto");
87
- return result;
9
+ return handleKeyServerWebhook({
10
+ chargeStore: deps.chargeStore,
11
+ creditLedger: deps.creditLedger,
12
+ replayGuard: deps.replayGuard,
13
+ onCreditsPurchased: deps.botBilling
14
+ ? (tenantId, ledger) => deps.botBilling?.checkReactivation(tenantId, ledger) ?? Promise.resolve([])
15
+ : undefined,
16
+ }, payload);
88
17
  }
@@ -41,8 +41,8 @@ export type { BudgetCheckerConfig, BudgetCheckResult, SpendLimits } from "./budg
41
41
  export { BudgetChecker, DrizzleBudgetChecker } from "./budget/index.js";
42
42
  export type { BillingState, CreditType, DebitType, GetActiveBotCount, HistoryOptions, ILedger, JournalEntry, OnSuspend, RuntimeCronConfig, RuntimeCronResult, TransactionType, } from "./credits/index.js";
43
43
  export { BotBilling, buildResourceTierCosts, DAILY_BOT_COST, DrizzleBotBilling, DrizzleLedger, grantSignupCredits, InsufficientBalanceError, Ledger, runRuntimeDeductions, SIGNUP_GRANT, SUSPENSION_GRACE_DAYS, } from "./credits/index.js";
44
- export type { CryptoBillingConfig, CryptoCheckoutOpts, CryptoConfig, CryptoPaymentState, CryptoWebhookDeps, CryptoWebhookPayload, CryptoWebhookResult, } from "./crypto/index.js";
45
- export { BTCPayClient, CryptoChargeRepository, createCryptoCheckout, DrizzleCryptoChargeRepository, handleCryptoWebhook, loadCryptoConfig, MIN_PAYMENT_USD, mapBtcPayEventToStatus, verifyCryptoWebhookSignature, } from "./crypto/index.js";
44
+ export type { CryptoConfig, CryptoPaymentState, CryptoWebhookDeps, CryptoWebhookPayload, CryptoWebhookResult, } from "./crypto/index.js";
45
+ export { CryptoChargeRepository, CryptoServiceClient, DrizzleCryptoChargeRepository, handleCryptoWebhook, handleKeyServerWebhook, loadCryptoConfig, MIN_PAYMENT_USD, } from "./crypto/index.js";
46
46
  export { type CreditGateConfig, createBalanceGate, createCreditGate, createFeatureGate, type FeatureGateConfig, type GetUserBalance, type ResolveTenantId, } from "./feature-gate.js";
47
47
  export type { BillingPeriod, BillingPeriodSummary, MeterEventRow, UsageSummary, } from "./metering/index.js";
48
48
  export { DrizzleMeterAggregator, DrizzleMeterEmitter, MeterAggregator, MeterEmitter, } from "./metering/index.js";
@@ -50,7 +50,7 @@ export { withMargin, } from "./adapters/types.js";
50
50
  export { ArbitrageRouter, NoProviderAvailableError, ProviderRegistry, } from "./arbitrage/index.js";
51
51
  export { BudgetChecker, DrizzleBudgetChecker } from "./budget/index.js";
52
52
  export { BotBilling, buildResourceTierCosts, DAILY_BOT_COST, DrizzleBotBilling, DrizzleLedger, grantSignupCredits, InsufficientBalanceError, Ledger, runRuntimeDeductions, SIGNUP_GRANT, SUSPENSION_GRACE_DAYS, } from "./credits/index.js";
53
- export { BTCPayClient, CryptoChargeRepository, createCryptoCheckout, DrizzleCryptoChargeRepository, handleCryptoWebhook, loadCryptoConfig, MIN_PAYMENT_USD, mapBtcPayEventToStatus, verifyCryptoWebhookSignature, } from "./crypto/index.js";
53
+ export { CryptoChargeRepository, CryptoServiceClient, DrizzleCryptoChargeRepository, handleCryptoWebhook, handleKeyServerWebhook, loadCryptoConfig, MIN_PAYMENT_USD, } from "./crypto/index.js";
54
54
  // Feature gating middleware (WOP-384 — replaced tier gates with balance gates)
55
55
  export { createBalanceGate, createCreditGate, createFeatureGate, } from "./feature-gate.js";
56
56
  export { DrizzleMeterAggregator, DrizzleMeterEmitter, MeterAggregator, MeterEmitter, } from "./metering/index.js";
@@ -0,0 +1,32 @@
1
+ -- Watcher service schema additions: webhook outbox + charge amount tracking.
2
+
3
+ -- 1. callback_url for webhook delivery
4
+ ALTER TABLE "crypto_charges" ADD COLUMN "callback_url" text;
5
+ --> statement-breakpoint
6
+
7
+ -- 2. Expected crypto amount in native base units (locked at charge creation)
8
+ ALTER TABLE "crypto_charges" ADD COLUMN "expected_amount" text;
9
+ --> statement-breakpoint
10
+
11
+ -- 3. Running total of received crypto in native base units (partial payments)
12
+ ALTER TABLE "crypto_charges" ADD COLUMN "received_amount" text;
13
+ --> statement-breakpoint
14
+
15
+ -- 4. Webhook delivery outbox — durable retry for payment callbacks
16
+ CREATE TABLE IF NOT EXISTS "webhook_deliveries" (
17
+ "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
18
+ "charge_id" text NOT NULL,
19
+ "callback_url" text NOT NULL,
20
+ "payload" text NOT NULL,
21
+ "status" text NOT NULL DEFAULT 'pending',
22
+ "attempts" integer NOT NULL DEFAULT 0,
23
+ "next_retry_at" text,
24
+ "last_error" text,
25
+ "created_at" text NOT NULL DEFAULT (now())
26
+ );
27
+ --> statement-breakpoint
28
+
29
+ CREATE INDEX IF NOT EXISTS "idx_webhook_deliveries_status" ON "webhook_deliveries" ("status");
30
+ --> statement-breakpoint
31
+
32
+ CREATE INDEX IF NOT EXISTS "idx_webhook_deliveries_charge" ON "webhook_deliveries" ("charge_id");
@@ -106,6 +106,13 @@
106
106
  "when": 1742832000000,
107
107
  "tag": "0014_crypto_key_server",
108
108
  "breakpoints": true
109
+ },
110
+ {
111
+ "idx": 15,
112
+ "version": "7",
113
+ "when": 1742918400000,
114
+ "tag": "0015_callback_url",
115
+ "breakpoints": true
109
116
  }
110
117
  ]
111
118
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.43.0",
3
+ "version": "1.44.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -87,7 +87,21 @@ function mockDeps(): KeyServerDeps & {
87
87
  },
88
88
  ]),
89
89
  listAll: vi.fn(),
90
- getById: vi.fn(),
90
+ getById: vi.fn().mockResolvedValue({
91
+ id: "btc",
92
+ type: "native",
93
+ token: "BTC",
94
+ chain: "bitcoin",
95
+ decimals: 8,
96
+ displayName: "Bitcoin",
97
+ contractAddress: null,
98
+ confirmations: 6,
99
+ oracleAddress: "0x64c911996D3c6aC71f9b455B1E8E7266BcbD848F",
100
+ xpub: null,
101
+ displayOrder: 0,
102
+ enabled: true,
103
+ rpcUrl: null,
104
+ }),
91
105
  listByType: vi.fn(),
92
106
  upsert: vi.fn().mockResolvedValue(undefined),
93
107
  setEnabled: vi.fn().mockResolvedValue(undefined),
@@ -96,6 +110,7 @@ function mockDeps(): KeyServerDeps & {
96
110
  db: createMockDb() as never,
97
111
  chargeStore: chargeStore as never,
98
112
  methodStore: methodStore as never,
113
+ oracle: { getPrice: vi.fn().mockResolvedValue({ priceCents: 6_500_000, updatedAt: new Date() }) } as never,
99
114
  };
100
115
  }
101
116
 
@@ -14,6 +14,8 @@ export interface BtcWatcherOpts {
14
14
  oracle: IPriceOracle;
15
15
  /** Required — BTC has no block cursor, so txid dedup must be persisted. */
16
16
  cursorStore: IWatcherCursorStore;
17
+ /** Override chain identity for cursor namespace (default: config.network). Prevents txid collisions across BTC/LTC/DOGE. */
18
+ chainId?: string;
17
19
  }
18
20
 
19
21
  interface ReceivedByAddress {
@@ -39,7 +41,7 @@ export class BtcWatcher {
39
41
  this.minConfirmations = opts.config.confirmations;
40
42
  this.oracle = opts.oracle;
41
43
  this.cursorStore = opts.cursorStore;
42
- this.watcherId = `btc:${opts.config.network}`;
44
+ this.watcherId = `btc:${opts.chainId ?? opts.config.network}`;
43
45
  }
44
46
 
45
47
  /** Update the set of watched addresses. */
@@ -17,6 +17,9 @@ export interface CryptoChargeRecord {
17
17
  token: string | null;
18
18
  depositAddress: string | null;
19
19
  derivationIndex: number | null;
20
+ callbackUrl: string | null;
21
+ expectedAmount: string | null;
22
+ receivedAmount: string | null;
20
23
  }
21
24
 
22
25
  export interface CryptoDepositChargeInput {
@@ -27,6 +30,9 @@ export interface CryptoDepositChargeInput {
27
30
  token: string;
28
31
  depositAddress: string;
29
32
  derivationIndex: number;
33
+ callbackUrl?: string;
34
+ /** Expected crypto amount in native base units (sats for BTC, base units for ERC20). */
35
+ expectedAmount?: string;
30
36
  }
31
37
 
32
38
  export interface ICryptoChargeRepository {
@@ -50,7 +56,7 @@ export interface ICryptoChargeRepository {
50
56
  /**
51
57
  * Manages crypto charge records in PostgreSQL.
52
58
  *
53
- * Each charge maps a BTCPay invoice ID to a tenant and tracks
59
+ * Each charge maps a deposit address to a tenant and tracks
54
60
  * the payment lifecycle (New → Processing → Settled/Expired/Invalid).
55
61
  *
56
62
  * amountUsdCents stores the requested amount in USD cents (integer).
@@ -92,6 +98,9 @@ export class DrizzleCryptoChargeRepository implements ICryptoChargeRepository {
92
98
  token: row.token ?? null,
93
99
  depositAddress: row.depositAddress ?? null,
94
100
  derivationIndex: row.derivationIndex ?? null,
101
+ callbackUrl: row.callbackUrl ?? null,
102
+ expectedAmount: row.expectedAmount ?? null,
103
+ receivedAmount: row.receivedAmount ?? null,
95
104
  };
96
105
  }
97
106
 
@@ -146,6 +155,9 @@ export class DrizzleCryptoChargeRepository implements ICryptoChargeRepository {
146
155
  token: input.token,
147
156
  depositAddress: input.depositAddress.toLowerCase(),
148
157
  derivationIndex: input.derivationIndex,
158
+ callbackUrl: input.callbackUrl,
159
+ expectedAmount: input.expectedAmount,
160
+ receivedAmount: "0",
149
161
  });
150
162
  }
151
163