@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
@@ -101,7 +101,7 @@ function mockDeps() {
101
101
  db: createMockDb(),
102
102
  chargeStore: chargeStore,
103
103
  methodStore: methodStore,
104
- oracle: { getPrice: vi.fn().mockResolvedValue({ priceCents: 6_500_000, updatedAt: new Date() }) },
104
+ oracle: { getPrice: vi.fn().mockResolvedValue({ priceMicros: 65_000_000_000, updatedAt: new Date() }) },
105
105
  };
106
106
  }
107
107
  describe("key-server routes", () => {
@@ -21,7 +21,7 @@ function makeCursorStore() {
21
21
  };
22
22
  }
23
23
  function makeOracle() {
24
- return { getPrice: vi.fn().mockResolvedValue({ priceCents: 6_500_000 }) };
24
+ return { getPrice: vi.fn().mockResolvedValue({ priceMicros: 65_000_000_000 }) };
25
25
  }
26
26
  describe("BtcWatcher — intermediate confirmations", () => {
27
27
  it("fires onPayment at 0 confirmations when tx first detected", async () => {
@@ -1,3 +1,4 @@
1
+ import { nativeToCents } from "../oracle/convert.js";
1
2
  export class BtcWatcher {
2
3
  rpc;
3
4
  addresses;
@@ -55,7 +56,7 @@ export class BtcWatcher {
55
56
  false, // include_empty
56
57
  true, // include_watchonly
57
58
  ]));
58
- const { priceCents } = await this.oracle.getPrice("BTC");
59
+ const { priceMicros } = await this.oracle.getPrice("BTC");
59
60
  for (const entry of received) {
60
61
  if (!this.addresses.has(entry.address))
61
62
  continue;
@@ -73,7 +74,8 @@ export class BtcWatcher {
73
74
  if (lastSeen !== null && tx.confirmations <= lastSeen)
74
75
  continue; // No change
75
76
  const amountSats = Math.round(detail.amount * 100_000_000);
76
- const amountUsdCents = Math.round((amountSats * priceCents) / 100_000_000);
77
+ // priceMicros is microdollars per 1 BTC. Convert sats→USD cents via nativeToCents.
78
+ const amountUsdCents = nativeToCents(BigInt(amountSats), priceMicros, 8);
77
79
  const event = {
78
80
  address: entry.address,
79
81
  txid,
@@ -27,7 +27,7 @@ export interface CreateChargeResult {
27
27
  derivationIndex: number;
28
28
  expiresAt: string;
29
29
  displayAmount?: string;
30
- priceCents?: number;
30
+ priceMicros?: number;
31
31
  }
32
32
  export interface ChargeStatus {
33
33
  chargeId: string;
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
  import { createEthCheckout, MIN_ETH_USD } from "../eth-checkout.js";
3
- const mockOracle = { getPrice: vi.fn().mockResolvedValue({ priceCents: 350_000, updatedAt: new Date() }) };
3
+ const mockOracle = { getPrice: vi.fn().mockResolvedValue({ priceMicros: 3_500_000_000, updatedAt: new Date() }) };
4
4
  function makeDeps(derivationIndex = 0) {
5
5
  return {
6
6
  chargeStore: {
@@ -16,9 +16,9 @@ describe("createEthCheckout", () => {
16
16
  const deps = makeDeps();
17
17
  const result = await createEthCheckout(deps, { tenant: "t1", amountUsd: 50, chain: "base" });
18
18
  expect(result.amountUsd).toBe(50);
19
- expect(result.priceCents).toBe(350_000);
19
+ expect(result.priceMicros).toBe(3_500_000_000);
20
20
  expect(result.chain).toBe("base");
21
- // $50 = 5000 cents. 5000 × 10^18 / 350000 = 14285714285714285n
21
+ // $50 = 5000 cents × 10000 micros/cent × 10^18 / 3_500_000_000 micros = 14285714285714285n
22
22
  expect(result.expectedWei).toBe("14285714285714285");
23
23
  expect(result.depositAddress).toMatch(/^0x/);
24
24
  expect(result.referenceId).toMatch(/^eth:base:0x/);
@@ -3,7 +3,7 @@ import { EthWatcher } from "../eth-watcher.js";
3
3
  function makeRpc(responses) {
4
4
  return vi.fn(async (method) => responses[method]);
5
5
  }
6
- const mockOracle = { getPrice: vi.fn().mockResolvedValue({ priceCents: 350_000, updatedAt: new Date() }) };
6
+ const mockOracle = { getPrice: vi.fn().mockResolvedValue({ priceMicros: 3_500_000_000, updatedAt: new Date() }) };
7
7
  describe("EthWatcher", () => {
8
8
  it("detects native ETH transfer to watched address", async () => {
9
9
  const onPayment = vi.fn();
@@ -35,7 +35,7 @@ describe("EthWatcher", () => {
35
35
  const event = onPayment.mock.calls[0][0];
36
36
  expect(event.to).toBe("0xdeposit");
37
37
  expect(event.valueWei).toBe("1000000000000000000");
38
- expect(event.amountUsdCents).toBe(350_000); // 1 ETH × $3,500
38
+ expect(event.amountUsdCents).toBe(350_000); // 1 ETH × $3,500 = $3,500 = 350,000 cents
39
39
  expect(event.txHash).toBe("0xabc");
40
40
  expect(event.confirmations).toBe(0);
41
41
  expect(event.confirmationsRequired).toBe(1);
@@ -17,8 +17,8 @@ export interface EthCheckoutResult {
17
17
  amountUsd: number;
18
18
  /** Expected ETH amount in wei (BigInt as string). */
19
19
  expectedWei: string;
20
- /** ETH price in USD cents at checkout time. */
21
- priceCents: number;
20
+ /** ETH price in microdollars at checkout time (10^-6 USD). */
21
+ priceMicros: number;
22
22
  chain: EvmChain;
23
23
  referenceId: string;
24
24
  }
@@ -16,8 +16,8 @@ export async function createEthCheckout(deps, opts) {
16
16
  throw new Error(`Minimum payment amount is $${MIN_ETH_USD}`);
17
17
  }
18
18
  const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
19
- const { priceCents } = await deps.oracle.getPrice("ETH");
20
- const expectedWei = centsToNative(amountUsdCents, priceCents, 18);
19
+ const { priceMicros } = await deps.oracle.getPrice("ETH");
20
+ const expectedWei = centsToNative(amountUsdCents, priceMicros, 18);
21
21
  const maxRetries = 3;
22
22
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
23
23
  const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
@@ -37,7 +37,7 @@ export async function createEthCheckout(deps, opts) {
37
37
  depositAddress,
38
38
  amountUsd: opts.amountUsd,
39
39
  expectedWei: expectedWei.toString(),
40
- priceCents,
40
+ priceMicros,
41
41
  chain: opts.chain,
42
42
  referenceId,
43
43
  };
@@ -60,7 +60,7 @@ export class EthWatcher {
60
60
  const confirmed = latest - this.confirmations;
61
61
  if (latest < this._cursor)
62
62
  return;
63
- const { priceCents } = await this.oracle.getPrice("ETH");
63
+ const { priceMicros } = await this.oracle.getPrice("ETH");
64
64
  // Scan up to latest (not just confirmed) to detect pending txs
65
65
  for (let blockNum = this._cursor; blockNum <= latest; blockNum++) {
66
66
  const block = (await this.rpc("eth_getBlockByNumber", [`0x${blockNum.toString(16)}`, true]));
@@ -82,7 +82,7 @@ export class EthWatcher {
82
82
  if (lastConf !== null && confs <= lastConf)
83
83
  continue;
84
84
  }
85
- const amountUsdCents = nativeToCents(valueWei, priceCents, 18);
85
+ const amountUsdCents = nativeToCents(valueWei, priceMicros, 18);
86
86
  const event = {
87
87
  chain: this.chain,
88
88
  from: tx.from.toLowerCase(),
@@ -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";
@@ -41,10 +43,13 @@ async function main() {
41
43
  const db = drizzle(pool, { schema });
42
44
  const chargeStore = new DrizzleCryptoChargeRepository(db);
43
45
  const methodStore = new DrizzlePaymentMethodStore(db);
44
- // Chainlink on-chain oracle for volatile assets (BTC, ETH).
45
- const oracle = BASE_RPC_URL
46
+ // Composite oracle: Chainlink on-chain (BTC, ETH on Base) + CoinGecko fallback (DOGE, LTC, etc.)
47
+ // Every volatile asset needs reliable USD pricing — the ledger credits nanodollars.
48
+ const chainlink = BASE_RPC_URL
46
49
  ? new ChainlinkOracle({ rpcCall: createRpcCaller(BASE_RPC_URL) })
47
50
  : new FixedPriceOracle();
51
+ const coingecko = new CoinGeckoOracle();
52
+ const oracle = new CompositeOracle(chainlink, coingecko);
48
53
  const app = createKeyServerApp({
49
54
  db,
50
55
  chargeStore,
@@ -13,6 +13,7 @@ import { derivedAddresses, pathAllocations, paymentMethods } from "../../db/sche
13
13
  import { deriveAddress, deriveP2pkhAddress } from "./btc/address-gen.js";
14
14
  import { deriveDepositAddress } from "./evm/address-gen.js";
15
15
  import { centsToNative } from "./oracle/convert.js";
16
+ import { AssetNotSupportedError } from "./oracle/types.js";
16
17
  /**
17
18
  * Derive the next unused address for a chain.
18
19
  * Atomically increments next_index and records address in a single transaction.
@@ -120,14 +121,24 @@ export function createKeyServerApp(deps) {
120
121
  // Compute expected crypto amount in native base units.
121
122
  // Price is locked NOW — this is what the user must send.
122
123
  let expectedAmount;
123
- if (method.oracleAddress) {
124
- // Volatile asset (BTC, ETH, DOGE) — oracle-priced
125
- const { priceCents } = await deps.oracle.getPrice(token);
126
- expectedAmount = centsToNative(amountUsdCents, priceCents, method.decimals);
124
+ const feedAddress = method.oracleAddress ? method.oracleAddress : undefined;
125
+ try {
126
+ // Try oracle pricing (Chainlink for BTC/ETH, CoinGecko for DOGE/LTC).
127
+ // feedAddress is a hint for Chainlink — undefined is fine, CompositeOracle
128
+ // falls through to CoinGecko or built-in feed maps.
129
+ const { priceMicros } = await deps.oracle.getPrice(token, feedAddress);
130
+ expectedAmount = centsToNative(amountUsdCents, priceMicros, method.decimals);
127
131
  }
128
- else {
129
- // Stablecoin (1:1 USD) — e.g. $50 USDC = 50_000_000 base units (6 decimals)
130
- expectedAmount = (BigInt(amountUsdCents) * 10n ** BigInt(method.decimals)) / 100n;
132
+ catch (err) {
133
+ if (err instanceof AssetNotSupportedError) {
134
+ // No oracle knows this token (e.g. USDC, DAI) stablecoin 1:1 USD.
135
+ expectedAmount = (BigInt(amountUsdCents) * 10n ** BigInt(method.decimals)) / 100n;
136
+ }
137
+ else {
138
+ // Transient oracle failure (network, rate limit, stale feed).
139
+ // Reject the charge — silently pricing BTC at $1 would be catastrophic.
140
+ return c.json({ error: `Price oracle unavailable for ${token}: ${err.message}` }, 503);
141
+ }
131
142
  }
132
143
  const referenceId = `${token.toLowerCase()}:${address.toLowerCase()}`;
133
144
  await deps.chargeStore.createStablecoinCharge({
@@ -22,7 +22,7 @@ describe("ChainlinkOracle", () => {
22
22
  const rpc = vi.fn().mockResolvedValue(encodeRoundData(350000000000n, nowSec));
23
23
  const oracle = new ChainlinkOracle({ rpcCall: rpc });
24
24
  const result = await oracle.getPrice("ETH");
25
- expect(result.priceCents).toBe(350_000); // $3,500.00
25
+ expect(result.priceMicros).toBe(3_500_000_000); // $3,500.00
26
26
  expect(result.updatedAt).toBeInstanceOf(Date);
27
27
  expect(rpc).toHaveBeenCalledWith("eth_call", [
28
28
  { to: "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70", data: "0xfeaf968c" },
@@ -34,14 +34,14 @@ describe("ChainlinkOracle", () => {
34
34
  const rpc = vi.fn().mockResolvedValue(encodeRoundData(6500000000000n, nowSec));
35
35
  const oracle = new ChainlinkOracle({ rpcCall: rpc });
36
36
  const result = await oracle.getPrice("BTC");
37
- expect(result.priceCents).toBe(6_500_000); // $65,000.00
37
+ expect(result.priceMicros).toBe(65_000_000_000); // $65,000.00
38
38
  });
39
39
  it("handles fractional dollar prices correctly", async () => {
40
40
  // ETH at $3,456.78 → answer = 345_678_000_000
41
41
  const rpc = vi.fn().mockResolvedValue(encodeRoundData(345678000000n, nowSec));
42
42
  const oracle = new ChainlinkOracle({ rpcCall: rpc });
43
43
  const result = await oracle.getPrice("ETH");
44
- expect(result.priceCents).toBe(345_678); // $3,456.78
44
+ expect(result.priceMicros).toBe(3_456_780_000); // $3,456.78
45
45
  });
46
46
  it("rejects stale prices", async () => {
47
47
  const staleTime = nowSec - 7200; // 2 hours ago
@@ -78,6 +78,6 @@ describe("ChainlinkOracle", () => {
78
78
  // 60-minute threshold → fresh
79
79
  const relaxed = new ChainlinkOracle({ rpcCall: rpc, maxStalenessMs: 60 * 60 * 1000 });
80
80
  const result = await relaxed.getPrice("ETH");
81
- expect(result.priceCents).toBe(350_000);
81
+ expect(result.priceMicros).toBe(3_500_000_000);
82
82
  });
83
83
  });
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { CoinGeckoOracle } from "../coingecko.js";
3
+ describe("CoinGeckoOracle", () => {
4
+ const mockFetch = (price) => vi.fn().mockResolvedValue({
5
+ ok: true,
6
+ json: () => Promise.resolve({ bitcoin: { usd: price } }),
7
+ });
8
+ it("returns price in microdollars from CoinGecko API", async () => {
9
+ const oracle = new CoinGeckoOracle({ fetchFn: mockFetch(84_532.17) });
10
+ const result = await oracle.getPrice("BTC");
11
+ expect(result.priceMicros).toBe(84_532_170_000);
12
+ expect(result.updatedAt).toBeInstanceOf(Date);
13
+ });
14
+ it("caches prices within TTL", async () => {
15
+ const fn = mockFetch(84_532.17);
16
+ const oracle = new CoinGeckoOracle({ fetchFn: fn, cacheTtlMs: 60_000 });
17
+ await oracle.getPrice("BTC");
18
+ await oracle.getPrice("BTC");
19
+ expect(fn).toHaveBeenCalledTimes(1);
20
+ });
21
+ it("re-fetches after cache expires", async () => {
22
+ const fn = mockFetch(84_532.17);
23
+ const oracle = new CoinGeckoOracle({ fetchFn: fn, cacheTtlMs: 0 });
24
+ await oracle.getPrice("BTC");
25
+ await oracle.getPrice("BTC");
26
+ expect(fn).toHaveBeenCalledTimes(2);
27
+ });
28
+ it("throws for unknown asset", async () => {
29
+ const oracle = new CoinGeckoOracle({ fetchFn: mockFetch(100) });
30
+ await expect(oracle.getPrice("UNKNOWN")).rejects.toThrow("No price oracle supports asset: UNKNOWN");
31
+ });
32
+ it("throws on API error", async () => {
33
+ const fn = vi.fn().mockResolvedValue({ ok: false, status: 429, statusText: "Too Many Requests" });
34
+ const oracle = new CoinGeckoOracle({ fetchFn: fn });
35
+ await expect(oracle.getPrice("BTC")).rejects.toThrow("CoinGecko API error");
36
+ });
37
+ it("throws on zero price", async () => {
38
+ const fn = vi.fn().mockResolvedValue({
39
+ ok: true,
40
+ json: () => Promise.resolve({ bitcoin: { usd: 0 } }),
41
+ });
42
+ const oracle = new CoinGeckoOracle({ fetchFn: fn });
43
+ await expect(oracle.getPrice("BTC")).rejects.toThrow("Invalid CoinGecko price");
44
+ });
45
+ it("resolves DOGE via coingecko ID mapping", async () => {
46
+ const fn = vi.fn().mockResolvedValue({
47
+ ok: true,
48
+ json: () => Promise.resolve({ dogecoin: { usd: 0.1742 } }),
49
+ });
50
+ const oracle = new CoinGeckoOracle({ fetchFn: fn });
51
+ const result = await oracle.getPrice("DOGE");
52
+ expect(result.priceMicros).toBe(174_200);
53
+ expect(fn).toHaveBeenCalledWith(expect.stringContaining("ids=dogecoin"));
54
+ });
55
+ it("resolves LTC via coingecko ID mapping", async () => {
56
+ const fn = vi.fn().mockResolvedValue({
57
+ ok: true,
58
+ json: () => Promise.resolve({ litecoin: { usd: 92.45 } }),
59
+ });
60
+ const oracle = new CoinGeckoOracle({ fetchFn: fn });
61
+ const result = await oracle.getPrice("LTC");
62
+ expect(result.priceMicros).toBe(92_450_000);
63
+ expect(fn).toHaveBeenCalledWith(expect.stringContaining("ids=litecoin"));
64
+ });
65
+ });
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { CompositeOracle } from "../composite.js";
3
+ function mockOracle(priceMicros) {
4
+ return { getPrice: vi.fn().mockResolvedValue({ priceMicros, updatedAt: new Date() }) };
5
+ }
6
+ function failingOracle(msg = "no feed") {
7
+ return { getPrice: vi.fn().mockRejectedValue(new Error(msg)) };
8
+ }
9
+ describe("CompositeOracle", () => {
10
+ it("uses primary when feedAddress is provided and primary succeeds", async () => {
11
+ const primary = mockOracle(8_500_000);
12
+ const fallback = mockOracle(8_400_000);
13
+ const oracle = new CompositeOracle(primary, fallback);
14
+ const result = await oracle.getPrice("BTC", "0x64c911996D3c6aC71f9b455B1E8E7266BcbD848F");
15
+ expect(result.priceMicros).toBe(8_500_000);
16
+ expect(primary.getPrice).toHaveBeenCalledWith("BTC", "0x64c911996D3c6aC71f9b455B1E8E7266BcbD848F");
17
+ expect(fallback.getPrice).not.toHaveBeenCalled();
18
+ });
19
+ it("falls back when primary fails with feedAddress", async () => {
20
+ const primary = failingOracle("stale");
21
+ const fallback = mockOracle(8_400_000);
22
+ const oracle = new CompositeOracle(primary, fallback);
23
+ const result = await oracle.getPrice("BTC", "0x64c911996D3c6aC71f9b455B1E8E7266BcbD848F");
24
+ expect(result.priceMicros).toBe(8_400_000);
25
+ });
26
+ it("tries primary without feed, then falls back for unknown assets", async () => {
27
+ const primary = failingOracle("No price feed for asset: DOGE");
28
+ const fallback = mockOracle(17);
29
+ const oracle = new CompositeOracle(primary, fallback);
30
+ const result = await oracle.getPrice("DOGE");
31
+ expect(result.priceMicros).toBe(17);
32
+ });
33
+ it("uses primary built-in feeds for BTC/ETH without explicit feedAddress", async () => {
34
+ const primary = mockOracle(8_500_000);
35
+ const fallback = mockOracle(8_400_000);
36
+ const oracle = new CompositeOracle(primary, fallback);
37
+ const result = await oracle.getPrice("BTC");
38
+ expect(result.priceMicros).toBe(8_500_000);
39
+ expect(primary.getPrice).toHaveBeenCalledWith("BTC");
40
+ expect(fallback.getPrice).not.toHaveBeenCalled();
41
+ });
42
+ it("propagates fallback errors when both fail", async () => {
43
+ const primary = failingOracle("no chainlink");
44
+ const fallback = failingOracle("no coingecko");
45
+ const oracle = new CompositeOracle(primary, fallback);
46
+ await expect(oracle.getPrice("UNKNOWN")).rejects.toThrow("no coingecko");
47
+ });
48
+ });
@@ -2,50 +2,60 @@ import { describe, expect, it } from "vitest";
2
2
  import { centsToNative, nativeToCents } from "../convert.js";
3
3
  describe("centsToNative", () => {
4
4
  it("converts $50 to ETH wei at $3,500", () => {
5
- // 5000 cents × 10^18 / 350000 cents = 14285714285714285n wei
6
- const wei = centsToNative(5000, 350_000, 18);
5
+ // 5000 cents × 10000 × 10^18 / 3_500_000_000 micros = 14285714285714285n wei
6
+ const wei = centsToNative(5000, 3_500_000_000, 18);
7
7
  expect(wei).toBe(14285714285714285n);
8
8
  });
9
9
  it("converts $50 to BTC sats at $65,000", () => {
10
- // 5000 cents × 10^8 / 6500000 cents = 76923n sats
11
- const sats = centsToNative(5000, 6_500_000, 8);
10
+ // 5000 cents × 10000 × 10^8 / 65_000_000_000 micros = 76923n sats
11
+ const sats = centsToNative(5000, 65_000_000_000, 8);
12
12
  expect(sats).toBe(76923n);
13
13
  });
14
+ it("converts $50 to DOGE at $0.094147", () => {
15
+ // 5000 cents × 10000 × 10^8 / 94_147 micros = 53_107_898_982n base units (531.08 DOGE)
16
+ const dogeUnits = centsToNative(5000, 94_147, 8);
17
+ expect(Number(dogeUnits) / 1e8).toBeCloseTo(531.08, 0);
18
+ });
19
+ it("converts $50 to LTC at $55.79", () => {
20
+ // 5000 cents × 10000 × 10^8 / 55_790_000 micros = 89_622_512n base units (0.896 LTC)
21
+ const ltcUnits = centsToNative(5000, 55_790_000, 8);
22
+ expect(Number(ltcUnits) / 1e8).toBeCloseTo(0.896, 2);
23
+ });
14
24
  it("converts $100 to ETH wei at $2,000", () => {
15
- // 10000 cents × 10^18 / 200000 cents = 50000000000000000n wei (0.05 ETH)
16
- const wei = centsToNative(10_000, 200_000, 18);
25
+ // 10000 cents × 10000 × 10^18 / 2_000_000_000 micros = 50_000_000_000_000_000n (0.05 ETH)
26
+ const wei = centsToNative(10_000, 2_000_000_000, 18);
17
27
  expect(wei).toBe(50000000000000000n);
18
28
  });
19
29
  it("rejects non-integer amountCents", () => {
20
- expect(() => centsToNative(50.5, 350_000, 18)).toThrow("positive integer");
30
+ expect(() => centsToNative(50.5, 3_500_000_000, 18)).toThrow("positive integer");
21
31
  });
22
32
  it("rejects zero amountCents", () => {
23
- expect(() => centsToNative(0, 350_000, 18)).toThrow("positive integer");
33
+ expect(() => centsToNative(0, 3_500_000_000, 18)).toThrow("positive integer");
24
34
  });
25
- it("rejects zero priceCents", () => {
35
+ it("rejects zero priceMicros", () => {
26
36
  expect(() => centsToNative(5000, 0, 18)).toThrow("positive integer");
27
37
  });
28
38
  it("rejects negative decimals", () => {
29
- expect(() => centsToNative(5000, 350_000, -1)).toThrow("non-negative integer");
39
+ expect(() => centsToNative(5000, 3_500_000_000, -1)).toThrow("non-negative integer");
30
40
  });
31
41
  });
32
42
  describe("nativeToCents", () => {
33
43
  it("converts ETH wei back to cents at $3,500", () => {
34
- // 14285714285714285n wei × 350000 / 10^18 = 4999 cents (truncated)
35
- const cents = nativeToCents(14285714285714285n, 350_000, 18);
36
- expect(cents).toBe(4999); // truncation from integer division
44
+ // 14285714285714285n × 3_500_000_000 / (10000 × 10^18) = 4999 cents (truncated)
45
+ const cents = nativeToCents(14285714285714285n, 3_500_000_000, 18);
46
+ expect(cents).toBe(4999);
37
47
  });
38
48
  it("converts BTC sats back to cents at $65,000", () => {
39
- // 76923n sats × 6500000 / 10^8 = 4999 cents (truncated)
40
- const cents = nativeToCents(76923n, 6_500_000, 8);
49
+ // 76923n × 65_000_000_000 / (10000 × 10^8) = 4999 cents
50
+ const cents = nativeToCents(76923n, 65_000_000_000, 8);
41
51
  expect(cents).toBe(4999);
42
52
  });
43
53
  it("exact round-trip for clean division", () => {
44
54
  // 0.05 ETH at $2,000 = $100
45
- const cents = nativeToCents(50000000000000000n, 200_000, 18);
55
+ const cents = nativeToCents(50000000000000000n, 2_000_000_000, 18);
46
56
  expect(cents).toBe(10_000); // $100.00
47
57
  });
48
58
  it("rejects negative rawAmount", () => {
49
- expect(() => nativeToCents(-1n, 350_000, 18)).toThrow("non-negative");
59
+ expect(() => nativeToCents(-1n, 3_500_000_000, 18)).toThrow("non-negative");
50
60
  });
51
61
  });
@@ -4,17 +4,17 @@ describe("FixedPriceOracle", () => {
4
4
  it("returns default ETH price", async () => {
5
5
  const oracle = new FixedPriceOracle();
6
6
  const result = await oracle.getPrice("ETH");
7
- expect(result.priceCents).toBe(350_000); // $3,500
7
+ expect(result.priceMicros).toBe(3_500_000_000); // $3,500
8
8
  expect(result.updatedAt).toBeInstanceOf(Date);
9
9
  });
10
10
  it("returns default BTC price", async () => {
11
11
  const oracle = new FixedPriceOracle();
12
12
  const result = await oracle.getPrice("BTC");
13
- expect(result.priceCents).toBe(6_500_000); // $65,000
13
+ expect(result.priceMicros).toBe(65_000_000_000); // $65,000
14
14
  });
15
15
  it("accepts custom prices", async () => {
16
- const oracle = new FixedPriceOracle({ ETH: 200_000, BTC: 5_000_000 });
17
- expect((await oracle.getPrice("ETH")).priceCents).toBe(200_000);
18
- expect((await oracle.getPrice("BTC")).priceCents).toBe(5_000_000);
16
+ const oracle = new FixedPriceOracle({ ETH: 2_000_000_000, BTC: 50_000_000_000 });
17
+ expect((await oracle.getPrice("ETH")).priceMicros).toBe(2_000_000_000);
18
+ expect((await oracle.getPrice("BTC")).priceMicros).toBe(50_000_000_000);
19
19
  });
20
20
  });
@@ -14,13 +14,13 @@ export interface ChainlinkOracleOpts {
14
14
  * No API key, no rate limits — just an RPC call to our own node.
15
15
  *
16
16
  * Chainlink USD feeds use 8 decimals. We convert to integer USD cents:
17
- * priceCents = answer / 10^6 (i.e. answer / 10^8 * 100)
17
+ * priceMicros = answer / 100 (i.e. answer / 10^8 * 10^6)
18
18
  */
19
19
  export declare class ChainlinkOracle implements IPriceOracle {
20
20
  private readonly rpc;
21
21
  private readonly feeds;
22
22
  private readonly maxStalenessMs;
23
23
  constructor(opts: ChainlinkOracleOpts);
24
- getPrice(asset: PriceAsset): Promise<PriceResult>;
24
+ getPrice(asset: PriceAsset, feedAddress?: `0x${string}`): Promise<PriceResult>;
25
25
  }
26
26
  export {};
@@ -17,7 +17,7 @@ const DEFAULT_MAX_STALENESS_MS = 60 * 60 * 1000;
17
17
  * No API key, no rate limits — just an RPC call to our own node.
18
18
  *
19
19
  * Chainlink USD feeds use 8 decimals. We convert to integer USD cents:
20
- * priceCents = answer / 10^6 (i.e. answer / 10^8 * 100)
20
+ * priceMicros = answer / 100 (i.e. answer / 10^8 * 10^6)
21
21
  */
22
22
  export class ChainlinkOracle {
23
23
  rpc;
@@ -28,11 +28,11 @@ export class ChainlinkOracle {
28
28
  this.feeds = new Map(Object.entries({ ...FEED_ADDRESSES, ...opts.feedAddresses }));
29
29
  this.maxStalenessMs = opts.maxStalenessMs ?? DEFAULT_MAX_STALENESS_MS;
30
30
  }
31
- async getPrice(asset) {
32
- const feedAddress = this.feeds.get(asset);
33
- if (!feedAddress)
31
+ async getPrice(asset, feedAddress) {
32
+ const resolvedFeed = feedAddress ?? this.feeds.get(asset);
33
+ if (!resolvedFeed)
34
34
  throw new Error(`No price feed for asset: ${asset}`);
35
- const result = (await this.rpc("eth_call", [{ to: feedAddress, data: LATEST_ROUND_DATA }, "latest"]));
35
+ const result = (await this.rpc("eth_call", [{ to: resolvedFeed, data: LATEST_ROUND_DATA }, "latest"]));
36
36
  // ABI decode latestRoundData() return:
37
37
  // [0] roundId (uint80) — skip
38
38
  // [1] answer (int256) — price × 10^8
@@ -51,11 +51,12 @@ export class ChainlinkOracle {
51
51
  if (ageMs > this.maxStalenessMs) {
52
52
  throw new Error(`Price feed for ${asset} is stale (${Math.round(ageMs / 1000)}s old, max ${Math.round(this.maxStalenessMs / 1000)}s)`);
53
53
  }
54
- // Chainlink USD feeds: 8 decimals. answer / 10^6 = cents (integer).
55
- const priceCents = Number(answer / 1000000n);
56
- if (priceCents <= 0) {
57
- throw new Error(`Invalid price for ${asset}: ${priceCents} cents`);
54
+ // Chainlink USD feeds: 8 decimals. answer / 100 = microdollars (10^-6 USD).
55
+ // e.g. BTC at $70,315 → answer = 7_031_500_000_000 → 70_315_000_000 microdollars
56
+ const priceMicros = Number(answer / 100n);
57
+ if (priceMicros <= 0) {
58
+ throw new Error(`Invalid price for ${asset}: ${priceMicros} microdollars`);
58
59
  }
59
- return { priceCents, updatedAt };
60
+ return { priceMicros, updatedAt };
60
61
  }
61
62
  }
@@ -0,0 +1,22 @@
1
+ import type { IPriceOracle, PriceAsset, PriceResult } from "./types.js";
2
+ export interface CoinGeckoOracleOpts {
3
+ /** Override token→id mapping. */
4
+ tokenIds?: Record<string, string>;
5
+ /** Cache TTL in ms. Default: 60s. */
6
+ cacheTtlMs?: number;
7
+ /** Custom fetch function (for testing). */
8
+ fetchFn?: typeof fetch;
9
+ }
10
+ /**
11
+ * CoinGecko price oracle — free API, no key required.
12
+ * Used for assets without Chainlink on-chain feeds (DOGE, LTC).
13
+ * Caches prices to stay within rate limits.
14
+ */
15
+ export declare class CoinGeckoOracle implements IPriceOracle {
16
+ private readonly ids;
17
+ private readonly cacheTtlMs;
18
+ private readonly fetchFn;
19
+ private readonly cache;
20
+ constructor(opts?: CoinGeckoOracleOpts);
21
+ getPrice(asset: PriceAsset, _feedAddress?: `0x${string}`): Promise<PriceResult>;
22
+ }
@@ -0,0 +1,67 @@
1
+ import { AssetNotSupportedError } from "./types.js";
2
+ /**
3
+ * Token symbol → CoinGecko API ID mapping.
4
+ * CoinGecko uses lowercase slugs, not ticker symbols.
5
+ */
6
+ const COINGECKO_IDS = {
7
+ BTC: "bitcoin",
8
+ ETH: "ethereum",
9
+ DOGE: "dogecoin",
10
+ LTC: "litecoin",
11
+ SOL: "solana",
12
+ LINK: "chainlink",
13
+ UNI: "uniswap",
14
+ AERO: "aerodrome-finance",
15
+ };
16
+ /** Default cache TTL: 60 seconds. CoinGecko free tier allows 10-30 req/min. */
17
+ const DEFAULT_CACHE_TTL_MS = 60_000;
18
+ /**
19
+ * CoinGecko price oracle — free API, no key required.
20
+ * Used for assets without Chainlink on-chain feeds (DOGE, LTC).
21
+ * Caches prices to stay within rate limits.
22
+ */
23
+ export class CoinGeckoOracle {
24
+ ids;
25
+ cacheTtlMs;
26
+ fetchFn;
27
+ cache = new Map();
28
+ constructor(opts = {}) {
29
+ this.ids = { ...COINGECKO_IDS, ...opts.tokenIds };
30
+ this.cacheTtlMs = opts.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
31
+ this.fetchFn = opts.fetchFn ?? fetch;
32
+ }
33
+ async getPrice(asset, _feedAddress) {
34
+ const cached = this.cache.get(asset);
35
+ if (cached && Date.now() - cached.fetchedAt < this.cacheTtlMs) {
36
+ return { priceMicros: cached.priceMicros, updatedAt: cached.updatedAt };
37
+ }
38
+ const coinId = this.ids[asset];
39
+ if (!coinId)
40
+ throw new AssetNotSupportedError(asset);
41
+ const url = `https://api.coingecko.com/api/v3/simple/price?ids=${coinId}&vs_currencies=usd`;
42
+ try {
43
+ const res = await this.fetchFn(url);
44
+ if (!res.ok) {
45
+ throw new Error(`CoinGecko API error for ${asset}: ${res.status} ${res.statusText}`);
46
+ }
47
+ const data = (await res.json());
48
+ const usdPrice = data[coinId]?.usd;
49
+ if (usdPrice === undefined || usdPrice <= 0) {
50
+ throw new Error(`Invalid CoinGecko price for ${asset}: ${usdPrice}`);
51
+ }
52
+ const priceMicros = Math.round(usdPrice * 1_000_000);
53
+ const updatedAt = new Date();
54
+ this.cache.set(asset, { priceMicros, updatedAt, fetchedAt: Date.now() });
55
+ return { priceMicros, updatedAt };
56
+ }
57
+ catch (err) {
58
+ // Serve stale cache on transient failure (rate limit, network error).
59
+ // A slightly old price is better than rejecting the charge entirely.
60
+ const stale = this.cache.get(asset);
61
+ if (stale) {
62
+ return { priceMicros: stale.priceMicros, updatedAt: stale.updatedAt };
63
+ }
64
+ throw err;
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,14 @@
1
+ import type { IPriceOracle, PriceAsset, PriceResult } from "./types.js";
2
+ /**
3
+ * Composite oracle — tries primary (Chainlink on-chain), falls back to secondary (CoinGecko).
4
+ *
5
+ * When a feedAddress is provided (from payment_methods.oracle_address), the primary
6
+ * oracle is used with that address. When no feed exists or the primary fails,
7
+ * the fallback oracle is consulted.
8
+ */
9
+ export declare class CompositeOracle implements IPriceOracle {
10
+ private readonly primary;
11
+ private readonly fallback;
12
+ constructor(primary: IPriceOracle, fallback: IPriceOracle);
13
+ getPrice(asset: PriceAsset, feedAddress?: `0x${string}`): Promise<PriceResult>;
14
+ }