@wopr-network/platform-core 1.43.0 → 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 (58) hide show
  1. package/dist/billing/crypto/__tests__/key-server.test.js +16 -1
  2. package/dist/billing/crypto/btc/watcher.d.ts +2 -0
  3. package/dist/billing/crypto/btc/watcher.js +1 -1
  4. package/dist/billing/crypto/charge-store.d.ts +7 -1
  5. package/dist/billing/crypto/charge-store.js +7 -1
  6. package/dist/billing/crypto/client.d.ts +0 -26
  7. package/dist/billing/crypto/client.js +0 -13
  8. package/dist/billing/crypto/client.test.js +1 -11
  9. package/dist/billing/crypto/index.d.ts +5 -7
  10. package/dist/billing/crypto/index.js +3 -5
  11. package/dist/billing/crypto/key-server-entry.js +43 -2
  12. package/dist/billing/crypto/key-server-webhook.d.ts +33 -0
  13. package/dist/billing/crypto/key-server-webhook.js +73 -0
  14. package/dist/billing/crypto/key-server.d.ts +2 -0
  15. package/dist/billing/crypto/key-server.js +25 -1
  16. package/dist/billing/crypto/watcher-service.d.ts +33 -0
  17. package/dist/billing/crypto/watcher-service.js +295 -0
  18. package/dist/billing/index.js +1 -1
  19. package/dist/db/schema/crypto.d.ts +217 -2
  20. package/dist/db/schema/crypto.js +25 -2
  21. package/dist/monetization/crypto/__tests__/webhook.test.js +57 -92
  22. package/dist/monetization/crypto/index.d.ts +4 -4
  23. package/dist/monetization/crypto/index.js +2 -2
  24. package/dist/monetization/crypto/webhook.d.ts +13 -14
  25. package/dist/monetization/crypto/webhook.js +12 -83
  26. package/dist/monetization/index.d.ts +2 -2
  27. package/dist/monetization/index.js +1 -1
  28. package/drizzle/migrations/0015_callback_url.sql +32 -0
  29. package/drizzle/migrations/meta/_journal.json +7 -0
  30. package/package.json +1 -1
  31. package/src/billing/crypto/__tests__/key-server.test.ts +16 -1
  32. package/src/billing/crypto/btc/watcher.ts +3 -1
  33. package/src/billing/crypto/charge-store.ts +13 -1
  34. package/src/billing/crypto/client.test.ts +1 -13
  35. package/src/billing/crypto/client.ts +0 -21
  36. package/src/billing/crypto/index.ts +9 -13
  37. package/src/billing/crypto/key-server-entry.ts +46 -2
  38. package/src/billing/crypto/key-server-webhook.ts +119 -0
  39. package/src/billing/crypto/key-server.ts +29 -1
  40. package/src/billing/crypto/watcher-service.ts +381 -0
  41. package/src/billing/index.ts +1 -1
  42. package/src/db/schema/crypto.ts +30 -2
  43. package/src/monetization/crypto/__tests__/webhook.test.ts +61 -104
  44. package/src/monetization/crypto/index.ts +9 -11
  45. package/src/monetization/crypto/webhook.ts +25 -99
  46. package/src/monetization/index.ts +3 -7
  47. package/dist/billing/crypto/checkout.d.ts +0 -18
  48. package/dist/billing/crypto/checkout.js +0 -35
  49. package/dist/billing/crypto/checkout.test.d.ts +0 -1
  50. package/dist/billing/crypto/checkout.test.js +0 -71
  51. package/dist/billing/crypto/webhook.d.ts +0 -34
  52. package/dist/billing/crypto/webhook.js +0 -107
  53. package/dist/billing/crypto/webhook.test.d.ts +0 -1
  54. package/dist/billing/crypto/webhook.test.js +0 -266
  55. package/src/billing/crypto/checkout.test.ts +0 -93
  56. package/src/billing/crypto/checkout.ts +0 -48
  57. package/src/billing/crypto/webhook.test.ts +0 -340
  58. package/src/billing/crypto/webhook.ts +0 -136
@@ -78,7 +78,21 @@ function mockDeps() {
78
78
  },
79
79
  ]),
80
80
  listAll: vi.fn(),
