Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/src/lib/launchpad/baseStocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions app/src/lib/launchpad/baseStocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Compare the feed timestamp with the clock after the read completes. ethUsd() captures t before awaiting Coinbase, then passes it through refresh() and fromChainlink(). If Coinbase fails after 5 seconds and Chainlink returns a round published 3 seconds after the request started, this guard rejects it even though it is already 2 seconds old. I reproduced a cold fallback returning null instead of 2441.34; the previous implementation accepts it. Pass the current/injected clock through the fallback and sample it after await feedFn(), then add an integration test with elapsed time during the failed Coinbase request.

return Number(r.answer) / 10 ** feedDecimals;
}

Expand Down
18 changes: 17 additions & 1 deletion app/src/lib/launchpad/ethPrice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
});
17 changes: 11 additions & 6 deletions app/src/lib/launchpad/ethPrice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -88,9 +88,13 @@ async function fromCoinbase(fetchFn: FetchFn): Promise<number | null> {
}
}

async function fromChainlink(feedFn: FeedFn, nowMs: number): Promise<number | null> {
async function fromChainlink(feedFn: FeedFn, now: () => number): Promise<number | null> {
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;
}
Expand All @@ -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<number | null> {
async function refresh(fetchFn: FetchFn, feedFn: FeedFn, now: () => number): Promise<number | null> {
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 };
Expand Down
Loading