@wopr-network/platform-core 1.20.0 → 1.21.0
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/index.d.ts +1 -0
- package/dist/billing/crypto/index.js +1 -0
- package/dist/billing/crypto/oracle/__tests__/chainlink.test.d.ts +1 -0
- package/dist/billing/crypto/oracle/__tests__/chainlink.test.js +83 -0
- package/dist/billing/crypto/oracle/__tests__/convert.test.d.ts +1 -0
- package/dist/billing/crypto/oracle/__tests__/convert.test.js +51 -0
- package/dist/billing/crypto/oracle/__tests__/fixed.test.d.ts +1 -0
- package/dist/billing/crypto/oracle/__tests__/fixed.test.js +20 -0
- package/dist/billing/crypto/oracle/chainlink.d.ts +26 -0
- package/dist/billing/crypto/oracle/chainlink.js +61 -0
- package/dist/billing/crypto/oracle/convert.d.ts +20 -0
- package/dist/billing/crypto/oracle/convert.js +38 -0
- package/dist/billing/crypto/oracle/fixed.d.ts +10 -0
- package/dist/billing/crypto/oracle/fixed.js +20 -0
- package/dist/billing/crypto/oracle/index.d.ts +5 -0
- package/dist/billing/crypto/oracle/index.js +3 -0
- package/dist/billing/crypto/oracle/types.d.ts +13 -0
- package/dist/billing/crypto/oracle/types.js +1 -0
- package/package.json +1 -1
- package/src/billing/crypto/index.ts +1 -0
- package/src/billing/crypto/oracle/__tests__/chainlink.test.ts +107 -0
- package/src/billing/crypto/oracle/__tests__/convert.test.ts +62 -0
- package/src/billing/crypto/oracle/__tests__/fixed.test.ts +23 -0
- package/src/billing/crypto/oracle/chainlink.ts +85 -0
- package/src/billing/crypto/oracle/convert.ts +38 -0
- package/src/billing/crypto/oracle/fixed.ts +23 -0
- package/src/billing/crypto/oracle/index.ts +5 -0
- package/src/billing/crypto/oracle/types.ts +15 -0
|
@@ -5,6 +5,7 @@ export { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
|
|
|
5
5
|
export type { CryptoConfig } from "./client.js";
|
|
6
6
|
export { BTCPayClient, loadCryptoConfig } from "./client.js";
|
|
7
7
|
export * from "./evm/index.js";
|
|
8
|
+
export * from "./oracle/index.js";
|
|
8
9
|
export type { CryptoBillingConfig, CryptoCheckoutOpts, CryptoPaymentState, CryptoWebhookPayload, CryptoWebhookResult, } from "./types.js";
|
|
9
10
|
export { mapBtcPayEventToStatus } from "./types.js";
|
|
10
11
|
export type { CryptoWebhookDeps } from "./webhook.js";
|
|
@@ -3,5 +3,6 @@ export { CryptoChargeRepository, DrizzleCryptoChargeRepository } from "./charge-
|
|
|
3
3
|
export { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
|
|
4
4
|
export { BTCPayClient, loadCryptoConfig } from "./client.js";
|
|
5
5
|
export * from "./evm/index.js";
|
|
6
|
+
export * from "./oracle/index.js";
|
|
6
7
|
export { mapBtcPayEventToStatus } from "./types.js";
|
|
7
8
|
export { handleCryptoWebhook, verifyCryptoWebhookSignature } from "./webhook.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { ChainlinkOracle } from "../chainlink.js";
|
|
3
|
+
/**
|
|
4
|
+
* Encode a mock latestRoundData() response.
|
|
5
|
+
* Chainlink returns 5 × 32-byte ABI-encoded words:
|
|
6
|
+
* roundId, answer, startedAt, updatedAt, answeredInRound
|
|
7
|
+
*/
|
|
8
|
+
function encodeRoundData(answer, updatedAtSec) {
|
|
9
|
+
const pad = (v) => v.toString(16).padStart(64, "0");
|
|
10
|
+
return ("0x" +
|
|
11
|
+
pad(1n) + // roundId
|
|
12
|
+
pad(answer) + // answer (price × 10^8)
|
|
13
|
+
pad(BigInt(updatedAtSec)) + // startedAt
|
|
14
|
+
pad(BigInt(updatedAtSec)) + // updatedAt
|
|
15
|
+
pad(1n) // answeredInRound
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
describe("ChainlinkOracle", () => {
|
|
19
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
20
|
+
it("decodes ETH/USD price from latestRoundData", async () => {
|
|
21
|
+
// ETH at $3,500.00 → answer = 3500 × 10^8 = 350_000_000_000
|
|
22
|
+
const rpc = vi.fn().mockResolvedValue(encodeRoundData(350000000000n, nowSec));
|
|
23
|
+
const oracle = new ChainlinkOracle({ rpcCall: rpc });
|
|
24
|
+
const result = await oracle.getPrice("ETH");
|
|
25
|
+
expect(result.priceCents).toBe(350_000); // $3,500.00
|
|
26
|
+
expect(result.updatedAt).toBeInstanceOf(Date);
|
|
27
|
+
expect(rpc).toHaveBeenCalledWith("eth_call", [
|
|
28
|
+
{ to: "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70", data: "0xfeaf968c" },
|
|
29
|
+
"latest",
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
it("decodes BTC/USD price from latestRoundData", async () => {
|
|
33
|
+
// BTC at $65,000.00 → answer = 65000 × 10^8 = 6_500_000_000_000
|
|
34
|
+
const rpc = vi.fn().mockResolvedValue(encodeRoundData(6500000000000n, nowSec));
|
|
35
|
+
const oracle = new ChainlinkOracle({ rpcCall: rpc });
|
|
36
|
+
const result = await oracle.getPrice("BTC");
|
|
37
|
+
expect(result.priceCents).toBe(6_500_000); // $65,000.00
|
|
38
|
+
});
|
|
39
|
+
it("handles fractional dollar prices correctly", async () => {
|
|
40
|
+
// ETH at $3,456.78 → answer = 345_678_000_000
|
|
41
|
+
const rpc = vi.fn().mockResolvedValue(encodeRoundData(345678000000n, nowSec));
|
|
42
|
+
const oracle = new ChainlinkOracle({ rpcCall: rpc });
|
|
43
|
+
const result = await oracle.getPrice("ETH");
|
|
44
|
+
expect(result.priceCents).toBe(345_678); // $3,456.78
|
|
45
|
+
});
|
|
46
|
+
it("rejects stale prices", async () => {
|
|
47
|
+
const staleTime = nowSec - 7200; // 2 hours ago
|
|
48
|
+
const rpc = vi.fn().mockResolvedValue(encodeRoundData(350000000000n, staleTime));
|
|
49
|
+
const oracle = new ChainlinkOracle({ rpcCall: rpc, maxStalenessMs: 3600_000 });
|
|
50
|
+
await expect(oracle.getPrice("ETH")).rejects.toThrow("stale");
|
|
51
|
+
});
|
|
52
|
+
it("rejects zero price", async () => {
|
|
53
|
+
const rpc = vi.fn().mockResolvedValue(encodeRoundData(0n, nowSec));
|
|
54
|
+
const oracle = new ChainlinkOracle({ rpcCall: rpc });
|
|
55
|
+
await expect(oracle.getPrice("ETH")).rejects.toThrow("Invalid price");
|
|
56
|
+
});
|
|
57
|
+
it("rejects malformed response", async () => {
|
|
58
|
+
const rpc = vi.fn().mockResolvedValue("0xdead");
|
|
59
|
+
const oracle = new ChainlinkOracle({ rpcCall: rpc });
|
|
60
|
+
await expect(oracle.getPrice("ETH")).rejects.toThrow("Malformed");
|
|
61
|
+
});
|
|
62
|
+
it("accepts custom feed addresses", async () => {
|
|
63
|
+
const customFeed = "0x1234567890abcdef1234567890abcdef12345678";
|
|
64
|
+
const rpc = vi.fn().mockResolvedValue(encodeRoundData(350000000000n, nowSec));
|
|
65
|
+
const oracle = new ChainlinkOracle({
|
|
66
|
+
rpcCall: rpc,
|
|
67
|
+
feedAddresses: { ETH: customFeed },
|
|
68
|
+
});
|
|
69
|
+
await oracle.getPrice("ETH");
|
|
70
|
+
expect(rpc).toHaveBeenCalledWith("eth_call", [{ to: customFeed, data: "0xfeaf968c" }, "latest"]);
|
|
71
|
+
});
|
|
72
|
+
it("respects custom staleness threshold", async () => {
|
|
73
|
+
const thirtyMinAgo = nowSec - 1800;
|
|
74
|
+
const rpc = vi.fn().mockResolvedValue(encodeRoundData(350000000000n, thirtyMinAgo));
|
|
75
|
+
// 20-minute threshold → stale
|
|
76
|
+
const strict = new ChainlinkOracle({ rpcCall: rpc, maxStalenessMs: 20 * 60 * 1000 });
|
|
77
|
+
await expect(strict.getPrice("ETH")).rejects.toThrow("stale");
|
|
78
|
+
// 60-minute threshold → fresh
|
|
79
|
+
const relaxed = new ChainlinkOracle({ rpcCall: rpc, maxStalenessMs: 60 * 60 * 1000 });
|
|
80
|
+
const result = await relaxed.getPrice("ETH");
|
|
81
|
+
expect(result.priceCents).toBe(350_000);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { centsToNative, nativeToCents } from "../convert.js";
|
|
3
|
+
describe("centsToNative", () => {
|
|
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);
|
|
7
|
+
expect(wei).toBe(14285714285714285n);
|
|
8
|
+
});
|
|
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);
|
|
12
|
+
expect(sats).toBe(76923n);
|
|
13
|
+
});
|
|
14
|
+
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);
|
|
17
|
+
expect(wei).toBe(50000000000000000n);
|
|
18
|
+
});
|
|
19
|
+
it("rejects non-integer amountCents", () => {
|
|
20
|
+
expect(() => centsToNative(50.5, 350_000, 18)).toThrow("positive integer");
|
|
21
|
+
});
|
|
22
|
+
it("rejects zero amountCents", () => {
|
|
23
|
+
expect(() => centsToNative(0, 350_000, 18)).toThrow("positive integer");
|
|
24
|
+
});
|
|
25
|
+
it("rejects zero priceCents", () => {
|
|
26
|
+
expect(() => centsToNative(5000, 0, 18)).toThrow("positive integer");
|
|
27
|
+
});
|
|
28
|
+
it("rejects negative decimals", () => {
|
|
29
|
+
expect(() => centsToNative(5000, 350_000, -1)).toThrow("non-negative integer");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe("nativeToCents", () => {
|
|
33
|
+
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
|
|
37
|
+
});
|
|
38
|
+
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);
|
|
41
|
+
expect(cents).toBe(4999);
|
|
42
|
+
});
|
|
43
|
+
it("exact round-trip for clean division", () => {
|
|
44
|
+
// 0.05 ETH at $2,000 = $100
|
|
45
|
+
const cents = nativeToCents(50000000000000000n, 200_000, 18);
|
|
46
|
+
expect(cents).toBe(10_000); // $100.00
|
|
47
|
+
});
|
|
48
|
+
it("rejects negative rawAmount", () => {
|
|
49
|
+
expect(() => nativeToCents(-1n, 350_000, 18)).toThrow("non-negative");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { FixedPriceOracle } from "../fixed.js";
|
|
3
|
+
describe("FixedPriceOracle", () => {
|
|
4
|
+
it("returns default ETH price", async () => {
|
|
5
|
+
const oracle = new FixedPriceOracle();
|
|
6
|
+
const result = await oracle.getPrice("ETH");
|
|
7
|
+
expect(result.priceCents).toBe(350_000); // $3,500
|
|
8
|
+
expect(result.updatedAt).toBeInstanceOf(Date);
|
|
9
|
+
});
|
|
10
|
+
it("returns default BTC price", async () => {
|
|
11
|
+
const oracle = new FixedPriceOracle();
|
|
12
|
+
const result = await oracle.getPrice("BTC");
|
|
13
|
+
expect(result.priceCents).toBe(6_500_000); // $65,000
|
|
14
|
+
});
|
|
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);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { IPriceOracle, PriceAsset, PriceResult } from "./types.js";
|
|
2
|
+
type RpcCall = (method: string, params: unknown[]) => Promise<unknown>;
|
|
3
|
+
export interface ChainlinkOracleOpts {
|
|
4
|
+
rpcCall: RpcCall;
|
|
5
|
+
/** Override feed addresses (e.g. for testnet or Anvil forks). */
|
|
6
|
+
feedAddresses?: Partial<Record<PriceAsset, `0x${string}`>>;
|
|
7
|
+
/** Maximum age of price data before rejecting (ms). Default: 1 hour. */
|
|
8
|
+
maxStalenessMs?: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* On-chain Chainlink price oracle.
|
|
12
|
+
*
|
|
13
|
+
* Reads latestRoundData() from Chainlink aggregator contracts via eth_call.
|
|
14
|
+
* No API key, no rate limits — just an RPC call to our own node.
|
|
15
|
+
*
|
|
16
|
+
* Chainlink USD feeds use 8 decimals. We convert to integer USD cents:
|
|
17
|
+
* priceCents = answer / 10^6 (i.e. answer / 10^8 * 100)
|
|
18
|
+
*/
|
|
19
|
+
export declare class ChainlinkOracle implements IPriceOracle {
|
|
20
|
+
private readonly rpc;
|
|
21
|
+
private readonly feeds;
|
|
22
|
+
private readonly maxStalenessMs;
|
|
23
|
+
constructor(opts: ChainlinkOracleOpts);
|
|
24
|
+
getPrice(asset: PriceAsset): Promise<PriceResult>;
|
|
25
|
+
}
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chainlink price feed addresses on Base mainnet.
|
|
3
|
+
* These are ERC-1967 proxy contracts — addresses are stable.
|
|
4
|
+
*/
|
|
5
|
+
const FEED_ADDRESSES = {
|
|
6
|
+
ETH: "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70",
|
|
7
|
+
BTC: "0x64c911996D3c6aC71f9b455B1E8E7266BcbD848F",
|
|
8
|
+
};
|
|
9
|
+
/** Function selector for latestRoundData(). */
|
|
10
|
+
const LATEST_ROUND_DATA = "0xfeaf968c";
|
|
11
|
+
/** Default max staleness: 1 hour. */
|
|
12
|
+
const DEFAULT_MAX_STALENESS_MS = 60 * 60 * 1000;
|
|
13
|
+
/**
|
|
14
|
+
* On-chain Chainlink price oracle.
|
|
15
|
+
*
|
|
16
|
+
* Reads latestRoundData() from Chainlink aggregator contracts via eth_call.
|
|
17
|
+
* No API key, no rate limits — just an RPC call to our own node.
|
|
18
|
+
*
|
|
19
|
+
* Chainlink USD feeds use 8 decimals. We convert to integer USD cents:
|
|
20
|
+
* priceCents = answer / 10^6 (i.e. answer / 10^8 * 100)
|
|
21
|
+
*/
|
|
22
|
+
export class ChainlinkOracle {
|
|
23
|
+
rpc;
|
|
24
|
+
feeds;
|
|
25
|
+
maxStalenessMs;
|
|
26
|
+
constructor(opts) {
|
|
27
|
+
this.rpc = opts.rpcCall;
|
|
28
|
+
this.feeds = { ...FEED_ADDRESSES, ...opts.feedAddresses };
|
|
29
|
+
this.maxStalenessMs = opts.maxStalenessMs ?? DEFAULT_MAX_STALENESS_MS;
|
|
30
|
+
}
|
|
31
|
+
async getPrice(asset) {
|
|
32
|
+
const feedAddress = this.feeds[asset];
|
|
33
|
+
if (!feedAddress)
|
|
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"]));
|
|
36
|
+
// ABI decode latestRoundData() return:
|
|
37
|
+
// [0] roundId (uint80) — skip
|
|
38
|
+
// [1] answer (int256) — price × 10^8
|
|
39
|
+
// [2] startedAt (uint256) — skip
|
|
40
|
+
// [3] updatedAt (uint256) — unix seconds
|
|
41
|
+
// [4] answeredInRound (uint80) — skip
|
|
42
|
+
const hex = result.slice(2);
|
|
43
|
+
if (hex.length < 320) {
|
|
44
|
+
throw new Error(`Malformed Chainlink response for ${asset}: expected 320 hex chars, got ${hex.length}`);
|
|
45
|
+
}
|
|
46
|
+
const answer = BigInt(`0x${hex.slice(64, 128)}`);
|
|
47
|
+
const updatedAtSec = Number(BigInt(`0x${hex.slice(192, 256)}`));
|
|
48
|
+
const updatedAt = new Date(updatedAtSec * 1000);
|
|
49
|
+
// Staleness guard.
|
|
50
|
+
const ageMs = Date.now() - updatedAt.getTime();
|
|
51
|
+
if (ageMs > this.maxStalenessMs) {
|
|
52
|
+
throw new Error(`Price feed for ${asset} is stale (${Math.round(ageMs / 1000)}s old, max ${Math.round(this.maxStalenessMs / 1000)}s)`);
|
|
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`);
|
|
58
|
+
}
|
|
59
|
+
return { priceCents, updatedAt };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert USD cents to native token amount using a price in cents.
|
|
3
|
+
*
|
|
4
|
+
* Formula: rawAmount = amountCents × 10^decimals / priceCents
|
|
5
|
+
*
|
|
6
|
+
* Examples:
|
|
7
|
+
* $50 in ETH at $3,500: centsToNative(5000, 350000, 18) = 14285714285714285n (≈0.01429 ETH)
|
|
8
|
+
* $50 in BTC at $65,000: centsToNative(5000, 6500000, 8) = 76923n (76,923 sats ≈ 0.00077 BTC)
|
|
9
|
+
*
|
|
10
|
+
* Integer math only. No floating point.
|
|
11
|
+
*/
|
|
12
|
+
export declare function centsToNative(amountCents: number, priceCents: number, decimals: number): bigint;
|
|
13
|
+
/**
|
|
14
|
+
* Convert native token amount back to USD cents using a price in cents.
|
|
15
|
+
*
|
|
16
|
+
* Inverse of centsToNative. Truncates fractional cents.
|
|
17
|
+
*
|
|
18
|
+
* Integer math only.
|
|
19
|
+
*/
|
|
20
|
+
export declare function nativeToCents(rawAmount: bigint, priceCents: number, decimals: number): number;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert USD cents to native token amount using a price in cents.
|
|
3
|
+
*
|
|
4
|
+
* Formula: rawAmount = amountCents × 10^decimals / priceCents
|
|
5
|
+
*
|
|
6
|
+
* Examples:
|
|
7
|
+
* $50 in ETH at $3,500: centsToNative(5000, 350000, 18) = 14285714285714285n (≈0.01429 ETH)
|
|
8
|
+
* $50 in BTC at $65,000: centsToNative(5000, 6500000, 8) = 76923n (76,923 sats ≈ 0.00077 BTC)
|
|
9
|
+
*
|
|
10
|
+
* Integer math only. No floating point.
|
|
11
|
+
*/
|
|
12
|
+
export function centsToNative(amountCents, priceCents, decimals) {
|
|
13
|
+
if (!Number.isInteger(amountCents) || amountCents <= 0) {
|
|
14
|
+
throw new Error(`amountCents must be a positive integer, got ${amountCents}`);
|
|
15
|
+
}
|
|
16
|
+
if (!Number.isInteger(priceCents) || priceCents <= 0) {
|
|
17
|
+
throw new Error(`priceCents must be a positive integer, got ${priceCents}`);
|
|
18
|
+
}
|
|
19
|
+
if (!Number.isInteger(decimals) || decimals < 0) {
|
|
20
|
+
throw new Error(`decimals must be a non-negative integer, got ${decimals}`);
|
|
21
|
+
}
|
|
22
|
+
return (BigInt(amountCents) * 10n ** BigInt(decimals)) / BigInt(priceCents);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Convert native token amount back to USD cents using a price in cents.
|
|
26
|
+
*
|
|
27
|
+
* Inverse of centsToNative. Truncates fractional cents.
|
|
28
|
+
*
|
|
29
|
+
* Integer math only.
|
|
30
|
+
*/
|
|
31
|
+
export function nativeToCents(rawAmount, priceCents, decimals) {
|
|
32
|
+
if (rawAmount < 0n)
|
|
33
|
+
throw new Error("rawAmount must be non-negative");
|
|
34
|
+
if (!Number.isInteger(priceCents) || priceCents <= 0) {
|
|
35
|
+
throw new Error(`priceCents must be a positive integer, got ${priceCents}`);
|
|
36
|
+
}
|
|
37
|
+
return Number((rawAmount * BigInt(priceCents)) / 10n ** BigInt(decimals));
|
|
38
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { IPriceOracle, PriceAsset, PriceResult } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Fixed-price oracle for testing and local dev (Anvil, regtest).
|
|
4
|
+
* Returns hardcoded prices — no RPC calls.
|
|
5
|
+
*/
|
|
6
|
+
export declare class FixedPriceOracle implements IPriceOracle {
|
|
7
|
+
private readonly prices;
|
|
8
|
+
constructor(prices?: Partial<Record<PriceAsset, number>>);
|
|
9
|
+
getPrice(asset: PriceAsset): Promise<PriceResult>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed-price oracle for testing and local dev (Anvil, regtest).
|
|
3
|
+
* Returns hardcoded prices — no RPC calls.
|
|
4
|
+
*/
|
|
5
|
+
export class FixedPriceOracle {
|
|
6
|
+
prices;
|
|
7
|
+
constructor(prices = {}) {
|
|
8
|
+
this.prices = {
|
|
9
|
+
ETH: 350_000, // $3,500
|
|
10
|
+
BTC: 6_500_000, // $65,000
|
|
11
|
+
...prices,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
async getPrice(asset) {
|
|
15
|
+
const priceCents = this.prices[asset];
|
|
16
|
+
if (priceCents === undefined)
|
|
17
|
+
throw new Error(`No fixed price for ${asset}`);
|
|
18
|
+
return { priceCents, updatedAt: new Date() };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type { ChainlinkOracleOpts } from "./chainlink.js";
|
|
2
|
+
export { ChainlinkOracle } from "./chainlink.js";
|
|
3
|
+
export { centsToNative, nativeToCents } from "./convert.js";
|
|
4
|
+
export { FixedPriceOracle } from "./fixed.js";
|
|
5
|
+
export type { IPriceOracle, PriceAsset, PriceResult } from "./types.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Assets with Chainlink price feeds. */
|
|
2
|
+
export type PriceAsset = "ETH" | "BTC";
|
|
3
|
+
/** Result from a price oracle query. */
|
|
4
|
+
export interface PriceResult {
|
|
5
|
+
/** USD cents per 1 unit of asset (integer). */
|
|
6
|
+
priceCents: number;
|
|
7
|
+
/** When the price was last updated on-chain. */
|
|
8
|
+
updatedAt: Date;
|
|
9
|
+
}
|
|
10
|
+
/** Read-only price oracle. */
|
|
11
|
+
export interface IPriceOracle {
|
|
12
|
+
getPrice(asset: PriceAsset): Promise<PriceResult>;
|
|
13
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ export { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
|
|
|
5
5
|
export type { CryptoConfig } from "./client.js";
|
|
6
6
|
export { BTCPayClient, loadCryptoConfig } from "./client.js";
|
|
7
7
|
export * from "./evm/index.js";
|
|
8
|
+
export * from "./oracle/index.js";
|
|
8
9
|
export type {
|
|
9
10
|
CryptoBillingConfig,
|
|
10
11
|
CryptoCheckoutOpts,
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { ChainlinkOracle } from "../chainlink.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Encode a mock latestRoundData() response.
|
|
6
|
+
* Chainlink returns 5 × 32-byte ABI-encoded words:
|
|
7
|
+
* roundId, answer, startedAt, updatedAt, answeredInRound
|
|
8
|
+
*/
|
|
9
|
+
function encodeRoundData(answer: bigint, updatedAtSec: number): string {
|
|
10
|
+
const pad = (v: bigint) => v.toString(16).padStart(64, "0");
|
|
11
|
+
return (
|
|
12
|
+
"0x" +
|
|
13
|
+
pad(1n) + // roundId
|
|
14
|
+
pad(answer) + // answer (price × 10^8)
|
|
15
|
+
pad(BigInt(updatedAtSec)) + // startedAt
|
|
16
|
+
pad(BigInt(updatedAtSec)) + // updatedAt
|
|
17
|
+
pad(1n) // answeredInRound
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("ChainlinkOracle", () => {
|
|
22
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
23
|
+
|
|
24
|
+
it("decodes ETH/USD price from latestRoundData", async () => {
|
|
25
|
+
// ETH at $3,500.00 → answer = 3500 × 10^8 = 350_000_000_000
|
|
26
|
+
const rpc = vi.fn().mockResolvedValue(encodeRoundData(350_000_000_000n, nowSec));
|
|
27
|
+
const oracle = new ChainlinkOracle({ rpcCall: rpc });
|
|
28
|
+
|
|
29
|
+
const result = await oracle.getPrice("ETH");
|
|
30
|
+
|
|
31
|
+
expect(result.priceCents).toBe(350_000); // $3,500.00
|
|
32
|
+
expect(result.updatedAt).toBeInstanceOf(Date);
|
|
33
|
+
expect(rpc).toHaveBeenCalledWith("eth_call", [
|
|
34
|
+
{ to: "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70", data: "0xfeaf968c" },
|
|
35
|
+
"latest",
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("decodes BTC/USD price from latestRoundData", async () => {
|
|
40
|
+
// BTC at $65,000.00 → answer = 65000 × 10^8 = 6_500_000_000_000
|
|
41
|
+
const rpc = vi.fn().mockResolvedValue(encodeRoundData(6_500_000_000_000n, nowSec));
|
|
42
|
+
const oracle = new ChainlinkOracle({ rpcCall: rpc });
|
|
43
|
+
|
|
44
|
+
const result = await oracle.getPrice("BTC");
|
|
45
|
+
|
|
46
|
+
expect(result.priceCents).toBe(6_500_000); // $65,000.00
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("handles fractional dollar prices correctly", async () => {
|
|
50
|
+
// ETH at $3,456.78 → answer = 345_678_000_000
|
|
51
|
+
const rpc = vi.fn().mockResolvedValue(encodeRoundData(345_678_000_000n, nowSec));
|
|
52
|
+
const oracle = new ChainlinkOracle({ rpcCall: rpc });
|
|
53
|
+
|
|
54
|
+
const result = await oracle.getPrice("ETH");
|
|
55
|
+
|
|
56
|
+
expect(result.priceCents).toBe(345_678); // $3,456.78
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("rejects stale prices", async () => {
|
|
60
|
+
const staleTime = nowSec - 7200; // 2 hours ago
|
|
61
|
+
const rpc = vi.fn().mockResolvedValue(encodeRoundData(350_000_000_000n, staleTime));
|
|
62
|
+
const oracle = new ChainlinkOracle({ rpcCall: rpc, maxStalenessMs: 3600_000 });
|
|
63
|
+
|
|
64
|
+
await expect(oracle.getPrice("ETH")).rejects.toThrow("stale");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("rejects zero price", async () => {
|
|
68
|
+
const rpc = vi.fn().mockResolvedValue(encodeRoundData(0n, nowSec));
|
|
69
|
+
const oracle = new ChainlinkOracle({ rpcCall: rpc });
|
|
70
|
+
|
|
71
|
+
await expect(oracle.getPrice("ETH")).rejects.toThrow("Invalid price");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("rejects malformed response", async () => {
|
|
75
|
+
const rpc = vi.fn().mockResolvedValue("0xdead");
|
|
76
|
+
const oracle = new ChainlinkOracle({ rpcCall: rpc });
|
|
77
|
+
|
|
78
|
+
await expect(oracle.getPrice("ETH")).rejects.toThrow("Malformed");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("accepts custom feed addresses", async () => {
|
|
82
|
+
const customFeed = "0x1234567890abcdef1234567890abcdef12345678" as `0x${string}`;
|
|
83
|
+
const rpc = vi.fn().mockResolvedValue(encodeRoundData(350_000_000_000n, nowSec));
|
|
84
|
+
const oracle = new ChainlinkOracle({
|
|
85
|
+
rpcCall: rpc,
|
|
86
|
+
feedAddresses: { ETH: customFeed },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await oracle.getPrice("ETH");
|
|
90
|
+
|
|
91
|
+
expect(rpc).toHaveBeenCalledWith("eth_call", [{ to: customFeed, data: "0xfeaf968c" }, "latest"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("respects custom staleness threshold", async () => {
|
|
95
|
+
const thirtyMinAgo = nowSec - 1800;
|
|
96
|
+
const rpc = vi.fn().mockResolvedValue(encodeRoundData(350_000_000_000n, thirtyMinAgo));
|
|
97
|
+
|
|
98
|
+
// 20-minute threshold → stale
|
|
99
|
+
const strict = new ChainlinkOracle({ rpcCall: rpc, maxStalenessMs: 20 * 60 * 1000 });
|
|
100
|
+
await expect(strict.getPrice("ETH")).rejects.toThrow("stale");
|
|
101
|
+
|
|
102
|
+
// 60-minute threshold → fresh
|
|
103
|
+
const relaxed = new ChainlinkOracle({ rpcCall: rpc, maxStalenessMs: 60 * 60 * 1000 });
|
|
104
|
+
const result = await relaxed.getPrice("ETH");
|
|
105
|
+
expect(result.priceCents).toBe(350_000);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { centsToNative, nativeToCents } from "../convert.js";
|
|
3
|
+
|
|
4
|
+
describe("centsToNative", () => {
|
|
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);
|
|
8
|
+
expect(wei).toBe(14_285_714_285_714_285n);
|
|
9
|
+
});
|
|
10
|
+
|
|
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);
|
|
14
|
+
expect(sats).toBe(76_923n);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
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);
|
|
20
|
+
expect(wei).toBe(50_000_000_000_000_000n);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("rejects non-integer amountCents", () => {
|
|
24
|
+
expect(() => centsToNative(50.5, 350_000, 18)).toThrow("positive integer");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("rejects zero amountCents", () => {
|
|
28
|
+
expect(() => centsToNative(0, 350_000, 18)).toThrow("positive integer");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("rejects zero priceCents", () => {
|
|
32
|
+
expect(() => centsToNative(5000, 0, 18)).toThrow("positive integer");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("rejects negative decimals", () => {
|
|
36
|
+
expect(() => centsToNative(5000, 350_000, -1)).toThrow("non-negative integer");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("nativeToCents", () => {
|
|
41
|
+
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
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
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);
|
|
50
|
+
expect(cents).toBe(4999);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("exact round-trip for clean division", () => {
|
|
54
|
+
// 0.05 ETH at $2,000 = $100
|
|
55
|
+
const cents = nativeToCents(50_000_000_000_000_000n, 200_000, 18);
|
|
56
|
+
expect(cents).toBe(10_000); // $100.00
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("rejects negative rawAmount", () => {
|
|
60
|
+
expect(() => nativeToCents(-1n, 350_000, 18)).toThrow("non-negative");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { FixedPriceOracle } from "../fixed.js";
|
|
3
|
+
|
|
4
|
+
describe("FixedPriceOracle", () => {
|
|
5
|
+
it("returns default ETH price", async () => {
|
|
6
|
+
const oracle = new FixedPriceOracle();
|
|
7
|
+
const result = await oracle.getPrice("ETH");
|
|
8
|
+
expect(result.priceCents).toBe(350_000); // $3,500
|
|
9
|
+
expect(result.updatedAt).toBeInstanceOf(Date);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("returns default BTC price", async () => {
|
|
13
|
+
const oracle = new FixedPriceOracle();
|
|
14
|
+
const result = await oracle.getPrice("BTC");
|
|
15
|
+
expect(result.priceCents).toBe(6_500_000); // $65,000
|
|
16
|
+
});
|
|
17
|
+
|
|
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);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { IPriceOracle, PriceAsset, PriceResult } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Chainlink price feed addresses on Base mainnet.
|
|
5
|
+
* These are ERC-1967 proxy contracts — addresses are stable.
|
|
6
|
+
*/
|
|
7
|
+
const FEED_ADDRESSES: Record<PriceAsset, `0x${string}`> = {
|
|
8
|
+
ETH: "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70",
|
|
9
|
+
BTC: "0x64c911996D3c6aC71f9b455B1E8E7266BcbD848F",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Function selector for latestRoundData(). */
|
|
13
|
+
const LATEST_ROUND_DATA = "0xfeaf968c";
|
|
14
|
+
|
|
15
|
+
/** Default max staleness: 1 hour. */
|
|
16
|
+
const DEFAULT_MAX_STALENESS_MS = 60 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
type RpcCall = (method: string, params: unknown[]) => Promise<unknown>;
|
|
19
|
+
|
|
20
|
+
export interface ChainlinkOracleOpts {
|
|
21
|
+
rpcCall: RpcCall;
|
|
22
|
+
/** Override feed addresses (e.g. for testnet or Anvil forks). */
|
|
23
|
+
feedAddresses?: Partial<Record<PriceAsset, `0x${string}`>>;
|
|
24
|
+
/** Maximum age of price data before rejecting (ms). Default: 1 hour. */
|
|
25
|
+
maxStalenessMs?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* On-chain Chainlink price oracle.
|
|
30
|
+
*
|
|
31
|
+
* Reads latestRoundData() from Chainlink aggregator contracts via eth_call.
|
|
32
|
+
* No API key, no rate limits — just an RPC call to our own node.
|
|
33
|
+
*
|
|
34
|
+
* Chainlink USD feeds use 8 decimals. We convert to integer USD cents:
|
|
35
|
+
* priceCents = answer / 10^6 (i.e. answer / 10^8 * 100)
|
|
36
|
+
*/
|
|
37
|
+
export class ChainlinkOracle implements IPriceOracle {
|
|
38
|
+
private readonly rpc: RpcCall;
|
|
39
|
+
private readonly feeds: Record<PriceAsset, `0x${string}`>;
|
|
40
|
+
private readonly maxStalenessMs: number;
|
|
41
|
+
|
|
42
|
+
constructor(opts: ChainlinkOracleOpts) {
|
|
43
|
+
this.rpc = opts.rpcCall;
|
|
44
|
+
this.feeds = { ...FEED_ADDRESSES, ...opts.feedAddresses };
|
|
45
|
+
this.maxStalenessMs = opts.maxStalenessMs ?? DEFAULT_MAX_STALENESS_MS;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async getPrice(asset: PriceAsset): Promise<PriceResult> {
|
|
49
|
+
const feedAddress = this.feeds[asset];
|
|
50
|
+
if (!feedAddress) throw new Error(`No price feed for asset: ${asset}`);
|
|
51
|
+
|
|
52
|
+
const result = (await this.rpc("eth_call", [{ to: feedAddress, data: LATEST_ROUND_DATA }, "latest"])) as string;
|
|
53
|
+
|
|
54
|
+
// ABI decode latestRoundData() return:
|
|
55
|
+
// [0] roundId (uint80) — skip
|
|
56
|
+
// [1] answer (int256) — price × 10^8
|
|
57
|
+
// [2] startedAt (uint256) — skip
|
|
58
|
+
// [3] updatedAt (uint256) — unix seconds
|
|
59
|
+
// [4] answeredInRound (uint80) — skip
|
|
60
|
+
const hex = result.slice(2);
|
|
61
|
+
if (hex.length < 320) {
|
|
62
|
+
throw new Error(`Malformed Chainlink response for ${asset}: expected 320 hex chars, got ${hex.length}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const answer = BigInt(`0x${hex.slice(64, 128)}`);
|
|
66
|
+
const updatedAtSec = Number(BigInt(`0x${hex.slice(192, 256)}`));
|
|
67
|
+
const updatedAt = new Date(updatedAtSec * 1000);
|
|
68
|
+
|
|
69
|
+
// Staleness guard.
|
|
70
|
+
const ageMs = Date.now() - updatedAt.getTime();
|
|
71
|
+
if (ageMs > this.maxStalenessMs) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Price feed for ${asset} is stale (${Math.round(ageMs / 1000)}s old, max ${Math.round(this.maxStalenessMs / 1000)}s)`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
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`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { priceCents, updatedAt };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert USD cents to native token amount using a price in cents.
|
|
3
|
+
*
|
|
4
|
+
* Formula: rawAmount = amountCents × 10^decimals / priceCents
|
|
5
|
+
*
|
|
6
|
+
* Examples:
|
|
7
|
+
* $50 in ETH at $3,500: centsToNative(5000, 350000, 18) = 14285714285714285n (≈0.01429 ETH)
|
|
8
|
+
* $50 in BTC at $65,000: centsToNative(5000, 6500000, 8) = 76923n (76,923 sats ≈ 0.00077 BTC)
|
|
9
|
+
*
|
|
10
|
+
* Integer math only. No floating point.
|
|
11
|
+
*/
|
|
12
|
+
export function centsToNative(amountCents: number, priceCents: number, decimals: number): bigint {
|
|
13
|
+
if (!Number.isInteger(amountCents) || amountCents <= 0) {
|
|
14
|
+
throw new Error(`amountCents must be a positive integer, got ${amountCents}`);
|
|
15
|
+
}
|
|
16
|
+
if (!Number.isInteger(priceCents) || priceCents <= 0) {
|
|
17
|
+
throw new Error(`priceCents must be a positive integer, got ${priceCents}`);
|
|
18
|
+
}
|
|
19
|
+
if (!Number.isInteger(decimals) || decimals < 0) {
|
|
20
|
+
throw new Error(`decimals must be a non-negative integer, got ${decimals}`);
|
|
21
|
+
}
|
|
22
|
+
return (BigInt(amountCents) * 10n ** BigInt(decimals)) / BigInt(priceCents);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Convert native token amount back to USD cents using a price in cents.
|
|
27
|
+
*
|
|
28
|
+
* Inverse of centsToNative. Truncates fractional cents.
|
|
29
|
+
*
|
|
30
|
+
* Integer math only.
|
|
31
|
+
*/
|
|
32
|
+
export function nativeToCents(rawAmount: bigint, priceCents: number, decimals: number): number {
|
|
33
|
+
if (rawAmount < 0n) throw new Error("rawAmount must be non-negative");
|
|
34
|
+
if (!Number.isInteger(priceCents) || priceCents <= 0) {
|
|
35
|
+
throw new Error(`priceCents must be a positive integer, got ${priceCents}`);
|
|
36
|
+
}
|
|
37
|
+
return Number((rawAmount * BigInt(priceCents)) / 10n ** BigInt(decimals));
|
|
38
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { IPriceOracle, PriceAsset, PriceResult } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fixed-price oracle for testing and local dev (Anvil, regtest).
|
|
5
|
+
* Returns hardcoded prices — no RPC calls.
|
|
6
|
+
*/
|
|
7
|
+
export class FixedPriceOracle implements IPriceOracle {
|
|
8
|
+
private readonly prices: Record<string, number>;
|
|
9
|
+
|
|
10
|
+
constructor(prices: Partial<Record<PriceAsset, number>> = {}) {
|
|
11
|
+
this.prices = {
|
|
12
|
+
ETH: 350_000, // $3,500
|
|
13
|
+
BTC: 6_500_000, // $65,000
|
|
14
|
+
...prices,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async getPrice(asset: PriceAsset): Promise<PriceResult> {
|
|
19
|
+
const priceCents = this.prices[asset];
|
|
20
|
+
if (priceCents === undefined) throw new Error(`No fixed price for ${asset}`);
|
|
21
|
+
return { priceCents, updatedAt: new Date() };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type { ChainlinkOracleOpts } from "./chainlink.js";
|
|
2
|
+
export { ChainlinkOracle } from "./chainlink.js";
|
|
3
|
+
export { centsToNative, nativeToCents } from "./convert.js";
|
|
4
|
+
export { FixedPriceOracle } from "./fixed.js";
|
|
5
|
+
export type { IPriceOracle, PriceAsset, PriceResult } from "./types.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Assets with Chainlink price feeds. */
|
|
2
|
+
export type PriceAsset = "ETH" | "BTC";
|
|
3
|
+
|
|
4
|
+
/** Result from a price oracle query. */
|
|
5
|
+
export interface PriceResult {
|
|
6
|
+
/** USD cents per 1 unit of asset (integer). */
|
|
7
|
+
priceCents: number;
|
|
8
|
+
/** When the price was last updated on-chain. */
|
|
9
|
+
updatedAt: Date;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Read-only price oracle. */
|
|
13
|
+
export interface IPriceOracle {
|
|
14
|
+
getPrice(asset: PriceAsset): Promise<PriceResult>;
|
|
15
|
+
}
|