@wopr-network/platform-core 1.48.0 → 1.49.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 (114) hide show
  1. package/dist/billing/crypto/__tests__/key-server.test.js +1 -1
  2. package/dist/billing/crypto/__tests__/unified-checkout.test.d.ts +1 -0
  3. package/dist/billing/crypto/__tests__/unified-checkout.test.js +63 -0
  4. package/dist/billing/crypto/__tests__/watcher-service.test.d.ts +1 -0
  5. package/dist/billing/crypto/__tests__/watcher-service.test.js +174 -0
  6. package/dist/billing/crypto/__tests__/webhook-confirmations.test.d.ts +1 -0
  7. package/dist/billing/crypto/__tests__/webhook-confirmations.test.js +304 -0
  8. package/dist/billing/crypto/btc/__tests__/settler.test.js +1 -0
  9. package/dist/billing/crypto/btc/__tests__/watcher.test.d.ts +1 -0
  10. package/dist/billing/crypto/btc/__tests__/watcher.test.js +170 -0
  11. package/dist/billing/crypto/btc/types.d.ts +3 -1
  12. package/dist/billing/crypto/btc/watcher.d.ts +6 -1
  13. package/dist/billing/crypto/btc/watcher.js +24 -8
  14. package/dist/billing/crypto/charge-store.d.ts +27 -2
  15. package/dist/billing/crypto/charge-store.js +67 -1
  16. package/dist/billing/crypto/charge-store.test.js +180 -1
  17. package/dist/billing/crypto/client.d.ts +2 -0
  18. package/dist/billing/crypto/cursor-store.d.ts +10 -3
  19. package/dist/billing/crypto/cursor-store.js +21 -1
  20. package/dist/billing/crypto/evm/__tests__/eth-checkout.test.js +3 -3
  21. package/dist/billing/crypto/evm/__tests__/eth-settler.test.js +2 -0
  22. package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +33 -6
  23. package/dist/billing/crypto/evm/__tests__/settler.test.js +2 -0
  24. package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.d.ts +1 -0
  25. package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.js +144 -0
  26. package/dist/billing/crypto/evm/__tests__/watcher.test.js +6 -2
  27. package/dist/billing/crypto/evm/eth-checkout.d.ts +2 -2
  28. package/dist/billing/crypto/evm/eth-checkout.js +3 -3
  29. package/dist/billing/crypto/evm/eth-watcher.d.ts +11 -8
  30. package/dist/billing/crypto/evm/eth-watcher.js +29 -15
  31. package/dist/billing/crypto/evm/types.d.ts +5 -1
  32. package/dist/billing/crypto/evm/watcher.d.ts +9 -1
  33. package/dist/billing/crypto/evm/watcher.js +36 -13
  34. package/dist/billing/crypto/index.d.ts +3 -3
  35. package/dist/billing/crypto/index.js +1 -1
  36. package/dist/billing/crypto/key-server-entry.js +7 -2
  37. package/dist/billing/crypto/key-server-webhook.d.ts +17 -4
  38. package/dist/billing/crypto/key-server-webhook.js +76 -15
  39. package/dist/billing/crypto/key-server.js +18 -7
  40. package/dist/billing/crypto/oracle/__tests__/chainlink.test.js +4 -4
  41. package/dist/billing/crypto/oracle/__tests__/coingecko.test.d.ts +1 -0
  42. package/dist/billing/crypto/oracle/__tests__/coingecko.test.js +65 -0
  43. package/dist/billing/crypto/oracle/__tests__/composite.test.d.ts +1 -0
  44. package/dist/billing/crypto/oracle/__tests__/composite.test.js +48 -0
  45. package/dist/billing/crypto/oracle/__tests__/convert.test.js +27 -17
  46. package/dist/billing/crypto/oracle/__tests__/fixed.test.js +5 -5
  47. package/dist/billing/crypto/oracle/chainlink.d.ts +2 -2
  48. package/dist/billing/crypto/oracle/chainlink.js +11 -10
  49. package/dist/billing/crypto/oracle/coingecko.d.ts +22 -0
  50. package/dist/billing/crypto/oracle/coingecko.js +67 -0
  51. package/dist/billing/crypto/oracle/composite.d.ts +14 -0
  52. package/dist/billing/crypto/oracle/composite.js +34 -0
  53. package/dist/billing/crypto/oracle/convert.d.ts +17 -7
  54. package/dist/billing/crypto/oracle/convert.js +26 -13
  55. package/dist/billing/crypto/oracle/fixed.d.ts +2 -2
  56. package/dist/billing/crypto/oracle/fixed.js +9 -7
  57. package/dist/billing/crypto/oracle/index.d.ts +4 -0
  58. package/dist/billing/crypto/oracle/index.js +3 -0
  59. package/dist/billing/crypto/oracle/types.d.ts +12 -3
  60. package/dist/billing/crypto/oracle/types.js +7 -1
  61. package/dist/billing/crypto/types.d.ts +16 -0
  62. package/dist/billing/crypto/unified-checkout.d.ts +10 -19
  63. package/dist/billing/crypto/unified-checkout.js +17 -131
  64. package/dist/billing/crypto/watcher-service.d.ts +22 -2
  65. package/dist/billing/crypto/watcher-service.js +71 -30
  66. package/dist/db/schema/crypto.d.ts +68 -0
  67. package/dist/db/schema/crypto.js +8 -0
  68. package/dist/monetization/crypto/__tests__/webhook.test.js +2 -1
  69. package/drizzle/migrations/0016_charge_progress_columns.sql +4 -0
  70. package/drizzle/migrations/meta/_journal.json +7 -0
  71. package/package.json +1 -1
  72. package/src/billing/crypto/__tests__/key-server.test.ts +1 -1
  73. package/src/billing/crypto/__tests__/unified-checkout.test.ts +83 -0
  74. package/src/billing/crypto/__tests__/watcher-service.test.ts +242 -0
  75. package/src/billing/crypto/__tests__/webhook-confirmations.test.ts +367 -0
  76. package/src/billing/crypto/btc/__tests__/settler.test.ts +1 -0
  77. package/src/billing/crypto/btc/__tests__/watcher.test.ts +201 -0
  78. package/src/billing/crypto/btc/types.ts +3 -1
  79. package/src/billing/crypto/btc/watcher.ts +26 -8
  80. package/src/billing/crypto/charge-store.test.ts +204 -1
  81. package/src/billing/crypto/charge-store.ts +86 -2
  82. package/src/billing/crypto/client.ts +2 -0
  83. package/src/billing/crypto/cursor-store.ts +31 -3
  84. package/src/billing/crypto/evm/__tests__/eth-checkout.test.ts +3 -3
  85. package/src/billing/crypto/evm/__tests__/eth-settler.test.ts +2 -0
  86. package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +33 -6
  87. package/src/billing/crypto/evm/__tests__/settler.test.ts +2 -0
  88. package/src/billing/crypto/evm/__tests__/watcher-confirmations.test.ts +176 -0
  89. package/src/billing/crypto/evm/__tests__/watcher.test.ts +6 -2
  90. package/src/billing/crypto/evm/eth-checkout.ts +5 -5
  91. package/src/billing/crypto/evm/eth-watcher.ts +36 -16
  92. package/src/billing/crypto/evm/types.ts +5 -1
  93. package/src/billing/crypto/evm/watcher.ts +39 -13
  94. package/src/billing/crypto/index.ts +12 -3
  95. package/src/billing/crypto/key-server-entry.ts +7 -2
  96. package/src/billing/crypto/key-server-webhook.ts +92 -21
  97. package/src/billing/crypto/key-server.ts +17 -7
  98. package/src/billing/crypto/oracle/__tests__/chainlink.test.ts +4 -4
  99. package/src/billing/crypto/oracle/__tests__/coingecko.test.ts +75 -0
  100. package/src/billing/crypto/oracle/__tests__/composite.test.ts +61 -0
  101. package/src/billing/crypto/oracle/__tests__/convert.test.ts +29 -17
  102. package/src/billing/crypto/oracle/__tests__/fixed.test.ts +5 -5
  103. package/src/billing/crypto/oracle/chainlink.ts +11 -10
  104. package/src/billing/crypto/oracle/coingecko.ts +92 -0
  105. package/src/billing/crypto/oracle/composite.ts +35 -0
  106. package/src/billing/crypto/oracle/convert.ts +28 -13
  107. package/src/billing/crypto/oracle/fixed.ts +9 -7
  108. package/src/billing/crypto/oracle/index.ts +4 -0
  109. package/src/billing/crypto/oracle/types.ts +16 -3
  110. package/src/billing/crypto/types.ts +18 -0
  111. package/src/billing/crypto/unified-checkout.ts +22 -181
  112. package/src/billing/crypto/watcher-service.ts +85 -32
  113. package/src/db/schema/crypto.ts +8 -0
  114. package/src/monetization/crypto/__tests__/webhook.test.ts +2 -1
