feat(ws): push each swap as it settles on NEAR Intents
`ServerMessage::IntentsSwap` carries the trade exactly as /intents/swaps returns it — one conversion, `intents::to_swap`, serves both, so the route and the push cannot describe the same trade differently. Only trades read within LIVE_WITHIN_BLOCKS (~a minute) of NEAR's final tip are pushed. The backfill never broadcasts, and the threshold is what keeps an upward catch-up after a restart from announcing an hour of history as news — the confusion the wormhole pulse shipped once already. The market panel re-reads its figures on each push rather than adjusting a copy: a VWAP over the latest N trades drops its oldest as it gains one. Verified by replaying the trade at NEAR 218,261,895 with the threshold opened locally and catching the frame on /v1/ws. Refs #27 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
This commit is contained in:
@@ -51,6 +51,14 @@ const BACKFILL_PACE: Duration = Duration::from_secs(1);
|
||||
/// every ~0.6 s; checking twice a block is plenty.
|
||||
const TIP_WAIT: Duration = Duration::from_secs(1);
|
||||
|
||||
/// How close to NEAR's final tip a trade must be read for it to be pushed live.
|
||||
///
|
||||
/// About a minute of NEAR blocks. The backfill never broadcasts; this is what
|
||||
/// keeps a catch-up after a restart — which walks *upward*, like the live path
|
||||
/// — from announcing an hour of trades as though each had just happened. The
|
||||
/// wormhole pulse shipped exactly that confusion once, by a different road.
|
||||
const LIVE_WITHIN_BLOCKS: u64 = 100;
|
||||
|
||||
/// Pause after a failure before trying again.
|
||||
const RETRY: Duration = Duration::from_secs(15);
|
||||
|
||||
@@ -170,6 +178,9 @@ async fn scan(chain: Arc<ChainRuntime>, store: Store, near: NearClient, config:
|
||||
}
|
||||
tracing::info!(chain = %id, near_height = height, trades = rows.len(),
|
||||
"intents trades recorded");
|
||||
if !descending && tip.saturating_sub(height) <= LIVE_WITHIN_BLOCKS {
|
||||
announce(&chain, &store, &config.asset, rows).await;
|
||||
}
|
||||
}
|
||||
reached = Some(height);
|
||||
}
|
||||
@@ -348,3 +359,81 @@ async fn listing(chain: Arc<ChainRuntime>, store: Store, near: NearClient, confi
|
||||
tokio::time::sleep(LISTING_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Push freshly settled trades to connected browsers.
|
||||
async fn announce(chain: &Arc<ChainRuntime>, store: &Store, asset: &str, rows: Vec<IntentTrade>) {
|
||||
let mut names: Vec<String> = vec![asset.to_owned()];
|
||||
names.extend(rows.iter().map(|r| r.counter_token.clone()));
|
||||
let tokens = token_meta(store, names).await;
|
||||
let asset_decimals = tokens.get(asset).map(|t| t.1);
|
||||
for row in rows {
|
||||
chain.broadcast(
|
||||
&blackbeard_entities::ServerMessage::IntentsSwap {
|
||||
chain: chain.id(),
|
||||
asset: asset.to_owned(),
|
||||
swap: to_swap(row, &tokens, asset_decimals),
|
||||
},
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached `(symbol, decimals)` for every token named that has them.
|
||||
///
|
||||
/// A token with no cached metadata is simply absent, and its trades go out
|
||||
/// without a price — the same answer the routes give.
|
||||
pub async fn token_meta(
|
||||
store: &Store,
|
||||
names: Vec<String>,
|
||||
) -> std::collections::HashMap<String, (String, u8)> {
|
||||
let mut out = std::collections::HashMap::new();
|
||||
for name in names {
|
||||
if out.contains_key(&name) {
|
||||
continue;
|
||||
}
|
||||
match store.near_token(&name).await {
|
||||
Ok(Some(meta)) => {
|
||||
out.insert(name, meta);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => tracing::warn!(token = %name, error = %e, "token metadata unreadable"),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A stored trade as the wire carries it. One conversion for the route and
|
||||
/// the push, so the two cannot describe the same trade differently.
|
||||
pub fn to_swap(
|
||||
r: IntentTrade,
|
||||
tokens: &std::collections::HashMap<String, (String, u8)>,
|
||||
asset_decimals: Option<u8>,
|
||||
) -> blackbeard_entities::IntentsSwap {
|
||||
let counter = tokens.get(&r.counter_token);
|
||||
let price = match (
|
||||
asset_decimals,
|
||||
counter,
|
||||
r.asset_amount.parse::<u128>(),
|
||||
r.counter_amount.parse::<u128>(),
|
||||
) {
|
||||
(Some(ad), Some((_, cd)), Ok(a), Ok(c)) => Some(core::price(a, ad, c, *cd)),
|
||||
_ => None,
|
||||
};
|
||||
blackbeard_entities::IntentsSwap {
|
||||
near_height: r.near_height,
|
||||
trade_index: r.trade_index,
|
||||
at: r.at,
|
||||
tx_hash: r.tx_hash,
|
||||
receipt_id: r.receipt_id,
|
||||
counter_symbol: counter.map(|c| c.0.clone()),
|
||||
counter_token: r.counter_token,
|
||||
asset_amount: BigUintDec(r.asset_amount),
|
||||
counter_amount: BigUintDec(r.counter_amount),
|
||||
price,
|
||||
side: r.side,
|
||||
taker: r.taker,
|
||||
referral: r.referral,
|
||||
accounts: r.accounts,
|
||||
intent_hashes: r.intent_hashes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1809,37 +1809,7 @@ async fn intents_swaps(
|
||||
.map(|r| format!("{}:{}", r.near_height, r.trade_index));
|
||||
let swaps = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let counter = tokens.get(&r.counter_token);
|
||||
let price = match (
|
||||
asset_decimals,
|
||||
counter,
|
||||
r.asset_amount.parse::<u128>(),
|
||||
r.counter_amount.parse::<u128>(),
|
||||
) {
|
||||
(Some(ad), Some((_, cd)), Ok(a), Ok(c)) => {
|
||||
Some(blackbeard_core::intents::price(a, ad, c, *cd))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
blackbeard_entities::IntentsSwap {
|
||||
near_height: r.near_height,
|
||||
trade_index: r.trade_index,
|
||||
at: r.at,
|
||||
tx_hash: r.tx_hash,
|
||||
receipt_id: r.receipt_id,
|
||||
counter_symbol: counter.map(|c| c.0.clone()),
|
||||
counter_token: r.counter_token,
|
||||
asset_amount: BigUintDec(r.asset_amount),
|
||||
counter_amount: BigUintDec(r.counter_amount),
|
||||
price,
|
||||
side: r.side,
|
||||
taker: r.taker,
|
||||
referral: r.referral,
|
||||
accounts: r.accounts,
|
||||
intent_hashes: r.intent_hashes,
|
||||
}
|
||||
})
|
||||
.map(|r| crate::intents::to_swap(r, &tokens, asset_decimals))
|
||||
.collect();
|
||||
|
||||
Ok(Json(blackbeard_entities::IntentsSwaps {
|
||||
|
||||
@@ -18,7 +18,7 @@ use ts_rs::TS;
|
||||
use crate::{BigUintDec, ChainId};
|
||||
|
||||
/// One settled trade of the asset against one counter token.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "IntentsSwap.ts")]
|
||||
pub struct IntentsSwap {
|
||||
/// NEAR block it settled in.
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::{BigUintDec, ChainId, ChainInfo, ChainSummary, LeaderboardRow, RecentBlock};
|
||||
use crate::{
|
||||
BigUintDec, ChainId, ChainInfo, ChainSummary, IntentsSwap, LeaderboardRow, RecentBlock,
|
||||
};
|
||||
|
||||
/// How far back a leaderboard looks.
|
||||
///
|
||||
@@ -251,6 +253,20 @@ pub enum ServerMessage {
|
||||
/// showing.
|
||||
notes: Vec<WormholeNote>,
|
||||
},
|
||||
/// A swap of the chain's token just settled on NEAR Intents.
|
||||
///
|
||||
/// Sent only for a trade read close to NEAR's tip — never by the backfill,
|
||||
/// and never by a long catch-up after a restart, both of which would
|
||||
/// announce history as though it had just happened. A page that needs
|
||||
/// what it missed asks `/intents/swaps`.
|
||||
IntentsSwap {
|
||||
/// Which chain's token.
|
||||
chain: ChainId,
|
||||
/// The tracked asset, as a multi-token id.
|
||||
asset: String,
|
||||
/// The trade, exactly as `/intents/swaps` would return it.
|
||||
swap: IntentsSwap,
|
||||
},
|
||||
/// A chain's reachability changed — the node went away, or a chain that was
|
||||
/// awaiting launch has started producing blocks.
|
||||
ChainStatus {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { BigUintDec } from "./BigUintDec";
|
||||
import type { ChainId } from "./ChainId";
|
||||
import type { ChainInfo } from "./ChainInfo";
|
||||
import type { ChainSummary } from "./ChainSummary";
|
||||
import type { IntentsSwap } from "./IntentsSwap";
|
||||
import type { LeaderboardRow } from "./LeaderboardRow";
|
||||
import type { RecentBlock } from "./RecentBlock";
|
||||
import type { Window } from "./Window";
|
||||
@@ -113,7 +114,19 @@ amount_out: BigUintDec,
|
||||
* reads. Anything rendering these has to say how many it is not
|
||||
* showing.
|
||||
*/
|
||||
notes: Array<WormholeNote>, } | { "type": "chain_status",
|
||||
notes: Array<WormholeNote>, } | { "type": "intents_swap",
|
||||
/**
|
||||
* Which chain's token.
|
||||
*/
|
||||
chain: ChainId,
|
||||
/**
|
||||
* The tracked asset, as a multi-token id.
|
||||
*/
|
||||
asset: string,
|
||||
/**
|
||||
* The trade, exactly as `/intents/swaps` would return it.
|
||||
*/
|
||||
swap: IntentsSwap, } | { "type": "chain_status",
|
||||
/**
|
||||
* Which chain.
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import type { ChainInfo } from './generated/ChainInfo'
|
||||
import type { ChainSummary } from './generated/ChainSummary'
|
||||
import type { ClientMessage } from './generated/ClientMessage'
|
||||
import type { IntentsSwap } from './generated/IntentsSwap'
|
||||
import type { LeaderboardRow } from './generated/LeaderboardRow'
|
||||
import type { RecentBlock } from './generated/RecentBlock'
|
||||
import type { ServerMessage } from './generated/ServerMessage'
|
||||
@@ -127,6 +128,8 @@ export class Observer {
|
||||
private listeners = new Set<() => void>()
|
||||
/** Pulse subscribers, kept apart from `listeners` — see `WormholePulse`. */
|
||||
private pulseListeners = new Set<(pulse: WormholePulse) => void>()
|
||||
/** Swap subscribers, kept apart for the same reason: a swap is an event. */
|
||||
private swapListeners = new Set<(swap: IntentsSwap) => void>()
|
||||
private socket: WebSocket | null = null
|
||||
private reconnectDelay = RECONNECT_MIN_MS
|
||||
private reconnectTimer: number | null = null
|
||||
@@ -147,6 +150,18 @@ export class Observer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hear every swap of the subscribed chain's token as it settles on NEAR
|
||||
* Intents. Only trades read near NEAR's tip are pushed; anything older is
|
||||
* a query, not news.
|
||||
*/
|
||||
onIntentsSwap = (listener: (swap: IntentsSwap) => void): (() => void) => {
|
||||
this.swapListeners.add(listener)
|
||||
return () => {
|
||||
this.swapListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
/** `useSyncExternalStore` subscribe. */
|
||||
subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener)
|
||||
@@ -305,6 +320,18 @@ export class Observer {
|
||||
break
|
||||
}
|
||||
|
||||
case 'intents_swap': {
|
||||
if (message.chain !== this.state.chain) return
|
||||
for (const listener of this.swapListeners) {
|
||||
try {
|
||||
listener(message.swap)
|
||||
} catch (e) {
|
||||
console.warn('[observer] intents swap subscriber threw', e)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'chain_status':
|
||||
this.patch({
|
||||
chains: this.state.chains.map((c) => (c.id === message.chain ? message.info : c)),
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useEffect, useState } from 'react'
|
||||
import type { IntentsPrice } from '../api/generated/IntentsPrice'
|
||||
import { RequestFailed, fetchIntentsPrice } from '../api/rest'
|
||||
import { ago, height as fmtHeight, price as fmtPrice, tokens } from '../lib/format'
|
||||
import { useObserverInstance } from '../lib/observer-context'
|
||||
import { Field } from './Field'
|
||||
|
||||
/** A multi-token id short enough for a label: `nep141:zec.omft.near` → `zec.omft.near`. */
|
||||
@@ -42,10 +43,17 @@ export function IntentsPanel({
|
||||
const [data, setData] = useState<IntentsPrice | null>(null)
|
||||
const [absent, setAbsent] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Bumped by every pushed swap. The figures are re-read rather than adjusted
|
||||
// here: a VWAP over the latest N trades loses its oldest trade as it gains a
|
||||
// new one, and arithmetic on a copy drifts from the server's answer while
|
||||
// looking authoritative — the reason the wormhole stage re-reads its stock.
|
||||
const [settled, setSettled] = useState(0)
|
||||
const observer = useObserverInstance()
|
||||
|
||||
useEffect(() => observer.onIntentsSwap(() => setSettled((n) => n + 1)), [observer])
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setData(null)
|
||||
setAbsent(false)
|
||||
setError(null)
|
||||
fetchIntentsPrice(chain, controller.signal)
|
||||
@@ -56,7 +64,10 @@ export function IntentsPanel({
|
||||
else setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [chain])
|
||||
}, [chain, settled])
|
||||
|
||||
// A different chain's figures must never stand in for this one's while it loads.
|
||||
useEffect(() => setData(null), [chain])
|
||||
|
||||
if (absent) return null
|
||||
|
||||
|
||||
Reference in New Issue
Block a user