@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
@@ -1,100 +1,83 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { BTCPayClient, loadCryptoConfig } from "./client.js";
3
- describe("BTCPayClient", () => {
4
- it("createInvoice sends correct request and returns id + checkoutLink", async () => {
5
- const mockResponse = { id: "inv-001", checkoutLink: "https://btcpay.example.com/i/inv-001" };
6
- const fetchSpy = vi
7
- .spyOn(globalThis, "fetch")
8
- .mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
9
- const client = new BTCPayClient({
10
- apiKey: "test-key",
11
- baseUrl: "https://btcpay.example.com",
12
- storeId: "store-abc",
13
- });
14
- const result = await client.createInvoice({
15
- amountUsd: 25,
16
- orderId: "order-123",
17
- buyerEmail: "test@example.com",
18
- });
19
- expect(result.id).toBe("inv-001");
20
- expect(result.checkoutLink).toBe("https://btcpay.example.com/i/inv-001");
21
- expect(fetchSpy).toHaveBeenCalledOnce();
22
- const [url, opts] = fetchSpy.mock.calls[0];
23
- expect(url).toBe("https://btcpay.example.com/api/v1/stores/store-abc/invoices");
2
+ import { CryptoServiceClient, loadCryptoConfig } from "./client.js";
3
+ describe("CryptoServiceClient", () => {
4
+ afterEach(() => vi.restoreAllMocks());
5
+ it("deriveAddress sends POST /address with chain", async () => {
6
+ const mockResponse = { address: "bc1q...", index: 42, chain: "bitcoin", token: "BTC" };
7
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 201 }));
8
+ const client = new CryptoServiceClient({ baseUrl: "http://localhost:3100" });
9
+ const result = await client.deriveAddress("btc");
10
+ expect(result.address).toBe("bc1q...");
11
+ expect(result.index).toBe(42);
12
+ const [url, opts] = vi.mocked(fetch).mock.calls[0];
13
+ expect(url).toBe("http://localhost:3100/address");
24
14
  expect(opts?.method).toBe("POST");
15
+ expect(JSON.parse(opts?.body)).toEqual({ chain: "btc" });
16
+ });
17
+ it("createCharge sends POST /charges", async () => {
18
+ const mockResponse = {
19
+ chargeId: "btc:bc1q...",
20
+ address: "bc1q...",
21
+ chain: "btc",
22
+ token: "BTC",
23
+ amountUsd: 50,
24
+ derivationIndex: 42,
25
+ expiresAt: "2026-03-20T04:00:00Z",
26
+ };
27
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 201 }));
28
+ const client = new CryptoServiceClient({
29
+ baseUrl: "http://localhost:3100",
30
+ serviceKey: "sk-test",
31
+ tenantId: "tenant-1",
32
+ });
33
+ const result = await client.createCharge({ chain: "btc", amountUsd: 50 });
34
+ expect(result.chargeId).toBe("btc:bc1q...");
35
+ expect(result.address).toBe("bc1q...");
36
+ const [, opts] = vi.mocked(fetch).mock.calls[0];
25
37
  const headers = opts?.headers;
26
- expect(headers.Authorization).toBe("token test-key");
27
- expect(headers["Content-Type"]).toBe("application/json");
28
- const body = JSON.parse(opts?.body);
29
- expect(body.amount).toBe("25");
30
- expect(body.currency).toBe("USD");
31
- expect(body.metadata.orderId).toBe("order-123");
32
- expect(body.metadata.buyerEmail).toBe("test@example.com");
33
- expect(body.checkout.speedPolicy).toBe("MediumSpeed");
34
- fetchSpy.mockRestore();
38
+ expect(headers.Authorization).toBe("Bearer sk-test");
39
+ expect(headers["X-Tenant-Id"]).toBe("tenant-1");
35
40
  });
