diff --git a/src/api/models/MinersDashboard.ts b/src/api/models/MinersDashboard.ts index 760b0fa..82ff447 100644 --- a/src/api/models/MinersDashboard.ts +++ b/src/api/models/MinersDashboard.ts @@ -1,3 +1,5 @@ +import { directionalRate, rateUnit } from '../../utils/format'; + // Each hub↔spoke leg is its own crown/pool: the forward SOL→spoke quotes plus // their SOL-hub reverses. Mirrors das-allways' Direction / allways.constants. export type Direction = 'SOL-BTC' | 'BTC-SOL' | 'SOL-TAO' | 'TAO-SOL'; @@ -37,6 +39,24 @@ export const decomposeDirection = ( }; }; +// Directional presentation of a canonical stored rate for `dir` — "to per 1 +// from", what the user receives per 1 sent. Stored rates (quotes, swaps, +// crown, rate history) are ALWAYS canonical "spoke per 1 SOL"; reverse legs +// invert here, at the presentation boundary. +export const directionalRateFor = ( + dir: Direction, + rate: string | number | null | undefined, +): number | null => { + const { from, to } = decomposeDirection(dir); + return directionalRate(from, to, rate); +}; + +// "BTC/SOL" — compact unit for directionalRateFor's output (to per 1 from). +export const rateUnitFor = (dir: Direction): string => { + const { from, to } = decomposeDirection(dir); + return rateUnit(from, to); +}; + export type CurrentCrown = { uid: number | null; hotkey: string | null; diff --git a/src/components/agents/RateQuoteHelper.tsx b/src/components/agents/RateQuoteHelper.tsx index 4979a2a..9092ff8 100644 --- a/src/components/agents/RateQuoteHelper.tsx +++ b/src/components/agents/RateQuoteHelper.tsx @@ -15,6 +15,7 @@ import { ALL_DIRECTIONS, decomposeDirection, directionLabel, + directionalRateFor, useMiners, type Direction, } from '../../api'; @@ -25,10 +26,10 @@ import { formatRate, trimTrailingZeros } from '../../utils/format'; interface BestQuote { uid: number | null; hotkey: string; - // The miner's quote for the chosen leg, "dest per 1 source" (forward = - // m.rate, reverse = m.counterRate — see Miner model docs). + // The miner's STORED quote for the chosen leg — canonical "spoke per 1 SOL" + // (forward = m.rate, reverse = m.counterRate — see Miner model docs). rawRate: string; - // Same value, as a number: destSym per 1 sourceSym. + // Directional "to per 1 from" of the chosen direction (reverse inverts). effectiveRate: number; out: string; } @@ -48,9 +49,10 @@ const computeBest = ( ): BestQuote | null => { // A miner serves one hub↔spoke pair; canonical order pins SOL as source, so // its spoke is the non-SOL leg. The forward leg (SOL→spoke) is quoted in - // m.rate, the reverse (spoke→SOL) in m.counterRate — both "dest per 1 - // source", so more output per unit in is always the better deal (highest - // first). Filter case-insensitively so an API casing change can't zero this. + // m.rate, the reverse (spoke→SOL) in m.counterRate — both stored CANONICAL + // ("spoke per 1 SOL"). Convert to the directional "to per 1 from" so more + // output per unit in is always the better deal (highest first). Filter + // case-insensitively so an API casing change can't zero this. const { spoke, leg } = decomposeDirection(direction); const candidates = miners .filter((m) => m.isActive) @@ -61,7 +63,7 @@ const computeBest = ( if (minerSpoke !== spoke) return null; const r = leg === 'reverse' ? m.counterRate : m.rate; if (!r) return null; - const parsed = parseFloat(r); + const parsed = directionalRateFor(direction, r) ?? 0; if (!isFinite(parsed) || parsed <= 0) return null; return { uid: m.uid, hotkey: m.hotkey, rawRate: r, parsed }; }) @@ -173,9 +175,14 @@ const RateQuoteHelper: React.FC = () => { // The miner row is canonical (sourceChain=sol, destChain=spoke); the leg // picks which quote to read (forward = .rate, reverse = .counterRate). Both - // are "dest per 1 source", so the best deal is always the highest. + // stored values are canonical "spoke per 1 SOL", so the best FORWARD quote + // is the highest stored value and the best REVERSE quote the lowest. const rateField = leg === 'reverse' ? '.counterRate' : '.rate'; - const curlCmd = `curl -s https://api.all-ways.io/miners | jq '.[] | select(.isActive and (.sourceChain | ascii_downcase) == "sol" and (.destChain | ascii_downcase) == "${spoke}") | {uid, rate: ${rateField}, hotkey}' | jq -s 'sort_by(-(.rate | tonumber))[0]'`; + const jqSort = + leg === 'reverse' + ? 'sort_by(.rate | tonumber)' + : 'sort_by(-(.rate | tonumber))'; + const curlCmd = `curl -s https://api.all-ways.io/miners | jq '.[] | select(.isActive and (.sourceChain | ascii_downcase) == "sol" and (.destChain | ascii_downcase) == "${spoke}") | {uid, rate: ${rateField}, hotkey} | select((.rate // "0" | tonumber) > 0)' | jq -s '${jqSort}[0]'`; return ( Executed rate of recently completed swaps (points) with an EMA (line) over the most recent window, plus a dashed line at the - live crown rate. X-axis is time, y-axis is rate in SOL. + live crown rate. X-axis is time; y-axis is the directional rate + — what you receive per 1 you send for the selected direction. } arrow diff --git a/src/components/dashboard/MarketRateChart.tsx b/src/components/dashboard/MarketRateChart.tsx index df598e5..66f71ef 100644 --- a/src/components/dashboard/MarketRateChart.tsx +++ b/src/components/dashboard/MarketRateChart.tsx @@ -11,6 +11,8 @@ import { Box, Typography, useTheme, type Theme } from '@mui/material'; import { useAllSwaps, useCurrentCrown } from '../../api'; import { directionLabel, + directionalRateFor, + rateUnitFor, type Direction, } from '../../api/models/MinersDashboard'; import { FONTS } from '../../theme'; @@ -43,14 +45,15 @@ const accentFor = (theme: Theme, index: number) => const labelFor = directionLabel; -// The market-rate chart. With one direction it shows that direction's scatter + -// EMA (with a gradient area fill), live crown reference, and per-timestamp -// volume with a max-volume marker. With two directions it overlays both on a -// single shared price scale — the vertical gap between the two EMA lines IS the -// directional spread — drawing each in its own accent (BTC orange / primary -// blue) over a shared time x-axis and a shared SOL-volume sub-chart. Both modes -// run through one option builder; the few visual differences are gated on the -// number of directions. +// The market-rate chart. All rates render DIRECTIONALLY ("to per 1 from" — +// completedPoints converts the canonical stored values; the crown reference is +// converted here). With one direction it shows that direction's scatter + EMA +// (with a gradient area fill), live crown reference, and per-timestamp volume +// with a max-volume marker. With two directions it overlays both on a single +// shared price scale — only meaningful for same-orientation directions (e.g. +// SOL→BTC vs SOL→TAO); a forward and its reverse now live on reciprocal +// scales. Both modes run through one option builder; the few visual +// differences are gated on the number of directions. const MarketRateChart: React.FC<{ directions: Direction[]; fill?: boolean; @@ -110,7 +113,8 @@ const MarketRateChart: React.FC<{ const prepared = series.map((s, i) => ({ ...s, accent: accentFor(theme, i), - crownRate: crown?.[s.dir]?.rate ?? null, + // Directional, matching the (already-converted) scatter/EMA scale. + crownRate: directionalRateFor(s.dir, crown?.[s.dir]?.rate), })); // One shared price range across every direction's rates + EMAs + crowns, so @@ -128,8 +132,8 @@ const MarketRateChart: React.FC<{ // Adaptive y-axis precision: a wide span (e.g. once a far-off crown rate is // included) reads fine as integers, but a tight band would collapse every // tick to the same rounded value — so show decimals when the span is small. - // Below 0.01 a fixed 2dp collapsed a whole BTC-scale axis (~0.0021 SOL) to - // "0.00", so scale the decimals to the span's magnitude instead. + // Below 0.01 a fixed 2dp collapsed a whole SOL→BTC axis (~0.0021 BTC/SOL) + // to "0.00", so scale the decimals to the span's magnitude instead. const ySpan = yRange ? yRange.max - yRange.min : 0; const yDecimals = ySpan <= 0 @@ -185,7 +189,7 @@ const MarketRateChart: React.FC<{ // Dashed reference line at a direction's live crown rate so the chart shows // where "now" sits versus recent fills. Keep the label inside the frame: // when the crown sits in the top half, render below it, else above. - const crownMarkLine = (rate: number | null, color: string) => + const crownMarkLine = (rate: number | null, unit: string, color: string) => rate != null ? { silent: true, @@ -200,7 +204,7 @@ const MarketRateChart: React.FC<{ color, fontFamily: FONTS.mono, fontSize: 9, - formatter: `crown ${formatRate(rate)} SOL`, + formatter: `crown ${formatRate(rate)} ${unit}`, }, } : undefined; @@ -244,7 +248,11 @@ const MarketRateChart: React.FC<{ }, }), z: 3, - markLine: crownMarkLine(s.crownRate, single ? crownColor : s.accent), + markLine: crownMarkLine( + s.crownRate, + rateUnitFor(s.dir), + single ? crownColor : s.accent, + ), }, ]); @@ -290,13 +298,21 @@ const MarketRateChart: React.FC<{ }[], ) => { const t = params[0]?.axisValue; + // Rate series carry their direction's unit; single mode names them + // 'Rate'/'EMA', so fall back to the lone direction's unit. + const fallbackUnit = rateUnitFor(prepared[0].dir); + const unitByName = new Map( + prepared.map((s) => [labelFor(s.dir), rateUnitFor(s.dir)]), + ); const lines = params .map((p) => { const v = Array.isArray(p.value) ? p.value[1] : p.value; const isVolume = p.seriesName === 'Volume'; - const unit = isVolume ? 'SOL vol' : 'SOL'; + const unit = isVolume + ? 'SOL vol' + : (unitByName.get(p.seriesName) ?? fallbackUnit); // Volume is an amount (2dp reads fine); a rate needs sig figs - // or a BTC leg (~0.0021 SOL) shows as "0.00". + // or SOL→BTC (~0.0021 BTC/SOL) shows as "0.00". const shown = isVolume ? Number(v).toFixed(2) : formatRate(Number(v)); diff --git a/src/components/dashboard/MinerRatesTable.tsx b/src/components/dashboard/MinerRatesTable.tsx index 5a313bd..e72fce6 100644 --- a/src/components/dashboard/MinerRatesTable.tsx +++ b/src/components/dashboard/MinerRatesTable.tsx @@ -29,7 +29,12 @@ import { import { FONTS } from '../../theme'; import CopyableAddress from '../CopyableAddress'; import { MinerRatesTableSkeleton } from './Skeletons'; -import { formatRate } from '../../utils/format'; +import { + HUB_CHAIN, + directionalRate, + formatRate, + rateUnit, +} from '../../utils/format'; type SortKey = 'uid' | 'rate' | 'collateral' | 'status'; type SortDir = 'asc' | 'desc'; @@ -62,11 +67,23 @@ const parseRate = (raw: string | null): number => { const statusRank = (m: Miner) => !m.isActive ? 3 : m.hasActiveSwap ? 2 : m.isReserved ? 1 : 0; -// The quoted rate for the active leg. Both rates are "dest per 1 source", so a -// higher value is always more output per unit in — best-first is desc for every -// direction. forward = SOL→spoke (m.rate); reverse = spoke→SOL (m.counterRate). -const directionRate = (m: Miner, filter: DirectionFilter): number => - filter === 'reverse' ? parseRate(m.counterRate) : parseRate(m.rate); +// The leg's (from, to) chains: forward = SOL→spoke (m.rate), reverse = +// spoke→SOL (m.counterRate). Both STORED values are canonical "spoke per 1 +// SOL" (see api/models/Miners.ts) — never per-direction. +const legChains = (m: Miner, filter: DirectionFilter): [string, string] => { + const spoke = minerSpoke(m) ?? ''; + return filter === 'reverse' ? [spoke, HUB_CHAIN] : [HUB_CHAIN, spoke]; +}; + +// The DIRECTIONAL rate for the active leg ("to per 1 from" — what the user +// receives per 1 sent; the reverse leg inverts the canonical stored value). +// Higher is always more output per unit in — best-first is desc for every +// direction. +const directionRate = (m: Miner, filter: DirectionFilter): number => { + const [from, to] = legChains(m, filter); + const raw = filter === 'reverse' ? m.counterRate : m.rate; + return directionalRate(from, to, raw) ?? 0; +}; const getSortValue = ( m: Miner, @@ -159,9 +176,9 @@ const MinerRatesTable: React.FC<{ syncDirection?: Direction }> = ({ const [statusFilter, setStatusFilter] = useState('active'); // When the EMA chart's direction flips, re-default the table to the most - // advantageous rate first. Both legs quote "dest per source", so more output - // is always better → highest first (desc) for every direction. A manual - // re-sort persists until the next flip. + // advantageous rate first. Rates sort DIRECTIONALLY ("to per 1 from"), so + // more output is always better → highest first (desc) for every direction. + // A manual re-sort persists until the next flip. useEffect(() => { setSortKey('rate'); setSortDir('desc'); @@ -211,21 +228,19 @@ const MinerRatesTable: React.FC<{ syncDirection?: Direction }> = ({ const hasSearch = search.trim().length > 0; const renderRate = (m: Miner) => { - const v = - directionFilter === 'reverse' - ? parseRate(m.counterRate) - : parseRate(m.rate); + const v = directionRate(m, directionFilter); if (v <= 0) return ( {'—'} ); + const [from, to] = legChains(m, directionFilter); return ( {formatRate(v)} - SOL + {rateUnit(from, to)} ); diff --git a/src/components/dashboard/OrderbookDepth.tsx b/src/components/dashboard/OrderbookDepth.tsx index 3ee9a5c..759c7c3 100644 --- a/src/components/dashboard/OrderbookDepth.tsx +++ b/src/components/dashboard/OrderbookDepth.tsx @@ -17,6 +17,8 @@ import { useMiners, type Miner } from '../../api'; import { decomposeDirection, directionLabel, + directionalRateFor, + rateUnitFor, type Direction, } from '../../api/models/MinersDashboard'; import { FONTS } from '../../theme'; @@ -35,8 +37,9 @@ const minerSpoke = (m: Miner): string | null => { // Depth of market for the page's active direction: hittable collateral grouped // by quoted rate, best rate first, with a cumulative running total. Follows the // Active Rates table's direction semantics — forward = SOL→spoke (m.rate), -// reverse = spoke→SOL (m.counterRate); both quote "dest per 1 source", so -// higher is always the better rate. +// reverse = spoke→SOL (m.counterRate); stored values are canonical and are +// converted to the DIRECTIONAL "to per 1 from" here, so higher is always the +// better rate. const OrderbookDepth: React.FC<{ direction: Direction; embedded?: boolean; @@ -80,7 +83,7 @@ const OrderbookDepth: React.FC<{ const capacitySol = parseInt(m.collateral, 10) / 1e9; if (!Number.isFinite(capacitySol) || capacitySol <= 0) return; const raw = leg === 'reverse' ? m.counterRate : m.rate; - const r = raw ? parseFloat(raw) : 0; + const r = directionalRateFor(direction, raw) ?? 0; if (!Number.isFinite(r) || r <= 0) return; // formatRate is the price LEVEL key — it groups miners, sorts the book, // and is rendered verbatim (2dp would collapse BTC-scale quotes to 0.00). @@ -88,8 +91,8 @@ const OrderbookDepth: React.FC<{ groups[key] = (groups[key] || 0) + capacitySol; }); - // Both legs quote "dest per 1 source" — more output per unit in is always - // better, so best-first is highest-first for every direction. + // Levels are directional "to per 1 from" — more output per unit in is + // always better, so best-first is highest-first for every direction. const rates = Object.keys(groups).sort( (a, b) => parseFloat(b) - parseFloat(a), ); @@ -100,7 +103,7 @@ const OrderbookDepth: React.FC<{ cum += capacity; return { rate: key, capacity, cumCapacity: cum }; }); - }, [miners, spoke, leg]); + }, [miners, spoke, leg, direction]); const maxCum = useMemo( () => @@ -189,7 +192,9 @@ const OrderbookDepth: React.FC<{ - Rate + + Rate ({rateUnitFor(direction)}) + Capacity (SOL) diff --git a/src/components/dashboard/RatesTicker.tsx b/src/components/dashboard/RatesTicker.tsx index fc529cc..1966571 100644 --- a/src/components/dashboard/RatesTicker.tsx +++ b/src/components/dashboard/RatesTicker.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Box, Stack } from '@mui/material'; import { useAllSwaps, useCurrentCrown } from '../../api'; -import { ALL_DIRECTIONS } from '../../api/models/MinersDashboard'; +import { ALL_DIRECTIONS, rateUnitFor } from '../../api/models/MinersDashboard'; import { FONTS } from '../../theme'; import { BlockIndicator } from '../index'; import Ticker from '../Ticker'; @@ -12,9 +12,10 @@ import { formatRate } from '../../utils/format'; const DIRECTIONS = ALL_DIRECTIONS; // Mirrors the miners-page StickyNetworkHeader eyebrow — the "updated " -// indicator plus the current crown holder and its live rate per direction -// (uid N @ rate SOL), then the smoothed EMA market rate. No last-refresh / -// health segment. Both share CrownDirectionSegment; only the EMA suffix is ours. +// indicator plus the current crown holder and its live directional rate per +// direction (uid N @ rate TO/FROM), then the smoothed EMA market rate. No +// last-refresh / health segment. Both share CrownDirectionSegment; only the +// EMA suffix is ours. const RatesTicker: React.FC = () => { const { data: crown } = useCurrentCrown(); const { data: swaps } = useAllSwaps({ limit: 600 }); @@ -86,7 +87,7 @@ const RatesTicker: React.FC = () => { fontWeight: 400, }} > - SOL + {rateUnitFor(dir)} diff --git a/src/components/dashboard/marketRate.ts b/src/components/dashboard/marketRate.ts index f527161..3dd75ea 100644 --- a/src/components/dashboard/marketRate.ts +++ b/src/components/dashboard/marketRate.ts @@ -1,6 +1,7 @@ import type { ActiveSwap } from '../../api/models'; import { decomposeDirection, + directionalRateFor, type Direction, } from '../../api/models/MinersDashboard'; import { lamportsToSol } from '../../utils/format'; @@ -11,6 +12,9 @@ export const WINDOW = 100; export const EMA_PERIOD = 10; // `t` is the completion time in unix seconds; `vol` is the SOL numeraire volume. +// `rate` is DIRECTIONAL ("to per 1 from" of the requested direction) — the +// canonical stored swap rate is converted at this boundary, BEFORE the EMA / +// Tukey math, so all downstream chart values share the directional scale. export type RatePoint = { t: number; rate: number; vol: number }; const matchesDirection = (s: ActiveSwap, dir: Direction): boolean => { @@ -39,7 +43,7 @@ export const completedPoints = ( const vol = s.solAmount ? lamportsToSol(s.solAmount) : 0; return { t: parseInt(s.completedAt as string, 10), - rate: parseFloat(s.rate as string), + rate: directionalRateFor(dir, s.rate) ?? 0, vol: Number.isFinite(vol) ? vol : 0, }; }) diff --git a/src/components/miners/CrownDirectionSegment.tsx b/src/components/miners/CrownDirectionSegment.tsx index 6f9784c..3675ccf 100644 --- a/src/components/miners/CrownDirectionSegment.tsx +++ b/src/components/miners/CrownDirectionSegment.tsx @@ -1,10 +1,15 @@ import React from 'react'; import { Box, Stack, Typography } from '@mui/material'; -import type { CurrentCrown, Direction } from '../../api/models/MinersDashboard'; +import { + directionalRateFor, + rateUnitFor, + type CurrentCrown, + type Direction, +} from '../../api/models/MinersDashboard'; import { formatRate } from '../../utils/format'; import CrownIcon from './CrownIcon'; -// One eyebrow segment: "👑 sol → btc uid 2 @ 0.0021 SOL". Shared by the miners +// One eyebrow segment: "👑 sol → btc uid 2 @ 0.0021 BTC/SOL". Shared by the miners // page (StickyNetworkHeader) and the dashboard (RatesTicker), which previously // carried byte-identical copies of this markup — so a rate-formatting fix in one // silently skipped the other. @@ -50,9 +55,18 @@ const CrownDirectionSegment: React.FC<{ }} > uid {holder.uid} - {/* Significant figures, not 2dp: a BTC leg quotes ~0.0021 SOL, which + {/* Directional "to per 1 from" via directionalRateFor. Significant + figures, not 2dp: SOL→BTC quotes ~0.0021 BTC/SOL, which toFixed(2) renders as a flat "0.00". */} - {holder.rate != null && <> @ {formatRate(holder.rate)} SOL} + {holder.rate != null && ( + <> + {' '} + @ {formatRate( + directionalRateFor(direction, holder.rate) ?? 0, + )}{' '} + {rateUnitFor(direction)} + + )} ) : ( = ({ hover, isDark }) => { +}> = ({ hover, direction, isDark }) => { const { cell, x, y } = hover; const bg = isDark ? 'rgba(8,10,14,0.97)' : 'rgba(255,255,255,0.98)'; const border = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(9,11,13,0.18)'; @@ -124,7 +130,10 @@ const CrownGridHoverCard: React.FC<{ })} /> {cell.holderHotkey && ( - + )} {cell.isTie && ( - {hover && } + {hover && ( + + )} {subjectAbsent && ( = { // One panel per direction. Monochrome like Network Stats — the line is the // theme's primary text shade, the crown reference (miner mode) the disabled -// shade. Every rate is "to per 1 from" ("1 {from} = {value} {to}") for all -// four. +// shade. Every rendered rate is directional "to per 1 from" ("1 {from} = +// {value} {to}") for all four — seriesByDir converts the canonical stored +// values at ingest. const DIRECTION_META: Record< Direction, { @@ -155,23 +157,31 @@ const CrownRateChart: React.FC<{ const lo = Math.max(0, head - secs + 1); // {crown, miner} rows per direction, clipped to the shared window so all - // four panels cover the same time span. + // four panels cover the same time span. Stored rates are canonical "spoke + // per 1 SOL"; convert to the panel's directional "to per 1 from" HERE so + // every downstream value (line, header, tooltip) shares one scale. const seriesByDir = useMemo(() => { const inRange = (arr: T[] | undefined) => (arr ?? []).filter((p) => p.t >= lo && p.t <= head); - const strip = (rows: CrownRateHistoryRow[]): RateRow[] => - rows.map((r) => ({ t: r.t, rate: r.rate })); + const toDirectional = ( + dir: Direction, + rows: { t: number; rate: number }[], + ): RateRow[] => + rows.map((r) => ({ t: r.t, rate: directionalRateFor(dir, r.rate) ?? 0 })); const minerFor = (direction: Direction): RateRow[] => { if (!minerHotkey) return []; const { from, to } = decomposeDirection(direction); - return inRange(minerRates ?? []) - .filter((r) => r.fromChain === from && r.toChain === to) - .map((r) => ({ t: r.t, rate: r.rate })); + return toDirectional( + direction, + inRange(minerRates ?? []).filter( + (r) => r.fromChain === from && r.toChain === to, + ), + ); }; return ALL_DIRECTIONS.reduce( (acc, dir) => { acc[dir] = { - crown: strip(inRange(crownByDir[dir])), + crown: toDirectional(dir, inRange(crownByDir[dir])), miner: minerFor(dir), }; return acc; diff --git a/src/components/miners/MinerDetailHeader.tsx b/src/components/miners/MinerDetailHeader.tsx index 4eefa06..d28f565 100644 --- a/src/components/miners/MinerDetailHeader.tsx +++ b/src/components/miners/MinerDetailHeader.tsx @@ -3,7 +3,13 @@ import { Box, Button, Stack, Typography, alpha, useTheme } from '@mui/material'; import type { MinerStats, Range } from '../../api'; import type { Miner } from '../../api/models/Miners'; import { FONTS } from '../../theme'; -import { formatRate, formatSol, formatUnixTime } from '../../utils/format'; +import { + directionalRate, + formatRate, + formatSol, + formatUnixTime, + rateUnit, +} from '../../utils/format'; import CopyableAddress from '../CopyableAddress'; import CrownIcon from './CrownIcon'; @@ -188,18 +194,21 @@ const MinerDetailHeader: React.FC<{ }> = ({ hotkey, uid, stats, liveMiner, range, onRangeChange }) => { const theme = useTheme(); const crownDirections = stats?.currentCrownDirections ?? []; - // On-chain commitment is canonicalized so the hub (SOL) is pinned as source. - // `rate` is source→dest; `counterRate` is the reverse leg. - const fwdRate = parseFloat(liveMiner?.rate ?? '0'); - const revRate = parseFloat(liveMiner?.counterRate ?? '0'); + // On-chain commitment is canonicalized so the hub (SOL) is pinned as source; + // both stored rates are canonical "spoke per 1 SOL". Render each leg + // DIRECTIONALLY — "to per 1 from" of that leg (the reverse inverts). + const src = liveMiner?.sourceChain?.toLowerCase() ?? null; + const dst = liveMiner?.destChain?.toLowerCase() ?? null; + const fwdRate = + src && dst ? (directionalRate(src, dst, liveMiner?.rate) ?? 0) : 0; + const revRate = + src && dst ? (directionalRate(dst, src, liveMiner?.counterRate) ?? 0) : 0; const fwdLabel = - liveMiner?.sourceChain && liveMiner?.destChain - ? `${liveMiner.sourceChain.toUpperCase()} → ${liveMiner.destChain.toUpperCase()}` - : null; + src && dst ? `${src.toUpperCase()} → ${dst.toUpperCase()}` : null; const revLabel = - liveMiner?.sourceChain && liveMiner?.destChain - ? `${liveMiner.destChain.toUpperCase()} → ${liveMiner.sourceChain.toUpperCase()}` - : null; + src && dst ? `${dst.toUpperCase()} → ${src.toUpperCase()}` : null; + const fwdUnit = src && dst ? rateUnit(src, dst) : ''; + const revUnit = src && dst ? rateUnit(dst, src) : ''; return ( )} - {/* Significant figures, not 2dp — a BTC quote (~0.0021 SOL) read "0.00". */} + {/* Significant figures, not 2dp — SOL→BTC (~0.0021 BTC/SOL) read "0.00". */} {fwdRate > 0 && fwdLabel && ( {formatRate(fwdRate)} - SOL + {fwdUnit} )} @@ -330,7 +339,7 @@ const MinerDetailHeader: React.FC<{ {formatRate(revRate)} - SOL + {revUnit} )} diff --git a/src/components/miners/RateHistoryTable.tsx b/src/components/miners/RateHistoryTable.tsx index fe138da..6b76af7 100644 --- a/src/components/miners/RateHistoryTable.tsx +++ b/src/components/miners/RateHistoryTable.tsx @@ -19,7 +19,7 @@ import { type MinerRateHistoryRow, } from '../../api'; import { FONTS } from '../../theme'; -import { formatTimeAgo } from '../../utils/format'; +import { directionalRate, formatTimeAgo, rateUnit } from '../../utils/format'; const MAX_ROWS = 50; @@ -42,7 +42,9 @@ const pairLabel = (row: MinerRateHistoryRow): string => { }; // Chronological table twin of the rate graph above it — every quote this -// miner posted, newest first. A QuoteRemoved lands as rate 0. +// miner posted, newest first. A QuoteRemoved lands as rate 0. Stored rates +// are canonical "spoke per 1 SOL"; each row renders DIRECTIONALLY ("to per 1 +// from" of its own pair), unit-labeled. const RateHistoryTable: React.FC<{ hotkey: string; direction: Direction | null; @@ -170,7 +172,18 @@ const RateHistoryTable: React.FC<{ removed ) : ( - fmtRate(row.rate) + <> + {fmtRate( + directionalRate(row.fromChain, row.toChain, row.rate) ?? + 0, + )} + + {rateUnit(row.fromChain, row.toChain)} + + )} diff --git a/src/utils/format.ts b/src/utils/format.ts index 3083340..f14e6ad 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -144,6 +144,48 @@ export const formatRateLine = ( export const chainSymbol = (chain: string): string => CHAIN_DECIMALS[chain.toLowerCase()]?.symbol ?? chain.toUpperCase(); +// ── Directional rate display ── +// Machines store ONE canonical denomination per pair: "dest per 1 canonical +// source" with the hub pinned as source — the same unit in BOTH direction +// quotes (see api/models/Miners.ts). Humans always read "what you receive +// per 1 you send", so a reverse leg inverts at presentation. +// Mirror of allways.utils.rate.directional_rate / chains.canonical_pair — +// keep in lockstep. + +// canonical_pair ordering: hub is always source; else TAO is dest (legacy +// non-hub pairs); else alphabetical. +const canonicalSource = (a: string, b: string): string => { + if (a === HUB_CHAIN || b === HUB_CHAIN) return HUB_CHAIN; + if (b === 'tao') return a; + if (a === 'tao') return b; + return a < b ? a : b; +}; + +export const isReverseLeg = (fromChain: string, toChain: string): boolean => { + const from = fromChain.toLowerCase(); + return from !== canonicalSource(from, toChain.toLowerCase()); +}; + +// Canonical stored rate → directional number for the (from → to) leg. +// null when the input is missing/unparseable; 0 passes through (not offered). +export const directionalRate = ( + fromChain: string, + toChain: string, + canonicalRate: string | number | null | undefined, +): number | null => { + const n = + typeof canonicalRate === 'string' + ? parseFloat(canonicalRate) + : (canonicalRate ?? NaN); + if (!Number.isFinite(n)) return null; + return n > 0 && isReverseLeg(fromChain, toChain) ? 1 / n : n; +}; + +// "BTC/SOL" — the compact unit label matching directionalRate's output +// ("to per 1 from"). The one source for every rate suffix in the app. +export const rateUnit = (fromChain: string, toChain: string): string => + `${chainSymbol(toChain)}/${chainSymbol(fromChain)}`; + // ── Time formatting (unix seconds) ── // The chain is time-native now: all lifecycle timestamps and windows are unix // seconds. These replace the old block-count estimators.