@wopr-network/platform-core 1.48.0 → 1.49.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 (70) hide show
  1. package/dist/billing/crypto/__tests__/unified-checkout.test.d.ts +1 -0
  2. package/dist/billing/crypto/__tests__/unified-checkout.test.js +63 -0
  3. package/dist/billing/crypto/__tests__/watcher-service.test.d.ts +1 -0
  4. package/dist/billing/crypto/__tests__/watcher-service.test.js +174 -0
  5. package/dist/billing/crypto/__tests__/webhook-confirmations.test.d.ts +1 -0
  6. package/dist/billing/crypto/__tests__/webhook-confirmations.test.js +304 -0
  7. package/dist/billing/crypto/btc/__tests__/settler.test.js +1 -0
  8. package/dist/billing/crypto/btc/__tests__/watcher.test.d.ts +1 -0
  9. package/dist/billing/crypto/btc/__tests__/watcher.test.js +170 -0
  10. package/dist/billing/crypto/btc/types.d.ts +3 -1
  11. package/dist/billing/crypto/btc/watcher.d.ts +6 -1
  12. package/dist/billing/crypto/btc/watcher.js +20 -6
  13. package/dist/billing/crypto/charge-store.d.ts +27 -2
  14. package/dist/billing/crypto/charge-store.js +67 -1
  15. package/dist/billing/crypto/charge-store.test.js +180 -1
  16. package/dist/billing/crypto/client.d.ts +2 -0
  17. package/dist/billing/crypto/cursor-store.d.ts +10 -3
  18. package/dist/billing/crypto/cursor-store.js +21 -1
  19. package/dist/billing/crypto/evm/__tests__/eth-settler.test.js +2 -0
  20. package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +31 -4
  21. package/dist/billing/crypto/evm/__tests__/settler.test.js +2 -0
  22. package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.d.ts +1 -0
  23. package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.js +144 -0
  24. package/dist/billing/crypto/evm/__tests__/watcher.test.js +6 -2
  25. package/dist/billing/crypto/evm/eth-watcher.d.ts +11 -8
  26. package/dist/billing/crypto/evm/eth-watcher.js +27 -13
  27. package/dist/billing/crypto/evm/types.d.ts +5 -1
  28. package/dist/billing/crypto/evm/watcher.d.ts +9 -1
  29. package/dist/billing/crypto/evm/watcher.js +36 -13
  30. package/dist/billing/crypto/index.d.ts +3 -3
  31. package/dist/billing/crypto/index.js +1 -1
  32. package/dist/billing/crypto/key-server-webhook.d.ts +17 -4
  33. package/dist/billing/crypto/key-server-webhook.js +76 -15
  34. package/dist/billing/crypto/types.d.ts +16 -0
  35. package/dist/billing/crypto/unified-checkout.d.ts +8 -17
  36. package/dist/billing/crypto/unified-checkout.js +17 -131
  37. package/dist/billing/crypto/watcher-service.d.ts +22 -2
  38. package/dist/billing/crypto/watcher-service.js +71 -30
  39. package/dist/db/schema/crypto.d.ts +68 -0
  40. package/dist/db/schema/crypto.js +8 -0
  41. package/dist/monetization/crypto/__tests__/webhook.test.js +2 -1
  42. package/drizzle/migrations/0016_charge_progress_columns.sql +4 -0
  43. package/drizzle/migrations/meta/_journal.json +7 -0
  44. package/package.json +1 -1
  45. package/src/billing/crypto/__tests__/unified-checkout.test.ts +83 -0
  46. package/src/billing/crypto/__tests__/watcher-service.test.ts +242 -0
  47. package/src/billing/crypto/__tests__/webhook-confirmations.test.ts +367 -0
  48. package/src/billing/crypto/btc/__tests__/settler.test.ts +1 -0
  49. package/src/billing/crypto/btc/__tests__/watcher.test.ts +201 -0
  50. package/src/billing/crypto/btc/types.ts +3 -1
  51. package/src/billing/crypto/btc/watcher.ts +22 -6
  52. package/src/billing/crypto/charge-store.test.ts +204 -1
  53. package/src/billing/crypto/charge-store.ts +86 -2
  54. package/src/billing/crypto/client.ts +2 -0
  55. package/src/billing/crypto/cursor-store.ts +31 -3
  56. package/src/billing/crypto/evm/__tests__/eth-settler.test.ts +2 -0
  57. package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +31 -4
  58. package/src/billing/crypto/evm/__tests__/settler.test.ts +2 -0
  59. package/src/billing/crypto/evm/__tests__/watcher-confirmations.test.ts +176 -0
  60. package/src/billing/crypto/evm/__tests__/watcher.test.ts +6 -2
  61. package/src/billing/crypto/evm/eth-watcher.ts +34 -14
  62. package/src/billing/crypto/evm/types.ts +5 -1
  63. package/src/billing/crypto/evm/watcher.ts +39 -13
  64. package/src/billing/crypto/index.ts +12 -3
  65. package/src/billing/crypto/key-server-webhook.ts +92 -21
  66. package/src/billing/crypto/types.ts +18 -0
  67. package/src/billing/crypto/unified-checkout.ts +20 -179
  68. package/src/billing/crypto/watcher-service.ts +85 -32
  69. package/src/db/schema/crypto.ts +8 -0
  70. package/src/monetization/crypto/__tests__/webhook.test.ts +2 -1
