diff --git a/app/src/lib/launchpad/baseStocks.test.ts b/app/src/lib/launchpad/baseStocks.test.ts index 1a48f81..e29671d 100644 --- a/app/src/lib/launchpad/baseStocks.test.ts +++ b/app/src/lib/launchpad/baseStocks.test.ts @@ -48,11 +48,13 @@ test("parseRoundData decodes latestRoundData words and negative answers", () => assert.equal(parseRoundData("0x1234"), null); }); -test("feedUsd: 8-dec answer → USD; stale, zero or negative → null", () => { +test("feedUsd: 8-dec answer → USD; stale, future, zero or negative → null", () => { const now = 1_700_100_000; assert.equal(feedUsd({ answer: 22996000000n, updatedAt: now - 3600 }, now), 229.96); assert.equal(feedUsd({ answer: 22996000000n, updatedAt: now - 2 * 24 * 3600 }, now), 229.96, "weekend hold is fine"); assert.equal(feedUsd({ answer: 22996000000n, updatedAt: now - BASE_STOCK_MAX_FEED_AGE_S - 1 }, now), null, "too old"); + assert.equal(feedUsd({ answer: 22996000000n, updatedAt: now + 60 }, now), null, "future round (clock skew / bad RPC) is not a price"); + assert.equal(feedUsd({ answer: 22996000000n, updatedAt: now + BASE_STOCK_MAX_FEED_AGE_S }, now), null, "far-future round is not a price either"); assert.equal(feedUsd({ answer: 0n, updatedAt: now }, now), null); assert.equal(feedUsd({ answer: -1n, updatedAt: now }, now), null); assert.equal(feedUsd(null, now), null); diff --git a/app/src/lib/launchpad/baseStocks.ts b/app/src/lib/launchpad/baseStocks.ts index aeb8caf..79c2701 100644 --- a/app/src/lib/launchpad/baseStocks.ts +++ b/app/src/lib/launchpad/baseStocks.ts @@ -67,10 +67,10 @@ export function parseRoundData(hex: string): { answer: bigint; updatedAt: number return { answer, updatedAt: Number(word(3)) }; } -/** USD per token from a feed reading; null when non-positive or older than the trust window. */ +/** USD per token from a feed reading; null when non-positive, from the future, or older than the trust window. */ export function feedUsd(r: { answer: bigint; updatedAt: number } | null, nowS: number, feedDecimals = 8, maxAgeS = BASE_STOCK_MAX_FEED_AGE_S): number | null { if (!r || r.answer <= 0n) return null; - if (!(r.updatedAt > 0) || nowS - r.updatedAt > maxAgeS) return null; + if (!(r.updatedAt > 0) || r.updatedAt > nowS || nowS - r.updatedAt > maxAgeS) return null; return Number(r.answer) / 10 ** feedDecimals; } diff --git a/app/src/lib/launchpad/ethPrice.test.ts b/app/src/lib/launchpad/ethPrice.test.ts index 572952f..795e0db 100644 --- a/app/src/lib/launchpad/ethPrice.test.ts +++ b/app/src/lib/launchpad/ethPrice.test.ts @@ -139,11 +139,12 @@ test("failures back off for one TTL and the request carries a timeout", async () assert.equal(ETH_FETCH_TIMEOUT_MS, 5_000); }); -test("feedEthUsd: a positive round under the max age is a price; older, zero or negative is not", () => { +test("feedEthUsd: a positive round under the max age is a price; older, future, zero or negative is not", () => { const nowS = 1_700_000_000; assert.equal(feedEthUsd({ answer: 244134000000n, updatedAt: nowS - 488 }, nowS), 2441.34, "the round seen on-chain when the feed address was verified"); assert.equal(feedEthUsd({ answer: 244134000000n, updatedAt: nowS - ETH_FEED_MAX_AGE_S }, nowS), 2441.34, "exactly the max age still counts"); assert.equal(feedEthUsd({ answer: 244134000000n, updatedAt: nowS - ETH_FEED_MAX_AGE_S - 1 }, nowS), null, "one second past it does not"); + assert.equal(feedEthUsd({ answer: 244134000000n, updatedAt: nowS + 60 }, nowS), null, "future round (clock skew / bad RPC) is not a price"); assert.equal(feedEthUsd({ answer: 0n, updatedAt: nowS }, nowS), null); assert.equal(feedEthUsd({ answer: -1n, updatedAt: nowS }, nowS), null); assert.equal(feedEthUsd({ answer: 244134000000n, updatedAt: 0 }, nowS), null, "no update timestamp"); @@ -233,3 +234,18 @@ test("concurrent callers share one refresh, so an earlier slow attempt cannot ov c.advance(ETH_PRICE_TTL_MS + 1); assert.equal(await ethUsd({ fetchFn: mockFetch(okBody("2600"), seen), feedFn: feedDown, now: c.now }), 2600, "after the TTL a new refresh runs"); }); + +test("a Chainlink round published while Coinbase is failing is accepted, not rejected as future (PR #29)", async () => { + resetEthPriceCache(); + const c = clock(); + const startS = Math.floor(c.now() / 1000); + // Coinbase fails after 5s; the feed round is published 3s after the request + // started, so it is 2s old once the fallback runs. + const slowDown = async () => { + c.advance(5_000); + return new Response("x", { status: 503 }); + }; + const publishedWhileWaiting = async () => ({ answer: 244134000000n, updatedAt: startS + 3 }); + assert.equal(await ethUsd({ fetchFn: slowDown, feedFn: publishedWhileWaiting, now: c.now }), 2441.34); + assert.equal(ethPriceSource(), "chainlink"); +}); diff --git a/app/src/lib/launchpad/ethPrice.ts b/app/src/lib/launchpad/ethPrice.ts index 339f293..daf48d2 100644 --- a/app/src/lib/launchpad/ethPrice.ts +++ b/app/src/lib/launchpad/ethPrice.ts @@ -56,7 +56,7 @@ export function parseEthSpot(body: unknown): number | null { export type FeedRound = { answer: bigint; updatedAt: number }; -/** USD from a Chainlink ETH/USD round: positive answer, updated within ETH_FEED_MAX_AGE_S of `nowS`; else null. */ +/** USD from a Chainlink ETH/USD round: positive answer, updated within ETH_FEED_MAX_AGE_S of `nowS` and never from the future; else null. */ export function feedEthUsd(round: FeedRound | null, nowS: number): number | null { return feedUsd(round, nowS, ETH_FEED_DECIMALS, ETH_FEED_MAX_AGE_S); } @@ -88,9 +88,13 @@ async function fromCoinbase(fetchFn: FetchFn): Promise { } } -async function fromChainlink(feedFn: FeedFn, nowMs: number): Promise { +async function fromChainlink(feedFn: FeedFn, now: () => number): Promise { try { - return feedEthUsd(await feedFn(), Math.floor(nowMs / 1000)); + const round = await feedFn(); + // Sample the clock after the read completes: the Coinbase fallback can + // take seconds, and a round published while it was running is already + // seconds old — not from the future. + return feedEthUsd(round, Math.floor(now() / 1000)); } catch { return null; } @@ -106,17 +110,18 @@ export async function ethUsd( if (t - cached.at < ETH_PRICE_TTL_MS && !staleExpired) return cached.usd; const fetchFn: FetchFn = opts.fetchFn ?? ((input, init) => fetch(input, init as RequestInit)); const feedFn: FeedFn = opts.feedFn ?? readFeed; - inflight ??= refresh(fetchFn, feedFn, t).finally(() => { inflight = null; }); + inflight ??= refresh(fetchFn, feedFn, now).finally(() => { inflight = null; }); return inflight; } -async function refresh(fetchFn: FetchFn, feedFn: FeedFn, t: number): Promise { +async function refresh(fetchFn: FetchFn, feedFn: FeedFn, now: () => number): Promise { let source: EthPriceSource = "coinbase"; let usd = await fromCoinbase(fetchFn); if (usd === null) { source = "chainlink"; - usd = await fromChainlink(feedFn, t); + usd = await fromChainlink(feedFn, now); } + const t = now(); if (usd !== null) { if (source !== cached.source && cached.source !== null) console.warn(`[eth-price] serving ${source} (${source === "chainlink" ? "Coinbase spot unavailable" : "Coinbase spot back"})`); cached = { at: t, goodAt: t, usd, source };