@@ -0,0 +1,35 @@
1
+ import type { IPriceOracle, PriceAsset, PriceResult } from "./types.js";
2
+
3
+ /**
4
+ * Composite oracle — tries primary (Chainlink on-chain), falls back to secondary (CoinGecko).
5
+ *
6
+ * When a feedAddress is provided (from payment_methods.oracle_address), the primary
7
+ * oracle is used with that address. When no feed exists or the primary fails,
8
+ * the fallback oracle is consulted.
9
+ */
10
+ export class CompositeOracle implements IPriceOracle {
11
+ constructor(
12
+ private readonly primary: IPriceOracle,
13
+ private readonly fallback: IPriceOracle,
14
+ ) {}
15
+
16
+ async getPrice(asset: PriceAsset, feedAddress?: `0x${string}`): Promise<PriceResult> {
17
+ // If a specific feed address is provided, try the primary (Chainlink) first
18
+ if (feedAddress) {
19
+ try {
20
+ return await this.primary.getPrice(asset, feedAddress);
21
+ } catch {
22
+ // Primary failed (stale, network error) — fall through to fallback
23
+ }
24
+ }
25
+
26
+ // Try primary without explicit feed (uses built-in feed map for BTC/ETH)
27
+ try {
28
+ return await this.primary.getPrice(asset);
29
+ } catch {
30
+ // No feed configured or call failed — use fallback
31
+ }
32
+
33
+ return this.fallback.getPrice(asset);
34
+ }
35
+ }
@@ -1,38 +1,53 @@
1
1
  /**
2
- * Convert USD cents to native token amount using a price in cents.
2
+ * Price units: **microdollars** (1 microdollar = $0.000001 = 10^-6 USD).
3
3
  *
4
- * Formula: rawAmount = amountCents × 10^decimals / priceCents
4
+ * Why not cents? DOGE at $0.094 rounds to 9 cents — 6% error.
5
+ * Microdollars give 6 decimal places: $0.094147 = 94,147 microdollars.
6
+ *
7
+ * Chainlink feeds: 8 decimals → answer / 100 = microdollars.
8
+ * CoinGecko: Math.round(usd * 1_000_000) = microdollars.
9
+ * All math is integer bigint — no floating point.
10
+ */
11
+
12
+ /** Microdollars per cent. Multiply amountCents by this to get microdollars. */
13
+ const MICROS_PER_CENT = 10_000n;
14
+
15
+ /**
16
+ * Convert USD cents to native token amount using a price in microdollars.
17
+ *
18
+ * Formula: rawAmount = (amountCents × 10_000) × 10^decimals / priceMicros
5
19
  *
6
20
  * Examples:
7
- * $50 in ETH at $3,500: centsToNative(5000, 350000, 18) = 14285714285714285n (≈0.01429 ETH)
8
- * $50 in BTC at $65,000: centsToNative(5000, 6500000, 8) = 76923n (76,923 sats 0.00077 BTC)
21
+ * $50 in BTC at $70,315: centsToNative(5000, 70_315_000_000, 8) = 71,119 sats
22
+ * $50 in DOGE at $0.094: centsToNative(5000, 94_147, 8) = 53,107,898,982 base units (531.08 DOGE)
9
23
  *
10
24
  * Integer math only. No floating point.
11
25
  */
