feat: probe whether 1Click will quote QTC, against what could have filled it
ci / gate (push) Successful in 4m40s
deploy / validate (push) Skipped
deploy / build (push) Successful in 9m23s
deploy / deploy-web (push) Successful in 10s
deploy / deploy-api (push) Successful in 24s

On NEAR Intents a price exists only when a solver chooses to quote, and
nothing on-chain records a refusal. Every minute the observer now asks
1Click for a keyless dry quote on QTC pairs only, both ways, against
native USDC and Ethereum USDC: the smallest size each minute and the
full grid each quarter hour, averaging about 4 requests a minute against
a published limit of 1,200. At the same moment it reads two things from
its own nodes: the Ref pool's quote for the same input, and every
solver's balance of the output token inside intents.near.

So each "No liquidity available" is stored with whether one solver
alone held enough to settle the trade. A probe that got no answer is
recorded as `failed`, never as a refusal. Only our own observations are
kept: the 1ClickSwap API Terms (s4.1(e)) forbid keeping API data past 24
hours, so a quote becomes a markup against the pool on arrival and raw
responses are pruned hourly.

The first live round, 2026-10-07: "No liquidity available" both ways.
Selling 1 QTC, the pool would have paid 101.53 USDC and three solvers
each held enough. Buying with 100 USDC, solver-ref-2.near held 2,308
QTC.

The near-intents market page shows a strip per direction and counter
token. It is an emphasis chart: the accent marks only "no quote while a
solver could fill", the other states are quiet ink told apart by
height, and the neutral token is validated against the accent in both
themes. Beside it are a summary table and a list of the latest
refusals. The pause log and the price-move statistics in #45 come later,
once there is history to support them.

