diff --git a/lib/contracts/abi.ts b/lib/contracts/abi.ts index 956b8970..b441095a 100644 --- a/lib/contracts/abi.ts +++ b/lib/contracts/abi.ts @@ -132,6 +132,21 @@ export const priceForNextMintFunction = { outputs: [{ name: "", type: "uint128" }], } as const; +/** 1inch Spot Price Aggregator: get exchange rate between two tokens. */ +export const spotPriceAbi = [ + { + inputs: [ + { name: "srcToken", type: "address" }, + { name: "dstToken", type: "address" }, + { name: "useWrappers", type: "bool" }, + ], + name: "getRate", + outputs: [{ name: "weightedRate", type: "uint256" }], + stateMutability: "view", + type: "function", + }, +] as const; + /** Full bond info for a token: creator, royalties, creation time, reserve. */ export const tokenBondFunction = { type: "function", diff --git a/lib/contracts/constants.ts b/lib/contracts/constants.ts index 36352e17..92b6be0f 100644 --- a/lib/contracts/constants.ts +++ b/lib/contracts/constants.ts @@ -51,6 +51,9 @@ export const RESERVE_LABEL = "PLOT"; // Supported Zap input tokens (Base) // --------------------------------------------------------------------------- +/** 1inch Spot Price Aggregator on Base */ +export const ONEINCH_SPOT_PRICE_AGGREGATOR = "0x00000000000D6FFc74A8feb35aF5827bf57f6786" as const; + /** USDC on Base */ export const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const; diff --git a/lib/reserve-usd-rate.ts b/lib/reserve-usd-rate.ts new file mode 100644 index 00000000..cc9e6a61 --- /dev/null +++ b/lib/reserve-usd-rate.ts @@ -0,0 +1,69 @@ +/** + * Server-side helper to fetch the current PLOT/USD exchange rate. + * + * Derivation: PLOT/USD = priceForNextMint(PLOT_TOKEN) in HUNT × HUNT/USD via 1inch oracle. + * + * Used by trade indexers to store `reserve_usd_rate` alongside each trade, + * enabling USD-denominated price charts. + */ + +import { formatEther } from "viem"; +import { publicClient } from "./rpc"; +import { + MCV2_BOND, + PLOT_TOKEN, + HUNT, + USDC, + ONEINCH_SPOT_PRICE_AGGREGATOR, +} from "./contracts/constants"; +import { priceForNextMintFunction, spotPriceAbi } from "./contracts/abi"; + +/** + * Fetch the current HUNT/USD rate from the 1inch spot price aggregator. + * Returns USD price per 1 HUNT. + */ +export async function getHuntPriceUSD( + client?: typeof publicClient, +): Promise { + const rpc = client ?? publicClient; + const weightedRate = await rpc.readContract({ + address: ONEINCH_SPOT_PRICE_AGGREGATOR, + abi: spotPriceAbi, + functionName: "getRate", + args: [HUNT, USDC, false], + }); + // USDC has 6 decimals on Base (hardcoded — Base USDC is a known constant). + // HUNT has 18 → rate is scaled to 1e18. USD price = weightedRate / 1e6. + return Number(weightedRate) / 1_000_000; +} + +/** + * Fetch the current PLOT/USD rate. + * PLOT/USD = priceForNextMint(PLOT_TOKEN) in HUNT × HUNT/USD. + * + * Returns null if the rate cannot be determined (RPC failure, etc.). + */ +export async function getReserveUsdRate( + client?: typeof publicClient, +): Promise { + try { + const rpc = client ?? publicClient; + const [plotInHuntWei, huntUsd] = await Promise.all([ + rpc.readContract({ + address: MCV2_BOND, + abi: [priceForNextMintFunction], + functionName: "priceForNextMint", + args: [PLOT_TOKEN], + }), + getHuntPriceUSD(rpc), + ]); + const plotInHunt = Number(formatEther(BigInt(plotInHuntWei))); + return plotInHunt * huntUsd; + } catch (err) { + console.error( + "[reserve-usd-rate] Failed to fetch PLOT/USD:", + err instanceof Error ? err.message : err, + ); + return null; + } +} diff --git a/lib/supabase.ts b/lib/supabase.ts index c9e5d0d2..42ede9e9 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -358,6 +358,8 @@ export interface Database { log_index: number; contract_address: string; user_address: string | null; + reserve_usd_rate: number | null; + rate_source: string | null; }; Insert: { id?: never; @@ -373,6 +375,8 @@ export interface Database { log_index: number; contract_address: string; user_address?: string | null; + reserve_usd_rate?: number | null; + rate_source?: string | null; }; Update: { id?: never; @@ -388,6 +392,8 @@ export interface Database { log_index?: number; contract_address?: string; user_address?: string | null; + reserve_usd_rate?: number | null; + rate_source?: string | null; }; Relationships: []; }; diff --git a/scripts/backfill-usd-rates.ts b/scripts/backfill-usd-rates.ts new file mode 100644 index 00000000..b16df002 --- /dev/null +++ b/scripts/backfill-usd-rates.ts @@ -0,0 +1,220 @@ +#!/usr/bin/env npx tsx +/** + * Backfill trade_history.reserve_usd_rate with PLOT/USD rates. + * + * Strategy (tiered): + * 1. Try exact historical read: priceForNextMint(PLOT_TOKEN) at trade block × HUNT/USD. + * Some public RPCs (mainnet.base.org, base.drpc.org) support historical state. + * 2. Fallback: current PLOT/HUNT ratio × historical daily HUNT/USD from CoinGecko. + * Marked as 'backfill_approx' — PLOT/HUNT ratio shifts with bonding curve supply, + * so this is directionally correct but not precise for older trades. + * + * Usage: + * npx tsx scripts/backfill-usd-rates.ts + * + * Requires: NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY + */ + +import { createClient } from "@supabase/supabase-js"; +import { createPublicClient, formatEther, http, type PublicClient } from "viem"; +import { base } from "viem/chains"; +import { + MCV2_BOND, + PLOT_TOKEN, + HUNT, + USDC, + ONEINCH_SPOT_PRICE_AGGREGATOR, +} from "../lib/contracts/constants"; +import { priceForNextMintFunction, spotPriceAbi } from "../lib/contracts/abi"; + +const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL || ""; +const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || ""; + +if (!SUPABASE_URL || !SUPABASE_KEY) { + console.error("Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY"); + process.exit(1); +} + +const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, { + auth: { autoRefreshToken: false, persistSession: false }, +}); + +// RPCs known to support historical state reads on Base +const ARCHIVE_RPCS = [ + "https://mainnet.base.org", + "https://base.drpc.org", +]; + +/** + * Try to read PLOT/USD at a historical block using archive-capable RPCs. + * Returns { rate, source: 'backfill_exact' } or null if all RPCs fail. + */ +async function getExactHistoricalRate(blockNumber: bigint): Promise { + for (const rpcUrl of ARCHIVE_RPCS) { + try { + const client = createPublicClient({ + chain: base, + transport: http(rpcUrl, { timeout: 5_000, retryCount: 0 }), + }) as PublicClient; + + const [plotInHuntWei, huntUsdRate] = await Promise.all([ + client.readContract({ + address: MCV2_BOND, + abi: [priceForNextMintFunction], + functionName: "priceForNextMint", + args: [PLOT_TOKEN], + blockNumber, + }), + client.readContract({ + address: ONEINCH_SPOT_PRICE_AGGREGATOR, + abi: spotPriceAbi, + functionName: "getRate", + args: [HUNT, USDC, false], + blockNumber, + }), + ]); + + const plotInHunt = Number(formatEther(BigInt(plotInHuntWei))); + const huntUsd = Number(huntUsdRate) / 1_000_000; + return plotInHunt * huntUsd; + } catch { + // Try next RPC + continue; + } + } + return null; +} + +/** + * Approximate fallback: use current PLOT/HUNT ratio × historical HUNT/USD at + * the given block. This is more accurate than a single global rate because + * HUNT/USD varies over time, even though PLOT/HUNT is only current-state. + * + * Returns null if the historical HUNT/USD read fails. + */ +async function getApproxHistoricalRate(blockNumber: bigint): Promise { + for (const rpcUrl of ARCHIVE_RPCS) { + try { + const client = createPublicClient({ + chain: base, + transport: http(rpcUrl, { timeout: 5_000, retryCount: 0 }), + }) as PublicClient; + + // Current PLOT/HUNT (cannot read historically without archive for bonding curve) + const plotInHuntWei = await client.readContract({ + address: MCV2_BOND, + abi: [priceForNextMintFunction], + functionName: "priceForNextMint", + args: [PLOT_TOKEN], + }); + + // Historical HUNT/USD at the trade's block + const huntUsdRate = await client.readContract({ + address: ONEINCH_SPOT_PRICE_AGGREGATOR, + abi: spotPriceAbi, + functionName: "getRate", + args: [HUNT, USDC, false], + blockNumber, + }); + + const plotInHunt = Number(formatEther(BigInt(plotInHuntWei))); + const huntUsd = Number(huntUsdRate) / 1_000_000; + return plotInHunt * huntUsd; + } catch { + continue; + } + } + return null; +} + +async function main() { + console.log("=== Backfill USD Rates ==="); + + // Fetch trades missing reserve_usd_rate + const { data: trades, error: fetchError } = await supabase + .from("trade_history") + .select("id, block_number, block_timestamp") + .is("reserve_usd_rate", null) + .order("block_number", { ascending: true }); + + if (fetchError) { + console.error("Failed to fetch trades:", fetchError.message); + process.exit(1); + } + + if (!trades || trades.length === 0) { + console.log("No trades need USD rate backfill."); + return; + } + + console.log(`Found ${trades.length} trades missing USD rates.`); + + let exact = 0; + let approx = 0; + let failed = 0; + + // Group trades by block to minimize RPC calls + const blockGroups = new Map(); + for (const trade of trades) { + const group = blockGroups.get(trade.block_number) || []; + group.push(trade); + blockGroups.set(trade.block_number, group); + } + + console.log(`Trades span ${blockGroups.size} unique blocks.`); + + for (const [blockNumber, blockTrades] of blockGroups) { + // Try exact historical rate (both PLOT/HUNT and HUNT/USD at historical block) + const exactRate = await getExactHistoricalRate(BigInt(blockNumber)); + + // Fallback: current PLOT/HUNT × historical HUNT/USD at this block + const approxRate = exactRate === null + ? await getApproxHistoricalRate(BigInt(blockNumber)) + : null; + + const rate = exactRate ?? approxRate; + const source = exactRate !== null ? "backfill_exact" : "backfill_approx"; + + if (rate === null) { + console.error(` [SKIP] block=${blockNumber}: no rate available`); + failed += blockTrades.length; + continue; + } + + // Update all trades in this block + const ids = blockTrades.map((t) => t.id); + const { error: updateError } = await supabase + .from("trade_history") + .update({ reserve_usd_rate: rate, rate_source: source }) + .in("id", ids); + + if (updateError) { + console.error(` [FAIL] block=${blockNumber}: ${updateError.message}`); + failed += blockTrades.length; + } else { + if (source === "backfill_exact") { + exact += blockTrades.length; + } else { + approx += blockTrades.length; + } + if (blockGroups.size <= 50 || exact + approx <= 10) { + console.log(` [${source.toUpperCase()}] block=${blockNumber} rate=$${rate.toFixed(8)} (${blockTrades.length} trades)`); + } + } + + // Delay between blocks to avoid RPC rate limits + await new Promise((r) => setTimeout(r, 300)); + } + + console.log(""); + console.log("=== Backfill complete ==="); + console.log(` Exact: ${exact}`); + console.log(` Approximate: ${approx}`); + console.log(` Failed: ${failed}`); + console.log(` Total: ${trades.length}`); +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(2); +}); diff --git a/src/app/api/cron/trade-history/route.ts b/src/app/api/cron/trade-history/route.ts index 99099430..6505eaf9 100644 --- a/src/app/api/cron/trade-history/route.ts +++ b/src/app/api/cron/trade-history/route.ts @@ -5,6 +5,7 @@ import { createServerClient } from "../../../../../lib/supabase"; import { mcv2BondEventAbi } from "../../../../../lib/contracts/abi"; import { MCV2_BOND, ZAP_PLOTLINK } from "../../../../../lib/contracts/constants"; import { erc20Abi } from "../../../../../lib/price"; +import { getReserveUsdRate } from "../../../../../lib/reserve-usd-rate"; import type { Database } from "../../../../../lib/supabase"; const SCAN_BLOCKS = BigInt(200); @@ -86,6 +87,16 @@ export async function GET(req: Request) { toBlock, }); + // Fetch current PLOT/USD rate once per batch. + // If the oldest block in this batch (fromBlock) is far behind head, the current + // rate may not reflect trade-time pricing. Use fromBlock for a conservative check + // so the entire batch is labeled consistently — only near-head batches get 'live'. + const reserveUsdRate = await getReserveUsdRate(); + const isCatchUp = currentBlock - fromBlock > BigInt(200); + const rateSource = reserveUsdRate !== null + ? (isCatchUp ? "backfill_approx" : "live") + : null; + let inserted = 0; let skipped = 0; let errors = 0; @@ -129,6 +140,8 @@ export async function GET(req: Request) { storylineId, supabase, getTimestamp, + reserveUsdRate, + rateSource, ); inserted++; } catch (err) { @@ -168,6 +181,8 @@ async function processTradeEvent( storylineId: number, supabase: SupabaseClient, getTimestamp: (blockNumber: bigint) => Promise, + reserveUsdRate: number | null, + rateSource: string | null, ) { const args = decoded.args as { token: `0x${string}`; @@ -220,6 +235,8 @@ async function processTradeEvent( log_index: log.logIndex!, contract_address: MCV2_BOND.toLowerCase(), user_address: args.receiver.toLowerCase(), + reserve_usd_rate: reserveUsdRate, + rate_source: rateSource, }; const { error } = await supabase diff --git a/src/app/api/index/trade/route.ts b/src/app/api/index/trade/route.ts index 93c7b3cc..f1c67261 100644 --- a/src/app/api/index/trade/route.ts +++ b/src/app/api/index/trade/route.ts @@ -6,6 +6,7 @@ import { mcv2BondEventAbi } from "../../../../../lib/contracts/abi"; import { MCV2_BOND, ZAP_PLOTLINK } from "../../../../../lib/contracts/constants"; import { erc20Abi } from "../../../../../lib/price"; import { validateRecentTx } from "../../../../../lib/index-auth"; +import { getReserveUsdRate } from "../../../../../lib/reserve-usd-rate"; import type { Database } from "../../../../../lib/supabase"; type TradeInsert = Database["public"]["Tables"]["trade_history"]["Insert"]; @@ -54,6 +55,9 @@ export async function POST(req: Request) { } } + // Fetch current PLOT/USD rate for this trade batch + const reserveUsdRate = await getReserveUsdRate(); + let indexed = 0; for (const log of receipt.logs) { @@ -118,6 +122,8 @@ export async function POST(req: Request) { log_index: log.logIndex!, contract_address: MCV2_BOND.toLowerCase(), user_address: args.receiver.toLowerCase(), + reserve_usd_rate: reserveUsdRate, + rate_source: reserveUsdRate !== null ? "live" : null, }; const { error: dbError } = await supabase diff --git a/src/components/PriceChart.tsx b/src/components/PriceChart.tsx index fb355b68..e4bfa58d 100644 --- a/src/components/PriceChart.tsx +++ b/src/components/PriceChart.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { type Address, formatUnits } from "viem"; import { supabase } from "../../lib/supabase"; @@ -12,11 +13,20 @@ const PLOT_W = CHART_W - PAD.left - PAD.right; const PLOT_H = CHART_H - PAD.top - PAD.bottom; const MAX_POINTS = 50; +type PriceMode = "usd" | "reserve"; + interface PriceChartProps { tokenAddress: Address; currentPriceRaw: bigint; } +interface TradePoint { + price_per_token: number; + block_timestamp: string; + reserve_usd_rate: number | null; + rate_source: string | null; +} + function formatTime(iso: string): string { const d = new Date(iso); const now = new Date(); @@ -28,14 +38,23 @@ function formatTime(iso: string): string { return d.toLocaleDateString([], { month: "short", day: "numeric" }); } -function formatPrice(v: number): string { +function formatReservePrice(v: number): string { if (v === 0) return "0"; if (v < 0.001) return v.toExponential(0); if (v < 1) return v.toFixed(4); return v.toFixed(2); } +function formatUsdPrice(v: number): string { + if (v === 0) return "$0"; + if (v >= 1) return `$${v.toFixed(2)}`; + if (v >= 0.01) return `$${v.toFixed(4)}`; + if (v >= 0.0001) return `$${v.toFixed(6)}`; + return `$${v.toExponential(2)}`; +} + export function PriceChart({ tokenAddress, currentPriceRaw }: PriceChartProps) { + const [mode, setMode] = useState("usd"); const currentPrice = Number(formatUnits(currentPriceRaw, 18)); const { data: tradePoints } = useQuery({ @@ -44,17 +63,17 @@ export function PriceChart({ tokenAddress, currentPriceRaw }: PriceChartProps) { if (!supabase) return []; const { data } = await supabase .from("trade_history") - .select("price_per_token, block_timestamp") + .select("price_per_token, block_timestamp, reserve_usd_rate, rate_source") .eq("token_address", tokenAddress.toLowerCase()) .order("block_timestamp", { ascending: true }); if (!data || data.length === 0) return []; // Downsample if too many points - if (data.length <= MAX_POINTS) return data; + if (data.length <= MAX_POINTS) return data as TradePoint[]; const step = (data.length - 1) / (MAX_POINTS - 1); - const sampled = []; + const sampled: TradePoint[] = []; for (let i = 0; i < MAX_POINTS; i++) { - sampled.push(data[Math.round(i * step)]); + sampled.push(data[Math.round(i * step)] as TradePoint); } return sampled; }, @@ -64,6 +83,14 @@ export function PriceChart({ tokenAddress, currentPriceRaw }: PriceChartProps) { const hasData = tradePoints && tradePoints.length > 0; + // Check if USD data is available (at least some points have a rate) + const hasUsdData = hasData && tradePoints.some((t) => t.reserve_usd_rate !== null); + const hasApproxData = hasData && tradePoints.some((t) => t.rate_source === "backfill_approx"); + + // If in USD mode but no USD data, fall back to reserve + const effectiveMode = mode === "usd" && !hasUsdData ? "reserve" : mode; + const formatPrice = effectiveMode === "usd" ? formatUsdPrice : formatReservePrice; + // Empty state if (!hasData) { return ( @@ -80,7 +107,7 @@ export function PriceChart({ tokenAddress, currentPriceRaw }: PriceChartProps) {

No trading activity yet

{currentPrice > 0 && (

- {formatPrice(currentPrice)} {RESERVE_LABEL} + {formatReservePrice(currentPrice)} {RESERVE_LABEL}

)} @@ -88,14 +115,37 @@ export function PriceChart({ tokenAddress, currentPriceRaw }: PriceChartProps) { ); } - // Build points array (round to eliminate floating-point noise) - const points = tradePoints.map((t) => ({ - time: t.block_timestamp, - price: Math.round(Number(t.price_per_token) * 1e8) / 1e8, - })); + // Build points array with USD conversion where available + const points = tradePoints.map((t) => { + const reservePrice = Math.round(Number(t.price_per_token) * 1e8) / 1e8; + const usdPrice = t.reserve_usd_rate !== null + ? reservePrice * t.reserve_usd_rate + : null; + return { + time: t.block_timestamp, + price: effectiveMode === "usd" && usdPrice !== null ? usdPrice : reservePrice, + hasUsd: usdPrice !== null, + isApprox: t.rate_source === "backfill_approx", + }; + }); + + // For USD mode, filter to only points with USD data + const chartPoints = effectiveMode === "usd" + ? points.filter((p) => p.hasUsd) + : points; + + if (chartPoints.length === 0) { + // All points filtered out — shouldn't happen, but fallback + return ( +
+

