@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
@@ -1,17 +1,23 @@
1
1
  /**
2
- * Key Server webhook handler — processes payment confirmations from the
2
+ * Key Server webhook handler — processes payment events from the
3
3
  * centralized crypto key server.
4
4
  *
5
+ * Called on EVERY status update (not just terminal):
6
+ * - "partial" / "Processing" → update progress, no credit
7
+ * - "confirmed" / "Settled" → update progress + credit ledger
8
+ * - "expired" / "failed" → update progress, no credit
9
+ *
5
10
  * Payload shape (from watcher-service.ts):
6
11
  * {
7
12
  * chargeId: "btc:bc1q...",
8
13
  * chain: "bitcoin",
9
14
  * address: "bc1q...",
10
- * amountUsdCents: 5000,
15
+ * amountReceivedCents: 5000,
11
16
  * status: "confirmed",
12
17
  * txHash: "abc123...",
13
18
  * amountReceived: "50000 sats",
14
- * confirmations: 6
19
+ * confirmations: 6,
20
+ * confirmationsRequired: 6
15
21
  * }
16
22
  *
17
23
  * Replaces handleCryptoWebhook() for products using the key server.
@@ -20,16 +26,20 @@ import { Credit } from "../../credits/credit.js";
20
26
  import type { ILedger } from "../../credits/ledger.js";
21
27
  import type { IWebhookSeenRepository } from "../webhook-seen-repository.js";
22
28
  import type { ICryptoChargeRepository } from "./charge-store.js";
29
+ import type { CryptoChargeStatus } from "./types.js";
23
30
 
24
31
  export interface KeyServerWebhookPayload {
25
32
  chargeId: string;
26
33
  chain: string;
27
34
  address: string;
28
- amountUsdCents: number;
35
+ /** @deprecated Use amountReceivedCents instead. Kept for one release cycle. */
36
+ amountUsdCents?: number;
37
+ amountReceivedCents?: number;
29
38
  status: string;
30
39
  txHash?: string;
31
40
  amountReceived?: string;
32
41
  confirmations?: number;
42
+ confirmationsRequired?: number;
33
43
  }
34
44
 
