ci / gate (push) Failing after 1m34s
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
222 lines
7.6 KiB
TypeScript
222 lines
7.6 KiB
TypeScript
/**
|
||
* 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'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'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's routing through other tokens is how a swap was filled, never a
|
||
trade of its own, and is not listed.
|
||
</p>
|
||
</section>
|
||
</>
|
||
)
|
||
}
|