Price

+

USD pricing data not yet available

+
+ ); + } // Scale with minimum Y range to prevent micro-noise exaggeration - const prices = points.map((p) => p.price); + const prices = chartPoints.map((p) => p.price); const minY = Math.min(...prices); const maxY = Math.max(...prices); const rawRange = maxY - minY; @@ -104,35 +154,105 @@ export function PriceChart({ tokenAddress, currentPriceRaw }: PriceChartProps) { const yPad = yRange * 0.1; const scaleX = (i: number) => - PAD.left + (i / (points.length - 1 || 1)) * PLOT_W; + PAD.left + (i / (chartPoints.length - 1 || 1)) * PLOT_W; const scaleY = (v: number) => PAD.top + PLOT_H - ((v - (minY - yPad)) / (yRange + yPad * 2)) * PLOT_H; - const linePoints = points + // Build line segments: solid for exact data, dashed for approximate + const lineSegments: { points: string; isApprox: boolean }[] = []; + let currentSegment: { indices: number[]; isApprox: boolean } | null = null; + + for (let i = 0; i < chartPoints.length; i++) { + const isApprox = chartPoints[i].isApprox; + if (!currentSegment || currentSegment.isApprox !== isApprox) { + // Overlap with previous segment's last point for continuity + if (currentSegment && currentSegment.indices.length > 0) { + lineSegments.push({ + points: currentSegment.indices + .map((idx) => `${scaleX(idx)},${scaleY(chartPoints[idx].price)}`) + .join(" "), + isApprox: currentSegment.isApprox, + }); + } + currentSegment = { + indices: currentSegment ? [currentSegment.indices[currentSegment.indices.length - 1], i] : [i], + isApprox, + }; + } else { + currentSegment.indices.push(i); + } + } + if (currentSegment && currentSegment.indices.length > 0) { + lineSegments.push({ + points: currentSegment.indices + .map((idx) => `${scaleX(idx)},${scaleY(chartPoints[idx].price)}`) + .join(" "), + isApprox: currentSegment.isApprox, + }); + } + + // Full line for area fill + const allLinePoints = chartPoints .map((p, i) => `${scaleX(i)},${scaleY(p.price)}`) .join(" "); // Last point for pulse marker - const lastIdx = points.length - 1; + const lastIdx = chartPoints.length - 1; const lastX = scaleX(lastIdx); - const lastY = scaleY(points[lastIdx].price); + const lastY = scaleY(chartPoints[lastIdx].price); // Y-axis ticks const yTicks = [minY, (minY + maxY) / 2, maxY]; // X-axis time labels (first, mid, last) — deduplicated when indices overlap const xLabelCandidates = [ - { idx: 0, label: formatTime(points[0].time) }, - { idx: Math.floor(lastIdx / 2), label: formatTime(points[Math.floor(lastIdx / 2)].time) }, - { idx: lastIdx, label: formatTime(points[lastIdx].time) }, + { idx: 0, label: formatTime(chartPoints[0].time) }, + { idx: Math.floor(lastIdx / 2), label: formatTime(chartPoints[Math.floor(lastIdx / 2)].time) }, + { idx: lastIdx, label: formatTime(chartPoints[lastIdx].time) }, ]; const xLabels = xLabelCandidates.filter( (item, i, arr) => arr.findIndex((a) => a.idx === item.idx) === i, ); + const priceLabel = effectiveMode === "usd" ? "USD" : RESERVE_LABEL; + + // In USD mode, check if the last charted point is actually the most recent trade. + // If newer trades exist without USD data, label accordingly. + const lastChartTime = new Date(chartPoints[lastIdx].time).getTime(); + const lastTradeTime = new Date(tradePoints[tradePoints.length - 1].block_timestamp).getTime(); + const isLatest = effectiveMode === "reserve" || lastChartTime >= lastTradeTime; + return (
-