12
- export function centsToNative(amountCents: number, priceCents: number, decimals: number): bigint {
26
+ export function centsToNative(amountCents: number, priceMicros: number, decimals: number): bigint {
13
27
  if (!Number.isInteger(amountCents) || amountCents <= 0) {
14
28
  throw new Error(`amountCents must be a positive integer, got ${amountCents}`);
15
29
  }
16
- if (!Number.isInteger(priceCents) || priceCents <= 0) {
17
- throw new Error(`priceCents must be a positive integer, got ${priceCents}`);
30
+ if (!Number.isInteger(priceMicros) || priceMicros <= 0) {
31
+ throw new Error(`priceMicros must be a positive integer, got ${priceMicros}`);
18
32
  }
19
33
  if (!Number.isInteger(decimals) || decimals < 0) {
20
34
  throw new Error(`decimals must be a non-negative integer, got ${decimals}`);
21
35
  }
22
- return (BigInt(amountCents) * 10n ** BigInt(decimals)) / BigInt(priceCents);
36
+ // Convert amountCents to microdollars to match priceMicros units
37
+ return (BigInt(amountCents) * MICROS_PER_CENT * 10n ** BigInt(decimals)) / BigInt(priceMicros);
23
38
  }
24
39
 
25
40
  /**
26
- * Convert native token amount back to USD cents using a price in cents.
41
+ * Convert native token amount back to USD cents using a price in microdollars.
27
42
  *
28
43
  * Inverse of centsToNative. Truncates fractional cents.
29
44
  *
30
45
  * Integer math only.
31
46
  */
32
- export function nativeToCents(rawAmount: bigint, priceCents: number, decimals: number): number {
47
+ export function nativeToCents(rawAmount: bigint, priceMicros: number, decimals: number): number {
33
48
  if (rawAmount < 0n) throw new Error("rawAmount must be non-negative");
34
- if (!Number.isInteger(priceCents) || priceCents <= 0) {
35
- throw new Error(`priceCents must be a positive integer, got ${priceCents}`);
49
+ if (!Number.isInteger(priceMicros) || priceMicros <= 0) {
50
+ throw new Error(`priceMicros must be a positive integer, got ${priceMicros}`);
36
51
  }
37
- return Number((rawAmount * BigInt(priceCents)) / 10n ** BigInt(decimals));
52
+ return Number((rawAmount * BigInt(priceMicros)) / (MICROS_PER_CENT * 10n ** BigInt(decimals)));
38
53
  }
@@ -2,22 +2,24 @@ import type { IPriceOracle, PriceAsset, PriceResult } from "./types.js";
2
2
 
3
3
  /**
4
4
  * Fixed-price oracle for testing and local dev (Anvil, regtest).
5
- * Returns hardcoded prices — no RPC calls.
5
+ * Returns hardcoded prices in microdollars — no RPC calls.
6
6
  */
7
7
  export class FixedPriceOracle implements IPriceOracle {
8
8
  private readonly prices: Record<string, number>;
9
9
 
10
10
  constructor(prices: Partial<Record<PriceAsset, number>> = {}) {
11
11
  this.prices = {
12
- ETH: 350_000, // $3,500
13
- BTC: 6_500_000, // $65,000
12
+ ETH: 3_500_000_000, // $3,500 in microdollars
13
+ BTC: 65_000_000_000, // $65,000 in microdollars
14
+ DOGE: 94_000, // $0.094 in microdollars
15
+ LTC: 55_000_000, // $55 in microdollars
14
16
  ...prices,
15
17
  };
16
18
  }
17
19
 
18
- async getPrice(asset: PriceAsset): Promise<PriceResult> {
19
- const priceCents = this.prices[asset];
20
- if (priceCents === undefined) throw new Error(`No fixed price for ${asset}`);
21
- return { priceCents, updatedAt: new Date() };
20
+ async getPrice(asset: PriceAsset, _feedAddress?: `0x${string}`): Promise<PriceResult> {
21
+ const priceMicros = this.prices[asset];
22
+ if (priceMicros === undefined) throw new Error(`No fixed price for ${asset}`);
23
+ return { priceMicros, updatedAt: new Date() };
22
24
  }
23
25
  }
@@ -1,5 +1,9 @@
1
1
  export type { ChainlinkOracleOpts } from "./chainlink.js";
2
2
  export { ChainlinkOracle } from "./chainlink.js";
3
+ export type { CoinGeckoOracleOpts } from "./coingecko.js";
4
+ export { CoinGeckoOracle } from "./coingecko.js";
5
+ export { CompositeOracle } from "./composite.js";
3
6
  export { centsToNative, nativeToCents } from "./convert.js";
4
7
  export { FixedPriceOracle } from "./fixed.js";
5
8
  export type { IPriceOracle, PriceAsset, PriceResult } from "./types.js";
9
+ export { AssetNotSupportedError } from "./types.js";
@@ -1,15 +1,28 @@
1
1
  /** Assets with Chainlink price feeds. */
2
2
  export type PriceAsset = string;
3
3
 
4
+ /** Thrown when no oracle supports a given asset (not a transient failure). */
5
+ export class AssetNotSupportedError extends Error {
6
+ constructor(asset: string) {
7
+ super(`No price oracle supports asset: ${asset}`);
8
+ this.name = "AssetNotSupportedError";
9
+ }
10
+ }
11
+
4
12
  /** Result from a price oracle query. */
5
13
  export interface PriceResult {
6
- /** USD cents per 1 unit of asset (integer). */
7
- priceCents: number;
14
+ /** Microdollars per 1 unit of asset (integer, 10^-6 USD). */
15
+ priceMicros: number;
8
16
  /** When the price was last updated on-chain. */
9
17
  updatedAt: Date;
10
18
  }
11
19
 
12
20
  /** Read-only price oracle. */
13
21
  export interface IPriceOracle {
14
- getPrice(asset: PriceAsset): Promise<PriceResult>;
22
+ /**
23
+ * Get the current USD price for an asset.
24
+ * @param asset — token symbol (e.g. "BTC", "DOGE")
25
+ * @param feedAddress — optional Chainlink feed address override (from payment_methods.oracle_address)
26
+ */
27
+ getPrice(asset: PriceAsset, feedAddress?: `0x${string}`): Promise<PriceResult>;
15
28
  }
@@ -1,6 +1,24 @@
1
1
  /** BTCPay Server invoice states (Greenfield API v1). */
2
2
  export type CryptoPaymentState = "New" | "Processing" | "Expired" | "Invalid" | "Settled";
3
3
 
4
+ /** Charge status for the UI-facing payment lifecycle. */
5
+ export type CryptoChargeStatus = "pending" | "partial" | "confirmed" | "expired" | "failed";
6
+
7
+ /** Full charge record for UI display — includes partial payment progress and confirmations. */
8
+ export interface CryptoCharge {
9
+ id: string;
10
+ tenantId: string;
11
+ chain: string;
12
+ status: CryptoChargeStatus;
13
+ amountExpectedCents: number;
14
+ amountReceivedCents: number;
15
+ confirmations: number;
16
+ confirmationsRequired: number;
17
+ txHash?: string;
18
+ credited: boolean;
19
+ createdAt: Date;
20
+ }
21
+
4
22
  /** Options for creating a crypto payment session. */
5
23
  export interface CryptoCheckoutOpts {
6
24
  /** Internal tenant ID. */
@@ -1,20 +1,9 @@
1
- import { Credit } from "../../credits/credit.js";
2
- import { deriveAddress, deriveP2pkhAddress } from "./btc/address-gen.js";
3
- import type { ICryptoChargeRepository } from "./charge-store.js";
4
- import { deriveDepositAddress } from "./evm/address-gen.js";
5
- import { centsToNative } from "./oracle/convert.js";
6
- import type { IPriceOracle } from "./oracle/types.js";
7
- import type { PaymentMethodRecord } from "./payment-method-store.js";
1
+ import type { CryptoServiceClient } from "./client.js";
8
2
 
9
3
  export const MIN_CHECKOUT_USD = 10;
10
4
 
11
5
  export interface UnifiedCheckoutDeps {
12
- chargeStore: Pick<ICryptoChargeRepository, "getNextDerivationIndex" | "createStablecoinCharge">;
13
- oracle: IPriceOracle;
14
- evmXpub: string;
15
- btcXpub?: string;
16
- /** UTXO network override (auto-detected from node in production). Default: "mainnet". */
17
- utxoNetwork?: "mainnet" | "testnet" | "regtest";
6
+ cryptoService: CryptoServiceClient;
18
7
  }
19
8
 
20
9
  export interface UnifiedCheckoutResult {
@@ -25,187 +14,39 @@ export interface UnifiedCheckoutResult {
25
14
  token: string;
26
15
  chain: string;
27
16
  referenceId: string;
28
- /** For volatile assets: price at checkout time (USD cents per unit). */
29
- priceCents?: number;
17
+ /** For volatile assets: price at checkout time (microdollars per unit, 10^-6 USD). */
18
+ priceMicros?: number;
30
19
  }
31
20
 
32
21
  /**
33
- * Unified checkout — one entry point for all payment methods.
22
+ * Unified checkout — delegates to CryptoServiceClient.createCharge().
34
23
  *
35
- * Looks up the method record, routes by type:
36
- * - erc20: derives EVM address, computes token amount (1:1 USD for stablecoins)
37
- * - native (ETH): derives EVM address, oracle-priced
38
- * - native (BTC): derives BTC address, oracle-priced
39
- *
40
- * CRITICAL: amountUsd → integer cents via Credit.fromDollars().toCentsRounded().
24
+ * The pay server handles xpub management, address derivation, and charge
25
+ * creation. This function is a thin wrapper that validates the amount
26
+ * and maps the response to `UnifiedCheckoutResult`.
41
27
  */
42
28
  export async function createUnifiedCheckout(
43
29
  deps: UnifiedCheckoutDeps,
44
- method: PaymentMethodRecord,
45
- opts: { tenant: string; amountUsd: number },
30
+ chain: string,
31
+ opts: { tenant: string; amountUsd: number; callbackUrl?: string },
46
32
  ): Promise<UnifiedCheckoutResult> {
47
33
  if (!Number.isFinite(opts.amountUsd) || opts.amountUsd < MIN_CHECKOUT_USD) {
48
34
  throw new Error(`Minimum payment amount is $${MIN_CHECKOUT_USD}`);
49
35
  }
50
36
 
51
- const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
52
-
53
- if (method.type === "erc20") {
54
- return handleErc20(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
55
- }
56
- if (method.type === "native" && method.chain === "base") {
57
- return handleNativeEvm(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
58
- }
59
- if (method.type === "native") {
60
- return handleNativeUtxo(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
61
- }
62
-
63
- throw new Error(`Unsupported payment method type: ${method.type}/${method.token}`);
64
- }
65
-
66
- async function handleErc20(
67
- deps: UnifiedCheckoutDeps,
68
- method: PaymentMethodRecord,
69
- tenant: string,
70
- amountUsdCents: number,
71
- amountUsd: number,
72
- ): Promise<UnifiedCheckoutResult> {
73
- const depositAddress = await deriveAndStore(deps, method, tenant, amountUsdCents);
37
+ const result = await deps.cryptoService.createCharge({
38
+ chain,
39
+ amountUsd: opts.amountUsd,
40
+ callbackUrl: opts.callbackUrl,
41
+ });
74
42
 
75
43
  return {
76
- depositAddress,
77
- displayAmount: `${amountUsd} ${method.token}`,
78
- amountUsd,
79
- token: method.token,
80
- chain: method.chain,
81
- referenceId: `erc20:${method.chain}:${depositAddress}`,
44
+ depositAddress: result.address,
45
+ displayAmount: result.displayAmount ?? `${opts.amountUsd} ${result.token}`,
46
+ amountUsd: opts.amountUsd,
47
+ token: result.token,
48
+ chain: result.chain,
49
+ referenceId: result.chargeId,
50
+ priceMicros: result.priceMicros,
82
51
  };
83
52
  }
84
-
85
- async function handleNativeEvm(
86
- deps: UnifiedCheckoutDeps,
87
- method: PaymentMethodRecord,
88
- tenant: string,
89
- amountUsdCents: number,
90
- amountUsd: number,
91
- ): Promise<UnifiedCheckoutResult> {
92
- const { priceCents } = await deps.oracle.getPrice("ETH");
93
- const expectedWei = centsToNative(amountUsdCents, priceCents, 18);
94
- const depositAddress = await deriveAndStore(deps, method, tenant, amountUsdCents);
95
-
96
- const divisor = BigInt("1000000000000000000");
97
- const whole = expectedWei / divisor;
98
- const frac = (expectedWei % divisor).toString().padStart(18, "0").slice(0, 6);
99
-
100
- return {
101
- depositAddress,
102
- displayAmount: `${whole}.${frac} ETH`,
103
- amountUsd,
104
- token: "ETH",
105
- chain: method.chain,
106
- referenceId: `${method.type}:${method.chain}:${depositAddress}`,
107
- priceCents,
108
- };
109
- }
110
-
111
- /**
112
- * Handle native UTXO coins (BTC, LTC, DOGE, BCH, etc.).
113
- * Uses the xpub from the payment method record (DB-driven).
114
- * Derives bech32 addresses for BTC/LTC, Base58 P2PKH for DOGE.
115
- */
116
- async function handleNativeUtxo(
117
- deps: UnifiedCheckoutDeps,
118
- method: PaymentMethodRecord,
119
- tenant: string,
120
- amountUsdCents: number,
121
- amountUsd: number,
122
- ): Promise<UnifiedCheckoutResult> {
123
- const xpub = method.xpub ?? deps.btcXpub;
124
- if (!xpub) throw new Error(`${method.token} payments not configured (no xpub)`);
125
-
126
- const { priceCents } = await deps.oracle.getPrice(method.token);
127
- const rawAmount = centsToNative(amountUsdCents, priceCents, method.decimals);
128
-
129
- const maxRetries = 3;
130
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
131
- const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
132
-
133
- // Derive address by chain type
134
- let depositAddress: string;
135
- if (method.chain === "dogecoin") {
136
- depositAddress = deriveP2pkhAddress(xpub, derivationIndex, "dogecoin");
137
- } else {
138
- depositAddress = deriveAddress(
139
- xpub,
140
- derivationIndex,
141
- deps.utxoNetwork ?? "mainnet",
142
- method.chain as "bitcoin" | "litecoin",
143
- );
144
- }
145
-
146
- const referenceId = `${method.token.toLowerCase()}:${depositAddress}`;
147
-
148
- try {
149
- await deps.chargeStore.createStablecoinCharge({
150
- referenceId,
151
- tenantId: tenant,
152
- amountUsdCents,
153
- chain: method.chain,
154
- token: method.token,
155
- depositAddress,
156
- derivationIndex,
157
- });
158
-
159
- const divisor = 10 ** method.decimals;
160
- const displayAmt = (Number(rawAmount) / divisor).toFixed(method.decimals);
161
- return {
162
- depositAddress,
163
- displayAmount: `${displayAmt} ${method.token}`,
164
- amountUsd,
165
- token: method.token,
166
- chain: method.chain,
167
- referenceId,
168
- priceCents,
169
- };
170
- } catch (err: unknown) {
171
- const code = (err as { code?: string }).code;
172
- const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
173
- if (!isConflict || attempt === maxRetries) throw err;
174
- }
175
- }
176
-
177
- throw new Error("Failed to claim derivation index after retries");
178
- }
179
-
180
- /** Derive an EVM deposit address and store the charge. Retries on unique conflict. */
181
- async function deriveAndStore(
182
- deps: UnifiedCheckoutDeps,
183
- method: PaymentMethodRecord,
184
- tenant: string,
185
- amountUsdCents: number,
186
- ): Promise<string> {
187
- const maxRetries = 3;
188
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
189
- const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
190
- const depositAddress = deriveDepositAddress(deps.evmXpub, derivationIndex);
191
- const referenceId = `${method.type}:${method.chain}:${depositAddress}`;
192
-
193
- try {
194
- await deps.chargeStore.createStablecoinCharge({
195
- referenceId,
196
- tenantId: tenant,
197
- amountUsdCents,
198
- chain: method.chain,
199
- token: method.token,
200
- depositAddress,
201
- derivationIndex,
202
- });
203
- return depositAddress;
204
- } catch (err: unknown) {
205
- const code = (err as { code?: string }).code;
206
- const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
207
- if (!isConflict || attempt === maxRetries) throw err;
208
- }
209
- }
210
- throw new Error("Failed to claim derivation index after retries");
211
- }
@@ -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).
@@ -23,7 +23,7 @@ import type { EvmChain, EvmPaymentEvent, StablecoinToken } from "./evm/types.js"
23
23
  import { createRpcCaller, EvmWatcher } from "./evm/watcher.js";
24
24
  import type { IPriceOracle } from "./oracle/types.js";
25
25
  import type { IPaymentMethodStore } from "./payment-method-store.js";
26
- import type { CryptoPaymentState } from "./types.js";
26
+ import type { CryptoChargeStatus } from "./types.js";
27
27
 
28
28
  const MAX_DELIVERY_ATTEMPTS = 10;
29
29
  const BACKOFF_BASE_MS = 5_000;
@@ -131,20 +131,34 @@ async function processDeliveries(
131
131
  return delivered;
132
132
  }
133
133
 
134
- // --- Payment handling (partial + full) ---
134
+ // --- Payment handling (partial + full + confirmation tracking) ---
135
+
136
+ export interface PaymentPayload {
137
+ txHash: string;
138
+ confirmations: number;
139
+ confirmationsRequired: number;
140
+ amountReceivedCents: number;
141
+ [key: string]: unknown;
142
+ }
135
143
 
136
144
  /**
137
145
  * Handle a payment event. Accumulates partial payments in native units.
138
- * Settles when totalReceived >= expectedAmount. Fires webhook on every payment.
146
+ * Fires webhook on every payment/confirmation change with canonical statuses.
139
147
  *
140
- * @param nativeAmount — received amount in native base units (sats for BTC/DOGE, raw token units for ERC20)
148
+ * 3-phase webhook lifecycle:
149
+ * 1. Tx first seen -> status: "partial", confirmations: 0
150
+ * 2. Each new block -> status: "partial", confirmations: current
151
+ * 3. Threshold reached + full payment -> status: "confirmed"
152
+ *
153
+ * @param nativeAmount — received amount in native base units (sats for BTC/DOGE, raw token units for ERC20).
154
+ * Pass "0" for confirmation-only updates (no new payment, just more confirmations).
141
155
  */
142
- async function handlePayment(
156
+ export async function handlePayment(
143
157
  db: DrizzleDb,
144
158
  chargeStore: ICryptoChargeRepository,
145
159
  address: string,
146
160
  nativeAmount: string,
147
- payload: Record<string, unknown>,
161
+ payload: PaymentPayload,
148
162
  log: (msg: string, meta?: Record<string, unknown>) => void,
149
163
  ): Promise<void> {
150
164
  const charge = await chargeStore.getByDepositAddress(address);
@@ -156,41 +170,64 @@ async function handlePayment(
156
170
  return; // Already fully paid and credited
157
171
  }
158
172
 
159
- // Accumulate: add this payment to the running total
173
+ const { confirmations, confirmationsRequired, amountReceivedCents, txHash } = payload;
174
+
175
+ // Accumulate: add this payment to the running total (if nativeAmount > 0)
160
176
  const prevReceived = BigInt(charge.receivedAmount ?? "0");
161
177
  const thisPayment = BigInt(nativeAmount);
162
178
  const totalReceived = (prevReceived + thisPayment).toString();
163
179
  const expected = BigInt(charge.expectedAmount ?? "0");
164
180
  const isFull = expected > 0n && BigInt(totalReceived) >= expected;
181
+ const isConfirmed = isFull && confirmations >= confirmationsRequired;
182
+
183
+ // Update received_amount in DB (only when there's a new payment)
184
+ if (thisPayment > 0n) {
185
+ await db
186
+ .update(cryptoCharges)
187
+ .set({ receivedAmount: totalReceived, filledAmount: totalReceived })
188
+ .where(eq(cryptoCharges.referenceId, charge.referenceId));
189
+ }
165
190
 
166
- // Update received_amount in DB
167
- await db
168
- .update(cryptoCharges)
169
- .set({ receivedAmount: totalReceived, filledAmount: totalReceived })
170
- .where(eq(cryptoCharges.referenceId, charge.referenceId));
191
+ // Determine canonical status
192
+ const status: CryptoChargeStatus = isConfirmed ? "confirmed" : "partial";
171
193
 
172
- if (isFull) {
173
- const settled: CryptoPaymentState = "Settled";
174
- await chargeStore.updateStatus(charge.referenceId, settled, charge.token ?? undefined, totalReceived);
194
+ // Update progress via new API
195
+ await chargeStore.updateProgress(charge.referenceId, {
196
+ status,
197
+ amountReceivedCents,
198
+ confirmations,
199
+ confirmationsRequired,
200
+ txHash,
201
+ });
202
+
203
+ if (isConfirmed) {
175
204
  await chargeStore.markCredited(charge.referenceId);
176
- log("Charge settled", { chargeId: charge.referenceId, expected: expected.toString(), received: totalReceived });
205
+ log("Charge confirmed", {
206
+ chargeId: charge.referenceId,
207
+ confirmations,
208
+ confirmationsRequired,
209
+ });
177
210
  } else {
178
- const processing: CryptoPaymentState = "Processing";
179
- await chargeStore.updateStatus(charge.referenceId, processing, charge.token ?? undefined, totalReceived);
180
- log("Partial payment", { chargeId: charge.referenceId, expected: expected.toString(), received: totalReceived });
211
+ log("Payment progress", {
212
+ chargeId: charge.referenceId,
213
+ confirmations,
214
+ confirmationsRequired,
215
+ received: totalReceived,
216
+ });
181
217
  }
182
218
 
183
- // Webhook on every payment — product shows progress to user
219
+ // Webhook on every event — product shows confirmation progress to user
184
220
  if (charge.callbackUrl) {
185
221
  await enqueueWebhook(db, charge.referenceId, charge.callbackUrl, {
186
222
  chargeId: charge.referenceId,
187
223
  chain: charge.chain,
188
224
  address: charge.depositAddress,
189
- expectedAmount: expected.toString(),
190
- receivedAmount: totalReceived,
191
- amountUsdCents: charge.amountUsdCents,
192
- status: isFull ? "confirmed" : "partial",
193
- ...payload,
225
+ amountExpectedCents: charge.amountUsdCents,
226
+ amountReceivedCents,
227
+ confirmations,
228
+ confirmationsRequired,
229
+ txHash,
230
+ status,
194
231
  });
195
232
  }
196
233
  }
@@ -245,8 +282,14 @@ export async function startWatchers(opts: WatcherServiceOpts): Promise<() => voi
245
282
  oracle,
246
283
  cursorStore,
247
284
  onPayment: async (event: BtcPaymentEvent) => {
248
- log("UTXO payment", { chain: method.chain, address: event.address, txid: event.txid, sats: event.amountSats });
249
- // Pass native amount (sats) — NOT USD cents
285
+ log("UTXO payment", {
286
+ chain: method.chain,
287
+ address: event.address,
288
+ txid: event.txid,
289
+ sats: event.amountSats,
290
+ confirmations: event.confirmations,
291
+ confirmationsRequired: event.confirmationsRequired,
292
+ });
250
293
  await handlePayment(
251
294
  db,
252
295
  chargeStore,
@@ -255,6 +298,8 @@ export async function startWatchers(opts: WatcherServiceOpts): Promise<() => voi
255
298
  {
256
299
  txHash: event.txid,
257
300
  confirmations: event.confirmations,
301
+ confirmationsRequired: event.confirmationsRequired,
302
+ amountReceivedCents: event.amountUsdCents,
258
303
  },
259
304
  log,
260
305
  );
@@ -323,8 +368,14 @@ export async function startWatchers(opts: WatcherServiceOpts): Promise<() => voi
323
368
  watchedAddresses: chainAddresses,
324
369
  cursorStore,
325
370
  onPayment: async (event: EvmPaymentEvent) => {
326
- log("EVM payment", { chain: event.chain, token: event.token, to: event.to, txHash: event.txHash });
327
- // Pass native amount (raw token units) — NOT USD cents
371
+ log("EVM payment", {
372
+ chain: event.chain,
373
+ token: event.token,
374
+ to: event.to,
375
+ txHash: event.txHash,
376
+ confirmations: event.confirmations,
377
+ confirmationsRequired: event.confirmationsRequired,
378
+ });
328
379
  await handlePayment(
329
380
  db,
330
381
  chargeStore,
@@ -332,7 +383,9 @@ export async function startWatchers(opts: WatcherServiceOpts): Promise<() => voi
332
383
  event.rawAmount,
333
384
  {
334
385
  txHash: event.txHash,
335
- confirmations: method.confirmations,
386
+ confirmations: event.confirmations,
387
+ confirmationsRequired: event.confirmationsRequired,
388
+ amountReceivedCents: event.amountUsdCents,
336
389
  },
337
390
  log,
338
391
  );
@@ -30,6 +30,14 @@ export const cryptoCharges = pgTable(
30
30
  expectedAmount: text("expected_amount"),
31
31
  /** Running total of received crypto in native units. Accumulates across partial payments. */
32
32
  receivedAmount: text("received_amount"),
33
+ /** Number of blockchain confirmations observed so far. */
34
+ confirmations: integer("confirmations").notNull().default(0),
35
+ /** Required confirmations for settlement (copied from payment method at creation). */
36
+ confirmationsRequired: integer("confirmations_required").notNull().default(1),
37
+ /** Blockchain transaction hash for the payment. */
38
+ txHash: text("tx_hash"),
39
+ /** Amount received so far in USD cents (integer). Converted from crypto at time of receipt. */
40
+ amountReceivedCents: integer("amount_received_cents").notNull().default(0),
33
41
  },
34
42
  (table) => [
35
43
  index("idx_crypto_charges_tenant").on(table.tenantId),
@@ -164,7 +164,8 @@ describe("handleCryptoWebhook (monetization layer)", () => {
164
164
  await handleCryptoWebhook(deps, makePayload({ status: "partial" }));
165
165
 
166
166
  const charge = await chargeStore.getByReferenceId("chg-test-001");
167
- expect(charge?.status).toBe("partial");
167
+ // DB stores legacy status values; "partial" maps to "Processing" internally
168
+ expect(charge?.status).toBe("Processing");
168
169
  });
169
170
 
170
171
  it("settles charge when status is confirmed", async () => {