@wopr-network/platform-core 1.49.0 → 1.49.2

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 (60) hide show
  1. package/dist/billing/crypto/__tests__/key-server.test.js +1 -1
  2. package/dist/billing/crypto/btc/__tests__/watcher.test.js +1 -1
  3. package/dist/billing/crypto/btc/watcher.js +4 -2
  4. package/dist/billing/crypto/client.d.ts +1 -1
  5. package/dist/billing/crypto/evm/__tests__/eth-checkout.test.js +3 -3
  6. package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +2 -2
  7. package/dist/billing/crypto/evm/eth-checkout.d.ts +2 -2
  8. package/dist/billing/crypto/evm/eth-checkout.js +3 -3
  9. package/dist/billing/crypto/evm/eth-watcher.js +2 -2
  10. package/dist/billing/crypto/key-server-entry.js +7 -2
  11. package/dist/billing/crypto/key-server.js +18 -7
  12. package/dist/billing/crypto/oracle/__tests__/chainlink.test.js +4 -4
  13. package/dist/billing/crypto/oracle/__tests__/coingecko.test.d.ts +1 -0
  14. package/dist/billing/crypto/oracle/__tests__/coingecko.test.js +65 -0
  15. package/dist/billing/crypto/oracle/__tests__/composite.test.d.ts +1 -0
  16. package/dist/billing/crypto/oracle/__tests__/composite.test.js +48 -0
  17. package/dist/billing/crypto/oracle/__tests__/convert.test.js +27 -17
  18. package/dist/billing/crypto/oracle/__tests__/fixed.test.js +5 -5
  19. package/dist/billing/crypto/oracle/chainlink.d.ts +2 -2
  20. package/dist/billing/crypto/oracle/chainlink.js +11 -10
  21. package/dist/billing/crypto/oracle/coingecko.d.ts +22 -0
  22. package/dist/billing/crypto/oracle/coingecko.js +67 -0
  23. package/dist/billing/crypto/oracle/composite.d.ts +14 -0
  24. package/dist/billing/crypto/oracle/composite.js +34 -0
  25. package/dist/billing/crypto/oracle/convert.d.ts +17 -7
  26. package/dist/billing/crypto/oracle/convert.js +26 -13
  27. package/dist/billing/crypto/oracle/fixed.d.ts +2 -2
  28. package/dist/billing/crypto/oracle/fixed.js +9 -7
  29. package/dist/billing/crypto/oracle/index.d.ts +4 -0
  30. package/dist/billing/crypto/oracle/index.js +3 -0
  31. package/dist/billing/crypto/oracle/types.d.ts +12 -3
  32. package/dist/billing/crypto/oracle/types.js +7 -1
  33. package/dist/billing/crypto/unified-checkout.d.ts +2 -2
  34. package/dist/billing/crypto/unified-checkout.js +1 -1
  35. package/drizzle/migrations/0017_fix_derivation_index_constraint.sql +7 -0
  36. package/drizzle/migrations/meta/_journal.json +7 -0
  37. package/package.json +1 -1
  38. package/src/billing/crypto/__tests__/key-server.test.ts +1 -1
  39. package/src/billing/crypto/btc/__tests__/watcher.test.ts +1 -1
  40. package/src/billing/crypto/btc/watcher.ts +4 -2
  41. package/src/billing/crypto/client.ts +1 -1
  42. package/src/billing/crypto/evm/__tests__/eth-checkout.test.ts +3 -3
  43. package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +2 -2
  44. package/src/billing/crypto/evm/eth-checkout.ts +5 -5
  45. package/src/billing/crypto/evm/eth-watcher.ts +2 -2
  46. package/src/billing/crypto/key-server-entry.ts +7 -2
  47. package/src/billing/crypto/key-server.ts +17 -7
  48. package/src/billing/crypto/oracle/__tests__/chainlink.test.ts +4 -4
  49. package/src/billing/crypto/oracle/__tests__/coingecko.test.ts +75 -0
  50. package/src/billing/crypto/oracle/__tests__/composite.test.ts +61 -0
  51. package/src/billing/crypto/oracle/__tests__/convert.test.ts +29 -17
  52. package/src/billing/crypto/oracle/__tests__/fixed.test.ts +5 -5
  53. package/src/billing/crypto/oracle/chainlink.ts +11 -10
  54. package/src/billing/crypto/oracle/coingecko.ts +92 -0
  55. package/src/billing/crypto/oracle/composite.ts +35 -0
  56. package/src/billing/crypto/oracle/convert.ts +28 -13
  57. package/src/billing/crypto/oracle/fixed.ts +9 -7
  58. package/src/billing/crypto/oracle/index.ts +4 -0
  59. package/src/billing/crypto/oracle/types.ts +16 -3
  60. package/src/billing/crypto/unified-checkout.ts +3 -3
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Composite oracle — tries primary (Chainlink on-chain), falls back to secondary (CoinGecko).
3
+ *
4
+ * When a feedAddress is provided (from payment_methods.oracle_address), the primary
5
+ * oracle is used with that address. When no feed exists or the primary fails,
6
+ * the fallback oracle is consulted.
7
+ */
8
+ export class CompositeOracle {
9
+ primary;
10
+ fallback;
11
+ constructor(primary, fallback) {
12
+ this.primary = primary;
13
+ this.fallback = fallback;
14
+ }
15
+ async getPrice(asset, feedAddress) {
16
+ // If a specific feed address is provided, try the primary (Chainlink) first
17
+ if (feedAddress) {
18
+ try {
19
+ return await this.primary.getPrice(asset, feedAddress);
20
+ }
21
+ catch {
22
+ // Primary failed (stale, network error) — fall through to fallback
23
+ }
24
+ }
25
+ // Try primary without explicit feed (uses built-in feed map for BTC/ETH)
26
+ try {
27
+ return await this.primary.getPrice(asset);
28
+ }
29
+ catch {
30
+ // No feed configured or call failed — use fallback
31
+ }
32
+ return this.fallback.getPrice(asset);
33
+ }
34
+ }
@@ -1,20 +1,30 @@
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
+ * Convert USD cents to native token amount using a price in microdollars.
13
+ *
14
+ * Formula: rawAmount = (amountCents × 10_000) × 10^decimals / priceMicros
5
15
  *
