diff --git a/app/api/vibenet/validity/candles/route.ts b/app/api/vibenet/validity/candles/route.ts deleted file mode 100644 index 8c0321ab..00000000 --- a/app/api/vibenet/validity/candles/route.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { NextResponse } from 'next/server'; -import { - decodeFunctionResult, - encodeEventTopics, - encodeFunctionData, - parseAbi, - toHex, - type Address, -} from 'viem'; - -import { VIBENET_RPC_URL } from '../../../../vibenet/library/config'; -import { pairAbi } from '../../../../vibenet/demos/validity/lib/constants'; -import { quoteWad } from '../../../../vibenet/demos/validity/lib/quote'; -import { - isAddress, - lookbackBlocks, - needsLogBackfill, - parseTapeSamples, - readTape, - samplesFromSyncLogs, - writeTape, - type RpcLog, - type TapeSample, -} from '../../../../vibenet/demos/validity/lib/tape'; - -const SYNC_TOPIC = encodeEventTopics({ - abi: parseAbi(['event Sync(uint112 reserve0, uint112 reserve1)']), - eventName: 'Sync', -})[0]; - -type JsonRpcResponse = { result?: unknown; error?: { message?: string } }; - -async function rpc(method: string, params: unknown[]): Promise { - const response = await fetch(VIBENET_RPC_URL, { - method: 'POST', - cache: 'no-store', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), - }); - const body = ((await response.json().catch(() => null)) ?? {}) as JsonRpcResponse; - if (body.error?.message || body.result === undefined || body.result === null) return null; - return body.result as T; -} - -async function backfillFromLogs(pair: Address, vibeToken0: boolean, now: number): Promise { - const latestHex = await rpc('eth_blockNumber', []); - if (!latestHex) return []; - let latest: bigint; - try { - latest = BigInt(latestHex); - } catch { - return []; - } - const lookback = lookbackBlocks(); - const from = latest > lookback ? latest - lookback : 0n; - const logs = await rpc('eth_getLogs', [ - { - address: pair, - fromBlock: toHex(from), - toBlock: 'latest', - topics: [SYNC_TOPIC], - }, - ]); - if (!logs?.length) return []; - return samplesFromSyncLogs({ logs, pair, vibeToken0, latestBlock: latest, now }); -} - -async function currentMid(pair: Address, vibeToken0: boolean): Promise { - const data = encodeFunctionData({ abi: pairAbi, functionName: 'getReserves' }); - const raw = await rpc<`0x${string}`>('eth_call', [{ to: pair, data }, 'latest']); - if (!raw) return null; - try { - const decoded = decodeFunctionResult({ - abi: pairAbi, - functionName: 'getReserves', - data: raw, - }) as [bigint, bigint, number]; - const price = Number(quoteWad(decoded[0], decoded[1], vibeToken0)) / 1e18; - return Number.isFinite(price) && price > 0 ? price : null; - } catch { - return null; - } -} - -export async function GET(request: Request) { - const url = new URL(request.url); - const pair = url.searchParams.get('pair'); - if (!isAddress(pair)) { - return NextResponse.json({ error: 'pair required' }, { status: 400 }); - } - const vibeToken0 = url.searchParams.get('vibeToken0') !== '0'; - const now = Date.now(); - let samples = readTape(pair); - if (needsLogBackfill(samples, now)) { - const fromLogs = await backfillFromLogs(pair, vibeToken0, now); - if (fromLogs.length > 0) samples = writeTape(pair, fromLogs); - } - const mid = await currentMid(pair, vibeToken0); - if (mid !== null) samples = writeTape(pair, [{ t: now, price: mid }]); - return NextResponse.json( - { samples }, - { headers: { 'Cache-Control': 'no-store' } }, - ); -} - -export async function POST(request: Request) { - let body: unknown; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'invalid json' }, { status: 400 }); - } - const record = body && typeof body === 'object' ? (body as { pair?: unknown; samples?: unknown }) : {}; - if (!isAddress(typeof record.pair === 'string' ? record.pair : null)) { - return NextResponse.json({ error: 'pair required' }, { status: 400 }); - } - const samples = writeTape(record.pair as Address, parseTapeSamples(record.samples)); - return NextResponse.json({ ok: true, count: samples.length }); -} diff --git a/app/vibenet/demos/validity/ValidityDemo.tsx b/app/vibenet/demos/validity/ValidityDemo.tsx index d752b40e..a65a722e 100644 --- a/app/vibenet/demos/validity/ValidityDemo.tsx +++ b/app/vibenet/demos/validity/ValidityDemo.tsx @@ -29,6 +29,7 @@ import { helperApproveCalls, inventoryMints, reservesFromSyncLog, + SYNC_EVENT, tokenBalance, } from './lib/amm'; import { @@ -65,16 +66,14 @@ import { } from './lib/quote'; import { describeValidityError, - fetchTape, makePublicClient, - publishTape, sendValidityTransaction, VIBENET_CHAIN, type RpcSend, } from './lib/rpc'; import { connectJsonRpcStream, headNumber, type StreamHead, type StreamLog } from './lib/stream'; import { probeSingleton } from './lib/singleton'; -import { mergeTape } from './lib/tape'; +import { backfillBlocks, backfillSamples, mergeTape } from './lib/tape'; import { createState, loadState, saveState, type StoredState } from './lib/store'; import type { PlacedOrder, Rectangle, Reserves, Side } from './lib/types'; @@ -145,8 +144,6 @@ function ValidityDemoInner() { ordersRef.current = orders; const reservesRef = useRef(null); reservesRef.current = reserves; - const samplesRef = useRef([]); - samplesRef.current = samples; const stateRef = useRef(null); stateRef.current = state; @@ -195,38 +192,31 @@ function ValidityDemoInner() { const tapeVibeToken0 = Boolean(state?.deployment && vibeIsToken0(state.deployment)); useEffect(() => { - if (!hydrated || !pair) return; + const client = publicRef.current; + if (!hydrated || !pair || !client) return; let cancelled = false; - void fetchTape(pair, tapeVibeToken0) - .then((remote) => { - if (cancelled || remote.length === 0) return; - setSamples((prev) => mergeTape(remote, prev)); - }) - .catch(() => {}); + void (async () => { + const latestBlock = await client.getBlockNumber(); + const lookback = backfillBlocks(); + const [logs, current] = await Promise.all([ + client.getLogs({ + address: pair, + event: SYNC_EVENT, + fromBlock: latestBlock > lookback ? latestBlock - lookback : 0n, + toBlock: latestBlock, + }), + getReserves(client, pair), + ]); + if (cancelled) return; + const mid = Number(quoteWad(current.reserve0, current.reserve1, tapeVibeToken0)) / 1e18; + const history = backfillSamples({ logs, vibeToken0: tapeVibeToken0, latestBlock, now: Date.now(), mid }); + setSamples((prev) => mergeTape(history, prev)); + })().catch(() => {}); return () => { cancelled = true; }; }, [hydrated, pair, tapeVibeToken0]); - useEffect(() => { - if (!hydrated || !pair) return; - const flush = () => { - void publishTape(pair, samplesRef.current).catch(() => {}); - }; - const id = window.setInterval(flush, 2_000); - const onHide = () => { - if (document.visibilityState === 'hidden') flush(); - }; - document.addEventListener('visibilitychange', onHide); - window.addEventListener('pagehide', flush); - return () => { - window.clearInterval(id); - document.removeEventListener('visibilitychange', onHide); - window.removeEventListener('pagehide', flush); - flush(); - }; - }, [hydrated, pair]); - useEffect(() => { let cancelled = false; const client = makePublicClient(() => rpcSendRef.current); diff --git a/app/vibenet/demos/validity/components/PriceCandles.test.ts b/app/vibenet/demos/validity/components/PriceCandles.test.ts index c21619b4..8cb71330 100644 --- a/app/vibenet/demos/validity/components/PriceCandles.test.ts +++ b/app/vibenet/demos/validity/components/PriceCandles.test.ts @@ -6,7 +6,7 @@ import { isUpCandle, toCandles, type PriceSample } from './PriceCandles'; const BUCKET = CANDLE_BUCKET_MS; describe('toCandles', () => { - it('builds a wick when 200ms prints reverse inside a 5s bucket', () => { + it('builds a wick when 200ms prints reverse inside a 1s bucket', () => { const t0 = 1_000_000; const samples: PriceSample[] = [ { t: t0, price: 1.0 }, @@ -50,15 +50,15 @@ describe('toCandles', () => { expect(isUpCandle(candles[1], candles[0])).toBe(false); }); - it('fills empty 5s buckets so a 15s gap does not leave a hole', () => { + it('fills empty 1s buckets so a 3s gap does not leave a hole', () => { const t0 = 3_000_000; const samples: PriceSample[] = [ { t: t0, price: 0.07 }, - { t: t0 + 15_000, price: 0.071 }, + { t: t0 + 3 * BUCKET, price: 0.071 }, ]; - const candles = toCandles(samples, { now: t0 + 15_000, windowMs: 20_000 }); - expect(candles.map((candle) => candle.t)).toEqual([t0, t0 + 5_000, t0 + 10_000, t0 + 15_000]); - expect(candles[1]).toEqual({ t: t0 + 5_000, o: 0.07, h: 0.07, l: 0.07, c: 0.07 }); + const candles = toCandles(samples, { now: t0 + 3 * BUCKET, windowMs: 4 * BUCKET }); + expect(candles.map((candle) => candle.t)).toEqual([t0, t0 + BUCKET, t0 + 2 * BUCKET, t0 + 3 * BUCKET]); + expect(candles[1]).toEqual({ t: t0 + BUCKET, o: 0.07, h: 0.07, l: 0.07, c: 0.07 }); expect(candles[3].o).toBe(0.07); expect(candles[3].c).toBe(0.071); }); diff --git a/app/vibenet/demos/validity/components/PriceCandles.tsx b/app/vibenet/demos/validity/components/PriceCandles.tsx index 37216da2..40e2501e 100644 --- a/app/vibenet/demos/validity/components/PriceCandles.tsx +++ b/app/vibenet/demos/validity/components/PriceCandles.tsx @@ -194,7 +194,7 @@ export function PriceCandles({ samples, levels = [], fills = [] }: Props) {
VIBE / USDV
-
simulated pool · 5s candles
+
simulated pool · 1s candles
diff --git a/app/vibenet/demos/validity/lib/amm.ts b/app/vibenet/demos/validity/lib/amm.ts index 9b6e1986..4430e8f6 100644 --- a/app/vibenet/demos/validity/lib/amm.ts +++ b/app/vibenet/demos/validity/lib/amm.ts @@ -1,4 +1,5 @@ import { + encodeEventTopics, encodeFunctionData, parseAbi, parseEventLogs, @@ -13,6 +14,9 @@ const pairEvents = parseAbi([ 'event Sync(uint112 reserve0, uint112 reserve1)', ]); +export const SYNC_EVENT = pairEvents[1]; +export const SYNC_TOPIC = encodeEventTopics({ abi: pairEvents, eventName: 'Sync' })[0]; + import { QUOTE_SCALE, TRADER_USDV, TRADER_VIBE, erc20Abi, helperAbi, minterAbi, pairAbi } from './constants'; import { sqrt } from './predicates'; import { quoteFromPreSwapReserves } from './quote'; diff --git a/app/vibenet/demos/validity/lib/constants.ts b/app/vibenet/demos/validity/lib/constants.ts index ab853d0a..a7cd0c5c 100644 --- a/app/vibenet/demos/validity/lib/constants.ts +++ b/app/vibenet/demos/validity/lib/constants.ts @@ -42,8 +42,6 @@ export const minterAbi = parseAbi([ 'function mint(address token, address to, uint256 amount)', ]); -export const CANDLES_PATH = '/api/vibenet/validity/candles'; - export const STORAGE_KEY = 'vibenet.validity.v7'; export const WAD = 10n ** 18n; @@ -70,10 +68,11 @@ export const MAX_EXPIRY_SECONDS = 60; * are on committed 200ms blocks, not 2s pre-Denim heads or 250ms flashblocks. */ export const BLOCK_SECONDS = 0.2; -/** Stamp the mid on each 200ms head so the live 5s candle can wick. */ +/** Stamp the mid on each 200ms head so the live 1s candle can wick. */ export const CANDLE_SAMPLE_MS = 200; -export const CANDLE_BUCKET_MS = 5_000; -export const CANDLE_WINDOW_MS = 180_000; +export const CANDLE_BUCKET_MS = 1_000; +export const CANDLE_WINDOW_MS = 30_000; +export const CANDLE_BACKFILL_MS = 10_000; /** * Finite box span around the target point on the current hyperbola. diff --git a/app/vibenet/demos/validity/lib/rpc.ts b/app/vibenet/demos/validity/lib/rpc.ts index abd7d0a2..7d0490b4 100644 --- a/app/vibenet/demos/validity/lib/rpc.ts +++ b/app/vibenet/demos/validity/lib/rpc.ts @@ -12,8 +12,6 @@ import { import { VIBENET_RPC_URL, VIBENET_WS_URL } from '../../../library/config'; import { VIBENET } from '../../account/library/chains'; -import { CANDLES_PATH } from './constants'; -import { parseTapeSamples, type TapeSample } from './tape'; import type { ValidityPredicate } from './types'; export type RpcSend = (method: string, params: unknown[]) => Promise; @@ -69,26 +67,6 @@ export function makeWalletClient(account: Account): WalletClient { return createWalletClient({ chain: VIBENET_CHAIN, account, transport: custom(eip1193()) }); } -export async function fetchTape(pair: Address, vibeToken0: boolean): Promise { - const response = await fetch( - `${CANDLES_PATH}?pair=${pair}&vibeToken0=${vibeToken0 ? '1' : '0'}`, - { cache: 'no-store' }, - ); - if (!response.ok) return []; - const body = (await response.json().catch(() => null)) as { samples?: unknown } | null; - return parseTapeSamples(body?.samples); -} - -export async function publishTape(pair: Address, samples: readonly TapeSample[]): Promise { - if (samples.length === 0) return; - await fetch(CANDLES_PATH, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pair, samples }), - keepalive: true, - }); -} - export function describeValidityError(err: unknown): string { const record = err as { shortMessage?: string; diff --git a/app/vibenet/demos/validity/lib/singleton.test.ts b/app/vibenet/demos/validity/lib/singleton.test.ts new file mode 100644 index 00000000..51a13474 --- /dev/null +++ b/app/vibenet/demos/validity/lib/singleton.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { pickLivePair } from './singleton'; + +describe('pickLivePair', () => { + it('follows the most recently traded seeded pool over an idle duplicate', () => { + const idle = { id: 'idle', reserve0: 1n, reserve1: 1n, lastTradeAt: 100 }; + const live = { id: 'live', reserve0: 1n, reserve1: 1n, lastTradeAt: 900 }; + expect(pickLivePair([idle, live])?.id).toBe('live'); + }); + + it('skips unseeded pools', () => { + const empty = { id: 'empty', reserve0: 0n, reserve1: 5n, lastTradeAt: 999 }; + const seeded = { id: 'seeded', reserve0: 1n, reserve1: 1n, lastTradeAt: 1 }; + expect(pickLivePair([empty, seeded])?.id).toBe('seeded'); + expect(pickLivePair([empty])).toBeNull(); + }); +}); diff --git a/app/vibenet/demos/validity/lib/singleton.ts b/app/vibenet/demos/validity/lib/singleton.ts index 71b9d4e0..dbd58e8f 100644 --- a/app/vibenet/demos/validity/lib/singleton.ts +++ b/app/vibenet/demos/validity/lib/singleton.ts @@ -1,7 +1,6 @@ import { getContractAddress, keccak256, toBytes, type Address, type Hex, type PublicClient } from 'viem'; import { B20_FACTORY, factoryAbi as b20FactoryAbi } from '../../b20/lib/protocol'; -import { vibenetApi } from '../../../library/client'; import { factoryAbi, pairAbi } from './constants'; import type { Deployment } from './types'; @@ -44,10 +43,16 @@ export async function hasCode(client: PublicClient, address: Address): Promise { +type PairRow = { + pair: Address; + token0: Address; + token1: Address; + reserve0: bigint; + reserve1: bigint; + lastTradeAt: number; +}; + +async function pairTokens(client: PublicClient, pair: Address): Promise> { const [token0, token1, reserves] = await Promise.all([ client.readContract({ address: pair, abi: pairAbi, functionName: 'token0' }) as Promise
, client.readContract({ address: pair, abi: pairAbi, functionName: 'token1' }) as Promise
, @@ -55,7 +60,7 @@ async function pairTokens( [bigint, bigint, number] >, ]); - return { token0, token1, reserve0: reserves[0], reserve1: reserves[1] }; + return { token0, token1, reserve0: reserves[0], reserve1: reserves[1], lastTradeAt: reserves[2] }; } async function isB20Token(client: PublicClient, token: Address): Promise { @@ -69,10 +74,7 @@ async function isB20Token(client: PublicClient, token: Address): Promise false); } -async function listPairs( - client: PublicClient, - factory: Address, -): Promise<{ pair: Address; token0: Address; token1: Address; reserve0: bigint; reserve1: bigint }[]> { +async function listPairs(client: PublicClient, factory: Address): Promise { const length = (await client.readContract({ address: factory, abi: factoryAbi, @@ -91,54 +93,43 @@ async function listPairs( return rows; } -async function resolveVibenetUsdv(): Promise
{ - const status = await vibenetApi.faucet.status(); - const address = status.usdv_address; - if (!address || !/^0x[0-9a-fA-F]{40}$/.test(address)) { - throw new Error('Vibenet faucet did not return a USDV address.'); +/** A redeployed setup can leave several seeded VIBE/USDV pools; only the one the actors trade moves. */ +export function pickLivePair( + rows: readonly T[], +): T | null { + let best: T | null = null; + for (const row of rows) { + if (row.reserve0 === 0n || row.reserve1 === 0n) continue; + if (!best || row.lastTradeAt > best.lastTradeAt) best = row; } - return address as Address; -} - -function otherToken( - token0: Address, - token1: Address, - known: Address, -): Address | null { - const want = known.toLowerCase(); - if (token0.toLowerCase() === want) return token1; - if (token1.toLowerCase() === want) return token0; - return null; + return best; } /** - * Live shared pool against faucet USDV, or null if the central actor system - * has not deployed + seeded it yet. Read-only: the demo never deploys — the - * fixtures are created by vibenet-setup and driven by the actor system. + * Live shared VIBE pool, or null if the central actor system has not deployed + * + seeded it yet. Read-only: the demo never deploys — the fixtures are + * created by vibenet-setup and driven by the actor system. */ -export async function probeSingleton( - client: PublicClient, - usdv?: Address, -): Promise { - const tokenB = usdv ?? (await resolveVibenetUsdv()); +export async function probeSingleton(client: PublicClient): Promise { const predicted = { factory: VALIDITY_FACTORY, helper: VALIDITY_SWAP_HELPER, minter: VALIDITY_MINTER, }; - const [factory, helper, minter, usdvCode] = await Promise.all([ + const [factory, helper, minter] = await Promise.all([ hasCode(client, predicted.factory), hasCode(client, predicted.helper), hasCode(client, predicted.minter), - hasCode(client, tokenB), ]); - if (!factory || !helper || !minter || !usdvCode) return null; - const hit = (await listPairs(client, predicted.factory)).find((row) => { - if (row.reserve0 === 0n || row.reserve1 === 0n) return false; - return otherToken(row.token0, row.token1, tokenB) !== null; - }); + if (!factory || !helper || !minter) return null; + const rows = await listPairs(client, predicted.factory); + const vibePairs = []; + for (const row of rows) { + const [b20First, b20Second] = await Promise.all([isB20Token(client, row.token0), isB20Token(client, row.token1)]); + if (b20First === b20Second) continue; + vibePairs.push({ ...row, tokenA: b20First ? row.token0 : row.token1, tokenB: b20First ? row.token1 : row.token0 }); + } + const hit = pickLivePair(vibePairs); if (!hit) return null; - const tokenA = otherToken(hit.token0, hit.token1, tokenB); - if (!tokenA || !(await isB20Token(client, tokenA))) return null; - return { ...predicted, tokenA, tokenB, token0: hit.token0, token1: hit.token1, pair: hit.pair }; + return { ...predicted, tokenA: hit.tokenA, tokenB: hit.tokenB, token0: hit.token0, token1: hit.token1, pair: hit.pair }; } diff --git a/app/vibenet/demos/validity/lib/tape.test.ts b/app/vibenet/demos/validity/lib/tape.test.ts index 8eec6bc5..5977b4a1 100644 --- a/app/vibenet/demos/validity/lib/tape.test.ts +++ b/app/vibenet/demos/validity/lib/tape.test.ts @@ -1,16 +1,7 @@ -import { encodeAbiParameters, encodeEventTopics, parseAbi, zeroAddress } from 'viem'; import { describe, expect, it } from 'vitest'; -import { CANDLE_SAMPLE_MS, CANDLE_WINDOW_MS, USDV_UNIT, WAD } from './constants'; -import { - mergeTape, - needsLogBackfill, - parseTapeSamples, - resetTapeStore, - samplesFromSyncLogs, - tapeCoverageMs, - writeTape, -} from './tape'; +import { CANDLE_BACKFILL_MS, CANDLE_WINDOW_MS, USDV_UNIT, WAD } from './constants'; +import { backfillBlocks, backfillSamples, mergeTape, samplesFromSyncLogs } from './tape'; describe('mergeTape', () => { it('slots onto the 200ms clock and drops samples outside the window', () => { @@ -31,84 +22,36 @@ describe('mergeTape', () => { }); }); -describe('parseTapeSamples', () => { - it('keeps finite positive prices', () => { - expect( - parseTapeSamples([ - { t: 1, price: 0.07 }, - { t: 'nope', price: 1 }, - { t: 2, price: 0 }, - { price: 1 }, - ]), - ).toEqual([{ t: 1, price: 0.07 }]); - }); -}); +const logs = [{ blockNumber: 0x64n, args: { reserve0: 2_000_000n * WAD, reserve1: 140_000n * USDV_UNIT } }]; -describe('tape store', () => { - it('holds samples across write/read for one pair', () => { - resetTapeStore(); - const pair = '0x00000000000000000000000000000000000000aa' as const; - const now = Date.now(); - writeTape(pair, [{ t: now - 1_000, price: 0.07 }]); - expect(writeTape(pair, [{ t: now - 200, price: 0.071 }]).map((row) => row.price)).toEqual([ - 0.07, - 0.071, +describe('backfillSamples', () => { + it('pins a flat line across the backfill window when no Sync landed', () => { + const now = 20_000_000; + expect(backfillSamples({ logs: [], vibeToken0: true, latestBlock: 0x6en, now, mid: 0.07 })).toEqual([ + { t: now - CANDLE_BACKFILL_MS, price: 0.07 }, + { t: now, price: 0.07 }, ]); - resetTapeStore(); }); -}); -describe('needsLogBackfill', () => { - it('asks for logs until the in-memory tape covers most of the window', () => { + it('uses Sync history instead of the flat line when trades landed', () => { const now = 20_000_000; - expect(needsLogBackfill([], now)).toBe(true); - expect( - needsLogBackfill( - [ - { t: now - CANDLE_WINDOW_MS, price: 0.07 }, - { t: now, price: 0.07 }, - ], - now, - ), - ).toBe(false); - expect(tapeCoverageMs([{ t: now - 1_000, price: 0.07 }], now)).toBe(0); + const samples = backfillSamples({ logs, vibeToken0: true, latestBlock: 0x6en, now, mid: 0.08 }); + expect(samples).toHaveLength(2); + expect(samples[0].t).toBe(now - 10 * 200); + expect(samples[0].price).toBeCloseTo(0.07, 8); + expect(samples[1]).toEqual({ t: now, price: 0.08 }); + }); + + it('looks back 10s of 200ms blocks', () => { + expect(backfillBlocks()).toBe(50n); }); }); describe('samplesFromSyncLogs', () => { it('turns Sync reserves into mids stamped from the latest block', () => { - const abi = parseAbi(['event Sync(uint112 reserve0, uint112 reserve1)']); - const [topic] = encodeEventTopics({ abi, eventName: 'Sync' }); - const pair = '0x00000000000000000000000000000000000000aa' as const; - const logs = [ - { - address: pair, - topics: [topic], - data: encodeAbiParameters( - [{ type: 'uint112' }, { type: 'uint112' }], - [2_000_000n * WAD, 140_000n * USDV_UNIT], - ), - blockNumber: '0x64', - }, - ]; - const samples = samplesFromSyncLogs({ - logs, - pair, - vibeToken0: true, - latestBlock: 0x6en, - now: 5_000, - }); + const samples = samplesFromSyncLogs({ logs, vibeToken0: true, latestBlock: 0x6en, now: 5_000 }); expect(samples).toHaveLength(1); expect(samples[0].price).toBeCloseTo(0.07, 8); expect(samples[0].t).toBe(5_000 - 10 * 200); - expect( - samplesFromSyncLogs({ - logs, - pair: zeroAddress, - vibeToken0: true, - latestBlock: 0x6en, - now: 5_000, - }), - ).toEqual([]); }); }); diff --git a/app/vibenet/demos/validity/lib/tape.ts b/app/vibenet/demos/validity/lib/tape.ts index d9d3376e..1a649fb2 100644 --- a/app/vibenet/demos/validity/lib/tape.ts +++ b/app/vibenet/demos/validity/lib/tape.ts @@ -1,8 +1,6 @@ -import type { Address, Hex } from 'viem'; - -import { reservesFromSyncLog } from './amm'; import { BLOCK_SECONDS, + CANDLE_BACKFILL_MS, CANDLE_BUCKET_MS, CANDLE_SAMPLE_MS, CANDLE_WINDOW_MS, @@ -12,38 +10,6 @@ import { quoteWad } from './quote'; export type TapeSample = { t: number; price: number }; export const TAPE_KEEP_MS = CANDLE_WINDOW_MS + CANDLE_BUCKET_MS; -export const TAPE_MAX_SAMPLES = Math.ceil(TAPE_KEEP_MS / CANDLE_SAMPLE_MS) + 8; -const BACKFILL_COVERAGE_MS = (CANDLE_WINDOW_MS * 4) / 5; - -type TapeStore = Map; - -function globalStore(): TapeStore { - const root = globalThis as typeof globalThis & { __validityTape?: TapeStore }; - if (!root.__validityTape) root.__validityTape = new Map(); - return root.__validityTape; -} - -export function resetTapeStore(): void { - globalStore().clear(); -} - -export function isAddress(value: string | null | undefined): value is Address { - return Boolean(value && /^0x[0-9a-fA-F]{40}$/.test(value)); -} - -export function parseTapeSamples(value: unknown): TapeSample[] { - if (!Array.isArray(value)) return []; - const out: TapeSample[] = []; - for (const row of value) { - if (!row || typeof row !== 'object') continue; - const t = Number((row as { t?: unknown }).t); - const price = Number((row as { price?: unknown }).price); - if (!Number.isFinite(t) || !Number.isFinite(price) || price <= 0) continue; - out.push({ t, price }); - if (out.length >= TAPE_MAX_SAMPLES) break; - } - return out; -} export function mergeTape( existing: readonly TapeSample[], @@ -63,38 +29,13 @@ export function mergeTape( .map(([t, price]) => ({ t, price })); } -export function tapeCoverageMs(samples: readonly TapeSample[], now = Date.now()): number { - if (samples.length === 0) return 0; - const first = samples[0].t; - const last = samples[samples.length - 1].t; - return Math.max(0, Math.min(now, last) - first); -} - -export function needsLogBackfill(samples: readonly TapeSample[], now = Date.now()): boolean { - return tapeCoverageMs(samples, now) < BACKFILL_COVERAGE_MS; -} - -export function readTape(pair: Address): TapeSample[] { - return mergeTape(globalStore().get(pair.toLowerCase()) ?? [], [], Date.now()); -} - -export function writeTape(pair: Address, incoming: readonly TapeSample[]): TapeSample[] { - const key = pair.toLowerCase(); - const next = mergeTape(globalStore().get(key) ?? [], incoming, Date.now()); - globalStore().set(key, next); - return next; -} - -export type RpcLog = { - address?: string; - topics?: Hex[]; - data?: Hex; - blockNumber?: string; +export type SyncLog = { + blockNumber: bigint; + args: { reserve0?: bigint; reserve1?: bigint }; }; export function samplesFromSyncLogs(args: { - logs: readonly RpcLog[]; - pair: Address; + logs: readonly SyncLog[]; vibeToken0: boolean; latestBlock: bigint; now: number; @@ -102,27 +43,28 @@ export function samplesFromSyncLogs(args: { const blockMs = BLOCK_SECONDS * 1000; const incoming: TapeSample[] = []; for (const log of args.logs) { - if (!log.address || !log.topics?.length || !log.data || !log.blockNumber) continue; - if (log.address.toLowerCase() !== args.pair.toLowerCase()) continue; - const reserves = reservesFromSyncLog({ - address: log.address as Address, - topics: log.topics, - data: log.data, - }); - if (!reserves) continue; - let block: bigint; - try { - block = BigInt(log.blockNumber); - } catch { - continue; - } - const t = args.now - Number(args.latestBlock - block) * blockMs; - const price = Number(quoteWad(reserves.reserve0, reserves.reserve1, args.vibeToken0)) / 1e18; + const { reserve0, reserve1 } = log.args; + if (reserve0 === undefined || reserve1 === undefined) continue; + const t = args.now - Number(args.latestBlock - log.blockNumber) * blockMs; + const price = Number(quoteWad(reserve0, reserve1, args.vibeToken0)) / 1e18; incoming.push({ t, price }); } return mergeTape([], incoming, args.now); } -export function lookbackBlocks(): bigint { - return BigInt(Math.ceil(TAPE_KEEP_MS / (BLOCK_SECONDS * 1000)) + 8); +/** With no Sync in the lookback, the price was flat at the current mid the whole time. */ +export function backfillSamples(args: { + logs: readonly SyncLog[]; + vibeToken0: boolean; + latestBlock: bigint; + now: number; + mid: number; +}): TapeSample[] { + const fromLogs = samplesFromSyncLogs(args); + const flat = fromLogs.length === 0 ? [{ t: args.now - CANDLE_BACKFILL_MS, price: args.mid }] : []; + return mergeTape(flat, [...fromLogs, { t: args.now, price: args.mid }], args.now); +} + +export function backfillBlocks(): bigint { + return BigInt(Math.ceil(CANDLE_BACKFILL_MS / (BLOCK_SECONDS * 1000))); }