Skip to content
Merged
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
8 changes: 7 additions & 1 deletion .github/workflows/prod-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
18 changes: 13 additions & 5 deletions scripts/sitemap-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,16 @@

const base = process.argv[2];
if (!base) {
console.error("Usage: node scripts/sitemap-smoke.mjs <base-url>");
console.error("Usage: node scripts/sitemap-smoke.mjs <base-url> [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
Expand Down Expand Up @@ -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);
Expand Down
112 changes: 98 additions & 14 deletions src/app/api/fee-compare/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,10 +326,10 @@
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;
}
}

Expand Down Expand Up @@ -400,8 +400,9 @@
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;
Expand Down Expand Up @@ -639,6 +640,28 @@
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<HlOpenPos[]> {
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<HlFill[]> {
const res = await fetch(HL_API, {
method: "POST",
Expand Down Expand Up @@ -970,6 +993,35 @@
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<string>();
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"]);
Expand Down Expand Up @@ -1123,9 +1175,10 @@
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;
Expand All @@ -1148,7 +1201,7 @@
}

// Estimate Gains funding fees for a set of position slices.
// Uses the current (last known) per-second funding rate as a proxy for the period.

Check warning on line 1204 in src/app/api/fee-compare/route.ts

View workflow job for this annotation

GitHub Actions / check

'estimateGmxBorrowFees' is defined but never used
// Rate is absolute (direction already irrelevant for cost estimation).
function estimateGainsFundingFees(
positions: PositionSlice[],
Expand All @@ -1159,7 +1212,9 @@
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;
}
Expand All @@ -1173,7 +1228,10 @@
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 };
}
Expand Down Expand Up @@ -1275,6 +1333,7 @@

let hlFillsData: HlFill[] = [];
let hlFundingData: HlFundingEvent[] = [];
let hlOpenPositions: HlOpenPos[] = [];
let gainsTradesData: GainsApiTrade[] = [];
let gmxWalletData: GmxWalletData | null = null;
let dydxWalletData: DydxWalletData | null = null;
Expand All @@ -1289,7 +1348,13 @@
}),
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") {
Expand Down Expand Up @@ -1445,8 +1510,15 @@
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;
Expand Down Expand Up @@ -1482,7 +1554,11 @@
// 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),
Expand Down Expand Up @@ -1578,7 +1654,11 @@
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;
Expand Down Expand Up @@ -1614,7 +1694,11 @@
// 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),
Expand Down
Loading