6
16
  * 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)
17
+ * $50 in BTC at $70,315: centsToNative(5000, 70_315_000_000, 8) = 71,119 sats
18
+ * $50 in DOGE at $0.094: centsToNative(5000, 94_147, 8) = 53,107,898,982 base units (531.08 DOGE)
9
19
  *
10
20
  * Integer math only. No floating point.
11
21
  */
12
- export declare function centsToNative(amountCents: number, priceCents: number, decimals: number): bigint;
22
+ export declare function centsToNative(amountCents: number, priceMicros: number, decimals: number): bigint;
13
23
  /**
14
- * Convert native token amount back to USD cents using a price in cents.
24
+ * Convert native token amount back to USD cents using a price in microdollars.
15
25
  *
16
26
  * Inverse of centsToNative. Truncates fractional cents.
17
27
  *
18
28
  * Integer math only.
19
29
  */
20
- export declare function nativeToCents(rawAmount: bigint, priceCents: number, decimals: number): number;
30
+ export declare function nativeToCents(rawAmount: bigint, priceMicros: number, decimals: number): number;
@@ -1,38 +1,51 @@
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
+ /** Microdollars per cent. Multiply amountCents by this to get microdollars. */
12
+ const MICROS_PER_CENT = 10000n;
13
+ /**
14
+ * Convert USD cents to native token amount using a price in microdollars.
15
+ *
16
+ * Formula: rawAmount = (amountCents × 10_000) × 10^decimals / priceMicros
5
17
  *
6
18
  * 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)
19
+ * $50 in BTC at $70,315: centsToNative(5000, 70_315_000_000, 8) = 71,119 sats
20
+ * $50 in DOGE at $0.094: centsToNative(5000, 94_147, 8) = 53,107,898,982 base units (531.08 DOGE)
9
21
  *
10
22
  * Integer math only. No floating point.
11
23
  */