36
- it("createInvoice includes redirectURL when provided", async () => {
37
- const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ id: "inv-002", checkoutLink: "https://btcpay.example.com/i/inv-002" }), {
38
- status: 200,
39
- }));
40
- const client = new BTCPayClient({ apiKey: "k", baseUrl: "https://btcpay.example.com", storeId: "s" });
41
- await client.createInvoice({ amountUsd: 10, orderId: "o", redirectURL: "https://app.example.com/success" });
42
- const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body);
43
- expect(body.checkout.redirectURL).toBe("https://app.example.com/success");
44
- fetchSpy.mockRestore();
41
+ it("getCharge sends GET /charges/:id", async () => {
42
+ const mockResponse = { chargeId: "btc:bc1q...", status: "confirmed" };
43
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
44
+ const client = new CryptoServiceClient({ baseUrl: "http://localhost:3100" });
45
+ const result = await client.getCharge("btc:bc1q...");
46
+ expect(result.status).toBe("confirmed");
47
+ expect(vi.mocked(fetch).mock.calls[0][0]).toBe("http://localhost:3100/charges/btc%3Abc1q...");
45
48
  });
46
- it("createInvoice throws on non-ok response", async () => {
47
- const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Unauthorized", { status: 401 }));
48
- const client = new BTCPayClient({ apiKey: "bad-key", baseUrl: "https://btcpay.example.com", storeId: "s" });
49
- await expect(client.createInvoice({ amountUsd: 10, orderId: "o" })).rejects.toThrow("BTCPay createInvoice failed (401)");
50
- fetchSpy.mockRestore();
49
+ it("listChains sends GET /chains", async () => {
50
+ const mockResponse = [{ id: "btc", token: "BTC", chain: "bitcoin", decimals: 8 }];
51
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
52
+ const client = new CryptoServiceClient({ baseUrl: "http://localhost:3100" });
53
+ const result = await client.listChains();
54
+ expect(result).toHaveLength(1);
55
+ expect(result[0].token).toBe("BTC");
51
56
  });
52
- it("getInvoice sends correct request", async () => {
53
- const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ id: "inv-001", status: "Settled", amount: "25", currency: "USD" }), {
54
- status: 200,
55
- }));
56
- const client = new BTCPayClient({ apiKey: "k", baseUrl: "https://btcpay.example.com", storeId: "store-abc" });
57
- const result = await client.getInvoice("inv-001");
58
- expect(result.status).toBe("Settled");
59
- expect(fetchSpy.mock.calls[0][0]).toBe("https://btcpay.example.com/api/v1/stores/store-abc/invoices/inv-001");
60
- fetchSpy.mockRestore();
57
+ it("throws on non-ok response", async () => {
58
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Not found", { status: 404 }));
59
+ const client = new CryptoServiceClient({ baseUrl: "http://localhost:3100" });
60
+ await expect(client.getCharge("missing")).rejects.toThrow("CryptoService getCharge failed (404)");
61
61
  });
62
62
  });
