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,25 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { InventoryToken } from "./InventoryToken";
|
||||
|
||||
/**
|
||||
* Solver inventory inside `intents.near` for each side of the asset's pairs.
|
||||
*
|
||||
* **Inventory, not an order book.** NEAR Intents has none: solvers quote
|
||||
* privately per request, and nothing on chain carries a price level. This is
|
||||
* what solvers hold where they fill from — capacity without a price, shared
|
||||
* across every pair they quote, and not a ceiling, since a solver can deposit
|
||||
* just in time or route through another token. blackbeard/observer#34.
|
||||
*/
|
||||
export type IntentsInventory = {
|
||||
/**
|
||||
* When the balances were read.
|
||||
*/
|
||||
checked_at: string,
|
||||
/**
|
||||
* Solvers asked: accounts this observer has seen filling trades.
|
||||
*/
|
||||
solvers: number,
|
||||
/**
|
||||
* The tracked asset first, then its counter tokens by amount held.
|
||||
*/
|
||||
tokens: Array<InventoryToken>, };
|
||||
@@ -0,0 +1,15 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { BigUintDec } from "./BigUintDec";
|
||||
|
||||
/**
|
||||
* One solver's holding of one token inside `intents.near`.
|
||||
*/
|
||||
export type InventoryHolder = {
|
||||
/**
|
||||
* The solver's account, as named on NEAR.
|
||||
*/
|
||||
account: string,
|
||||
/**
|
||||
* What it holds, base units.
|
||||
*/
|
||||
held: BigUintDec, };
|
||||
@@ -0,0 +1,38 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { BigUintDec } from "./BigUintDec";
|
||||
import type { InventoryHolder } from "./InventoryHolder";
|
||||
|
||||
/**
|
||||
* What solvers hold of one token inside `intents.near`.
|
||||
*/
|
||||
export type InventoryToken = {
|
||||
/**
|
||||
* The token, as a multi-token id.
|
||||
*/
|
||||
token: string,
|
||||
/**
|
||||
* Its own symbol, when read. Display only.
|
||||
*/
|
||||
symbol: string | null,
|
||||
/**
|
||||
* Its decimals, when read.
|
||||
*/
|
||||
decimals: number | null,
|
||||
/**
|
||||
* `sell` for the tracked asset — what a buyer of it could be filled from —
|
||||
* or `buy` for a counter token — what a seller could be paid in.
|
||||
*/
|
||||
side: string,
|
||||
/**
|
||||
* Summed over every solver, base units. **Not** a figure to add across
|
||||
* counter tokens or pairs: one solver's USDT backs every pair it quotes.
|
||||
*/
|
||||
held: BigUintDec,
|
||||
/**
|
||||
* Solvers holding any of it.
|
||||
*/
|
||||
holders: number,
|
||||
/**
|
||||
* The largest holders, most first.
|
||||
*/
|
||||
top: Array<InventoryHolder>, };
|
||||
@@ -17,6 +17,7 @@ import type { ChainSeries } from './generated/ChainSeries'
|
||||
import type { ChainRoles } from './generated/ChainRoles'
|
||||
import type { ChainState } from './generated/ChainState'
|
||||
import type { GenesisDetail } from './generated/GenesisDetail'
|
||||
import type { IntentsInventory } from './generated/IntentsInventory'
|
||||
import type { IntentsPrice } from './generated/IntentsPrice'
|
||||
import type { IntentsSeries } from './generated/IntentsSeries'
|
||||
import type { IntentsSwaps } from './generated/IntentsSwaps'
|
||||
@@ -323,3 +324,11 @@ export async function fetchIntentsSeries(
|
||||
signal,
|
||||
)
|
||||
}
|
||||
|
||||
/** Solver inventory inside intents.near; null until first read. */
|
||||
export async function fetchIntentsInventory(
|
||||
chain: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<IntentsInventory | null> {
|
||||
return get<IntentsInventory | null>(`/chains/${chain}/intents/inventory`, signal)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -44,7 +44,8 @@ export function shortContract(tokenId: string): string {
|
||||
export function tokenLabel(tokenId: string, symbol: string | null | undefined): string {
|
||||
if (!symbol) return shortContract(tokenId)
|
||||
const origin = originOf(tokenId)
|
||||
// A chain's own coin needs no qualifier: `SOL · sol` says nothing `SOL`
|
||||
// does not. Only a token that could be confused with another gets one.
|
||||
return origin && origin !== symbol.toLowerCase() ? `${symbol} · ${origin}` : symbol
|
||||
// A chain's own coin needs no qualifier: `SOL · sol` and `wNEAR · near` say
|
||||
// nothing the symbol does not. Only a token that could be confused with
|
||||
// another gets one.
|
||||
return origin && !symbol.toLowerCase().endsWith(origin) ? `${symbol} · ${origin}` : symbol
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user