diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index 0d600162..8ec0f139 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -107,10 +107,16 @@ jobs: # the next step auto-rolls back the prod alias to the previous good # deploy. This catches the class of bug that bled SEO on 2026-06-25 # (bench page 404 + noindex while still in the sitemap). + # + # Sitemap fetched from the deployment URL (bypasses s-maxage=3600 + # CDN cache — was serving the previous deploy's sitemap for up to + # 1 h, causing false rollbacks). Pages checked against the prod + # domain (second arg) so the noindex injected on Vercel preview + # URLs does not false-fail the per-page checks. - name: Sitemap indexability smoke id: smoke continue-on-error: true - run: node scripts/sitemap-smoke.mjs https://openchainbench.com + run: node scripts/sitemap-smoke.mjs ${{ steps.deploy.outputs.url }} https://openchainbench.com - name: Auto-rollback on smoke failure if: steps.smoke.outcome == 'failure' diff --git a/scripts/sitemap-smoke.mjs b/scripts/sitemap-smoke.mjs index b39a75bc..1ef9699d 100644 --- a/scripts/sitemap-smoke.mjs +++ b/scripts/sitemap-smoke.mjs @@ -26,10 +26,16 @@ const base = process.argv[2]; if (!base) { - console.error("Usage: node scripts/sitemap-smoke.mjs "); + console.error("Usage: node scripts/sitemap-smoke.mjs [check-host]"); process.exit(2); } +// Optional second arg: host to check individual page URLs against. +// When base is a Vercel preview URL (which injects noindex on every page +// by design), pass the prod domain here so page checks run against the +// real indexed host while the sitemap is still fetched fresh from base. +const checkHost = process.argv[3] ? new URL(process.argv[3]).origin : null; + const CONCURRENCY = Number(process.env.SMOKE_CONCURRENCY ?? 8); const TIMEOUT_MS = Number(process.env.SMOKE_TIMEOUT_MS ?? 20000); const SKIP_REGEX = process.env.SMOKE_SKIP_REGEX @@ -87,10 +93,12 @@ if (locs.length === 0) { process.exit(1); } -// Rewrite each URL's host to match the target base, so a smoke test -// against a Vercel preview URL still exercises the right deployment -// rather than hitting prod. -const targetHost = new URL(base).origin; +// Rewrite each URL's host: use checkHost when provided (prod domain so +// preview-URL noindex doesn't false-fail), otherwise same host as base. +const targetHost = checkHost ?? new URL(base).origin; +if (checkHost) { + console.log(`[smoke] checking pages against ${targetHost}`); +} const urls = locs.map((u) => { try { const parsed = new URL(u); diff --git a/src/app/api/fee-compare/route.ts b/src/app/api/fee-compare/route.ts index 4023fed9..7fe88efe 100644 --- a/src/app/api/fee-compare/route.ts +++ b/src/app/api/fee-compare/route.ts @@ -326,10 +326,10 @@ async function fetchGainsFeeRates(): Promise<{ borrowPerSecPerCoin[p.from] = parseFloat(v2Borrow) / GAINS_BORROW_PRECISION; } - // Funding rate per second (absolute value — longs and shorts may face same magnitude) + // Signed funding rate: positive = longs pay shorts, negative = shorts pay longs const fundingRate = fundingPairData[i]?.lastFundingRatePerSecondP; if (fundingRate) { - fundingPerSecPerCoin[p.from] = Math.abs(parseFloat(fundingRate)) / GAINS_FUNDING_PRECISION; + fundingPerSecPerCoin[p.from] = parseFloat(fundingRate) / GAINS_FUNDING_PRECISION; } } @@ -400,8 +400,9 @@ async function fetchDydxCarryRates(): Promise { for (const [market, info] of Object.entries(data.markets ?? {})) { // "BTC-USD" → "BTC", "ETH-USD-PERP" → "ETH" const coin = market.replace(/-USD.*/, ""); - const rate = Math.abs(parseFloat(info.nextFundingRate ?? "0")) / 3600; // per hour → per sec - if (rate > 0) fundingPerSecPerCoin[coin] = rate; + // Signed: positive = longs pay shorts, negative = shorts pay longs + const rate = parseFloat(info.nextFundingRate ?? "0") / 3600; + if (rate !== 0) fundingPerSecPerCoin[coin] = rate; } const result: CarryRates = { fundingPerSecPerCoin, borrowPerSecPerCoin: {}, ts: Date.now() }; carryRateCache["dydx"] = result; @@ -639,6 +640,28 @@ async function resolveRate(slug: string): Promise<{ rate: number; note: string; return { rate: 0.0005, note: "Documented rate", rateIsLive: false }; } +type HlOpenPos = { + coin: string; + szi: string; + entryPx: string; + positionValue: string; +}; + +async function fetchHlOpenPositions(wallet: string): Promise { + const res = await fetch(HL_API, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "clearinghouseState", user: wallet }), + signal: AbortSignal.timeout(8000), + }); + const data = (await res.json()) as { + assetPositions?: Array<{ position: HlOpenPos }>; + }; + return (data.assetPositions ?? []) + .map((p) => p.position) + .filter((p) => Math.abs(parseFloat(p.szi)) > 0.000001); +} + async function fetchHlFills(wallet: string): Promise { const res = await fetch(HL_API, { method: "POST", @@ -970,6 +993,35 @@ function reconstructHlPositions(fills: HlFill[], cutoffMs: number): PositionSlic return slices; } +// Inject currently-open HL positions not captured by the 2000-fill API cap. +// For each open position not already in slices (keyed by coin:side), add a slice +// from cutoffMs to now using the current positionValue as notional. +function augmentWithHlOpenPositions( + slices: PositionSlice[], + openPositions: HlOpenPos[], + cutoffMs: number +): PositionSlice[] { + const now = Date.now(); + // Coins already tracked as still-open in the fill reconstruction + const tracked = new Set(); + for (const s of slices) { + if (s.closeMs >= now - 5 * 60 * 1000) { + tracked.add(`${s.coin}:${s.isLong ? "L" : "S"}`); + } + } + for (const pos of openPositions) { + const sz = parseFloat(pos.szi); + if (!sz) continue; + const isLong = sz > 0; + const key = `${pos.coin}:${isLong ? "L" : "S"}`; + if (tracked.has(key)) continue; + const notionalUsd = Math.abs(parseFloat(pos.positionValue)); + if (notionalUsd < 1) continue; + slices.push({ coin: pos.coin, notionalUsd, openMs: cutoffMs, closeMs: now, isLong }); + } + return slices; +} + function reconstructGainsPositions(trades: GainsApiTrade[], cutoffMs: number): PositionSlice[] { const OPEN_ACTIONS = new Set(["MarketOpened", "LimitOrderExecuted"]); const CLOSE_ACTIONS = new Set(["TradeClosedMarket", "TradeClosedTP", "TradeClosedSL", "TradeClosedLIQ"]); @@ -1123,9 +1175,10 @@ function computeHlFunding( const rates = (history.get(pos.coin) ?? []).filter( (r) => r.time >= pos.openMs && r.time <= pos.closeMs ); - // Each HL funding entry = one 8h interval. Rate is a fraction applied to notional. + // Each HL funding entry = one 8h interval. Rate > 0 = longs pay; < 0 = shorts pay. for (const r of rates) { - total += pos.notionalUsd * Math.abs(r.rate); + const cost = pos.isLong ? r.rate : -r.rate; + total += pos.notionalUsd * Math.max(0, cost); } } return total; @@ -1159,7 +1212,9 @@ function estimateGainsFundingFees( const rate = fundingPerSecPerCoin[pos.coin]; if (!rate) continue; const durationSec = Math.max(0, (pos.closeMs - pos.openMs) / 1000); - total += pos.notionalUsd * rate * durationSec; + // positive rate = longs pay; negative rate = shorts pay + const effectiveRate = pos.isLong ? Math.max(0, rate) : Math.max(0, -rate); + total += pos.notionalUsd * effectiveRate * durationSec; } return total; } @@ -1173,7 +1228,10 @@ function estimateCarryFees( for (const pos of positions) { const durationSec = Math.max(0, (pos.closeMs - pos.openMs) / 1000); borrowFees += pos.notionalUsd * (rates.borrowPerSecPerCoin[pos.coin] ?? 0) * durationSec; - fundingFees += pos.notionalUsd * (rates.fundingPerSecPerCoin[pos.coin] ?? 0) * durationSec; + const fundingRate = rates.fundingPerSecPerCoin[pos.coin] ?? 0; + // positive rate = longs pay; negative rate = shorts pay + const fundingCost = pos.isLong ? Math.max(0, fundingRate) : Math.max(0, -fundingRate); + fundingFees += pos.notionalUsd * fundingCost * durationSec; } return { borrowFees, fundingFees }; } @@ -1275,6 +1333,7 @@ export async function GET(req: Request) { let hlFillsData: HlFill[] = []; let hlFundingData: HlFundingEvent[] = []; + let hlOpenPositions: HlOpenPos[] = []; let gainsTradesData: GainsApiTrade[] = []; let gmxWalletData: GmxWalletData | null = null; let dydxWalletData: DydxWalletData | null = null; @@ -1289,7 +1348,13 @@ export async function GET(req: Request) { }), fetchHlFunding(wallet, cutoffMs).then((f) => { hlFundingData = f; - }) + }), + // clearinghouseState gives currently-open positions whose fill may be outside + // the 2000-fill API cap — without this, long-held positions are invisible to + // the carry projection even though they generate real HL funding payments. + fetchHlOpenPositions(wallet).then((p) => { + hlOpenPositions = p; + }).catch(() => {}) ); } if (venueA === "gains" || venueB === "gains") { @@ -1445,8 +1510,15 @@ export async function GET(req: Request) { const aFunding = hlW.fundingUsd; const aNetCost = aFees - aFunding; - // Estimate Gains carry (borrow + funding) by reconstructing HL positions - const hlPositions = reconstructHlPositions(hlFillsData, cutoffMs); + // Estimate Gains carry (borrow + funding) by reconstructing HL positions. + // augmentWithHlOpenPositions fills in positions whose open fill is older than the + // 2000-fill API cap — they still generate real HL funding but are invisible to + // fill-only reconstruction. + const hlPositions = augmentWithHlOpenPositions( + reconstructHlPositions(hlFillsData, cutoffMs), + hlOpenPositions, + cutoffMs + ); const gainsBorrow = estimateGainsBorrowFees(hlPositions, gainsData.borrowPerSecPerCoin, gainsData.avgBorrowPerSec); const gainsFunding = estimateGainsFundingFees(hlPositions, gainsData.fundingPerSecPerCoin); const bEquiv = takerEquiv + gainsBorrow + gainsFunding; @@ -1482,7 +1554,11 @@ export async function GET(req: Request) { // Reconstruct positions from venueA for carry projection let positions: PositionSlice[] = []; if (venueA === "hyperliquid" && hlFillsData.length > 0) { - positions = reconstructHlPositions(hlFillsData, cutoffMs); + positions = augmentWithHlOpenPositions( + reconstructHlPositions(hlFillsData, cutoffMs), + hlOpenPositions, + cutoffMs + ); } else if (venueA === "gains" && gainsTradesData.length > 0) { positions = reconstructGainsPositions( gainsTradesData.filter((t) => t.collateralIndex === 3), @@ -1578,7 +1654,11 @@ export async function GET(req: Request) { const bFunding = hlW.fundingUsd; const bNetCost = bFees - bFunding; - const hlPositions = reconstructHlPositions(hlFillsData, cutoffMs); + const hlPositions = augmentWithHlOpenPositions( + reconstructHlPositions(hlFillsData, cutoffMs), + hlOpenPositions, + cutoffMs + ); const gainsBorrow = estimateGainsBorrowFees(hlPositions, gainsData.borrowPerSecPerCoin, gainsData.avgBorrowPerSec); const gainsFunding = estimateGainsFundingFees(hlPositions, gainsData.fundingPerSecPerCoin); const aEquiv = takerEquiv + gainsBorrow + gainsFunding; @@ -1614,7 +1694,11 @@ export async function GET(req: Request) { // Reconstruct positions from venueB let positions: PositionSlice[] = []; if (venueB === "hyperliquid" && hlFillsData.length > 0) { - positions = reconstructHlPositions(hlFillsData, cutoffMs); + positions = augmentWithHlOpenPositions( + reconstructHlPositions(hlFillsData, cutoffMs), + hlOpenPositions, + cutoffMs + ); } else if (venueB === "gains" && gainsTradesData.length > 0) { positions = reconstructGainsPositions( gainsTradesData.filter((t) => t.collateralIndex === 3),