35
45
  export interface KeyServerWebhookDeps {
@@ -45,13 +55,49 @@ export interface KeyServerWebhookResult {
45
55
  tenant?: string;
46
56
  creditedCents?: number;
47
57
  reactivatedBots?: string[];
58
+ status?: CryptoChargeStatus;
59
+ confirmations?: number;
60
+ confirmationsRequired?: number;
61
+ }
62
+
63
+ /**
64
+ * Map legacy/watcher status strings to canonical CryptoChargeStatus.
65
+ * Accepts both old BTCPay-style ("Settled", "Processing") and new canonical ("confirmed", "partial").
66
+ */
67
+ export function normalizeStatus(raw: string): CryptoChargeStatus {
68
+ switch (raw) {
69
+ case "confirmed":
70
+ case "Settled":
71
+ case "InvoiceSettled":
72
+ return "confirmed";
73
+ case "partial":
74
+ case "Processing":
75
+ case "InvoiceProcessing":
76
+ case "InvoiceReceivedPayment":
77
+ return "partial";
78
+ case "expired":
79
+ case "Expired":
80
+ case "InvoiceExpired":
81
+ return "expired";
82
+ case "failed":
83
+ case "Invalid":
84
+ case "InvoiceInvalid":
85
+ return "failed";
86
+ case "pending":
87
+ case "New":
88
+ case "InvoiceCreated":
89
+ return "pending";
90
+ default:
91
+ return "pending";
92
+ }
48
93
  }
49
94
 
50
95
  /**
51
- * Process a payment confirmation from the crypto key server.
96
+ * Process a payment webhook from the crypto key server.
52
97
  *
53
- * Credits the ledger when status is "confirmed".
54
- * Idempotency: ledger referenceId + replay guard (same pattern as Stripe handler).
98
+ * Idempotency: deduplicate by chargeId + status + confirmations so that
99
+ * multiple progress updates (0→1→2→...→6 confirmations) each get through,
100
+ * but exact duplicates are rejected.
55
101
  */
56
102
  export async function handleKeyServerWebhook(
57
103
  deps: KeyServerWebhookDeps,
@@ -59,8 +105,15 @@ export async function handleKeyServerWebhook(
59
105
  ): Promise<KeyServerWebhookResult> {
60
106
  const { chargeStore, creditLedger } = deps;
61
107
 
62
- // Replay guard: deduplicate by chargeId
63
- const dedupeKey = `ks:${payload.chargeId}`;
108
+ const status = normalizeStatus(payload.status);
109
+ const confirmations = payload.confirmations ?? 0;
110
+ const confirmationsRequired = payload.confirmationsRequired ?? 1;
111
+ // Support deprecated amountUsdCents field as fallback
112
+ const amountReceivedCents = payload.amountReceivedCents ?? payload.amountUsdCents ?? 0;
113
+
114
+ // Replay guard: deduplicate by chargeId + status + confirmations
115
+ // This allows multiple progress updates for the same charge
116
+ const dedupeKey = `ks:${payload.chargeId}:${status}:${confirmations}`;
64
117
  if (await deps.replayGuard.isDuplicate(dedupeKey, "crypto")) {
65
118
  return { handled: true, duplicate: true };
66
119
  }
@@ -71,15 +124,36 @@ export async function handleKeyServerWebhook(
71
124
  return { handled: false };
72
125
  }
73
126
 
74
- if (payload.status === "confirmed") {
75
- // Only settle when payment is confirmed
76
- await chargeStore.updateStatus(payload.chargeId, "Settled", charge.token ?? undefined, payload.amountReceived);
127
+ // Always update progress on every webhook
128
+ await chargeStore.updateProgress(payload.chargeId, {
129
+ status,
130
+ amountReceivedCents,
131
+ confirmations,
132
+ confirmationsRequired,
133
+ txHash: payload.txHash,
134
+ });
77
135
 
136
+ // Also call deprecated updateStatus for backward compat with downstream consumers
137
+ const legacyStatusMap: Record<CryptoChargeStatus, string> = {
138
+ pending: "New",
139
+ partial: "Processing",
140
+ confirmed: "Settled",
141
+ expired: "Expired",
142
+ failed: "Invalid",
143
+ };
144
+ await chargeStore.updateStatus(
145
+ payload.chargeId,
146
+ legacyStatusMap[status] as "Settled",
147
+ charge.token ?? undefined,
148
+ payload.amountReceived,
149
+ );
150
+
151
+ if (status === "confirmed") {
78
152
  // Idempotency: check ledger referenceId (atomic, same as BTCPay handler)
79
153
  const creditRef = `crypto:${payload.chargeId}`;
80
154
  if (await creditLedger.hasReferenceId(creditRef)) {
81
155
  await deps.replayGuard.markSeen(dedupeKey, "crypto");
82
- return { handled: true, duplicate: true, tenant: charge.tenantId };
156
+ return { handled: true, duplicate: true, tenant: charge.tenantId, status, confirmations, confirmationsRequired };
83
157
  }
84
158
 
85
159
  // Credit the original USD amount requested.
@@ -104,16 +178,13 @@ export async function handleKeyServerWebhook(
104
178
  tenant: charge.tenantId,
105
179
  creditedCents: charge.amountUsdCents,
106
180
  reactivatedBots,
181
+ status,
182
+ confirmations,
183
+ confirmationsRequired,
107
184
  };
108
185
  }
109
186
 
110
- // Non-confirmed status — update status but don't settle or credit
111
- await chargeStore.updateStatus(
112
- payload.chargeId,
113
- payload.status as "Processing",
114
- charge.token ?? undefined,
115
- payload.amountReceived,
116
- );
187
+ // Non-confirmed status — progress already updated above, no credit
117
188
  await deps.replayGuard.markSeen(dedupeKey, "crypto");
118
- return { handled: true, tenant: charge.tenantId };
189
+ return { handled: true, tenant: charge.tenantId, status, confirmations, confirmationsRequired };
119
190
  }
@@ -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
+ });
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { CompositeOracle } from "../composite.js";
3
+ import type { IPriceOracle } from "../types.js";
4
+
5
+ function mockOracle(priceMicros: number): IPriceOracle {
6
+ return { getPrice: vi.fn().mockResolvedValue({ priceMicros, updatedAt: new Date() }) };
7
+ }
8
+
9
+ function failingOracle(msg = "no feed"): IPriceOracle {
10
+ return { getPrice: vi.fn().mockRejectedValue(new Error(msg)) };
11
+ }
12
+
13
+ describe("CompositeOracle", () => {
14
+ it("uses primary when feedAddress is provided and primary succeeds", async () => {
15
+ const primary = mockOracle(8_500_000);
16
+ const fallback = mockOracle(8_400_000);
17
+ const oracle = new CompositeOracle(primary, fallback);
18
+
19
+ const result = await oracle.getPrice("BTC", "0x64c911996D3c6aC71f9b455B1E8E7266BcbD848F");
20
+ expect(result.priceMicros).toBe(8_500_000);
21
+ expect(primary.getPrice).toHaveBeenCalledWith("BTC", "0x64c911996D3c6aC71f9b455B1E8E7266BcbD848F");
22
+ expect(fallback.getPrice).not.toHaveBeenCalled();
23
+ });
24
+
25
+ it("falls back when primary fails with feedAddress", async () => {
26
+ const primary = failingOracle("stale");
27
+ const fallback = mockOracle(8_400_000);
28
+ const oracle = new CompositeOracle(primary, fallback);
29
+
30
+ const result = await oracle.getPrice("BTC", "0x64c911996D3c6aC71f9b455B1E8E7266BcbD848F");
31
+ expect(result.priceMicros).toBe(8_400_000);
32
+ });
33
+
34
+ it("tries primary without feed, then falls back for unknown assets", async () => {
35
+ const primary = failingOracle("No price feed for asset: DOGE");
36
+ const fallback = mockOracle(17);
37
+ const oracle = new CompositeOracle(primary, fallback);
38
+
39
+ const result = await oracle.getPrice("DOGE");
40
+ expect(result.priceMicros).toBe(17);
41
+ });
42
+
43
+ it("uses primary built-in feeds for BTC/ETH without explicit feedAddress", async () => {
44
+ const primary = mockOracle(8_500_000);
45
+ const fallback = mockOracle(8_400_000);
46
+ const oracle = new CompositeOracle(primary, fallback);
47
+
48
+ const result = await oracle.getPrice("BTC");
49
+ expect(result.priceMicros).toBe(8_500_000);
50
+ expect(primary.getPrice).toHaveBeenCalledWith("BTC");
51
+ expect(fallback.getPrice).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it("propagates fallback errors when both fail", async () => {
55
+ const primary = failingOracle("no chainlink");
56
+ const fallback = failingOracle("no coingecko");
57
+ const oracle = new CompositeOracle(primary, fallback);
58
+
59
+ await expect(oracle.getPrice("UNKNOWN")).rejects.toThrow("no coingecko");
60
+ });
61
+ });
@@ -3,60 +3,72 @@ import { centsToNative, nativeToCents } from "../convert.js";
3
3
 
4
4
  describe("centsToNative", () => {
5
5
  it("converts $50 to ETH wei at $3,500", () => {
6
- // 5000 cents × 10^18 / 350000 cents = 14285714285714285n wei
7
- const wei = centsToNative(5000, 350_000, 18);
6
+ // 5000 cents × 10000 × 10^18 / 3_500_000_000 micros = 14285714285714285n wei
7
+ const wei = centsToNative(5000, 3_500_000_000, 18);
8
8
  expect(wei).toBe(14_285_714_285_714_285n);
9
9
  });
10
10
 
11
11
  it("converts $50 to BTC sats at $65,000", () => {
12
- // 5000 cents × 10^8 / 6500000 cents = 76923n sats
13
- const sats = centsToNative(5000, 6_500_000, 8);
12
+ // 5000 cents × 10000 × 10^8 / 65_000_000_000 micros = 76923n sats
13
+ const sats = centsToNative(5000, 65_000_000_000, 8);
14
14
  expect(sats).toBe(76_923n);
15
15
  });
16
16
 
17
+ it("converts $50 to DOGE at $0.094147", () => {
18
+ // 5000 cents × 10000 × 10^8 / 94_147 micros = 53_107_898_982n base units (531.08 DOGE)
19
+ const dogeUnits = centsToNative(5000, 94_147, 8);
20
+ expect(Number(dogeUnits) / 1e8).toBeCloseTo(531.08, 0);
21
+ });
22
+
23
+ it("converts $50 to LTC at $55.79", () => {
24
+ // 5000 cents × 10000 × 10^8 / 55_790_000 micros = 89_622_512n base units (0.896 LTC)
25
+ const ltcUnits = centsToNative(5000, 55_790_000, 8);
26
+ expect(Number(ltcUnits) / 1e8).toBeCloseTo(0.896, 2);
27
+ });
28
+
17
29
  it("converts $100 to ETH wei at $2,000", () => {
18
- // 10000 cents × 10^18 / 200000 cents = 50000000000000000n wei (0.05 ETH)
19
- const wei = centsToNative(10_000, 200_000, 18);
30
+ // 10000 cents × 10000 × 10^18 / 2_000_000_000 micros = 50_000_000_000_000_000n (0.05 ETH)
31
+ const wei = centsToNative(10_000, 2_000_000_000, 18);
20
32
  expect(wei).toBe(50_000_000_000_000_000n);
21
33
  });
22
34
 
23
35
  it("rejects non-integer amountCents", () => {
24
- expect(() => centsToNative(50.5, 350_000, 18)).toThrow("positive integer");
36
+ expect(() => centsToNative(50.5, 3_500_000_000, 18)).toThrow("positive integer");
25
37
  });
26
38
 
27
39
  it("rejects zero amountCents", () => {
28
- expect(() => centsToNative(0, 350_000, 18)).toThrow("positive integer");
40
+ expect(() => centsToNative(0, 3_500_000_000, 18)).toThrow("positive integer");
29
41
  });
30
42
 
31
- it("rejects zero priceCents", () => {
43
+ it("rejects zero priceMicros", () => {
32
44
  expect(() => centsToNative(5000, 0, 18)).toThrow("positive integer");
33
45
  });
34
46
 
35
47
  it("rejects negative decimals", () => {
36
- expect(() => centsToNative(5000, 350_000, -1)).toThrow("non-negative integer");
48
+ expect(() => centsToNative(5000, 3_500_000_000, -1)).toThrow("non-negative integer");
37
49
  });
38
50
  });
39
51
 
40
52
  describe("nativeToCents", () => {
41
53
  it("converts ETH wei back to cents at $3,500", () => {
42
- // 14285714285714285n wei × 350000 / 10^18 = 4999 cents (truncated)
43
- const cents = nativeToCents(14_285_714_285_714_285n, 350_000, 18);
44
- expect(cents).toBe(4999); // truncation from integer division
54
+ // 14285714285714285n × 3_500_000_000 / (10000 × 10^18) = 4999 cents (truncated)
55
+ const cents = nativeToCents(14_285_714_285_714_285n, 3_500_000_000, 18);
56
+ expect(cents).toBe(4999);
45
57
  });
46
58
 
47
59
  it("converts BTC sats back to cents at $65,000", () => {
48
- // 76923n sats × 6500000 / 10^8 = 4999 cents (truncated)
49
- const cents = nativeToCents(76_923n, 6_500_000, 8);
60
+ // 76923n × 65_000_000_000 / (10000 × 10^8) = 4999 cents
61
+ const cents = nativeToCents(76_923n, 65_000_000_000, 8);
50
62
  expect(cents).toBe(4999);
51
63
  });
52
64
 
53
65
  it("exact round-trip for clean division", () => {
54
66
  // 0.05 ETH at $2,000 = $100
55
- const cents = nativeToCents(50_000_000_000_000_000n, 200_000, 18);
67
+ const cents = nativeToCents(50_000_000_000_000_000n, 2_000_000_000, 18);
56
68
  expect(cents).toBe(10_000); // $100.00
57
69
  });
58
70
 
59
71
  it("rejects negative rawAmount", () => {
60
- expect(() => nativeToCents(-1n, 350_000, 18)).toThrow("non-negative");
72
+ expect(() => nativeToCents(-1n, 3_500_000_000, 18)).toThrow("non-negative");
61
73
  });
62
74
  });
@@ -5,19 +5,19 @@ describe("FixedPriceOracle", () => {
5
5
  it("returns default ETH price", async () => {
6
6
  const oracle = new FixedPriceOracle();
7
7
  const result = await oracle.getPrice("ETH");
8
- expect(result.priceCents).toBe(350_000); // $3,500
8
+ expect(result.priceMicros).toBe(3_500_000_000); // $3,500
9
9
  expect(result.updatedAt).toBeInstanceOf(Date);
10
10
  });
11
11
 
12
12
  it("returns default BTC price", async () => {
13
13
  const oracle = new FixedPriceOracle();
14
14
  const result = await oracle.getPrice("BTC");
15
- expect(result.priceCents).toBe(6_500_000); // $65,000
15
+ expect(result.priceMicros).toBe(65_000_000_000); // $65,000
16
16
  });
17
17
 
18
18
  it("accepts custom prices", async () => {
19
- const oracle = new FixedPriceOracle({ ETH: 200_000, BTC: 5_000_000 });
20
- expect((await oracle.getPrice("ETH")).priceCents).toBe(200_000);
21
- expect((await oracle.getPrice("BTC")).priceCents).toBe(5_000_000);
19
+ const oracle = new FixedPriceOracle({ ETH: 2_000_000_000, BTC: 50_000_000_000 });
20
+ expect((await oracle.getPrice("ETH")).priceMicros).toBe(2_000_000_000);
21
+ expect((await oracle.getPrice("BTC")).priceMicros).toBe(50_000_000_000);
22
22
  });
23
23
  });
@@ -32,7 +32,7 @@ export interface ChainlinkOracleOpts {
32
32
  * No API key, no rate limits — just an RPC call to our own node.
33
33
  *
34
34
  * Chainlink USD feeds use 8 decimals. We convert to integer USD cents:
35
- * priceCents = answer / 10^6 (i.e. answer / 10^8 * 100)
35
+ * priceMicros = answer / 100 (i.e. answer / 10^8 * 10^6)
36
36
  */
37
37
  export class ChainlinkOracle implements IPriceOracle {
38
38
  private readonly rpc: RpcCall;
@@ -45,11 +45,11 @@ export class ChainlinkOracle implements IPriceOracle {
45
45
  this.maxStalenessMs = opts.maxStalenessMs ?? DEFAULT_MAX_STALENESS_MS;
46
46
  }
47
47
 
48
- async getPrice(asset: PriceAsset): Promise<PriceResult> {
49
- const feedAddress = this.feeds.get(asset);
50
- if (!feedAddress) throw new Error(`No price feed for asset: ${asset}`);
48
+ async getPrice(asset: PriceAsset, feedAddress?: `0x${string}`): Promise<PriceResult> {
49
+ const resolvedFeed = feedAddress ?? this.feeds.get(asset);
50
+ if (!resolvedFeed) throw new Error(`No price feed for asset: ${asset}`);
51
51
 
52
- const result = (await this.rpc("eth_call", [{ to: feedAddress, data: LATEST_ROUND_DATA }, "latest"])) as string;
52
+ const result = (await this.rpc("eth_call", [{ to: resolvedFeed, data: LATEST_ROUND_DATA }, "latest"])) as string;
53
53
 
54
54
  // ABI decode latestRoundData() return:
55
55
  // [0] roundId (uint80) — skip
@@ -74,12 +74,13 @@ export class ChainlinkOracle implements IPriceOracle {
74
74
  );
75
75
  }
76
76
 
77
- // Chainlink USD feeds: 8 decimals. answer / 10^6 = cents (integer).
78
- const priceCents = Number(answer / 1_000_000n);
79
- if (priceCents <= 0) {
80
- throw new Error(`Invalid price for ${asset}: ${priceCents} cents`);
77
+ // Chainlink USD feeds: 8 decimals. answer / 100 = microdollars (10^-6 USD).
78
+ // e.g. BTC at $70,315 → answer = 7_031_500_000_000 → 70_315_000_000 microdollars
79
+ const priceMicros = Number(answer / 100n);
80
+ if (priceMicros <= 0) {
81
+ throw new Error(`Invalid price for ${asset}: ${priceMicros} microdollars`);
81
82
  }
82
83
 
83
- return { priceCents, updatedAt };
84
+ return { priceMicros, updatedAt };
84
85
  }
85
86
  }
@@ -0,0 +1,92 @@
1
+ import type { IPriceOracle, PriceAsset, PriceResult } from "./types.js";
2
+ import { AssetNotSupportedError } from "./types.js";
3
+
4
+ /**
5
+ * Token symbol → CoinGecko API ID mapping.
6
+ * CoinGecko uses lowercase slugs, not ticker symbols.
7
+ */
8
+ const COINGECKO_IDS: Record<string, string> = {
9
+ BTC: "bitcoin",
10
+ ETH: "ethereum",
11
+ DOGE: "dogecoin",
12
+ LTC: "litecoin",
13
+ SOL: "solana",
14
+ LINK: "chainlink",
15
+ UNI: "uniswap",
16
+ AERO: "aerodrome-finance",
17
+ };
18
+
19
+ /** Default cache TTL: 60 seconds. CoinGecko free tier allows 10-30 req/min. */
20
+ const DEFAULT_CACHE_TTL_MS = 60_000;
21
+
22
+ interface CachedPrice {
23
+ priceMicros: number;
24
+ updatedAt: Date;
25
+ fetchedAt: number;
26
+ }
27
+
28
+ export interface CoinGeckoOracleOpts {
29
+ /** Override token→id mapping. */
30
+ tokenIds?: Record<string, string>;
31
+ /** Cache TTL in ms. Default: 60s. */
32
+ cacheTtlMs?: number;
33
+ /** Custom fetch function (for testing). */
34
+ fetchFn?: typeof fetch;
35
+ }
36
+
37
+ /**
38
+ * CoinGecko price oracle — free API, no key required.
39
+ * Used for assets without Chainlink on-chain feeds (DOGE, LTC).
40
+ * Caches prices to stay within rate limits.
41
+ */
42
+ export class CoinGeckoOracle implements IPriceOracle {
43
+ private readonly ids: Record<string, string>;
44
+ private readonly cacheTtlMs: number;
45
+ private readonly fetchFn: typeof fetch;
46
+ private readonly cache = new Map<string, CachedPrice>();
47
+
48
+ constructor(opts: CoinGeckoOracleOpts = {}) {
49
+ this.ids = { ...COINGECKO_IDS, ...opts.tokenIds };
50
+ this.cacheTtlMs = opts.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
51
+ this.fetchFn = opts.fetchFn ?? fetch;
52
+ }
53
+
54
+ async getPrice(asset: PriceAsset, _feedAddress?: `0x${string}`): Promise<PriceResult> {
55
+ const cached = this.cache.get(asset);
56
+ if (cached && Date.now() - cached.fetchedAt < this.cacheTtlMs) {
57
+ return { priceMicros: cached.priceMicros, updatedAt: cached.updatedAt };
58
+ }
59
+
60
+ const coinId = this.ids[asset];
61
+ if (!coinId) throw new AssetNotSupportedError(asset);
62
+
63
+ const url = `https://api.coingecko.com/api/v3/simple/price?ids=${coinId}&vs_currencies=usd`;
64
+ try {
65
+ const res = await this.fetchFn(url);
66
+ if (!res.ok) {
67
+ throw new Error(`CoinGecko API error for ${asset}: ${res.status} ${res.statusText}`);
68
+ }
69
+
70
+ const data = (await res.json()) as Record<string, { usd?: number }>;
71
+ const usdPrice = data[coinId]?.usd;
72
+ if (usdPrice === undefined || usdPrice <= 0) {
73
+ throw new Error(`Invalid CoinGecko price for ${asset}: ${usdPrice}`);
74
+ }
75
+
76
+ const priceMicros = Math.round(usdPrice * 1_000_000);
77
+ const updatedAt = new Date();
78
+
79
+ this.cache.set(asset, { priceMicros, updatedAt, fetchedAt: Date.now() });
80
+
81
+ return { priceMicros, updatedAt };
82
+ } catch (err) {
83
+ // Serve stale cache on transient failure (rate limit, network error).
84
+ // A slightly old price is better than rejecting the charge entirely.
85
+ const stale = this.cache.get(asset);
86
+ if (stale) {
87
+ return { priceMicros: stale.priceMicros, updatedAt: stale.updatedAt };
88
+ }
89
+ throw err;
90
+ }
91
+ }
92
+ }