@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.
- package/dist/billing/crypto/__tests__/key-server.test.js +1 -1
- package/dist/billing/crypto/btc/__tests__/watcher.test.js +1 -1
- package/dist/billing/crypto/btc/watcher.js +4 -2
- package/dist/billing/crypto/client.d.ts +1 -1
- package/dist/billing/crypto/evm/__tests__/eth-checkout.test.js +3 -3
- package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +2 -2
- package/dist/billing/crypto/evm/eth-checkout.d.ts +2 -2
- package/dist/billing/crypto/evm/eth-checkout.js +3 -3
- package/dist/billing/crypto/evm/eth-watcher.js +2 -2
- package/dist/billing/crypto/key-server-entry.js +7 -2
- package/dist/billing/crypto/key-server.js +18 -7
- package/dist/billing/crypto/oracle/__tests__/chainlink.test.js +4 -4
- package/dist/billing/crypto/oracle/__tests__/coingecko.test.d.ts +1 -0
- package/dist/billing/crypto/oracle/__tests__/coingecko.test.js +65 -0
- package/dist/billing/crypto/oracle/__tests__/composite.test.d.ts +1 -0
- package/dist/billing/crypto/oracle/__tests__/composite.test.js +48 -0
- package/dist/billing/crypto/oracle/__tests__/convert.test.js +27 -17
- package/dist/billing/crypto/oracle/__tests__/fixed.test.js +5 -5
- package/dist/billing/crypto/oracle/chainlink.d.ts +2 -2
- package/dist/billing/crypto/oracle/chainlink.js +11 -10
- package/dist/billing/crypto/oracle/coingecko.d.ts +22 -0
- package/dist/billing/crypto/oracle/coingecko.js +67 -0
- package/dist/billing/crypto/oracle/composite.d.ts +14 -0
- package/dist/billing/crypto/oracle/composite.js +34 -0
- package/dist/billing/crypto/oracle/convert.d.ts +17 -7
- package/dist/billing/crypto/oracle/convert.js +26 -13
- package/dist/billing/crypto/oracle/fixed.d.ts +2 -2
- package/dist/billing/crypto/oracle/fixed.js +9 -7
- package/dist/billing/crypto/oracle/index.d.ts +4 -0
- package/dist/billing/crypto/oracle/index.js +3 -0
- package/dist/billing/crypto/oracle/types.d.ts +12 -3
- package/dist/billing/crypto/oracle/types.js +7 -1
- package/dist/billing/crypto/unified-checkout.d.ts +2 -2
- package/dist/billing/crypto/unified-checkout.js +1 -1
- package/drizzle/migrations/0017_fix_derivation_index_constraint.sql +7 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/billing/crypto/__tests__/key-server.test.ts +1 -1
- package/src/billing/crypto/btc/__tests__/watcher.test.ts +1 -1
- package/src/billing/crypto/btc/watcher.ts +4 -2
- package/src/billing/crypto/client.ts +1 -1
- package/src/billing/crypto/evm/__tests__/eth-checkout.test.ts +3 -3
- package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +2 -2
- package/src/billing/crypto/evm/eth-checkout.ts +5 -5
- package/src/billing/crypto/evm/eth-watcher.ts +2 -2
- package/src/billing/crypto/key-server-entry.ts +7 -2
- package/src/billing/crypto/key-server.ts +17 -7
- package/src/billing/crypto/oracle/__tests__/chainlink.test.ts +4 -4
- package/src/billing/crypto/oracle/__tests__/coingecko.test.ts +75 -0
- package/src/billing/crypto/oracle/__tests__/composite.test.ts +61 -0
- package/src/billing/crypto/oracle/__tests__/convert.test.ts +29 -17
- package/src/billing/crypto/oracle/__tests__/fixed.test.ts +5 -5
- package/src/billing/crypto/oracle/chainlink.ts +11 -10
- package/src/billing/crypto/oracle/coingecko.ts +92 -0
- package/src/billing/crypto/oracle/composite.ts +35 -0
- package/src/billing/crypto/oracle/convert.ts +28 -13
- package/src/billing/crypto/oracle/fixed.ts +9 -7
- package/src/billing/crypto/oracle/index.ts +4 -0
- package/src/billing/crypto/oracle/types.ts +16 -3
- 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
|
-
*
|
|
2
|
+
* Price units: **microdollars** (1 microdollar = $0.000001 = 10^-6 USD).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
|
8
|
-
* $50 in
|
|
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,
|
|
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
|
|
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,
|
|
30
|
+
export declare function nativeToCents(rawAmount: bigint, priceMicros: number, decimals: number): number;
|
|
@@ -1,38 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Price units: **microdollars** (1 microdollar = $0.000001 = 10^-6 USD).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
|
8
|
-
* $50 in
|
|
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,
|
|
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(
|
|
17
|
-
throw new Error(`
|
|
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
|
-
|
|
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
|
|
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,
|
|
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(
|
|
35
|
-
throw new Error(`
|
|
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(
|
|
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:
|
|
10
|
-
BTC:
|
|
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
|
|
16
|
-
if (
|
|
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 {
|
|
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
|
-
/**
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
15
|
-
|
|
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().
|
|
@@ -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
|
@@ -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({
|
|
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({
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
@@ -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({
|
|
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.
|
|
23
|
+
expect(result.priceMicros).toBe(3_500_000_000);
|
|
24
24
|
expect(result.chain).toBe("base");
|
|
25
|
-
// $50 = 5000 cents
|
|
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({
|
|
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
|
|
28
|
-
|
|
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 {
|
|
49
|
-
const expectedWei = centsToNative(amountUsdCents,
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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
|
|
52
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
expectedAmount = (
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
});
|