Files
observer/web/src/components/MarketPage.tsx
T
grenadeandClaude Opus 5.5 2d80325c3b
ci / gate (push) Failing after 1m34s
feat(api): solver inventory per side of each pair, read from our node
NEAR Intents has no order book: solvers quote privately per request and
nothing on chain carries a price level. What is on chain is the inventory
they fill from, inside intents.near — and intents.near is on the shard our
node tracks, so mt_batch_balance_of is answered locally in about 40 ms.

The scan now records every solver it sees filling any pair — a named account
with no referral, which the #30 survey found to be solvers without exception
— in intents_solver (migration 0011), so the list survives restarts and is
there before QTC has a trade of its own. Once a minute the observer asks
intents.near what each holds of the asset and every counter traded against
it, and serves it at /intents/inventory: the asset as the side that serves
buyers, each counter as the side that serves sellers, with how many solvers
hold it and the largest holder.

The market page shows it as a table, the asset and the ten counters most
solvers hold with the rest folded, under a note that says what it is not: no
price levels, shared across pairs so the rows do not add up, and not a
ceiling. Checked live against our node with wNEAR configured: 11 solvers,
74 tokens read in one pass. Before QTC is listed the panel's job is its one
row — when solvers start stocking QTC, it shows here before the first trade.

Closes #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
2026-10-03 23:12:32 +03:00

222 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* One market: what NEAR Intents has settled for this chain's token.
*
* Three readings, top to bottom, in order of how settled they are. The price
* figures and the listing evidence first — the same panel the network page
* carries — then swaps as they settle, then the history they join.
*
* Empty is a state here, not an error. Until the token is bridged, the honest
* page is the evidence that nothing can have traded, and that is what the
* header shows; the feed and the history then say plainly that there is
* nothing in them yet. blackbeard/observer#32.
*/
import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import type { IntentsSwap } from '../api/generated/IntentsSwap'
import type { IntentsSwaps } from '../api/generated/IntentsSwaps'
import { RequestFailed, fetchSwapsNewest } from '../api/rest'
import { height as fmtHeight } from '../lib/format'
import { useObserverInstance } from '../lib/observer-context'
import { href } from '../lib/routes'
import { IntentsPanel } from './IntentsPanel'
import { InventoryPanel } from './InventoryPanel'
import { PriceChart } from './PriceChart'
import { SwapHead, SwapRow } from './SwapRow'
/** The venues this page knows how to show. */
const KNOWN = new Set(['near-intents'])
/** Lines the live feed keeps. Older ones are in the history below it. */
const LIVE_LINES = 12
export function MarketPage({
chain,
market,
decimals,
symbol,
}: {
chain: string
market: string
decimals: number
symbol: string
}) {
if (!KNOWN.has(market)) {
return (
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">No such market</h2>
</div>
<p className="empty">
This chain&apos;s token is not followed on a market called “{market}”.{' '}
<Link to={href({ chain, index: 'market' })}>The markets it is followed on</Link>.
</p>
</section>
)
}
return <NearIntents chain={chain} decimals={decimals} symbol={symbol} />
}
function NearIntents({
chain,
decimals,
symbol,
}: {
chain: string
decimals: number
symbol: string
}) {
const [history, setHistory] = useState<IntentsSwaps | null>(null)
const [older, setOlder] = useState<IntentsSwap[]>([])
const [error, setError] = useState<string | null>(null)
const [loadingOlder, setLoadingOlder] = useState(false)
const [live, setLive] = useState<{ key: number; swap: IntentsSwap }[]>([])
// A monotonic key rather than height-and-index: the wormhole console's keys
// looked unique and were not, and React given duplicate keys renders
// duplicates and stops honouring the list's own length cap.
const nextKey = useRef(0)
const observer = useObserverInstance()
useEffect(() => {
const controller = new AbortController()
setHistory(null)
setOlder([])
setError(null)
setLive([])
fetchSwapsNewest(chain, null, controller.signal)
.then(setHistory)
.catch((e: unknown) => {
if (controller.signal.aborted) return
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
})
return () => controller.abort()
}, [chain])
useEffect(
() =>
observer.onIntentsSwap((swap) => {
const key = nextKey.current++
setLive((rows) => [{ key, swap }, ...rows].slice(0, LIVE_LINES))
}),
[observer],
)
const asset = history?.asset_symbol ?? symbol
const assetDecimals = history?.asset_decimals ?? decimals
// The previous page's `next`, carried forward as each older page is read.
const cursor = history?.next ?? null
const loadOlder = () => {
if (!cursor) return
setLoadingOlder(true)
fetchSwapsNewest(chain, cursor)
.then((page) => {
setOlder((rows) => [...rows, ...page.swaps])
setHistory((h) => (h ? { ...h, next: page.next } : page))
})
.catch((e: unknown) =>
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.'),
)
.finally(() => setLoadingOlder(false))
}
const rows = history ? [...history.swaps, ...older] : []
const range =
history && history.indexed_from !== null && history.indexed_to !== null
? `NEAR blocks ${fmtHeight(history.indexed_from)}–${fmtHeight(history.indexed_to)}`
: 'nothing read yet'
return (
<>
<p className="panel-note" style={{ marginBottom: 12 }}>
<Link to={href({ chain, index: 'market' })}>Markets</Link> › NEAR Intents
</p>
<IntentsPanel chain={chain} decimals={decimals} symbol={symbol} tiles={8} table />
<PriceChart chain={chain} symbol={symbol} />
<InventoryPanel chain={chain} symbol={symbol} />
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">Settling now</h2>
<span className="eyebrow">pushed as each block is read</span>
</div>
{live.length === 0 ? (
<p className="empty">
Waiting for the next {asset} swap to settle. Nothing appears here that has not.
</p>
) : (
<div className="scroll-x">
<table className="board">
<caption className="visually-hidden">Swaps as they settle, newest first</caption>
<SwapHead asset={asset} />
<tbody>
{live.map(({ key, swap }) => (
<SwapRow
key={key}
swap={swap}
asset={asset}
decimals={assetDecimals}
fresh
relative
/>
))}
</tbody>
</table>
</div>
)}
</section>
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">Settled</h2>
<span className="eyebrow">{range}</span>
</div>
{error && <p className="empty">{error}</p>}
{!error && !history && <p className="empty">Reading…</p>}
{history && rows.length === 0 && (
<p className="empty">
No {asset} swap has settled in the blocks read. That is a count over {range}, not a
guess.
</p>
)}
{rows.length > 0 && (
<div className="scroll-x">
<table className="board">
<caption className="visually-hidden">Settled swaps, newest first</caption>
<SwapHead asset={asset} />
<tbody>
{rows.map((swap) => (
<SwapRow
key={`${swap.near_height}:${swap.trade_index}`}
swap={swap}
asset={asset}
decimals={assetDecimals}
/>
))}
</tbody>
</table>
</div>
)}
{cursor && (
<div className="pager">
<button className="segmented panel-button" disabled={loadingOlder} onClick={loadOlder}>
{loadingOlder ? 'Reading…' : 'Older →'}
</button>
</div>
)}
<p className="panel-note">
A row is one user&apos;s side of a swap, priced from what they gave and what they got.
“Between solvers” is market makers trading with each other: a real trade at a real price,
with no taker and so no side. “Via” is the front end the taker came through, as it named
itself. A solver&apos;s routing through other tokens is how a swap was filled, never a
trade of its own, and is not listed.
</p>
</section>
</>
)
}