@@ -1,32 +1,76 @@
1
1
  /**
2
- * Key Server webhook handler — processes payment confirmations from the
2
+ * Key Server webhook handler — processes payment events from the
3
3
  * centralized crypto key server.
4
4
  *
5
+ * Called on EVERY status update (not just terminal):
6
+ * - "partial" / "Processing" → update progress, no credit
7
+ * - "confirmed" / "Settled" → update progress + credit ledger
8
+ * - "expired" / "failed" → update progress, no credit
9
+ *
5
10
  * Payload shape (from watcher-service.ts):
6
11
  * {
7
12
  * chargeId: "btc:bc1q...",
8
13
  * chain: "bitcoin",
9
14
  * address: "bc1q...",
10
- * amountUsdCents: 5000,
15
+ * amountReceivedCents: 5000,
11
16
  * status: "confirmed",
12
17
  * txHash: "abc123...",
13
18
  * amountReceived: "50000 sats",
14
- * confirmations: 6
19
+ * confirmations: 6,
20
+ * confirmationsRequired: 6
15
21
  * }
16
22
  *
17
23
  * Replaces handleCryptoWebhook() for products using the key server.
18
24
  */
19
25
  import { Credit } from "../../credits/credit.js";
20
26
  /**
21
- * Process a payment confirmation from the crypto key server.
27
+ * Map legacy/watcher status strings to canonical CryptoChargeStatus.
28
+ * Accepts both old BTCPay-style ("Settled", "Processing") and new canonical ("confirmed", "partial").
29
+ */
30
+ export function normalizeStatus(raw) {
31
+ switch (raw) {
32
+ case "confirmed":
33
+ case "Settled":
34
+ case "InvoiceSettled":
35
+ return "confirmed";
36
+ case "partial":
37
+ case "Processing":
38
+ case "InvoiceProcessing":
39
+ case "InvoiceReceivedPayment":
40
+ return "partial";
41
+ case "expired":
42
+ case "Expired":
43
+ case "InvoiceExpired":
44
+ return "expired";
45
+ case "failed":
46
+ case "Invalid":
47
+ case "InvoiceInvalid":
48
+ return "failed";
49
+ case "pending":
50
+ case "New":
51
+ case "InvoiceCreated":
52
+ return "pending";
53
+ default:
54
+ return "pending";
55
+ }
56
+ }
57
+ /**
58
+ * Process a payment webhook from the crypto key server.
22
59
  *
23
- * Credits the ledger when status is "confirmed".
24
- * Idempotency: ledger referenceId + replay guard (same pattern as Stripe handler).
60
+ * Idempotency: deduplicate by chargeId + status + confirmations so that
61
+ * multiple progress updates (0→1→2→...→6 confirmations) each get through,
62
+ * but exact duplicates are rejected.
25
63
  */