Refs #45

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tyzzRVjH9AdwGtYwsH5VC
This commit is contained in:
2026-10-07 13:39:52 +03:00
co-authored by Claude Opus 5.5
parent 6fa8cf342a
commit 6b3bf76b7e
25 changed files with 1935 additions and 0 deletions
@@ -0,0 +1,43 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* One direction and counter token, summed over the window.
*/
export type QuoteAvailability = {
/**
* `sell` or `buy`.
*/
direction: string,
/**
* The counter token.
*/
counter: string,
/**
* Probes that got an answer: every outcome but `failed`.
*/
answered: number,
/**
* Of those, quoted.
*/
quoted: number,
/**
* Of those, refused as having no liquidity.
*/
no_liquidity: number,
/**
* Of the no-liquidity answers, those given while at least one solver held
* enough of the output token to fill the trade on its own.
*/
no_liquidity_while_able: number,
/**
* Answered with some other refusal.
*/
refused: number,
/**
* Probes that got no answer at all.
*/
failed: number,
/**
* The median markup of the quotes, in basis points.
*/
median_markup_bps: number | null, };
+58
View File
@@ -0,0 +1,58 @@
// 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 probe: whether 1Click would quote one trade, and what this observer saw
* that could have filled it (blackbeard/observer#45).
*/
export type QuoteProbe = {
/**
* When.
*/
at: string,
/**
* `sell` gives the asset for the counter token; `buy` the reverse.
*/
direction: string,
/**
* The counter token.
*/
counter: string,
/**
* The input, base units of the token given.
*/
size: BigUintDec,
/**
* `quoted`, `no_liquidity`, `refused`, or `failed` — the last meaning the
* probe got no answer, which says nothing about the solvers.
*/
outcome: string,
/**
* The service's own words, or why the probe failed.
*/
message: string | null,
/**
* How long the service took, in milliseconds.
*/
latency_ms: number | null,
/**
* What the pool's own `quote` view gave for the same input, read from our
* node at the same moment: what an on-chain fill would have paid.
*/
pool_out: BigUintDec | null,
/**
* For a quote, how much less than the pool it paid, in basis points.
*/
markup_bps: number | null,
/**
* The solver holding the most of the output token inside intents.near.
*/
best_solver: string | null,
/**
* What it held, base units of the output token.
*/
best_held: BigUintDec | null,
/**
* How many solvers each held at least `pool_out` of the output token.
*/
solvers_able: number | null, };
+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 { ChainId } from "./ChainId";
import type { NearToken } from "./NearToken";
import type { QuoteAvailability } from "./QuoteAvailability";
import type { QuoteProbe } from "./QuoteProbe";
/**
* The probe's record over a window.
*/
export type QuoteProbes = {
/**
* Which chain's token.
*/
chain: ChainId,
/**
* The token, as NEAR Intents names it.
*/
asset: string,
/**
* The window, in hours.
*/
hours: number,
/**
* Whether the probe is configured to run at all.
*/
probing: boolean,
/**
* Every probe in the window, oldest first.
*/
probes: Array<QuoteProbe>,
/**
* Per direction and counter, over the window.
*/
availability: Array<QuoteAvailability>,
/**
* Display facts for the asset and every counter.
*/
tokens: Array<NearToken>, };
+10
View File
@@ -31,6 +31,7 @@ import type { IntentsSwaps } from './generated/IntentsSwaps'
import type { IntentsAccount } from './generated/IntentsAccount'
import type { Markets } from './generated/Markets'
import type { NearTx } from './generated/NearTx'
import type { QuoteProbes } from './generated/QuoteProbes'
import type { RefPools } from './generated/RefPools'
import type { RefSwaps } from './generated/RefSwaps'
import type { ReversibleState } from './generated/ReversibleState'
@@ -455,3 +456,12 @@ export async function fetchIntentsAccount(
): Promise<IntentsAccount> {
return get<IntentsAccount>(`/chains/${chain}/near/intents/${encodeURIComponent(account)}`, signal)
}
/** Whether 1Click would quote the asset, against what could have filled it. */
export async function fetchQuoteProbes(
chain: string,
hours: number,
signal?: AbortSignal,
): Promise<QuoteProbes> {
return get<QuoteProbes>(`/chains/${chain}/intents/quotes?hours=${hours}`, signal)
}
+3
View File
@@ -27,6 +27,7 @@ import { FillsPanel } from './FillsPanel'
import { IntentsPanel } from './IntentsPanel'
import { InventoryPanel } from './InventoryPanel'
import { PriceChart } from './PriceChart'
import { QuotePanel } from './QuotePanel'
import { PoolPriceChart } from './RefDclMarket'
import { RefDclMarket } from './RefDclMarket'
import { SwapHead, SwapRow } from './SwapRow'
@@ -161,6 +162,8 @@ function NearIntents({
<IntentsPanel chain={chain} decimals={decimals} symbol={symbol} tiles={8} table />
<QuotePanel chain={chain} symbol={symbol} />
<BridgePanel chain={chain} decimals={decimals} symbol={symbol} />
<BridgeLinksPanel chain={chain} symbol={symbol} />
+437
View File
@@ -0,0 +1,437 @@
/**
* Whether 1Click would quote the token, set against whether anyone could have
* filled it (blackbeard/observer#45).
*
* On NEAR Intents a price exists only when a solver chooses to quote, and
* "No liquidity available" is all a user is told when none does. The chain
* records trades, never refusals. So the observer asks — keyless, `dry`, once
* a minute each way — and beside every answer reads two things it can see for
* itself: what the pool would pay for the same trade, and what each solver held
* of the token the trade would need, one solver at a time.
*
* The strip is an emphasis chart, not a categorical one: its story is one
* state, "no quote while a solver held enough to fill it", and that state alone
* takes the accent. Everything else is the quiet ink, told apart by height
* rather than hue — a quote, a refusal nobody could have filled, a probe that
* got no answer. `--mark-quiet` is validated against `--accent` in both themes
* (index.css).
*
* It records and does not accuse. A refusal while inventory existed has
* benign explanations — risk limits, inventory reserved for other flows, a
* pause during an audit — and the page says so beside the figures.
*/
import { useEffect, useMemo, useState } from 'react'
import type { QuoteAvailability } from '../api/generated/QuoteAvailability'
import type { QuoteProbe } from '../api/generated/QuoteProbe'
import type { QuoteProbes } from '../api/generated/QuoteProbes'
import { RequestFailed, fetchQuoteProbes } from '../api/rest'
import { tokens as fmtTokens, utc, when, zone } from '../lib/format'
import { shortNear } from '../lib/near'
import { tokenLabel } from '../lib/tokens'
const SPANS: [number, string][] = [
[6, '6 h'],
[24, '24 h'],
[72, '3 d'],
]
const REFRESH_MS = 60_000
/** Cells per strip, whatever the span: each is one bucket of minutes. */
const BUCKETS = 144
/** Strip height, in the SVG's units and in pixels. */
const STRIP = 16
/** What a bucket shows, most significant first. */
type State = 'withheld' | 'quoted' | 'unquoted' | 'failed' | 'none'
/** A probe that came back "no liquidity" while a solver held enough. */
function withheld(p: QuoteProbe): boolean {
return p.outcome === 'no_liquidity' && (p.solvers_able ?? 0) > 0
}
function stateOf(p: QuoteProbe): State {
if (withheld(p)) return 'withheld'
if (p.outcome === 'quoted') return 'quoted'
if (p.outcome === 'failed') return 'failed'
return 'unquoted'
}
const RANK: Record<State, number> = { withheld: 4, quoted: 3, unquoted: 2, failed: 1, none: 0 }
const LEGEND: { state: State; label: string }[] = [
{ state: 'withheld', label: 'no quote, while a solver held enough to fill it' },
{ state: 'quoted', label: 'quoted' },
{ state: 'unquoted', label: 'no quote, and no solver held enough' },
{ state: 'failed', label: 'the probe got no answer' },
]
export function QuotePanel({ chain, symbol: chainSymbol }: { chain: string; symbol: string }) {
const [hours, setHours] = useState(24)
const [data, setData] = useState<QuoteProbes | null>(null)
const [error, setError] = useState<string | null>(null)
const [readout, setReadout] = useState<string | null>(null)
useEffect(() => {
const controller = new AbortController()
const load = () =>
fetchQuoteProbes(chain, hours, controller.signal)
.then((d) => {
setData(d)
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, hours])
const meta = (token: string) => data?.tokens.find((t) => t.token === token)
const label = (token: string) => tokenLabel(token, meta(token)?.symbol)
// The chain's own symbol when it has been read, else the token's on NEAR.
const symbol = chainSymbol || (data ? label(data.asset) : '')
const amount = (raw: string | null, token: string) => {
const m = meta(token)
return raw && m ? `${fmtTokens(raw, m.decimals)} ${label(token)}` : '—'
}
/** The token a probe's trade gives, and the one it would receive. */
const sides = (p: { direction: string; counter: string }) =>
p.direction === 'sell'
? { given: data?.asset ?? '', received: p.counter }
: { given: p.counter, received: data?.asset ?? '' }
const rows = useMemo(() => strips(data), [data])
const incidents = useMemo(
() => (data ? data.probes.filter(withheld).slice(-12).reverse() : []),
[data],
)
const describe = (p: QuoteProbe) => {
const { given, received } = sides(p)
const trade = `${p.direction === 'sell' ? 'selling' : 'buying with'} ${amount(p.size, given)}`
const pool = p.pool_out ? `; the pool would have paid ${amount(p.pool_out, received)}` : ''
const best = p.best_solver
? `; ${shortNear(p.best_solver)} held ${amount(p.best_held, received)}`
: ''
const verdict =
p.outcome === 'quoted'
? `quoted${p.markup_bps !== null ? `, ${(p.markup_bps / 100).toFixed(2)}% below the pool` : ''}`
: p.outcome === 'failed'
? `no answer (${p.message ?? 'probe failed'})`
: `“${p.message ?? p.outcome}”`
return `${when(p.at)}, ${trade}: ${verdict}${pool}${best}`
}
return (
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">Will anyone quote {symbol}?</h2>
<span className="eyebrow">
1Click, asked every minute, against what could have filled it
</span>
</div>
<div className="pager" style={{ justifyContent: 'flex-start' }}>
{SPANS.map(([h, l]) => (
<button
key={h}
className="segmented panel-button"
aria-pressed={hours === h}
onClick={() => setHours(h)}
>
{l}
</button>
))}
</div>
{error && <p className="empty">{error}</p>}
{!error && !data && <p className="empty">Reading the probe record…</p>}
{data && !data.probing && <p className="empty">The quote probe is not configured here.</p>}
{data && data.probing && data.probes.length === 0 && (
<p className="empty">No probes yet in this window: the first runs on the next minute.</p>
)}
{data && rows.length > 0 && (
<>
<div className="quote-strips">
{rows.map((row) => (
<div key={`${row.direction}-${row.counter}`} className="quote-strip">
<div className="quote-strip-label">
{row.direction === 'sell'
? `Selling ${symbol} for ${label(row.counter)}`
: `Buying ${symbol} with ${label(row.counter)}`}
<span className="receipt-meta">
{' '}
· {amount(row.size, sides(row).given)} each minute
</span>
</div>
<svg
className="quote-cells"
viewBox={`0 0 ${BUCKETS * 6} ${STRIP}`}
preserveAspectRatio="none"
role="img"
aria-label={summaryText(row, data.availability)}
onMouseLeave={() => setReadout(null)}
>
<line
className="quote-baseline"
x1={0}
x2={BUCKETS * 6}
y1={STRIP - 0.5}
y2={STRIP - 0.5}
/>
{row.buckets.map((b, i) => (
<g
key={i}
onMouseEnter={() =>
setReadout(
b.last
? `${b.count} probe${b.count === 1 ? '' : 's'} — latest ${describe(b.last)}`
: `${when(new Date(b.from).toISOString())}: no probe`,
)
}
>
{/* The hit area is the whole column, wider than any mark. */}
<rect x={i * 6} y={0} width={6} height={STRIP} fill="transparent" />
<Cell state={b.state} x={i * 6} />
</g>
))}
</svg>
</div>
))}
</div>
<div className="quote-axis receipt-meta">
<span>{rows[0] ? when(new Date(rows[0].from).toISOString()) : ''}</span>
<span>times in {zone()}</span>
<span>now</span>
</div>
<p className="quote-readout receipt-meta" aria-live="polite">
{readout ?? 'Hover the strip for any moment’s probes.'}
</p>
<ul className="quote-legend">
{LEGEND.map((l) => (
<li key={l.state}>
<svg width={10} height={16} aria-hidden="true">
<Cell state={l.state} x={2} height={16} />
</svg>
{l.label}
</li>
))}
</ul>
<div className="scroll-x">
<table className="board">
<caption className="visually-hidden">
Probe outcomes over the last {data.hours} hours, by direction and counter token
</caption>
<thead>
<tr>
<th scope="col" className="left">
Trade
</th>
<th scope="col">Answered</th>
<th scope="col">Quoted</th>
<th scope="col">No quote</th>
<th scope="col">…while a solver could fill</th>
<th scope="col">No answer</th>
<th scope="col">Median markup</th>
</tr>
</thead>
<tbody>
{data.availability.map((a) => (
<tr key={`${a.direction}-${a.counter}`}>
<td className="left">
{a.direction === 'sell' ? 'sell' : 'buy'} {symbol} · {label(a.counter)}
</td>
<td className="numeral">{a.answered}</td>
<td className="numeral">{share(a.quoted, a.answered)}</td>
<td className="numeral">{share(a.no_liquidity, a.answered)}</td>
<td className="numeral">
<strong>{share(a.no_liquidity_while_able, a.answered)}</strong>
</td>
<td className="numeral">{a.failed}</td>
<td className="numeral">
{a.median_markup_bps === null
? '—'
: `${(a.median_markup_bps / 100).toFixed(2)}%`}
</td>
</tr>
))}
</tbody>
</table>
</div>
{incidents.length > 0 && (
<div className="scroll-x">
<table className="board">
<caption className="visually-hidden">
The latest probes answered “no liquidity” while a solver held enough
</caption>
<thead>
<tr>
<th scope="col" className="left">
When
</th>
<th scope="col" className="left">
Trade asked for
</th>
<th scope="col">Pool would pay</th>
<th scope="col" className="left">
Largest solver holding
</th>
<th scope="col">Solvers able</th>
</tr>
</thead>
<tbody>
{incidents.map((p) => {
const { given, received } = sides(p)
return (
<tr key={`${p.at}-${p.direction}-${p.counter}-${p.size}`}>
<td className="left numeral event-when" title={utc(p.at)}>
{when(p.at)}
</td>
<td className="left">
{p.direction} {amount(p.size, given)}
</td>
<td className="numeral">{amount(p.pool_out, received)}</td>
<td className="left" title={p.best_solver ?? undefined}>
{p.best_solver
? `${shortNear(p.best_solver)}: ${amount(p.best_held, received)}`
: '—'}
</td>
<td className="numeral">{p.solvers_able ?? '—'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</>
)}
<p className="panel-note">
Each minute the observer asks 1Click for a dry quote — nothing is committed — selling and
buying {symbol} at the smallest size, and every size each quarter hour, without a key and
inside the rate limit 1Click publishes. At the same moment it reads, from its own NEAR
nodes, what the Ref pool would pay for the same trade and what each known solver holds of
the token the trade would need. A solver counts as able only if it alone held at least what
the pool would pay: solvers do not pool inventory. “No liquidity available” while a solver
was able says inventory existed and no quote was given; it does not say why. Risk limits,
inventory reserved for other flows, or a pause during an audit would all look the same from
here, and which solvers 1Click asked, and what they answered, is private to them. 1Click’s
terms allow no API data to be kept beyond a day, so only these observations are kept: the
quote itself becomes a markup against the pool and is then discarded. A probe that got no
answer is shown as that, never as a refusal.
</p>
</section>
)
}
function share(n: number, of: number): string {
if (of === 0) return '—'
return `${n} (${Math.round((n / of) * 100)}%)`
}
function summaryText(
row: { direction: string; counter: string },
availability: QuoteAvailability[],
): string {
const a = availability.find((x) => x.direction === row.direction && x.counter === row.counter)
if (!a) return 'no probes'
return `${a.answered} answered: ${a.quoted} quoted, ${a.no_liquidity} no quote, of which ${a.no_liquidity_while_able} while a solver could fill, ${a.failed} unanswered`
}
/** One cell: height and fill say the state; only the one that matters is in the accent. */
function Cell({ state, x, height = STRIP }: { state: State; x: number; height?: number }) {
// Thin marks with a wide surface gap: a day of refusals is a run of
// hairlines, not a slab.
const w = 2.5
switch (state) {
case 'withheld':
return <rect className="quote-withheld" x={x} y={0} width={w} height={height} rx={1} />
case 'quoted':
return (
<rect
className="quote-quoted"
x={x}
y={height * 0.4}
width={w}
height={height * 0.6}
rx={1}
/>
)
case 'unquoted':
return (
<rect
className="quote-unquoted"
x={x + 0.5}
y={height * 0.7}
width={w - 1}
height={height * 0.3 - 0.5}
rx={1}
/>
)
case 'failed':
return <rect className="quote-failed" x={x} y={height - 2} width={w} height={2} />
default:
return null
}
}
interface Bucket {
from: number
state: State
count: number
last: QuoteProbe | null
}
interface Strip {
direction: string
counter: string
/** The every-minute size. */
size: string
from: number
buckets: Bucket[]
}
/** One strip per direction and counter, from the every-minute (smallest) probes. */
function strips(data: QuoteProbes | null): Strip[] {
if (!data) return []
const end = Date.now()
const start = end - data.hours * 3_600_000
const width = (end - start) / BUCKETS
const out: Strip[] = []
for (const a of data.availability) {
const own = data.probes.filter((p) => p.direction === a.direction && p.counter === a.counter)
if (own.length === 0) continue
// The every-minute size is the smallest one probed.
const size = own.reduce((m, p) => (BigInt(p.size) < BigInt(m) ? p.size : m), own[0]!.size)
const buckets: Bucket[] = Array.from({ length: BUCKETS }, (_, i) => ({
from: start + i * width,
state: 'none' as State,
count: 0,
last: null,
}))
for (const p of own) {
if (p.size !== size) continue
const i = Math.min(BUCKETS - 1, Math.floor((Date.parse(p.at) - start) / width))
const b = buckets[i]
if (!b) continue
b.count += 1
const s = stateOf(p)
if (RANK[s] > RANK[b.state]) b.state = s
if (!b.last || Date.parse(p.at) > Date.parse(b.last.at)) b.last = p
}
out.push({ direction: a.direction, counter: a.counter, size, from: start, buckets })
}
return out
}
+83
View File
@@ -54,6 +54,11 @@
--text-primary: #f2ece0;
--text-secondary: #a2937c;
--text-muted: #7a6c59;
/* The quiet mark beside an accented one, where a chart's story is a single
state (emphasis, not categories): the quote strip (#45). Validated against
--accent on this surface: CVD ΔE 8.8 (deutan), normal 17.9. Low chroma is
the point — it is the gray the accent stands out from. */
--mark-quiet: #a2937c;
/* The single data hue: magnitude, share bars, the history area. */
--data: #bd8829;
@@ -108,6 +113,9 @@
--text-primary: #1b1710;
--text-secondary: #5b5142;
--text-muted: #7c7263;
/* Darker than the dark theme's step: against this --accent the secondary ink
scores CVD ΔE 4.5 (protan), a hard fail. #3a3226 scores 14.7. */
--mark-quiet: #3a3226;
/* The bronze, re-stepped. `--data-bright` is *darker* than `--data`: it is
the emphasis step, and emphasis on paper is weight, not glare. */
@@ -2111,3 +2119,78 @@ tr.mine .share-bar > i {
font-family: var(--font-mono);
font-size: 11px;
}
/* The quote strip (blackbeard/observer#45). An emphasis chart: one state in
the accent, the rest in the quiet ink, told apart by height — full for "no
quote while a solver could fill", 60% for a quote, a hollow 30% for a refusal
nobody could have filled, a baseline tick for a probe that got no answer.
Height carries the meaning in greyscale, print and forced-colors too. */
.quote-strips {
display: grid;
gap: 10px;
padding: 12px 16px 0;
}
.quote-strip-label {
font-size: 12px;
margin-bottom: 4px;
}
.quote-cells {
display: block;
width: 100%;
height: 16px;
}
.quote-baseline {
stroke: var(--border);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.quote-withheld {
fill: var(--accent);
}
.quote-quoted {
fill: var(--mark-quiet);
}
.quote-unquoted {
fill: none;
stroke: var(--mark-quiet);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.quote-failed {
fill: var(--text-muted);
}
.quote-axis {
display: flex;
justify-content: space-between;
padding: 4px 16px 0;
}
.quote-readout {
padding: 6px 16px 0;
min-height: 1.6em;
}
.quote-legend {
display: flex;
flex-wrap: wrap;
gap: 4px 18px;
list-style: none;
margin: 0;
padding: 8px 16px 12px;
font-size: 11px;
color: var(--text-secondary);
}
.quote-legend li {
display: inline-flex;
align-items: center;
gap: 6px;
}