feat(api): solver inventory per side of each pair, read from our node
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:
2026-10-03 23:12:32 +03:00
co-authored by Claude Opus 5.5
parent fb7148cc98
commit 2d80325c3b
19 changed files with 710 additions and 9 deletions
+25
View File
@@ -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>, };
+15
View File
@@ -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, };
+38
View File
@@ -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>, };
+9
View File
@@ -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)
}
+167
View File
@@ -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>
)
}
+3
View File
@@ -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>
+4 -3
View File
@@ -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
}