feat(api): solver inventory per side of each pair, read from our node
ci / gate (push) Failing after 1m34s
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
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* What solvers hold inside intents.near, for each side of the asset's pairs.
|
||||
*
|
||||
* Not an order book, and the panel says so before it shows a number: NEAR
|
||||
* Intents has none. Solvers quote privately per request and nothing on chain
|
||||
* carries a price level. What is on chain is the inventory they fill from —
|
||||
* read from our own node, which tracks intents.near's shard, once a minute.
|
||||
*
|
||||
* A table rather than a chart. Amounts in different tokens share no scale, and
|
||||
* the figures do not add up across rows: one solver's USDT backs every pair it
|
||||
* quotes. blackbeard/observer#34.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { IntentsInventory } from '../api/generated/IntentsInventory'
|
||||
import { RequestFailed, fetchIntentsInventory } from '../api/rest'
|
||||
import { ago, height as fmtHeight, tokens } from '../lib/format'
|
||||
import { tokenLabel } from '../lib/tokens'
|
||||
|
||||
/** Counter tokens shown before the rest fold away, by solvers holding them. */
|
||||
const SHOWN = 10
|
||||
|
||||
/** How often the page re-asks; the backend reads once a minute. */
|
||||
const REFRESH_MS = 60_000
|
||||
|
||||
export function InventoryPanel({ chain, symbol }: { chain: string; symbol: string }) {
|
||||
const [inventory, setInventory] = useState<IntentsInventory | null | undefined>(undefined)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let controller = new AbortController()
|
||||
const load = () => {
|
||||
controller = new AbortController()
|
||||
fetchIntentsInventory(chain, controller.signal)
|
||||
.then((inv) => {
|
||||
setInventory(inv)
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (controller.signal.aborted) return
|
||||
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
|
||||
})
|
||||
}
|
||||
load()
|
||||
const id = window.setInterval(load, REFRESH_MS)
|
||||
return () => {
|
||||
window.clearInterval(id)
|
||||
controller.abort()
|
||||
}
|
||||
}, [chain])
|
||||
|
||||
const asset = inventory?.tokens[0]?.symbol ?? symbol
|
||||
const amount = (raw: string, decimals: number | null) =>
|
||||
decimals === null ? `${raw} base units` : tokens(raw, decimals)
|
||||
// The asset, then the counters most solvers hold. A busy token has seventy
|
||||
// counters; the rest fold, so the panel stays a panel.
|
||||
const shown = inventory?.tokens.slice(0, SHOWN + 1) ?? []
|
||||
const folded = inventory?.tokens.slice(SHOWN + 1) ?? []
|
||||
const rows = (list: NonNullable<typeof inventory>['tokens']) =>
|
||||
list.map((t) => {
|
||||
const top = t.top[0]
|
||||
return (
|
||||
<tr key={t.token}>
|
||||
<td className="left" title={t.token}>
|
||||
{tokenLabel(t.token, t.symbol)}
|
||||
</td>
|
||||
<td className="left">
|
||||
{t.side === 'sell' ? `buyers of ${asset}` : `sellers of ${asset}`}
|
||||
</td>
|
||||
<td className="numeral" title={`${t.held} base units`}>
|
||||
{amount(t.held, t.decimals)}
|
||||
</td>
|
||||
<td className="numeral">{fmtHeight(t.holders)}</td>
|
||||
<td
|
||||
className="left"
|
||||
title={top ? `${amount(top.held, t.decimals)} ${t.symbol ?? ''}` : undefined}
|
||||
>
|
||||
{top ? top.account : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
const head = (
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="left">
|
||||
Token
|
||||
</th>
|
||||
<th scope="col" className="left">
|
||||
Serves
|
||||
</th>
|
||||
<th scope="col">Held by solvers</th>
|
||||
<th scope="col">Solvers holding</th>
|
||||
<th scope="col" className="left">
|
||||
Largest holder
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Solver inventory</h2>
|
||||
<span className="eyebrow">
|
||||
{inventory
|
||||
? `${fmtHeight(inventory.solvers)} solvers · read ${ago(inventory.checked_at)}`
|
||||
: 'inside intents.near'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <p className="empty">{error}</p>}
|
||||
{!error && inventory === undefined && <p className="empty">Reading…</p>}
|
||||
{!error && inventory === null && (
|
||||
<p className="empty">
|
||||
Not read yet — the first read is a few seconds after the observer starts.
|
||||
</p>
|
||||
)}
|
||||
{inventory && (
|
||||
<div className="scroll-x">
|
||||
<table className="board">
|
||||
<caption className="visually-hidden">
|
||||
What solvers hold inside intents.near, by token
|
||||
</caption>
|
||||
{head}
|
||||
<tbody>{rows(shown)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{folded.length > 0 && (
|
||||
<details className="panel-note">
|
||||
<summary>
|
||||
{fmtHeight(folded.length)} more counter token{folded.length === 1 ? '' : 's'}
|
||||
</summary>
|
||||
<div className="scroll-x">
|
||||
<table className="board">
|
||||
<caption className="visually-hidden">The remaining counter tokens</caption>
|
||||
{head}
|
||||
<tbody>{rows(folded)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{inventory && inventory.tokens.length === 1 && (
|
||||
<p className="panel-note">
|
||||
Only {asset} is listed because nothing has traded against it yet: the counter tokens
|
||||
appear here once there are trades to name them.
|
||||
</p>
|
||||
)}
|
||||
<p className="panel-note">
|
||||
<strong>This is inventory, not an order book.</strong> NEAR Intents has no book: solvers
|
||||
quote privately for each request, and nothing on chain carries a price. What is on chain is
|
||||
what solvers hold where they fill from — read from intents.near itself every minute. It has
|
||||
no price levels; it is shared across every pair a solver quotes, so the rows do not add up;
|
||||
and it is not a ceiling, since a solver can deposit just in time or route through another
|
||||
token. A zero says nothing is held there now, not that a trade cannot be filled.
|
||||
</p>
|
||||
<p className="panel-note">
|
||||
The solvers are the accounts this observer has seen filling trades on any pair — a named
|
||||
account with no referral. One that only fills by a route the indexer does not read is not
|
||||
among them.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -21,6 +21,7 @@ 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'
|
||||
|
||||
@@ -136,6 +137,8 @@ function NearIntents({
|
||||
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user