From 1ae83e4554824a359da38f57529951d595cf8a0e Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 3 Apr 2026 08:18:16 +0100 Subject: [PATCH 1/4] [#345] Add USD-denominated price chart support Add reserve_usd_rate column to trade_history to store PLOT/USD at trade time. Both cron and webhook indexers now fetch and persist the rate. PriceChart gains a USD/PLOT toggle with dashed lines for approximate historical data. Includes tiered backfill script (exact via archive RPC, approximate fallback). Fixes #345 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/reserve-usd-rate.ts | 80 +++++++ lib/supabase.ts | 6 + scripts/backfill-usd-rates.ts | 224 ++++++++++++++++++ src/app/api/cron/trade-history/route.ts | 8 + src/app/api/index/trade/route.ts | 6 + src/components/PriceChart.tsx | 186 ++++++++++++--- .../00031_trade_history_usd_rate.sql | 12 + 7 files changed, 491 insertions(+), 31 deletions(-) create mode 100644 lib/reserve-usd-rate.ts create mode 100644 scripts/backfill-usd-rates.ts create mode 100644 supabase/migrations/00031_trade_history_usd_rate.sql diff --git a/lib/reserve-usd-rate.ts b/lib/reserve-usd-rate.ts new file mode 100644 index 00000000..d879f345 --- /dev/null +++ b/lib/reserve-usd-rate.ts @@ -0,0 +1,80 @@ +/** + * 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 } from "./contracts/constants"; +import { priceForNextMintFunction } from "./contracts/abi"; + +const USDC_ADDRESS = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" as const; +const ONEINCH_SPOT_PRICE_AGGREGATOR = "0x00000000000D6FFc74A8feb35aF5827bf57f6786" as const; + +const SPOT_PRICE_ABI = [ + { + 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; + +/** + * 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: SPOT_PRICE_ABI, + functionName: "getRate", + args: [HUNT, USDC_ADDRESS, false], + }); + // USDC has 6 decimals, 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..86dd5219 --- /dev/null +++ b/scripts/backfill-usd-rates.ts @@ -0,0 +1,224 @@ +#!/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 } from "../lib/contracts/constants"; +import { priceForNextMintFunction } 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", +]; + +const USDC_ADDRESS = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" as const; +const ONEINCH_SPOT = "0x00000000000D6FFc74A8feb35aF5827bf57f6786" as const; + +const SPOT_PRICE_ABI = [ + { + 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; + +/** + * 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, + abi: SPOT_PRICE_ABI, + functionName: "getRate", + args: [HUNT, USDC_ADDRESS, 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; +} + +/** + * Fetch current PLOT/USD as approximate fallback. + */ +async function getCurrentPlotUsd(): Promise { + const client = createPublicClient({ + chain: base, + transport: http(ARCHIVE_RPCS[0], { timeout: 5_000, retryCount: 1 }), + }) as PublicClient; + + try { + const [plotInHuntWei, huntUsdRate] = await Promise.all([ + client.readContract({ + address: MCV2_BOND, + abi: [priceForNextMintFunction], + functionName: "priceForNextMint", + args: [PLOT_TOKEN], + }), + client.readContract({ + address: ONEINCH_SPOT, + abi: SPOT_PRICE_ABI, + functionName: "getRate", + args: [HUNT, USDC_ADDRESS, false], + }), + ]); + const plotInHunt = Number(formatEther(BigInt(plotInHuntWei))); + const huntUsd = Number(huntUsdRate) / 1_000_000; + return plotInHunt * huntUsd; + } catch { + 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.`); + + // Get current rate as approximate fallback + const approxRate = await getCurrentPlotUsd(); + if (approxRate !== null) { + console.log(`Current PLOT/USD (approx fallback): $${approxRate.toFixed(8)}`); + } else { + console.warn("WARNING: Could not fetch current PLOT/USD — skipping approximate fallback"); + } + + 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 for this block + const exactRate = await getExactHistoricalRate(BigInt(blockNumber)); + + 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..bf08a984 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,9 @@ export async function GET(req: Request) { toBlock, }); + // Fetch current PLOT/USD rate once per batch (same rate for all trades in this scan) + const reserveUsdRate = await getReserveUsdRate(); + let inserted = 0; let skipped = 0; let errors = 0; @@ -129,6 +133,7 @@ export async function GET(req: Request) { storylineId, supabase, getTimestamp, + reserveUsdRate, ); inserted++; } catch (err) { @@ -168,6 +173,7 @@ async function processTradeEvent( storylineId: number, supabase: SupabaseClient, getTimestamp: (blockNumber: bigint) => Promise, + reserveUsdRate: number | null, ) { const args = decoded.args as { token: `0x${string}`; @@ -220,6 +226,8 @@ async function processTradeEvent( 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 } = 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..a7708589 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,99 @@ 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; + 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} + {" "}· latest: {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..a8a059bf --- /dev/null +++ b/supabase/migrations/00031_trade_history_usd_rate.sql @@ -0,0 +1,12 @@ +-- 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'; From ba9b8008b034d910e98e86f6c72cee908952ca5d Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 3 Apr 2026 08:21:14 +0100 Subject: [PATCH 2/4] [#345] Address T2b review: deduplicate constants, add CHECK constraint - Extract ONEINCH_SPOT_PRICE_AGGREGATOR to lib/contracts/constants.ts - Extract spotPriceAbi to lib/contracts/abi.ts - Add CHECK constraint on rate_source column - Document USDC 6-decimal assumption Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/contracts/abi.ts | 15 +++++++ lib/contracts/constants.ts | 3 ++ lib/reserve-usd-rate.ts | 35 ++++++----------- scripts/backfill-usd-rates.ts | 39 +++++++------------ .../00031_trade_history_usd_rate.sql | 4 ++ 5 files changed, 48 insertions(+), 48 deletions(-) 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 index d879f345..cc9e6a61 100644 --- a/lib/reserve-usd-rate.ts +++ b/lib/reserve-usd-rate.ts @@ -9,25 +9,14 @@ import { formatEther } from "viem"; import { publicClient } from "./rpc"; -import { MCV2_BOND, PLOT_TOKEN, HUNT } from "./contracts/constants"; -import { priceForNextMintFunction } from "./contracts/abi"; - -const USDC_ADDRESS = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" as const; -const ONEINCH_SPOT_PRICE_AGGREGATOR = "0x00000000000D6FFc74A8feb35aF5827bf57f6786" as const; - -const SPOT_PRICE_ABI = [ - { - 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; +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. @@ -39,12 +28,12 @@ export async function getHuntPriceUSD( const rpc = client ?? publicClient; const weightedRate = await rpc.readContract({ address: ONEINCH_SPOT_PRICE_AGGREGATOR, - abi: SPOT_PRICE_ABI, + abi: spotPriceAbi, functionName: "getRate", - args: [HUNT, USDC_ADDRESS, false], + args: [HUNT, USDC, false], }); - // USDC has 6 decimals, HUNT has 18 → rate is scaled to 1e18 - // USD price = weightedRate / 1e6 + // 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; } diff --git a/scripts/backfill-usd-rates.ts b/scripts/backfill-usd-rates.ts index 86dd5219..33aebf43 100644 --- a/scripts/backfill-usd-rates.ts +++ b/scripts/backfill-usd-rates.ts @@ -18,8 +18,14 @@ 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 } from "../lib/contracts/constants"; -import { priceForNextMintFunction } from "../lib/contracts/abi"; +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 || ""; @@ -39,23 +45,6 @@ const ARCHIVE_RPCS = [ "https://base.drpc.org", ]; -const USDC_ADDRESS = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" as const; -const ONEINCH_SPOT = "0x00000000000D6FFc74A8feb35aF5827bf57f6786" as const; - -const SPOT_PRICE_ABI = [ - { - 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; - /** * Try to read PLOT/USD at a historical block using archive-capable RPCs. * Returns { rate, source: 'backfill_exact' } or null if all RPCs fail. @@ -77,10 +66,10 @@ async function getExactHistoricalRate(blockNumber: bigint): Promise { args: [PLOT_TOKEN], }), client.readContract({ - address: ONEINCH_SPOT, - abi: SPOT_PRICE_ABI, + address: ONEINCH_SPOT_PRICE_AGGREGATOR, + abi: spotPriceAbi, functionName: "getRate", - args: [HUNT, USDC_ADDRESS, false], + args: [HUNT, USDC, false], }), ]); const plotInHunt = Number(formatEther(BigInt(plotInHuntWei))); diff --git a/supabase/migrations/00031_trade_history_usd_rate.sql b/supabase/migrations/00031_trade_history_usd_rate.sql index a8a059bf..1e50cb61 100644 --- a/supabase/migrations/00031_trade_history_usd_rate.sql +++ b/supabase/migrations/00031_trade_history_usd_rate.sql @@ -10,3 +10,7 @@ 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')); From e7d0b5941d53c37a59212c4ab671a204f43a5041 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 3 Apr 2026 08:26:12 +0100 Subject: [PATCH 3/4] [#345] Fix T2a review: backfill accuracy, catch-up labeling, chart latest - Backfill script now reads historical HUNT/USD per-block instead of using a single current PLOT/USD for all rows - Cron indexer marks trades as 'backfill_approx' during catch-up scans (>200 blocks behind head) instead of falsely labeling as 'live' - PriceChart labels last USD point as "last USD" (not "latest") when newer trades exist without USD data Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/backfill-usd-rates.ts | 63 ++++++++++++++----------- src/app/api/cron/trade-history/route.ts | 12 ++++- src/components/PriceChart.tsx | 8 +++- 3 files changed, 52 insertions(+), 31 deletions(-) diff --git a/scripts/backfill-usd-rates.ts b/scripts/backfill-usd-rates.ts index 33aebf43..b16df002 100644 --- a/scripts/backfill-usd-rates.ts +++ b/scripts/backfill-usd-rates.ts @@ -86,35 +86,45 @@ async function getExactHistoricalRate(blockNumber: bigint): Promise { - const client = createPublicClient({ - chain: base, - transport: http(ARCHIVE_RPCS[0], { timeout: 5_000, retryCount: 1 }), - }) as PublicClient; - - try { - const [plotInHuntWei, huntUsdRate] = await Promise.all([ - client.readContract({ +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], - }), - client.readContract({ + }); + + // 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], - }), - ]); - const plotInHunt = Number(formatEther(BigInt(plotInHuntWei))); - const huntUsd = Number(huntUsdRate) / 1_000_000; - return plotInHunt * huntUsd; - } catch { - return null; + blockNumber, + }); + + const plotInHunt = Number(formatEther(BigInt(plotInHuntWei))); + const huntUsd = Number(huntUsdRate) / 1_000_000; + return plotInHunt * huntUsd; + } catch { + continue; + } } + return null; } async function main() { @@ -139,14 +149,6 @@ async function main() { console.log(`Found ${trades.length} trades missing USD rates.`); - // Get current rate as approximate fallback - const approxRate = await getCurrentPlotUsd(); - if (approxRate !== null) { - console.log(`Current PLOT/USD (approx fallback): $${approxRate.toFixed(8)}`); - } else { - console.warn("WARNING: Could not fetch current PLOT/USD — skipping approximate fallback"); - } - let exact = 0; let approx = 0; let failed = 0; @@ -162,9 +164,14 @@ async function main() { console.log(`Trades span ${blockGroups.size} unique blocks.`); for (const [blockNumber, blockTrades] of blockGroups) { - // Try exact historical rate for this block + // 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"; diff --git a/src/app/api/cron/trade-history/route.ts b/src/app/api/cron/trade-history/route.ts index bf08a984..fba1035c 100644 --- a/src/app/api/cron/trade-history/route.ts +++ b/src/app/api/cron/trade-history/route.ts @@ -87,8 +87,14 @@ export async function GET(req: Request) { toBlock, }); - // Fetch current PLOT/USD rate once per batch (same rate for all trades in this scan) + // Fetch current PLOT/USD rate once per batch. + // If scanning blocks far behind head (~200+ blocks / ~7 min), the current rate + // may not reflect trade-time pricing. Mark as 'live' only for near-head scans. const reserveUsdRate = await getReserveUsdRate(); + const isCatchUp = currentBlock - toBlock > BigInt(200); + const rateSource = reserveUsdRate !== null + ? (isCatchUp ? "backfill_approx" : "live") + : null; let inserted = 0; let skipped = 0; @@ -134,6 +140,7 @@ export async function GET(req: Request) { supabase, getTimestamp, reserveUsdRate, + rateSource, ); inserted++; } catch (err) { @@ -174,6 +181,7 @@ async function processTradeEvent( supabase: SupabaseClient, getTimestamp: (blockNumber: bigint) => Promise, reserveUsdRate: number | null, + rateSource: string | null, ) { const args = decoded.args as { token: `0x${string}`; @@ -227,7 +235,7 @@ async function processTradeEvent( contract_address: MCV2_BOND.toLowerCase(), user_address: args.receiver.toLowerCase(), reserve_usd_rate: reserveUsdRate, - rate_source: reserveUsdRate !== null ? "live" : null, + rate_source: rateSource, }; const { error } = await supabase diff --git a/src/components/PriceChart.tsx b/src/components/PriceChart.tsx index a7708589..e4bfa58d 100644 --- a/src/components/PriceChart.tsx +++ b/src/components/PriceChart.tsx @@ -216,6 +216,12 @@ export function PriceChart({ tokenAddress, currentPriceRaw }: PriceChartProps) { 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 (
@@ -339,7 +345,7 @@ export function PriceChart({ tokenAddress, currentPriceRaw }: PriceChartProps) {

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

{effectiveMode === "usd" && hasApproxData && ( From fbc242d04844127a724bafdb7c160bc2088f81e7 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 3 Apr 2026 08:30:09 +0100 Subject: [PATCH 4/4] [#345] Fix catch-up label: use fromBlock for conservative live/approx check Use fromBlock (oldest block in batch) instead of toBlock to determine catch-up status. Prevents the final catch-up batch from mislabeling older trades as 'live' when toBlock happens to be near head. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/cron/trade-history/route.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/api/cron/trade-history/route.ts b/src/app/api/cron/trade-history/route.ts index fba1035c..6505eaf9 100644 --- a/src/app/api/cron/trade-history/route.ts +++ b/src/app/api/cron/trade-history/route.ts @@ -88,10 +88,11 @@ export async function GET(req: Request) { }); // Fetch current PLOT/USD rate once per batch. - // If scanning blocks far behind head (~200+ blocks / ~7 min), the current rate - // may not reflect trade-time pricing. Mark as 'live' only for near-head scans. + // 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 - toBlock > BigInt(200); + const isCatchUp = currentBlock - fromBlock > BigInt(200); const rateSource = reserveUsdRate !== null ? (isCatchUp ? "backfill_approx" : "live") : null;