63
63
  describe("loadCryptoConfig", () => {
64
64
  beforeEach(() => {
65
- delete process.env.BTCPAY_API_KEY;
66
- delete process.env.BTCPAY_BASE_URL;
67
- delete process.env.BTCPAY_STORE_ID;
68
- });
69
- afterEach(() => {
70
- vi.unstubAllEnvs();
71
- });
72
- it("returns null when BTCPAY_API_KEY is missing", () => {
73
- vi.stubEnv("BTCPAY_BASE_URL", "https://btcpay.test");
74
- vi.stubEnv("BTCPAY_STORE_ID", "store-1");
75
- expect(loadCryptoConfig()).toBeNull();
76
- });
77
- it("returns null when BTCPAY_BASE_URL is missing", () => {
78
- vi.stubEnv("BTCPAY_API_KEY", "test-key");
79
- vi.stubEnv("BTCPAY_STORE_ID", "store-1");
80
- expect(loadCryptoConfig()).toBeNull();
81
- });
82
- it("returns null when BTCPAY_STORE_ID is missing", () => {
83
- vi.stubEnv("BTCPAY_API_KEY", "test-key");
84
- vi.stubEnv("BTCPAY_BASE_URL", "https://btcpay.test");
85
- expect(loadCryptoConfig()).toBeNull();
65
+ delete process.env.CRYPTO_SERVICE_URL;
66
+ delete process.env.CRYPTO_SERVICE_KEY;
67
+ delete process.env.TENANT_ID;
86
68
  });
87
- it("returns config when all env vars are set", () => {
88
- vi.stubEnv("BTCPAY_API_KEY", "test-key");
89
- vi.stubEnv("BTCPAY_BASE_URL", "https://btcpay.test");
90
- vi.stubEnv("BTCPAY_STORE_ID", "store-1");
69
+ afterEach(() => vi.unstubAllEnvs());
70
+ it("returns config when CRYPTO_SERVICE_URL is set", () => {
71
+ vi.stubEnv("CRYPTO_SERVICE_URL", "http://10.120.0.5:3100");
72
+ vi.stubEnv("CRYPTO_SERVICE_KEY", "sk-test");
73
+ vi.stubEnv("TENANT_ID", "tenant-1");
91
74
  expect(loadCryptoConfig()).toEqual({
92
- apiKey: "test-key",
93
- baseUrl: "https://btcpay.test",
94
- storeId: "store-1",
75
+ baseUrl: "http://10.120.0.5:3100",
76
+ serviceKey: "sk-test",
77
+ tenantId: "tenant-1",
95
78
  });
96
79
  });
97
- it("returns null when all env vars are missing", () => {
80
+ it("returns null when CRYPTO_SERVICE_URL is missing", () => {
98
81
  expect(loadCryptoConfig()).toBeNull();
99
82
  });
100
83
  });
@@ -1,18 +1,18 @@
1
1
  export * from "./btc/index.js";
2
2
  export type { CryptoChargeRecord, CryptoDepositChargeInput, ICryptoChargeRepository } from "./charge-store.js";
3
3
  export { CryptoChargeRepository, DrizzleCryptoChargeRepository } from "./charge-store.js";
4
- export { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
5
- export type { CryptoConfig } from "./client.js";
6
- export { BTCPayClient, loadCryptoConfig } from "./client.js";
4
+ export type { ChainInfo, ChargeStatus, CreateChargeResult, CryptoConfig, CryptoServiceConfig, DeriveAddressResult, } from "./client.js";
5
+ export { CryptoServiceClient, loadCryptoConfig } from "./client.js";
7
6
  export type { IWatcherCursorStore } from "./cursor-store.js";
8
7
  export { DrizzleWatcherCursorStore } from "./cursor-store.js";
9
8
  export * from "./evm/index.js";
9
+ export type { KeyServerDeps } from "./key-server.js";
10
+ export { createKeyServerApp } from "./key-server.js";
11
+ export type { KeyServerWebhookDeps as CryptoWebhookDeps, KeyServerWebhookPayload as CryptoWebhookPayload, KeyServerWebhookResult as CryptoWebhookResult, } from "./key-server-webhook.js";
12
+ export { handleKeyServerWebhook, handleKeyServerWebhook as handleCryptoWebhook } from "./key-server-webhook.js";
10
13
  export * from "./oracle/index.js";
11
14
  export type { IPaymentMethodStore, PaymentMethodRecord } from "./payment-method-store.js";
12
15
  export { DrizzlePaymentMethodStore } from "./payment-method-store.js";
13
- export type { CryptoBillingConfig, CryptoCheckoutOpts, CryptoPaymentState, CryptoWebhookPayload, CryptoWebhookResult, } from "./types.js";
14
- export { mapBtcPayEventToStatus } from "./types.js";
16
+ export type { CryptoPaymentState } from "./types.js";
15
17
  export type { UnifiedCheckoutDeps, UnifiedCheckoutResult } from "./unified-checkout.js";
16
- export { createUnifiedCheckout, MIN_CHECKOUT_USD } from "./unified-checkout.js";
17
- export type { CryptoWebhookDeps } from "./webhook.js";
18
- export { handleCryptoWebhook, verifyCryptoWebhookSignature } from "./webhook.js";
18
+ export { createUnifiedCheckout, MIN_CHECKOUT_USD as MIN_PAYMENT_USD, MIN_CHECKOUT_USD } from "./unified-checkout.js";
@@ -1,11 +1,10 @@
1
1
  export * from "./btc/index.js";
2
2
  export { CryptoChargeRepository, DrizzleCryptoChargeRepository } from "./charge-store.js";
3
- export { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
4
- export { BTCPayClient, loadCryptoConfig } from "./client.js";
3
+ export { CryptoServiceClient, loadCryptoConfig } from "./client.js";
5
4
  export { DrizzleWatcherCursorStore } from "./cursor-store.js";
6
5
  export * from "./evm/index.js";
6
+ export { createKeyServerApp } from "./key-server.js";
7
+ export { handleKeyServerWebhook, handleKeyServerWebhook as handleCryptoWebhook } from "./key-server-webhook.js";
7
8
  export * from "./oracle/index.js";
8
9
  export { DrizzlePaymentMethodStore } from "./payment-method-store.js";
9
- export { mapBtcPayEventToStatus } from "./types.js";
10
- export { createUnifiedCheckout, MIN_CHECKOUT_USD } from "./unified-checkout.js";
11
- export { handleCryptoWebhook, verifyCryptoWebhookSignature } from "./webhook.js";
10
+ export { createUnifiedCheckout, MIN_CHECKOUT_USD as MIN_PAYMENT_USD, MIN_CHECKOUT_USD } from "./unified-checkout.js";
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Standalone entry point for the crypto key server.
3
+ *
4
+ * Deploys on the chain server (pay.wopr.bot:3100).
5
+ * Boots: postgres → migrations → key server routes → watchers → serve.
6
+ *
7
+ * Usage: node dist/billing/crypto/key-server-entry.js
8
+ */
9
+ /* biome-ignore-all lint/suspicious/noConsole: standalone entry point */
10
+ import { serve } from "@hono/node-server";
11
+ import { drizzle } from "drizzle-orm/node-postgres";
12
+ import { migrate } from "drizzle-orm/node-postgres/migrator";
13
+ import pg from "pg";
14
+ import * as schema from "../../db/schema/index.js";
15
+ import { DrizzleCryptoChargeRepository } from "./charge-store.js";
16
+ import { DrizzleWatcherCursorStore } from "./cursor-store.js";
17
+ import { createRpcCaller } from "./evm/watcher.js";
18
+ import { createKeyServerApp } from "./key-server.js";
19
+ import { ChainlinkOracle } from "./oracle/chainlink.js";
20
+ import { FixedPriceOracle } from "./oracle/fixed.js";
21
+ import { DrizzlePaymentMethodStore } from "./payment-method-store.js";
22
+ import { startWatchers } from "./watcher-service.js";
23
+ const PORT = Number(process.env.PORT ?? "3100");
24
+ const DATABASE_URL = process.env.DATABASE_URL;
25
+ const SERVICE_KEY = process.env.SERVICE_KEY;
26
+ const ADMIN_TOKEN = process.env.ADMIN_TOKEN;
27
+ const BITCOIND_USER = process.env.BITCOIND_USER ?? "btcpay";
28
+ const BITCOIND_PASSWORD = process.env.BITCOIND_PASSWORD ?? "";
29
+ const BASE_RPC_URL = process.env.BASE_RPC_URL ?? "https://mainnet.base.org";
30
+ if (!DATABASE_URL) {
31
+ console.error("DATABASE_URL is required");
32
+ process.exit(1);
33
+ }
34
+ async function main() {
35
+ const pool = new pg.Pool({ connectionString: DATABASE_URL });
36
+ // Run migrations FIRST, before creating schema-typed db
37
+ console.log("[crypto-key-server] Running migrations...");
38
+ await migrate(drizzle(pool), { migrationsFolder: "./drizzle/migrations" });
39
+ // Now create the schema-typed db (columns guaranteed to exist)
40
+ console.log("[crypto-key-server] Connecting...");
41
+ const db = drizzle(pool, { schema });
42
+ const chargeStore = new DrizzleCryptoChargeRepository(db);
43
+ const methodStore = new DrizzlePaymentMethodStore(db);
44
+ // Chainlink on-chain oracle for volatile assets (BTC, ETH).
45
+ const oracle = BASE_RPC_URL
46
+ ? new ChainlinkOracle({ rpcCall: createRpcCaller(BASE_RPC_URL) })
47
+ : new FixedPriceOracle();
48
+ const app = createKeyServerApp({
49
+ db,
50
+ chargeStore,
51
+ methodStore,
52
+ oracle,
53
+ serviceKey: SERVICE_KEY,
54
+ adminToken: ADMIN_TOKEN,
55
+ });
56
+ // Boot watchers (BTC + EVM) — polls for payments, sends webhooks
57
+ const cursorStore = new DrizzleWatcherCursorStore(db);
58
+ const stopWatchers = await startWatchers({
59
+ db,
60
+ chargeStore,
61
+ methodStore,
62
+ cursorStore,
63
+ oracle,
64
+ bitcoindUser: BITCOIND_USER,
65
+ bitcoindPassword: BITCOIND_PASSWORD,
66
+ log: (msg, meta) => console.log(`[watcher] ${msg}`, meta ?? ""),
67
+ });
68
+ const server = serve({ fetch: app.fetch, port: PORT });
69
+ console.log(`[crypto-key-server] Listening on :${PORT}`);
70
+ // Graceful shutdown — stop accepting requests, drain watchers, close pool
71
+ const shutdown = async () => {
72
+ console.log("[crypto-key-server] Shutting down...");
73
+ stopWatchers();
74
+ server.close();
75
+ await pool.end();
76
+ process.exit(0);
77
+ };
78
+ process.on("SIGTERM", shutdown);
79
+ process.on("SIGINT", shutdown);
80
+ }
81
+ main().catch((err) => {
82
+ console.error("[crypto-key-server] Fatal:", err);
83
+ process.exit(1);
84
+ });
@@ -0,0 +1,33 @@
1
+ import type { ILedger } from "../../credits/ledger.js";
2
+ import type { IWebhookSeenRepository } from "../webhook-seen-repository.js";
3
+ import type { ICryptoChargeRepository } from "./charge-store.js";
4
+ export interface KeyServerWebhookPayload {
5
+ chargeId: string;
6
+ chain: string;
7
+ address: string;
8
+ amountUsdCents: number;
9
+ status: string;
10
+ txHash?: string;
11
+ amountReceived?: string;
12
+ confirmations?: number;
13
+ }
14
+ export interface KeyServerWebhookDeps {
15
+ chargeStore: ICryptoChargeRepository;
16
+ creditLedger: ILedger;
17
+ replayGuard: IWebhookSeenRepository;
18
+ onCreditsPurchased?: (tenantId: string, ledger: ILedger) => Promise<string[]>;
19
+ }
20
+ export interface KeyServerWebhookResult {
21
+ handled: boolean;
22
+ duplicate?: boolean;
23
+ tenant?: string;
24
+ creditedCents?: number;
25
+ reactivatedBots?: string[];
26
+ }
27
+ /**
28
+ * Process a payment confirmation from the crypto key server.
29
+ *
30
+ * Credits the ledger when status is "confirmed".
31
+ * Idempotency: ledger referenceId + replay guard (same pattern as Stripe handler).
32
+ */
33
+ export declare function handleKeyServerWebhook(deps: KeyServerWebhookDeps, payload: KeyServerWebhookPayload): Promise<KeyServerWebhookResult>;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Key Server webhook handler — processes payment confirmations from the
3
+ * centralized crypto key server.
4
+ *
5
+ * Payload shape (from watcher-service.ts):
6
+ * {
7
+ * chargeId: "btc:bc1q...",
8
+ * chain: "bitcoin",
9
+ * address: "bc1q...",
10
+ * amountUsdCents: 5000,
11
+ * status: "confirmed",
12
+ * txHash: "abc123...",
13
+ * amountReceived: "50000 sats",
14
+ * confirmations: 6
15
+ * }
16
+ *
17
+ * Replaces handleCryptoWebhook() for products using the key server.
18
+ */
19
+ import { Credit } from "../../credits/credit.js";
20
+ /**
21
+ * Process a payment confirmation from the crypto key server.
22
+ *
23
+ * Credits the ledger when status is "confirmed".
24
+ * Idempotency: ledger referenceId + replay guard (same pattern as Stripe handler).
25
+ */
26
+ export async function handleKeyServerWebhook(deps, payload) {
27
+ const { chargeStore, creditLedger } = deps;
28
+ // Replay guard: deduplicate by chargeId
29
+ const dedupeKey = `ks:${payload.chargeId}`;
30
+ if (await deps.replayGuard.isDuplicate(dedupeKey, "crypto")) {
31
+ return { handled: true, duplicate: true };
32
+ }
33
+ // Look up the charge to find the tenant + amount
34
+ const charge = await chargeStore.getByReferenceId(payload.chargeId);
35
+ if (!charge) {
36
+ return { handled: false };
37
+ }
38
+ if (payload.status === "confirmed") {
39
+ // Only settle when payment is confirmed
40
+ await chargeStore.updateStatus(payload.chargeId, "Settled", charge.token ?? undefined, payload.amountReceived);
41
+ // Idempotency: check ledger referenceId (atomic, same as BTCPay handler)
42
+ const creditRef = `crypto:${payload.chargeId}`;
43
+ if (await creditLedger.hasReferenceId(creditRef)) {
44
+ await deps.replayGuard.markSeen(dedupeKey, "crypto");
45
+ return { handled: true, duplicate: true, tenant: charge.tenantId };
46
+ }
47
+ // Credit the original USD amount requested.
48
+ // charge.amountUsdCents is integer cents. Credit.fromCents() → nanodollars.
49
+ await creditLedger.credit(charge.tenantId, Credit.fromCents(charge.amountUsdCents), "purchase", {
50
+ description: `Crypto payment confirmed (${payload.chain}, tx: ${payload.txHash ?? "unknown"})`,
51
+ referenceId: creditRef,
52
+ fundingSource: "crypto",
53
+ });
54
+ await chargeStore.markCredited(payload.chargeId);
55
+ let reactivatedBots;
56
+ if (deps.onCreditsPurchased) {
57
+ reactivatedBots = await deps.onCreditsPurchased(charge.tenantId, creditLedger);
58
+ if (reactivatedBots.length === 0)
59
+ reactivatedBots = undefined;
60
+ }
61
+ await deps.replayGuard.markSeen(dedupeKey, "crypto");
62
+ return {
63
+ handled: true,
64
+ tenant: charge.tenantId,
65
+ creditedCents: charge.amountUsdCents,
66
+ reactivatedBots,
67
+ };
68
+ }
69
+ // Non-confirmed status — update status but don't settle or credit
70
+ await chargeStore.updateStatus(payload.chargeId, payload.status, charge.token ?? undefined, payload.amountReceived);
71
+ await deps.replayGuard.markSeen(dedupeKey, "crypto");
72
+ return { handled: true, tenant: charge.tenantId };
73
+ }
@@ -0,0 +1,20 @@
1
+ import { Hono } from "hono";
2
+ import type { DrizzleDb } from "../../db/index.js";
3
+ import type { ICryptoChargeRepository } from "./charge-store.js";
4
+ import type { IPriceOracle } from "./oracle/types.js";
5
+ import type { IPaymentMethodStore } from "./payment-method-store.js";
6
+ export interface KeyServerDeps {
7
+ db: DrizzleDb;
8
+ chargeStore: ICryptoChargeRepository;
9
+ methodStore: IPaymentMethodStore;
10
+ oracle: IPriceOracle;
11
+ /** Bearer token for product API routes. If unset, auth is disabled. */
12
+ serviceKey?: string;
13
+ /** Bearer token for admin routes. If unset, admin routes are disabled. */
14
+ adminToken?: string;
15
+ }
16
+ /**
17
+ * Create the Hono app for the crypto key server.
18
+ * Mount this on the chain server at the root.
19
+ */
20
+ export declare function createKeyServerApp(deps: KeyServerDeps): Hono;