81
- getById: vi.fn(),
81
+ getById: vi.fn().mockResolvedValue({
82
+ id: "btc",
83
+ type: "native",
84
+ token: "BTC",
85
+ chain: "bitcoin",
86
+ decimals: 8,
87
+ displayName: "Bitcoin",
88
+ contractAddress: null,
89
+ confirmations: 6,
90
+ oracleAddress: "0x64c911996D3c6aC71f9b455B1E8E7266BcbD848F",
91
+ xpub: null,
92
+ displayOrder: 0,
93
+ enabled: true,
94
+ rpcUrl: null,
95
+ }),
82
96
  listByType: vi.fn(),
83
97
  upsert: vi.fn().mockResolvedValue(undefined),
84
98
  setEnabled: vi.fn().mockResolvedValue(undefined),
@@ -87,6 +101,7 @@ function mockDeps() {
87
101
  db: createMockDb(),
88
102
  chargeStore: chargeStore,
89
103
  methodStore: methodStore,
104
+ oracle: { getPrice: vi.fn().mockResolvedValue({ priceCents: 6_500_000, updatedAt: new Date() }) },
90
105
  };
91
106
  }
92
107
  describe("key-server routes", () => {
@@ -12,6 +12,8 @@ export interface BtcWatcherOpts {
12
12
  oracle: IPriceOracle;
13
13
  /** Required — BTC has no block cursor, so txid dedup must be persisted. */
14
14
  cursorStore: IWatcherCursorStore;
15
+ /** Override chain identity for cursor namespace (default: config.network). Prevents txid collisions across BTC/LTC/DOGE. */
16
+ chainId?: string;
15
17
  }
16
18
  export declare class BtcWatcher {
17
19
  private readonly rpc;
@@ -13,7 +13,7 @@ export class BtcWatcher {
13
13
  this.minConfirmations = opts.config.confirmations;
14
14
  this.oracle = opts.oracle;
15
15
  this.cursorStore = opts.cursorStore;
16
- this.watcherId = `btc:${opts.config.network}`;
16
+ this.watcherId = `btc:${opts.chainId ?? opts.config.network}`;
17
17
  }
18
18
  /** Update the set of watched addresses. */
19
19
  setWatchedAddresses(addresses) {
@@ -14,6 +14,9 @@ export interface CryptoChargeRecord {
14
14
  token: string | null;
15
15
  depositAddress: string | null;
16
16
  derivationIndex: number | null;
17
+ callbackUrl: string | null;
18
+ expectedAmount: string | null;
19
+ receivedAmount: string | null;
17
20
  }
18
21
  export interface CryptoDepositChargeInput {
19
22
  referenceId: string;
@@ -23,6 +26,9 @@ export interface CryptoDepositChargeInput {
23
26
  token: string;
24
27
  depositAddress: string;
25
28
  derivationIndex: number;
29
+ callbackUrl?: string;
30
+ /** Expected crypto amount in native base units (sats for BTC, base units for ERC20). */
31
+ expectedAmount?: string;
26
32
  }
27
33
  export interface ICryptoChargeRepository {
28
34
  create(referenceId: string, tenantId: string, amountUsdCents: number): Promise<void>;
@@ -42,7 +48,7 @@ export interface ICryptoChargeRepository {
42
48
  /**
43
49
  * Manages crypto charge records in PostgreSQL.
44
50
  *
45
- * Each charge maps a BTCPay invoice ID to a tenant and tracks
51
+ * Each charge maps a deposit address to a tenant and tracks
46
52
  * the payment lifecycle (New → Processing → Settled/Expired/Invalid).
47
53
  *
48
54
  * amountUsdCents stores the requested amount in USD cents (integer).
@@ -3,7 +3,7 @@ import { cryptoCharges } from "../../db/schema/crypto.js";
3
3
  /**
4
4
  * Manages crypto charge records in PostgreSQL.
5
5
  *
6
- * Each charge maps a BTCPay invoice ID to a tenant and tracks
6
+ * Each charge maps a deposit address to a tenant and tracks
7
7
  * the payment lifecycle (New → Processing → Settled/Expired/Invalid).
8
8
  *
9
9
  * amountUsdCents stores the requested amount in USD cents (integer).
@@ -46,6 +46,9 @@ export class DrizzleCryptoChargeRepository {
46
46
  token: row.token ?? null,
47
47
  depositAddress: row.depositAddress ?? null,
48
48
  derivationIndex: row.derivationIndex ?? null,
49
+ callbackUrl: row.callbackUrl ?? null,
50
+ expectedAmount: row.expectedAmount ?? null,
51
+ receivedAmount: row.receivedAmount ?? null,
49
52
  };
50
53
  }
51
54
  /** Update charge status and payment details from webhook. */
@@ -89,6 +92,9 @@ export class DrizzleCryptoChargeRepository {
89
92
  token: input.token,
90
93
  depositAddress: input.depositAddress.toLowerCase(),
91
94
  derivationIndex: input.derivationIndex,
95
+ callbackUrl: input.callbackUrl,
96
+ expectedAmount: input.expectedAmount,
97
+ receivedAmount: "0",
92
98
  });
93
99
  }
94
100
  /** Look up a charge by its deposit address. */
@@ -75,29 +75,3 @@ export declare class CryptoServiceClient {
75
75
  */
76
76
  export declare function loadCryptoConfig(): CryptoServiceConfig | null;
77
77
  export type CryptoConfig = CryptoServiceConfig;
78
- /**
79
- * @deprecated Use CryptoServiceClient instead. BTCPay is replaced by the crypto key server.
80
- * Kept for backwards compat — products still import BTCPayClient during migration.
81
- */
82
- export declare class BTCPayClient {
83
- constructor(_config: {
84
- apiKey: string;
85
- baseUrl: string;
86
- storeId: string;
87
- });
88
- createInvoice(_opts: {
89
- amountUsd: number;
90
- orderId: string;
91
- buyerEmail?: string;
92
- redirectURL?: string;
93
- }): Promise<{
94
- id: string;
95
- checkoutLink: string;
96
- }>;
97
- getInvoice(_invoiceId: string): Promise<{
98
- id: string;
99
- status: string;
100
- amount: string;
101
- currency: string;
102
- }>;
103
- }
@@ -87,16 +87,3 @@ export function loadCryptoConfig() {
87
87
  }
88
88
  return null;
89
89
  }
90
- /**
91
- * @deprecated Use CryptoServiceClient instead. BTCPay is replaced by the crypto key server.
92
- * Kept for backwards compat — products still import BTCPayClient during migration.
93
- */
94
- export class BTCPayClient {
95
- constructor(_config) { }
96
- async createInvoice(_opts) {
97
- throw new Error("BTCPayClient is deprecated — migrate to CryptoServiceClient");
98
- }
99
- async getInvoice(_invoiceId) {
100
- throw new Error("BTCPayClient is deprecated — migrate to CryptoServiceClient");
101
- }
102
- }
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { BTCPayClient, CryptoServiceClient, loadCryptoConfig } from "./client.js";
2
+ import { CryptoServiceClient, loadCryptoConfig } from "./client.js";
3
3
  describe("CryptoServiceClient", () => {
4
4
  afterEach(() => vi.restoreAllMocks());
5
5
  it("deriveAddress sends POST /address with chain", async () => {
@@ -60,16 +60,6 @@ describe("CryptoServiceClient", () => {
60
60
  await expect(client.getCharge("missing")).rejects.toThrow("CryptoService getCharge failed (404)");
61
61
  });
62
62
  });
63
- describe("BTCPayClient (deprecated)", () => {
64
- it("throws on createInvoice", async () => {
65
- const client = new BTCPayClient({ apiKey: "k", baseUrl: "https://example.com", storeId: "s" });
66
- await expect(client.createInvoice({ amountUsd: 10, orderId: "o" })).rejects.toThrow("deprecated");
67
- });
68
- it("throws on getInvoice", async () => {
69
- const client = new BTCPayClient({ apiKey: "k", baseUrl: "https://example.com", storeId: "s" });
70
- await expect(client.getInvoice("inv-1")).rejects.toThrow("deprecated");
71
- });
72
- });
73
63
  describe("loadCryptoConfig", () => {
74
64
  beforeEach(() => {
75
65
  delete process.env.CRYPTO_SERVICE_URL;
@@ -1,20 +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
4
  export type { ChainInfo, ChargeStatus, CreateChargeResult, CryptoConfig, CryptoServiceConfig, DeriveAddressResult, } from "./client.js";
6
- export { BTCPayClient, CryptoServiceClient, loadCryptoConfig } 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";
10
9
  export type { KeyServerDeps } from "./key-server.js";
11
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";
12
13
  export * from "./oracle/index.js";
13
14
  export type { IPaymentMethodStore, PaymentMethodRecord } from "./payment-method-store.js";
14
15
  export { DrizzlePaymentMethodStore } from "./payment-method-store.js";
15
- export type { CryptoBillingConfig, CryptoCheckoutOpts, CryptoPaymentState, CryptoWebhookPayload, CryptoWebhookResult, } from "./types.js";
16
- export { mapBtcPayEventToStatus } from "./types.js";
16
+ export type { CryptoPaymentState } from "./types.js";
17
17
  export type { UnifiedCheckoutDeps, UnifiedCheckoutResult } from "./unified-checkout.js";
18
- export { createUnifiedCheckout, MIN_CHECKOUT_USD } from "./unified-checkout.js";
19
- export type { CryptoWebhookDeps } from "./webhook.js";
20
- export { handleCryptoWebhook, verifyCryptoWebhookSignature } from "./webhook.js";
18
+ export { createUnifiedCheckout, MIN_CHECKOUT_USD as MIN_PAYMENT_USD, MIN_CHECKOUT_USD } from "./unified-checkout.js";
@@ -1,12 +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, CryptoServiceClient, 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";
7
6
  export { createKeyServerApp } from "./key-server.js";
7
+ export { handleKeyServerWebhook, handleKeyServerWebhook as handleCryptoWebhook } from "./key-server-webhook.js";
8
8
  export * from "./oracle/index.js";
9
9
  export { DrizzlePaymentMethodStore } from "./payment-method-store.js";
10
- export { mapBtcPayEventToStatus } from "./types.js";
11
- export { createUnifiedCheckout, MIN_CHECKOUT_USD } from "./unified-checkout.js";
12
- export { handleCryptoWebhook, verifyCryptoWebhookSignature } from "./webhook.js";
10
+ export { createUnifiedCheckout, MIN_CHECKOUT_USD as MIN_PAYMENT_USD, MIN_CHECKOUT_USD } from "./unified-checkout.js";
@@ -13,12 +13,20 @@ import { migrate } from "drizzle-orm/node-postgres/migrator";
13
13
  import pg from "pg";
14
14
  import * as schema from "../../db/schema/index.js";
15
15
  import { DrizzleCryptoChargeRepository } from "./charge-store.js";
16
+ import { DrizzleWatcherCursorStore } from "./cursor-store.js";
17
+ import { createRpcCaller } from "./evm/watcher.js";
16
18
  import { createKeyServerApp } from "./key-server.js";
19
+ import { ChainlinkOracle } from "./oracle/chainlink.js";
20
+ import { FixedPriceOracle } from "./oracle/fixed.js";
17
21
  import { DrizzlePaymentMethodStore } from "./payment-method-store.js";
22
+ import { startWatchers } from "./watcher-service.js";
18
23
  const PORT = Number(process.env.PORT ?? "3100");
19
24
  const DATABASE_URL = process.env.DATABASE_URL;
20
25
  const SERVICE_KEY = process.env.SERVICE_KEY;
21
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";
22
30
  if (!DATABASE_URL) {
23
31
  console.error("DATABASE_URL is required");
24
32
  process.exit(1);
@@ -33,9 +41,42 @@ async function main() {
33
41
  const db = drizzle(pool, { schema });
34
42
  const chargeStore = new DrizzleCryptoChargeRepository(db);
35
43
  const methodStore = new DrizzlePaymentMethodStore(db);
36
- const app = createKeyServerApp({ db, chargeStore, methodStore, serviceKey: SERVICE_KEY, adminToken: ADMIN_TOKEN });
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 });
37
69
  console.log(`[crypto-key-server] Listening on :${PORT}`);
38
- serve({ fetch: app.fetch, port: 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);
39
80
  }
40
81
  main().catch((err) => {
41
82
  console.error("[crypto-key-server] Fatal:", err);
@@ -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
+ }
@@ -1,11 +1,13 @@
1
1
  import { Hono } from "hono";
2
2
  import type { DrizzleDb } from "../../db/index.js";
3
3
  import type { ICryptoChargeRepository } from "./charge-store.js";
4
+ import type { IPriceOracle } from "./oracle/types.js";
4
5
  import type { IPaymentMethodStore } from "./payment-method-store.js";
5
6
  export interface KeyServerDeps {
6
7
  db: DrizzleDb;
7
8
  chargeStore: ICryptoChargeRepository;
8
9
  methodStore: IPaymentMethodStore;
10
+ oracle: IPriceOracle;
9
11
  /** Bearer token for product API routes. If unset, auth is disabled. */
10
12
  serviceKey?: string;
11
13
  /** Bearer token for admin routes. If unset, admin routes are disabled. */
@@ -12,6 +12,7 @@ import { Hono } from "hono";
12
12
  import { derivedAddresses, pathAllocations, paymentMethods } from "../../db/schema/crypto.js";
13
13
  import { deriveAddress, deriveP2pkhAddress } from "./btc/address-gen.js";
14
14
  import { deriveDepositAddress } from "./evm/address-gen.js";
15
+ import { centsToNative } from "./oracle/convert.js";
15
16
  /**
16
17
  * Derive the next unused address for a chain.
17
18
  * Atomically increments next_index and records address in a single transaction.
@@ -111,7 +112,23 @@ export function createKeyServerApp(deps) {
111
112
  }
112
113
  const tenantId = c.req.header("X-Tenant-Id") ?? "unknown";
113
114
  const { address, index, chain, token } = await deriveNextAddress(deps.db, body.chain, tenantId);
115
+ // Look up payment method for decimals + oracle config
116
+ const method = await deps.methodStore.getById(body.chain);
117
+ if (!method)
118
+ return c.json({ error: `Unknown chain: ${body.chain}` }, 400);
114
119
  const amountUsdCents = Math.round(body.amountUsd * 100);
120
+ // Compute expected crypto amount in native base units.
121
+ // Price is locked NOW — this is what the user must send.
122
+ let expectedAmount;
123
+ if (method.oracleAddress) {
124
+ // Volatile asset (BTC, ETH, DOGE) — oracle-priced
125
+ const { priceCents } = await deps.oracle.getPrice(token);
126
+ expectedAmount = centsToNative(amountUsdCents, priceCents, method.decimals);
127
+ }
128
+ else {
129
+ // Stablecoin (1:1 USD) — e.g. $50 USDC = 50_000_000 base units (6 decimals)
130
+ expectedAmount = (BigInt(amountUsdCents) * 10n ** BigInt(method.decimals)) / 100n;
131
+ }
115
132
  const referenceId = `${token.toLowerCase()}:${address.toLowerCase()}`;
116
133
  await deps.chargeStore.createStablecoinCharge({
117
134
  referenceId,
@@ -121,13 +138,20 @@ export function createKeyServerApp(deps) {
121
138
  token,
122
139
  depositAddress: address,
123
140
  derivationIndex: index,
141
+ callbackUrl: body.callbackUrl,
142
+ expectedAmount: expectedAmount.toString(),
124
143
  });
144
+ // Format display amount for the client
145
+ const divisor = 10 ** method.decimals;
146
+ const displayAmount = `${(Number(expectedAmount) / divisor).toFixed(Math.min(method.decimals, 8))} ${token}`;
125
147
  return c.json({
126
148
  chargeId: referenceId,
127
149
  address,
128
- chain: body.chain,
150
+ chain,
129
151
  token,
130
152
  amountUsd: body.amountUsd,
153
+ expectedAmount: expectedAmount.toString(),
154
+ displayAmount,
131
155
  derivationIndex: index,
132
156
  expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), // 30 min
133
157
  }, 201);
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Watcher Service — boots chain watchers and sends webhook callbacks.
3
+ *
4
+ * Payment flow:
5
+ * 1. Watcher detects payment → handlePayment()
6
+ * 2. Accumulate native amount (supports partial payments)
7
+ * 3. When totalReceived >= expectedAmount → settle + credit
8
+ * 4. Every payment (partial or full) enqueues a webhook delivery
9
+ * 5. Outbox processor retries failed deliveries with exponential backoff
10
+ *
11
+ * Amount comparison is ALWAYS in native crypto units (sats, wei, token base units).
12
+ * The exchange rate is locked at charge creation — no live price comparison.
13
+ */
14
+ import type { DrizzleDb } from "../../db/index.js";
15
+ import type { ICryptoChargeRepository } from "./charge-store.js";
16
+ import type { IWatcherCursorStore } from "./cursor-store.js";
17
+ import type { IPriceOracle } from "./oracle/types.js";
18
+ import type { IPaymentMethodStore } from "./payment-method-store.js";
19
+ export interface WatcherServiceOpts {
20
+ db: DrizzleDb;
21
+ chargeStore: ICryptoChargeRepository;
22
+ methodStore: IPaymentMethodStore;
23
+ cursorStore: IWatcherCursorStore;
24
+ oracle: IPriceOracle;
25
+ bitcoindUser?: string;
26
+ bitcoindPassword?: string;
27
+ pollIntervalMs?: number;
28
+ deliveryIntervalMs?: number;
29
+ log?: (msg: string, meta?: Record<string, unknown>) => void;
30
+ /** Allowed callback URL prefixes. Default: ["https://"] — enforces HTTPS. */
31
+ allowedCallbackPrefixes?: string[];
32
+ }
33
+ export declare function startWatchers(opts: WatcherServiceOpts): Promise<() => void>;