26
64
  export async function handleKeyServerWebhook(deps, payload) {
27
65
  const { chargeStore, creditLedger } = deps;
28
- // Replay guard: deduplicate by chargeId
29
- const dedupeKey = `ks:${payload.chargeId}`;
66
+ const status = normalizeStatus(payload.status);
67
+ const confirmations = payload.confirmations ?? 0;
68
+ const confirmationsRequired = payload.confirmationsRequired ?? 1;
69
+ // Support deprecated amountUsdCents field as fallback
70
+ const amountReceivedCents = payload.amountReceivedCents ?? payload.amountUsdCents ?? 0;
71
+ // Replay guard: deduplicate by chargeId + status + confirmations
72
+ // This allows multiple progress updates for the same charge
73
+ const dedupeKey = `ks:${payload.chargeId}:${status}:${confirmations}`;
30
74
  if (await deps.replayGuard.isDuplicate(dedupeKey, "crypto")) {
31
75
  return { handled: true, duplicate: true };
32
76
  }
@@ -35,14 +79,29 @@ export async function handleKeyServerWebhook(deps, payload) {
35
79
  if (!charge) {
36
80
  return { handled: false };
37
81
  }
38
- if (payload.status === "confirmed") {
39
- // Only settle when payment is confirmed
40
- await chargeStore.updateStatus(payload.chargeId, "Settled", charge.token ?? undefined, payload.amountReceived);
82
+ // Always update progress on every webhook
83
+ await chargeStore.updateProgress(payload.chargeId, {
84
+ status,
85
+ amountReceivedCents,
86
+ confirmations,
87
+ confirmationsRequired,
88
+ txHash: payload.txHash,
89
+ });
90
+ // Also call deprecated updateStatus for backward compat with downstream consumers
91
+ const legacyStatusMap = {
92
+ pending: "New",
93
+ partial: "Processing",
94
+ confirmed: "Settled",
95
+ expired: "Expired",
96
+ failed: "Invalid",
97
+ };
98
+ await chargeStore.updateStatus(payload.chargeId, legacyStatusMap[status], charge.token ?? undefined, payload.amountReceived);
99
+ if (status === "confirmed") {
41
100
  // Idempotency: check ledger referenceId (atomic, same as BTCPay handler)
42
101
  const creditRef = `crypto:${payload.chargeId}`;
43
102
  if (await creditLedger.hasReferenceId(creditRef)) {
44
103
  await deps.replayGuard.markSeen(dedupeKey, "crypto");
45
- return { handled: true, duplicate: true, tenant: charge.tenantId };
104
+ return { handled: true, duplicate: true, tenant: charge.tenantId, status, confirmations, confirmationsRequired };
46
105
  }
47
106
  // Credit the original USD amount requested.
48
107
  // charge.amountUsdCents is integer cents. Credit.fromCents() → nanodollars.
@@ -64,10 +123,12 @@ export async function handleKeyServerWebhook(deps, payload) {
64
123
  tenant: charge.tenantId,
65
124
  creditedCents: charge.amountUsdCents,
66
125
  reactivatedBots,
126
+ status,
127
+ confirmations,
128
+ confirmationsRequired,
67
129
  };
68
130
  }
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);
131
+ // Non-confirmed status — progress already updated above, no credit
71
132
  await deps.replayGuard.markSeen(dedupeKey, "crypto");
72
- return { handled: true, tenant: charge.tenantId };
133
+ return { handled: true, tenant: charge.tenantId, status, confirmations, confirmationsRequired };
73
134
  }
@@ -1,5 +1,21 @@
1
1
  /** BTCPay Server invoice states (Greenfield API v1). */
2
2
  export type CryptoPaymentState = "New" | "Processing" | "Expired" | "Invalid" | "Settled";
3
+ /** Charge status for the UI-facing payment lifecycle. */
4
+ export type CryptoChargeStatus = "pending" | "partial" | "confirmed" | "expired" | "failed";
5
+ /** Full charge record for UI display — includes partial payment progress and confirmations. */
6
+ export interface CryptoCharge {
7
+ id: string;
8
+ tenantId: string;
9
+ chain: string;
10
+ status: CryptoChargeStatus;
11
+ amountExpectedCents: number;
12
+ amountReceivedCents: number;
13
+ confirmations: number;
14
+ confirmationsRequired: number;
15
+ txHash?: string;
16
+ credited: boolean;
17
+ createdAt: Date;
18
+ }
3
19
  /** Options for creating a crypto payment session. */
4
20
  export interface CryptoCheckoutOpts {
5
21
  /** Internal tenant ID. */
@@ -1,14 +1,7 @@
1
- import type { ICryptoChargeRepository } from "./charge-store.js";
2
- import type { IPriceOracle } from "./oracle/types.js";
3
- import type { PaymentMethodRecord } from "./payment-method-store.js";
1
+ import type { CryptoServiceClient } from "./client.js";
4
2
  export declare const MIN_CHECKOUT_USD = 10;
5
3
  export interface UnifiedCheckoutDeps {
6
- chargeStore: Pick<ICryptoChargeRepository, "getNextDerivationIndex" | "createStablecoinCharge">;
7
- oracle: IPriceOracle;
8
- evmXpub: string;
9
- btcXpub?: string;
10
- /** UTXO network override (auto-detected from node in production). Default: "mainnet". */
11
- utxoNetwork?: "mainnet" | "testnet" | "regtest";
4
+ cryptoService: CryptoServiceClient;
12
5
  }
13
6
  export interface UnifiedCheckoutResult {
14
7
  depositAddress: string;
@@ -22,16 +15,14 @@ export interface UnifiedCheckoutResult {
22
15
  priceCents?: number;
23
16
  }
24
17
  /**
25
- * Unified checkout — one entry point for all payment methods.
18
+ * Unified checkout — delegates to CryptoServiceClient.createCharge().
26
19
  *
27
- * Looks up the method record, routes by type:
28
- * - erc20: derives EVM address, computes token amount (1:1 USD for stablecoins)
29
- * - native (ETH): derives EVM address, oracle-priced
30
- * - native (BTC): derives BTC address, oracle-priced
31
- *
32
- * CRITICAL: amountUsd → integer cents via Credit.fromDollars().toCentsRounded().
20
+ * The pay server handles xpub management, address derivation, and charge
21
+ * creation. This function is a thin wrapper that validates the amount
22
+ * and maps the response to `UnifiedCheckoutResult`.
33
23
  */
34
- export declare function createUnifiedCheckout(deps: UnifiedCheckoutDeps, method: PaymentMethodRecord, opts: {
24
+ export declare function createUnifiedCheckout(deps: UnifiedCheckoutDeps, chain: string, opts: {
35
25
  tenant: string;
36
26
  amountUsd: number;
27
+ callbackUrl?: string;
37
28
  }): Promise<UnifiedCheckoutResult>;
@@ -1,141 +1,27 @@
1
- import { Credit } from "../../credits/credit.js";
2
- import { deriveAddress, deriveP2pkhAddress } from "./btc/address-gen.js";
3
- import { deriveDepositAddress } from "./evm/address-gen.js";
4
- import { centsToNative } from "./oracle/convert.js";
5
1
  export const MIN_CHECKOUT_USD = 10;
6
2
  /**
7
- * Unified checkout — one entry point for all payment methods.
3
+ * Unified checkout — delegates to CryptoServiceClient.createCharge().
8
4
  *
9
- * Looks up the method record, routes by type:
10
- * - erc20: derives EVM address, computes token amount (1:1 USD for stablecoins)
11
- * - native (ETH): derives EVM address, oracle-priced
12
- * - native (BTC): derives BTC address, oracle-priced
13
- *
14
- * CRITICAL: amountUsd → integer cents via Credit.fromDollars().toCentsRounded().
5
+ * The pay server handles xpub management, address derivation, and charge
6
+ * creation. This function is a thin wrapper that validates the amount
7
+ * and maps the response to `UnifiedCheckoutResult`.
15
8
  */
16
- export async function createUnifiedCheckout(deps, method, opts) {
9
+ export async function createUnifiedCheckout(deps, chain, opts) {
17
10
  if (!Number.isFinite(opts.amountUsd) || opts.amountUsd < MIN_CHECKOUT_USD) {
18
11
  throw new Error(`Minimum payment amount is $${MIN_CHECKOUT_USD}`);
19
12
  }
20
- const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
21
- if (method.type === "erc20") {
22
- return handleErc20(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
23
- }
24
- if (method.type === "native" && method.chain === "base") {
25
- return handleNativeEvm(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
26
- }
27
- if (method.type === "native") {
28
- return handleNativeUtxo(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
29
- }
30
- throw new Error(`Unsupported payment method type: ${method.type}/${method.token}`);
31
- }
32
- async function handleErc20(deps, method, tenant, amountUsdCents, amountUsd) {
33
- const depositAddress = await deriveAndStore(deps, method, tenant, amountUsdCents);
34
- return {
35
- depositAddress,
36
- displayAmount: `${amountUsd} ${method.token}`,
37
- amountUsd,
38
- token: method.token,
39
- chain: method.chain,
40
- referenceId: `erc20:${method.chain}:${depositAddress}`,
41
- };
42
- }
43
- async function handleNativeEvm(deps, method, tenant, amountUsdCents, amountUsd) {
44
- const { priceCents } = await deps.oracle.getPrice("ETH");
45
- const expectedWei = centsToNative(amountUsdCents, priceCents, 18);
46
- const depositAddress = await deriveAndStore(deps, method, tenant, amountUsdCents);
47
- const divisor = BigInt("1000000000000000000");
48
- const whole = expectedWei / divisor;
49
- const frac = (expectedWei % divisor).toString().padStart(18, "0").slice(0, 6);
13
+ const result = await deps.cryptoService.createCharge({
14
+ chain,
15
+ amountUsd: opts.amountUsd,
16
+ callbackUrl: opts.callbackUrl,
17
+ });
50
18
  return {
51
- depositAddress,
52
- displayAmount: `${whole}.${frac} ETH`,
53
- amountUsd,
54
- token: "ETH",
55
- chain: method.chain,
56
- referenceId: `${method.type}:${method.chain}:${depositAddress}`,
57
- priceCents,
19
+ depositAddress: result.address,
20
+ displayAmount: result.displayAmount ?? `${opts.amountUsd} ${result.token}`,
21
+ amountUsd: opts.amountUsd,
22
+ token: result.token,
23
+ chain: result.chain,
24
+ referenceId: result.chargeId,
25
+ priceCents: result.priceCents,
58
26
  };
59
27
  }
60
- /**
61
- * Handle native UTXO coins (BTC, LTC, DOGE, BCH, etc.).
62
- * Uses the xpub from the payment method record (DB-driven).
63
- * Derives bech32 addresses for BTC/LTC, Base58 P2PKH for DOGE.
64
- */
65
- async function handleNativeUtxo(deps, method, tenant, amountUsdCents, amountUsd) {
66
- const xpub = method.xpub ?? deps.btcXpub;
67
- if (!xpub)
68
- throw new Error(`${method.token} payments not configured (no xpub)`);
69
- const { priceCents } = await deps.oracle.getPrice(method.token);
70
- const rawAmount = centsToNative(amountUsdCents, priceCents, method.decimals);
71
- const maxRetries = 3;
72
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
73
- const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
74
- // Derive address by chain type
75
- let depositAddress;
76
- if (method.chain === "dogecoin") {
77
- depositAddress = deriveP2pkhAddress(xpub, derivationIndex, "dogecoin");
78
- }
79
- else {
80
- depositAddress = deriveAddress(xpub, derivationIndex, deps.utxoNetwork ?? "mainnet", method.chain);
81
- }
82
- const referenceId = `${method.token.toLowerCase()}:${depositAddress}`;
83
- try {
84
- await deps.chargeStore.createStablecoinCharge({
85
- referenceId,
86
- tenantId: tenant,
87
- amountUsdCents,
88
- chain: method.chain,
89
- token: method.token,
90
- depositAddress,
91
- derivationIndex,
92
- });
93
- const divisor = 10 ** method.decimals;
94
- const displayAmt = (Number(rawAmount) / divisor).toFixed(method.decimals);
95
- return {
96
- depositAddress,
97
- displayAmount: `${displayAmt} ${method.token}`,
98
- amountUsd,
99
- token: method.token,
100
- chain: method.chain,
101
- referenceId,
102
- priceCents,
103
- };
104
- }
105
- catch (err) {
106
- const code = err.code;
107
- const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
108
- if (!isConflict || attempt === maxRetries)
109
- throw err;
110
- }
111
- }
112
- throw new Error("Failed to claim derivation index after retries");
113
- }
114
- /** Derive an EVM deposit address and store the charge. Retries on unique conflict. */
115
- async function deriveAndStore(deps, method, tenant, amountUsdCents) {
116
- const maxRetries = 3;
117
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
118
- const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
119
- const depositAddress = deriveDepositAddress(deps.evmXpub, derivationIndex);
120
- const referenceId = `${method.type}:${method.chain}:${depositAddress}`;
121
- try {
122
- await deps.chargeStore.createStablecoinCharge({
123
- referenceId,
124
- tenantId: tenant,
125
- amountUsdCents,
126
- chain: method.chain,
127
- token: method.token,
128
- depositAddress,
129
- derivationIndex,
130
- });
131
- return depositAddress;
132
- }
133
- catch (err) {
134
- const code = err.code;
135
- const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
136
- if (!isConflict || attempt === maxRetries)
137
- throw err;
138
- }
139
- }
140
- throw new Error("Failed to claim derivation index after retries");
141
- }
@@ -4,8 +4,8 @@
4
4
  * Payment flow:
5
5
  * 1. Watcher detects payment → handlePayment()
6
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
7
+ * 3. When totalReceived >= expectedAmount AND confirmations >= required confirmed + credit
8
+ * 4. Every payment/confirmation change enqueues a webhook delivery
9
9
  * 5. Outbox processor retries failed deliveries with exponential backoff
10
10
  *
11
11
  * Amount comparison is ALWAYS in native crypto units (sats, wei, token base units).
@@ -30,4 +30,24 @@ export interface WatcherServiceOpts {
30
30
  /** Allowed callback URL prefixes. Default: ["https://"] — enforces HTTPS. */
31
31
  allowedCallbackPrefixes?: string[];
32
32
  }
33
+ export interface PaymentPayload {
34
+ txHash: string;
35
+ confirmations: number;
36
+ confirmationsRequired: number;
37
+ amountReceivedCents: number;
38
+ [key: string]: unknown;
39
+ }
40
+ /**
41
+ * Handle a payment event. Accumulates partial payments in native units.
42
+ * Fires webhook on every payment/confirmation change with canonical statuses.
43
+ *
44
+ * 3-phase webhook lifecycle:
45
+ * 1. Tx first seen -> status: "partial", confirmations: 0
46
+ * 2. Each new block -> status: "partial", confirmations: current
47
+ * 3. Threshold reached + full payment -> status: "confirmed"
48
+ *
49
+ * @param nativeAmount — received amount in native base units (sats for BTC/DOGE, raw token units for ERC20).
50
+ * Pass "0" for confirmation-only updates (no new payment, just more confirmations).
51
+ */
52
+ export declare function handlePayment(db: DrizzleDb, chargeStore: ICryptoChargeRepository, address: string, nativeAmount: string, payload: PaymentPayload, log: (msg: string, meta?: Record<string, unknown>) => void): Promise<void>;
33
53
  export declare function startWatchers(opts: WatcherServiceOpts): Promise<() => void>;
@@ -4,8 +4,8 @@
4
4
  * Payment flow:
5
5
  * 1. Watcher detects payment → handlePayment()
6
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
7
+ * 3. When totalReceived >= expectedAmount AND confirmations >= required confirmed + credit
8
+ * 4. Every payment/confirmation change enqueues a webhook delivery
9
9
  * 5. Outbox processor retries failed deliveries with exponential backoff
10
10
  *
11
11
  * Amount comparison is ALWAYS in native crypto units (sats, wei, token base units).
@@ -90,14 +90,19 @@ async function processDeliveries(db, allowedPrefixes, log) {
90
90
  }
91
91
  return delivered;
92
92
  }
93
- // --- Payment handling (partial + full) ---
94
93
  /**
95
94
  * Handle a payment event. Accumulates partial payments in native units.
96
- * Settles when totalReceived >= expectedAmount. Fires webhook on every payment.
95
+ * Fires webhook on every payment/confirmation change with canonical statuses.
97
96
  *
98
- * @param nativeAmount — received amount in native base units (sats for BTC/DOGE, raw token units for ERC20)
97
+ * 3-phase webhook lifecycle:
98
+ * 1. Tx first seen -> status: "partial", confirmations: 0
99
+ * 2. Each new block -> status: "partial", confirmations: current
100
+ * 3. Threshold reached + full payment -> status: "confirmed"
101
+ *
102
+ * @param nativeAmount — received amount in native base units (sats for BTC/DOGE, raw token units for ERC20).
103
+ * Pass "0" for confirmation-only updates (no new payment, just more confirmations).
99
104
  */
100
- async function handlePayment(db, chargeStore, address, nativeAmount, payload, log) {
105
+ export async function handlePayment(db, chargeStore, address, nativeAmount, payload, log) {
101
106
  const charge = await chargeStore.getByDepositAddress(address);
102
107
  if (!charge) {
103
108
  log("Payment to unknown address", { address });
@@ -106,39 +111,59 @@ async function handlePayment(db, chargeStore, address, nativeAmount, payload, lo
106
111
  if (charge.creditedAt) {
107
112
  return; // Already fully paid and credited
108
113
  }
109
- // Accumulate: add this payment to the running total
114
+ const { confirmations, confirmationsRequired, amountReceivedCents, txHash } = payload;
115
+ // Accumulate: add this payment to the running total (if nativeAmount > 0)
110
116
  const prevReceived = BigInt(charge.receivedAmount ?? "0");
111
117
  const thisPayment = BigInt(nativeAmount);
112
118
  const totalReceived = (prevReceived + thisPayment).toString();
113
119
  const expected = BigInt(charge.expectedAmount ?? "0");
114
120
  const isFull = expected > 0n && BigInt(totalReceived) >= expected;
115
- // Update received_amount in DB
116
- await db
117
- .update(cryptoCharges)
118
- .set({ receivedAmount: totalReceived, filledAmount: totalReceived })
119
- .where(eq(cryptoCharges.referenceId, charge.referenceId));
120
- if (isFull) {
121
- const settled = "Settled";
122
- await chargeStore.updateStatus(charge.referenceId, settled, charge.token ?? undefined, totalReceived);
121
+ const isConfirmed = isFull && confirmations >= confirmationsRequired;
122
+ // Update received_amount in DB (only when there's a new payment)
123
+ if (thisPayment > 0n) {
124
+ await db
125
+ .update(cryptoCharges)
126
+ .set({ receivedAmount: totalReceived, filledAmount: totalReceived })
127
+ .where(eq(cryptoCharges.referenceId, charge.referenceId));
128
+ }
129
+ // Determine canonical status
130
+ const status = isConfirmed ? "confirmed" : "partial";
131
+ // Update progress via new API
132
+ await chargeStore.updateProgress(charge.referenceId, {
133
+ status,
134
+ amountReceivedCents,
135
+ confirmations,
136
+ confirmationsRequired,
137
+ txHash,
138
+ });
139
+ if (isConfirmed) {
123
140
  await chargeStore.markCredited(charge.referenceId);
124
- log("Charge settled", { chargeId: charge.referenceId, expected: expected.toString(), received: totalReceived });
141
+ log("Charge confirmed", {
142
+ chargeId: charge.referenceId,
143
+ confirmations,
144
+ confirmationsRequired,
145
+ });
125
146
  }
126
147
  else {
127
- const processing = "Processing";
128
- await chargeStore.updateStatus(charge.referenceId, processing, charge.token ?? undefined, totalReceived);
129
- log("Partial payment", { chargeId: charge.referenceId, expected: expected.toString(), received: totalReceived });
148
+ log("Payment progress", {
149
+ chargeId: charge.referenceId,
150
+ confirmations,
151
+ confirmationsRequired,
152
+ received: totalReceived,
153
+ });
130
154
  }
131
- // Webhook on every payment — product shows progress to user
155
+ // Webhook on every event — product shows confirmation progress to user
132
156
  if (charge.callbackUrl) {
133
157
  await enqueueWebhook(db, charge.referenceId, charge.callbackUrl, {
134
158
  chargeId: charge.referenceId,
135
159
  chain: charge.chain,
136
160
  address: charge.depositAddress,
137
- expectedAmount: expected.toString(),
138
- receivedAmount: totalReceived,
139
- amountUsdCents: charge.amountUsdCents,
140
- status: isFull ? "confirmed" : "partial",
141
- ...payload,
161
+ amountExpectedCents: charge.amountUsdCents,
162
+ amountReceivedCents,
163
+ confirmations,
164
+ confirmationsRequired,
165
+ txHash,
166
+ status,
142
167
  });
143
168
  }
144
169
  }
@@ -181,11 +206,19 @@ export async function startWatchers(opts) {
181
206
  oracle,
182
207
  cursorStore,
183
208
  onPayment: async (event) => {
184
- log("UTXO payment", { chain: method.chain, address: event.address, txid: event.txid, sats: event.amountSats });
185
- // Pass native amount (sats) — NOT USD cents
209
+ log("UTXO payment", {
210
+ chain: method.chain,
211
+ address: event.address,
212
+ txid: event.txid,
213
+ sats: event.amountSats,
214
+ confirmations: event.confirmations,
215
+ confirmationsRequired: event.confirmationsRequired,
216
+ });
186
217
  await handlePayment(db, chargeStore, event.address, String(event.amountSats), {
187
218
  txHash: event.txid,
188
219
  confirmations: event.confirmations,
220
+ confirmationsRequired: event.confirmationsRequired,
221
+ amountReceivedCents: event.amountUsdCents,
189
222
  }, log);
190
223
  },
191
224
  });
@@ -247,11 +280,19 @@ export async function startWatchers(opts) {
247
280
  watchedAddresses: chainAddresses,
248
281
  cursorStore,
249
282
  onPayment: async (event) => {
250
- log("EVM payment", { chain: event.chain, token: event.token, to: event.to, txHash: event.txHash });
251
- // Pass native amount (raw token units) — NOT USD cents
283
+ log("EVM payment", {
284
+ chain: event.chain,
285
+ token: event.token,
286
+ to: event.to,
287
+ txHash: event.txHash,
288
+ confirmations: event.confirmations,
289
+ confirmationsRequired: event.confirmationsRequired,
290
+ });
252
291
  await handlePayment(db, chargeStore, event.to, event.rawAmount, {
253
292
  txHash: event.txHash,
254
- confirmations: method.confirmations,
293
+ confirmations: event.confirmations,
294
+ confirmationsRequired: event.confirmationsRequired,
295
+ amountReceivedCents: event.amountUsdCents,
255
296
  }, log);
256
297
  },
257
298
  });
@@ -282,6 +282,74 @@ export declare const cryptoCharges: import("drizzle-orm/pg-core").PgTableWithCol
282
282
  identity: undefined;
283
283
  generated: undefined;
284
284
  }, {}, {}>;
285
+ confirmations: import("drizzle-orm/pg-core").PgColumn<{
286
+ name: "confirmations";
287
+ tableName: "crypto_charges";
288
+ dataType: "number";
289
+ columnType: "PgInteger";
290
+ data: number;
291
+ driverParam: string | number;
292
+ notNull: true;
293
+ hasDefault: true;
294
+ isPrimaryKey: false;
295
+ isAutoincrement: false;
296
+ hasRuntimeDefault: false;
297
+ enumValues: undefined;
298
+ baseColumn: never;
299
+ identity: undefined;
300
+ generated: undefined;
301
+ }, {}, {}>;
302
+ confirmationsRequired: import("drizzle-orm/pg-core").PgColumn<{
303
+ name: "confirmations_required";
304
+ tableName: "crypto_charges";
305
+ dataType: "number";
306
+ columnType: "PgInteger";
307
+ data: number;
308
+ driverParam: string | number;
309
+ notNull: true;
310
+ hasDefault: true;
311
+ isPrimaryKey: false;
312
+ isAutoincrement: false;
313
+ hasRuntimeDefault: false;
314
+ enumValues: undefined;
315
+ baseColumn: never;
316
+ identity: undefined;
317
+ generated: undefined;
318
+ }, {}, {}>;
319
+ txHash: import("drizzle-orm/pg-core").PgColumn<{
320
+ name: "tx_hash";
321
+ tableName: "crypto_charges";
322
+ dataType: "string";
323
+ columnType: "PgText";
324
+ data: string;
325
+ driverParam: string;
326
+ notNull: false;
327
+ hasDefault: false;
328
+ isPrimaryKey: false;
329
+ isAutoincrement: false;
330
+ hasRuntimeDefault: false;
331
+ enumValues: [string, ...string[]];
332
+ baseColumn: never;
333
+ identity: undefined;
334
+ generated: undefined;
335
+ }, {}, {}>;
336
+ amountReceivedCents: import("drizzle-orm/pg-core").PgColumn<{
337
+ name: "amount_received_cents";
338
+ tableName: "crypto_charges";
339
+ dataType: "number";
340
+ columnType: "PgInteger";
341
+ data: number;
342
+ driverParam: string | number;
343
+ notNull: true;
344
+ hasDefault: true;
345
+ isPrimaryKey: false;
346
+ isAutoincrement: false;
347
+ hasRuntimeDefault: false;
348
+ enumValues: undefined;
349
+ baseColumn: never;
350
+ identity: undefined;
351
+ generated: undefined;
352
+ }, {}, {}>;
285
353
  };
286
354
  dialect: "pg";
287
355
  }>;
@@ -27,6 +27,14 @@ export const cryptoCharges = pgTable("crypto_charges", {
27
27
  expectedAmount: text("expected_amount"),
28
28
  /** Running total of received crypto in native units. Accumulates across partial payments. */
29
29
  receivedAmount: text("received_amount"),
30
+ /** Number of blockchain confirmations observed so far. */
31
+ confirmations: integer("confirmations").notNull().default(0),
32
+ /** Required confirmations for settlement (copied from payment method at creation). */
33
+ confirmationsRequired: integer("confirmations_required").notNull().default(1),
34
+ /** Blockchain transaction hash for the payment. */
35
+ txHash: text("tx_hash"),
36
+ /** Amount received so far in USD cents (integer). Converted from crypto at time of receipt. */
37
+ amountReceivedCents: integer("amount_received_cents").notNull().default(0),
30
38
  }, (table) => [
31
39
  index("idx_crypto_charges_tenant").on(table.tenantId),
32
40
  index("idx_crypto_charges_status").on(table.status),
@@ -120,7 +120,8 @@ describe("handleCryptoWebhook (monetization layer)", () => {
120
120
  it("updates charge status on every webhook call", async () => {
121
121
  await handleCryptoWebhook(deps, makePayload({ status: "partial" }));
122
122
  const charge = await chargeStore.getByReferenceId("chg-test-001");
123
- expect(charge?.status).toBe("partial");
123
+ // DB stores legacy status values; "partial" maps to "Processing" internally
124
+ expect(charge?.status).toBe("Processing");
124
125
  });
125
126
  it("settles charge when status is confirmed", async () => {
126
127
  await handleCryptoWebhook(deps, makePayload({ status: "confirmed" }));
@@ -0,0 +1,4 @@
1
+ ALTER TABLE "crypto_charges" ADD COLUMN "confirmations" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
2
+ ALTER TABLE "crypto_charges" ADD COLUMN "confirmations_required" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
3
+ ALTER TABLE "crypto_charges" ADD COLUMN "tx_hash" text;--> statement-breakpoint
4
+ ALTER TABLE "crypto_charges" ADD COLUMN "amount_received_cents" integer DEFAULT 0 NOT NULL;