12
- export function centsToNative(amountCents, priceCents, decimals) {
24
+ export function centsToNative(amountCents, priceMicros, decimals) {
13
25
  if (!Number.isInteger(amountCents) || amountCents <= 0) {
14
26
  throw new Error(`amountCents must be a positive integer, got ${amountCents}`);
15
27
  }
16
- if (!Number.isInteger(priceCents) || priceCents <= 0) {
17
- throw new Error(`priceCents must be a positive integer, got ${priceCents}`);
28
+ if (!Number.isInteger(priceMicros) || priceMicros <= 0) {
29
+ throw new Error(`priceMicros must be a positive integer, got ${priceMicros}`);
18
30
  }
19
31
  if (!Number.isInteger(decimals) || decimals < 0) {
20
32
  throw new Error(`decimals must be a non-negative integer, got ${decimals}`);
21
33
  }
22
- return (BigInt(amountCents) * 10n ** BigInt(decimals)) / BigInt(priceCents);
34
+ // Convert amountCents to microdollars to match priceMicros units
35
+ return (BigInt(amountCents) * MICROS_PER_CENT * 10n ** BigInt(decimals)) / BigInt(priceMicros);
23
36
  }
24
37
  /**
25
- * Convert native token amount back to USD cents using a price in cents.
38
+ * Convert native token amount back to USD cents using a price in microdollars.
26
39
  *
27
40
  * Inverse of centsToNative. Truncates fractional cents.
28
41
  *
29
42
  * Integer math only.
30
43
  */
31
- export function nativeToCents(rawAmount, priceCents, decimals) {
44
+ export function nativeToCents(rawAmount, priceMicros, decimals) {
32
45
  if (rawAmount < 0n)
33
46
  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}`);
47
+ if (!Number.isInteger(priceMicros) || priceMicros <= 0) {
48
+ throw new Error(`priceMicros must be a positive integer, got ${priceMicros}`);
36
49
  }
37
- return Number((rawAmount * BigInt(priceCents)) / 10n ** BigInt(decimals));
50
+ return Number((rawAmount * BigInt(priceMicros)) / (MICROS_PER_CENT * 10n ** BigInt(decimals)));
38
51
  }
@@ -1,10 +1,10 @@
1
1
  import type { IPriceOracle, PriceAsset, PriceResult } from "./types.js";
2
2
  /**
3
3
  * Fixed-price oracle for testing and local dev (Anvil, regtest).
4
- * Returns hardcoded prices — no RPC calls.
4
+ * Returns hardcoded prices in microdollars — no RPC calls.
5
5
  */
6
6
  export declare class FixedPriceOracle implements IPriceOracle {
7
7
  private readonly prices;
8
8
  constructor(prices?: Partial<Record<PriceAsset, number>>);
9
- getPrice(asset: PriceAsset): Promise<PriceResult>;
9
+ getPrice(asset: PriceAsset, _feedAddress?: `0x${string}`): Promise<PriceResult>;
10
10
  }
@@ -1,20 +1,22 @@
1
1
  /**
2
2
  * Fixed-price oracle for testing and local dev (Anvil, regtest).
3
- * Returns hardcoded prices — no RPC calls.
3
+ * Returns hardcoded prices in microdollars — no RPC calls.
4
4
  */
5
5
  export class FixedPriceOracle {
6
6
  prices;
7
7
  constructor(prices = {}) {
8
8
  this.prices = {
9
- ETH: 350_000, // $3,500
10
- BTC: 6_500_000, // $65,000
9
+ ETH: 3_500_000_000, // $3,500 in microdollars
10
+ BTC: 65_000_000_000, // $65,000 in microdollars
11
+ DOGE: 94_000, // $0.094 in microdollars
12
+ LTC: 55_000_000, // $55 in microdollars
11
13
  ...prices,
12
14
  };
13
15
  }
14
- async getPrice(asset) {
15
- const priceCents = this.prices[asset];
16
- if (priceCents === undefined)
16
+ async getPrice(asset, _feedAddress) {
17
+ const priceMicros = this.prices[asset];
18
+ if (priceMicros === undefined)
17
19
  throw new Error(`No fixed price for ${asset}`);
18
- return { priceCents, updatedAt: new Date() };
20
+ return { priceMicros, updatedAt: new Date() };
19
21
  }
20
22
  }
@@ -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,3 +1,6 @@
1
1
  export { ChainlinkOracle } from "./chainlink.js";
2
+ export { CoinGeckoOracle } from "./coingecko.js";
3
+ export { CompositeOracle } from "./composite.js";
2
4
  export { centsToNative, nativeToCents } from "./convert.js";
3
5
  export { FixedPriceOracle } from "./fixed.js";
6
+ export { AssetNotSupportedError } from "./types.js";
@@ -1,13 +1,22 @@
1
1
  /** Assets with Chainlink price feeds. */
2
2
  export type PriceAsset = string;
3
+ /** Thrown when no oracle supports a given asset (not a transient failure). */
4
+ export declare class AssetNotSupportedError extends Error {
5
+ constructor(asset: string);
6
+ }
3
7
  /** Result from a price oracle query. */
4
8
  export interface PriceResult {
5
- /** USD cents per 1 unit of asset (integer). */
6
- priceCents: number;
9
+ /** Microdollars per 1 unit of asset (integer, 10^-6 USD). */
10
+ priceMicros: number;
7
11
  /** When the price was last updated on-chain. */
8
12
  updatedAt: Date;
9
13
  }
10
14
  /** Read-only price oracle. */
11
15
  export interface IPriceOracle {
12
- getPrice(asset: PriceAsset): Promise<PriceResult>;
16
+ /**
17
+ * Get the current USD price for an asset.
18
+ * @param asset — token symbol (e.g. "BTC", "DOGE")
19
+ * @param feedAddress — optional Chainlink feed address override (from payment_methods.oracle_address)
20
+ */
21
+ getPrice(asset: PriceAsset, feedAddress?: `0x${string}`): Promise<PriceResult>;
13
22
  }
@@ -1 +1,7 @@
1
- export {};
1
+ /** Thrown when no oracle supports a given asset (not a transient failure). */
2
+ export class AssetNotSupportedError extends Error {
3
+ constructor(asset) {
4
+ super(`No price oracle supports asset: ${asset}`);
5
+ this.name = "AssetNotSupportedError";
6
+ }
7
+ }
@@ -11,8 +11,8 @@ export interface UnifiedCheckoutResult {
11
11
  token: string;
12
12
  chain: string;
13
13
  referenceId: string;
14
- /** For volatile assets: price at checkout time (USD cents per unit). */
15
- priceCents?: number;
14
+ /** For volatile assets: price at checkout time (microdollars per unit, 10^-6 USD). */
15
+ priceMicros?: number;
16
16
  }
17
17
  /**
18
18
  * Unified checkout — delegates to CryptoServiceClient.createCharge().
@@ -22,6 +22,6 @@ export async function createUnifiedCheckout(deps, chain, opts) {
22
22
  token: result.token,
23
23
  chain: result.chain,
24
24
  referenceId: result.chargeId,
25
- priceCents: result.priceCents,
25
+ priceMicros: result.priceMicros,
26
26
  };
27
27
  }
@@ -0,0 +1,7 @@
1
+ -- Fix: derivation_index unique constraint was global, not per-chain.
2
+ -- DOGE index 1 collided with BTC index 1. Must be (chain, derivation_index).
3
+
4
+ DROP INDEX IF EXISTS "uq_crypto_charges_derivation_index";
5
+ --> statement-breakpoint
6
+
7
+ CREATE UNIQUE INDEX IF NOT EXISTS "uq_crypto_charges_chain_derivation" ON "crypto_charges" ("chain", "derivation_index") WHERE "chain" IS NOT NULL AND "derivation_index" IS NOT NULL;
@@ -120,6 +120,13 @@
120
120
  "when": 1743004800000,
121
121
  "tag": "0016_charge_progress_columns",
122
122
  "breakpoints": true
123
+ },
124
+ {
125
+ "idx": 17,
126
+ "version": "7",
127
+ "when": 1743091200000,
128
+ "tag": "0017_fix_derivation_index_constraint",
129
+ "breakpoints": true
123
130
  }
124
131
  ]
125
132
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.49.0",
3
+ "version": "1.49.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -110,7 +110,7 @@ function mockDeps(): KeyServerDeps & {
110
110
  db: createMockDb() as never,
111
111
  chargeStore: chargeStore as never,
112
112
  methodStore: methodStore as never,
113
- oracle: { getPrice: vi.fn().mockResolvedValue({ priceCents: 6_500_000, updatedAt: new Date() }) } as never,
113
+ oracle: { getPrice: vi.fn().mockResolvedValue({ priceMicros: 65_000_000_000, updatedAt: new Date() }) } as never,
114
114
  };
115
115
  }
116
116
 
@@ -23,7 +23,7 @@ function makeCursorStore() {
23
23
  }
24
24
 
25
25
  function makeOracle() {
26
- return { getPrice: vi.fn().mockResolvedValue({ priceCents: 6_500_000 }) };
26
+ return { getPrice: vi.fn().mockResolvedValue({ priceMicros: 65_000_000_000 }) };
27
27
  }
28
28
 
29
29
  describe("BtcWatcher — intermediate confirmations", () => {
@@ -1,4 +1,5 @@
1
1
  import type { IWatcherCursorStore } from "../cursor-store.js";
2
+ import { nativeToCents } from "../oracle/convert.js";
2
3
  import type { IPriceOracle } from "../oracle/types.js";
3
4
  import type { BitcoindConfig, BtcPaymentEvent } from "./types.js";
4
5
 
@@ -90,7 +91,7 @@ export class BtcWatcher {
90
91
  true, // include_watchonly
91
92
  ])) as ReceivedByAddress[];
92
93
 
93
- const { priceCents } = await this.oracle.getPrice("BTC");
94
+ const { priceMicros } = await this.oracle.getPrice("BTC");
94
95
 
95
96
  for (const entry of received) {
96
97
  if (!this.addresses.has(entry.address)) continue;
@@ -113,7 +114,8 @@ export class BtcWatcher {
113
114
  if (lastSeen !== null && tx.confirmations <= lastSeen) continue; // No change
114
115
 
115
116
  const amountSats = Math.round(detail.amount * 100_000_000);
116
- const amountUsdCents = Math.round((amountSats * priceCents) / 100_000_000);
117
+ // priceMicros is microdollars per 1 BTC. Convert sats→USD cents via nativeToCents.
118
+ const amountUsdCents = nativeToCents(BigInt(amountSats), priceMicros, 8);
117
119
 
118
120
  const event: BtcPaymentEvent = {
119
121
  address: entry.address,
@@ -30,7 +30,7 @@ export interface CreateChargeResult {
30
30
  derivationIndex: number;
31
31
  expiresAt: string;
32
32
  displayAmount?: string;
33
- priceCents?: number;
33
+ priceMicros?: number;
34
34
  }
35
35
 
36
36
  export interface ChargeStatus {
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
  import { createEthCheckout, MIN_ETH_USD } from "../eth-checkout.js";
3
3
 
4
- const mockOracle = { getPrice: vi.fn().mockResolvedValue({ priceCents: 350_000, updatedAt: new Date() }) };
4
+ const mockOracle = { getPrice: vi.fn().mockResolvedValue({ priceMicros: 3_500_000_000, updatedAt: new Date() }) };
5
5
 
6
6
  function makeDeps(derivationIndex = 0) {
7
7
  return {
@@ -20,9 +20,9 @@ describe("createEthCheckout", () => {
20
20
  const result = await createEthCheckout(deps, { tenant: "t1", amountUsd: 50, chain: "base" });
21
21
 
22
22
  expect(result.amountUsd).toBe(50);
23
- expect(result.priceCents).toBe(350_000);
23
+ expect(result.priceMicros).toBe(3_500_000_000);
24
24
  expect(result.chain).toBe("base");
25
- // $50 = 5000 cents. 5000 × 10^18 / 350000 = 14285714285714285n
25
+ // $50 = 5000 cents × 10000 micros/cent × 10^18 / 3_500_000_000 micros = 14285714285714285n
26
26
  expect(result.expectedWei).toBe("14285714285714285");
27
27
  expect(result.depositAddress).toMatch(/^0x/);
28
28
  expect(result.referenceId).toMatch(/^eth:base:0x/);
@@ -5,7 +5,7 @@ function makeRpc(responses: Record<string, unknown>) {
5
5
  return vi.fn(async (method: string) => responses[method]);
6
6
  }
7
7
 
8
- const mockOracle = { getPrice: vi.fn().mockResolvedValue({ priceCents: 350_000, updatedAt: new Date() }) };
8
+ const mockOracle = { getPrice: vi.fn().mockResolvedValue({ priceMicros: 3_500_000_000, updatedAt: new Date() }) };
9
9
 
10
10
  describe("EthWatcher", () => {
11
11
  it("detects native ETH transfer to watched address", async () => {
@@ -41,7 +41,7 @@ describe("EthWatcher", () => {
41
41
  const event = onPayment.mock.calls[0][0];
42
42
  expect(event.to).toBe("0xdeposit");
43
43
  expect(event.valueWei).toBe("1000000000000000000");
44
- expect(event.amountUsdCents).toBe(350_000); // 1 ETH × $3,500
44
+ expect(event.amountUsdCents).toBe(350_000); // 1 ETH × $3,500 = $3,500 = 350,000 cents
45
45
  expect(event.txHash).toBe("0xabc");
46
46
  expect(event.confirmations).toBe(0);
47
47
  expect(event.confirmationsRequired).toBe(1);
@@ -24,8 +24,8 @@ export interface EthCheckoutResult {
24
24
  amountUsd: number;
25
25
  /** Expected ETH amount in wei (BigInt as string). */
26
26
  expectedWei: string;
27
- /** ETH price in USD cents at checkout time. */
28
- priceCents: number;
27
+ /** ETH price in microdollars at checkout time (10^-6 USD). */
28
+ priceMicros: number;
29
29
  chain: EvmChain;
30
30
  referenceId: string;
31
31
  }
@@ -45,8 +45,8 @@ export async function createEthCheckout(deps: EthCheckoutDeps, opts: EthCheckout
45
45
  }
46
46
 
47
47
  const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
48
- const { priceCents } = await deps.oracle.getPrice("ETH");
49
- const expectedWei = centsToNative(amountUsdCents, priceCents, 18);
48
+ const { priceMicros } = await deps.oracle.getPrice("ETH");
49
+ const expectedWei = centsToNative(amountUsdCents, priceMicros, 18);
50
50
  const maxRetries = 3;
51
51
 
52
52
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
@@ -69,7 +69,7 @@ export async function createEthCheckout(deps: EthCheckoutDeps, opts: EthCheckout
69
69
  depositAddress,
70
70
  amountUsd: opts.amountUsd,
71
71
  expectedWei: expectedWei.toString(),
72
- priceCents,
72
+ priceMicros,
73
73
  chain: opts.chain,
74
74
  referenceId,
75
75
  };
@@ -105,7 +105,7 @@ export class EthWatcher {
105
105
 
106
106
  if (latest < this._cursor) return;
107
107
 
108
- const { priceCents } = await this.oracle.getPrice("ETH");
108
+ const { priceMicros } = await this.oracle.getPrice("ETH");
109
109
 
110
110
  // Scan up to latest (not just confirmed) to detect pending txs
111
111
  for (let blockNum = this._cursor; blockNum <= latest; blockNum++) {
@@ -131,7 +131,7 @@ export class EthWatcher {
131
131
  if (lastConf !== null && confs <= lastConf) continue;
132
132
  }
133
133
 
134
- const amountUsdCents = nativeToCents(valueWei, priceCents, 18);
134
+ const amountUsdCents = nativeToCents(valueWei, priceMicros, 18);
135
135
 
136
136
  const event: EthPaymentEvent = {
137
137
  chain: this.chain,
@@ -17,6 +17,8 @@ import { DrizzleWatcherCursorStore } from "./cursor-store.js";
17
17
  import { createRpcCaller } from "./evm/watcher.js";
18
18
  import { createKeyServerApp } from "./key-server.js";
19
19
  import { ChainlinkOracle } from "./oracle/chainlink.js";
20
+ import { CoinGeckoOracle } from "./oracle/coingecko.js";
21
+ import { CompositeOracle } from "./oracle/composite.js";
20
22
  import { FixedPriceOracle } from "./oracle/fixed.js";
21
23
  import { DrizzlePaymentMethodStore } from "./payment-method-store.js";
22
24
  import { startWatchers } from "./watcher-service.js";
@@ -48,10 +50,13 @@ async function main(): Promise<void> {
48
50
  const chargeStore = new DrizzleCryptoChargeRepository(db);
49
51
  const methodStore = new DrizzlePaymentMethodStore(db);
50
52
 
51
- // Chainlink on-chain oracle for volatile assets (BTC, ETH).
52
- const oracle = BASE_RPC_URL
53
+ // Composite oracle: Chainlink on-chain (BTC, ETH on Base) + CoinGecko fallback (DOGE, LTC, etc.)
54
+ // Every volatile asset needs reliable USD pricing — the ledger credits nanodollars.
55
+ const chainlink = BASE_RPC_URL
53
56
  ? new ChainlinkOracle({ rpcCall: createRpcCaller(BASE_RPC_URL) })
54
57
  : new FixedPriceOracle();
58
+ const coingecko = new CoinGeckoOracle();
59
+ const oracle = new CompositeOracle(chainlink, coingecko);
55
60
 
56
61
  const app = createKeyServerApp({
57
62
  db,
@@ -16,6 +16,7 @@ import type { ICryptoChargeRepository } from "./charge-store.js";
16
16
  import { deriveDepositAddress } from "./evm/address-gen.js";
17
17
  import { centsToNative } from "./oracle/convert.js";
18
18
  import type { IPriceOracle } from "./oracle/types.js";
19
+ import { AssetNotSupportedError } from "./oracle/types.js";
19
20
  import type { IPaymentMethodStore } from "./payment-method-store.js";
20
21
 
21
22
  export interface KeyServerDeps {
@@ -157,13 +158,22 @@ export function createKeyServerApp(deps: KeyServerDeps): Hono {
157
158
  // Compute expected crypto amount in native base units.
158
159
  // Price is locked NOW — this is what the user must send.
159
160
  let expectedAmount: bigint;
160
- if (method.oracleAddress) {
161
- // Volatile asset (BTC, ETH, DOGE) — oracle-priced
162
- const { priceCents } = await deps.oracle.getPrice(token);
163
- expectedAmount = centsToNative(amountUsdCents, priceCents, method.decimals);
164
- } else {
165
- // Stablecoin (1:1 USD) e.g. $50 USDC = 50_000_000 base units (6 decimals)
166
- expectedAmount = (BigInt(amountUsdCents) * 10n ** BigInt(method.decimals)) / 100n;
161
+ const feedAddress = method.oracleAddress ? (method.oracleAddress as `0x${string}`) : undefined;
162
+ try {
163
+ // Try oracle pricing (Chainlink for BTC/ETH, CoinGecko for DOGE/LTC).
164
+ // feedAddress is a hint for Chainlink — undefined is fine, CompositeOracle
165
+ // falls through to CoinGecko or built-in feed maps.
166
+ const { priceMicros } = await deps.oracle.getPrice(token, feedAddress);
167
+ expectedAmount = centsToNative(amountUsdCents, priceMicros, method.decimals);
168
+ } catch (err) {
169
+ if (err instanceof AssetNotSupportedError) {
170
+ // No oracle knows this token (e.g. USDC, DAI) — stablecoin 1:1 USD.
171
+ expectedAmount = (BigInt(amountUsdCents) * 10n ** BigInt(method.decimals)) / 100n;
172
+ } else {
173
+ // Transient oracle failure (network, rate limit, stale feed).
174
+ // Reject the charge — silently pricing BTC at $1 would be catastrophic.
175
+ return c.json({ error: `Price oracle unavailable for ${token}: ${(err as Error).message}` }, 503);
176
+ }
167
177
  }
168
178
 
169
179
  const referenceId = `${token.toLowerCase()}:${address.toLowerCase()}`;
@@ -28,7 +28,7 @@ describe("ChainlinkOracle", () => {
28
28
 
29
29
  const result = await oracle.getPrice("ETH");
30
30
 
31
- expect(result.priceCents).toBe(350_000); // $3,500.00
31
+ expect(result.priceMicros).toBe(3_500_000_000); // $3,500.00
32
32
  expect(result.updatedAt).toBeInstanceOf(Date);
33
33
  expect(rpc).toHaveBeenCalledWith("eth_call", [
34
34
  { to: "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70", data: "0xfeaf968c" },
@@ -43,7 +43,7 @@ describe("ChainlinkOracle", () => {
43
43
 
44
44
  const result = await oracle.getPrice("BTC");
45
45
 
46
- expect(result.priceCents).toBe(6_500_000); // $65,000.00
46
+ expect(result.priceMicros).toBe(65_000_000_000); // $65,000.00
47
47
  });
48
48
 
49
49
  it("handles fractional dollar prices correctly", async () => {
@@ -53,7 +53,7 @@ describe("ChainlinkOracle", () => {
53
53
 
54
54
  const result = await oracle.getPrice("ETH");
55
55
 
56
- expect(result.priceCents).toBe(345_678); // $3,456.78
56
+ expect(result.priceMicros).toBe(3_456_780_000); // $3,456.78
57
57
  });
58
58
 
59
59
  it("rejects stale prices", async () => {
@@ -102,6 +102,6 @@ describe("ChainlinkOracle", () => {
102
102
  // 60-minute threshold → fresh
103
103
  const relaxed = new ChainlinkOracle({ rpcCall: rpc, maxStalenessMs: 60 * 60 * 1000 });
104
104
  const result = await relaxed.getPrice("ETH");
105
- expect(result.priceCents).toBe(350_000);
105
+ expect(result.priceMicros).toBe(3_500_000_000);
106
106
  });
107
107
  });
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { CoinGeckoOracle } from "../coingecko.js";
3
+
4
+ describe("CoinGeckoOracle", () => {
5
+ const mockFetch = (price: number) =>
6
+ vi.fn().mockResolvedValue({
7
+ ok: true,
8
+ json: () => Promise.resolve({ bitcoin: { usd: price } }),
9
+ });
10
+
11
+ it("returns price in microdollars from CoinGecko API", async () => {
12
+ const oracle = new CoinGeckoOracle({ fetchFn: mockFetch(84_532.17) as unknown as typeof fetch });
13
+ const result = await oracle.getPrice("BTC");
14
+ expect(result.priceMicros).toBe(84_532_170_000);
15
+ expect(result.updatedAt).toBeInstanceOf(Date);
16
+ });
17
+
18
+ it("caches prices within TTL", async () => {
19
+ const fn = mockFetch(84_532.17);
20
+ const oracle = new CoinGeckoOracle({ fetchFn: fn as unknown as typeof fetch, cacheTtlMs: 60_000 });
21
+ await oracle.getPrice("BTC");
22
+ await oracle.getPrice("BTC");
23
+ expect(fn).toHaveBeenCalledTimes(1);
24
+ });
25
+
26
+ it("re-fetches after cache expires", async () => {
27
+ const fn = mockFetch(84_532.17);
28
+ const oracle = new CoinGeckoOracle({ fetchFn: fn as unknown as typeof fetch, cacheTtlMs: 0 });
29
+ await oracle.getPrice("BTC");
30
+ await oracle.getPrice("BTC");
31
+ expect(fn).toHaveBeenCalledTimes(2);
32
+ });
33
+
34
+ it("throws for unknown asset", async () => {
35
+ const oracle = new CoinGeckoOracle({ fetchFn: mockFetch(100) as unknown as typeof fetch });
36
+ await expect(oracle.getPrice("UNKNOWN")).rejects.toThrow("No price oracle supports asset: UNKNOWN");
37
+ });
38
+
39
+ it("throws on API error", async () => {
40
+ const fn = vi.fn().mockResolvedValue({ ok: false, status: 429, statusText: "Too Many Requests" });
41
+ const oracle = new CoinGeckoOracle({ fetchFn: fn as unknown as typeof fetch });
42
+ await expect(oracle.getPrice("BTC")).rejects.toThrow("CoinGecko API error");
43
+ });
44
+
45
+ it("throws on zero price", async () => {
46
+ const fn = vi.fn().mockResolvedValue({
47
+ ok: true,
48
+ json: () => Promise.resolve({ bitcoin: { usd: 0 } }),
49
+ });
50
+ const oracle = new CoinGeckoOracle({ fetchFn: fn as unknown as typeof fetch });
51
+ await expect(oracle.getPrice("BTC")).rejects.toThrow("Invalid CoinGecko price");
52
+ });
53
+
54
+ it("resolves DOGE via coingecko ID mapping", async () => {
55
+ const fn = vi.fn().mockResolvedValue({
56
+ ok: true,
57
+ json: () => Promise.resolve({ dogecoin: { usd: 0.1742 } }),
58
+ });
59
+ const oracle = new CoinGeckoOracle({ fetchFn: fn as unknown as typeof fetch });
60
+ const result = await oracle.getPrice("DOGE");
61
+ expect(result.priceMicros).toBe(174_200);
62
+ expect(fn).toHaveBeenCalledWith(expect.stringContaining("ids=dogecoin"));
63
+ });
64
+
65
+ it("resolves LTC via coingecko ID mapping", async () => {
66
+ const fn = vi.fn().mockResolvedValue({
67
+ ok: true,
68
+ json: () => Promise.resolve({ litecoin: { usd: 92.45 } }),
69
+ });
70
+ const oracle = new CoinGeckoOracle({ fetchFn: fn as unknown as typeof fetch });
71
+ const result = await oracle.getPrice("LTC");
72
+ expect(result.priceMicros).toBe(92_450_000);
73
+ expect(fn).toHaveBeenCalledWith(expect.stringContaining("ids=litecoin"));
74
+ });
75
+ });