Price

+
+

Price

+ {hasUsdData && ( +
+ + +
+ )} +
- {/* Price line */} - + {/* Price line segments: solid for exact, dashed for approximate */} + {lineSegments.map((seg, i) => ( + + ))} {/* Current price pulse marker */}

- Price per token ({RESERVE_LABEL}) + Price per token ({priceLabel}) - {" "}· latest: {formatPrice(points[lastIdx].price)} {RESERVE_LABEL} + {" "}· {isLatest ? "latest" : "last USD"}: {formatPrice(chartPoints[lastIdx].price)} {priceLabel}

+ {effectiveMode === "usd" && hasApproxData && ( +

+ Dashed segments use approximate USD conversion +

+ )}
); } diff --git a/supabase/migrations/00031_trade_history_usd_rate.sql b/supabase/migrations/00031_trade_history_usd_rate.sql new file mode 100644 index 00000000..1e50cb61 --- /dev/null +++ b/supabase/migrations/00031_trade_history_usd_rate.sql @@ -0,0 +1,16 @@ +-- Add USD rate columns to trade_history for USD-denominated price charts. +-- reserve_usd_rate: the USD value of 1 reserve token (PLOT) at trade time. +-- rate_source: how the rate was obtained ('live', 'backfill_exact', 'backfill_approx'). + +alter table public.trade_history + add column reserve_usd_rate numeric, + add column rate_source text; + +comment on column public.trade_history.reserve_usd_rate is + 'USD value of 1 reserve token (PLOT) at the time of this trade'; +comment on column public.trade_history.rate_source is + 'How reserve_usd_rate was obtained: live | backfill_exact | backfill_approx'; + +alter table public.trade_history + add constraint trade_history_rate_source_check + check (rate_source in ('live', 'backfill_exact', 'backfill_approx'));