From 6c2950a96e1e4df68454824aaf73d8b513b9aa9a Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Thu, 27 Aug 2026 16:20:06 -0700 Subject: [PATCH 1/6] feat(vibenet): add Validity demo for conditional inclusion Show how a swap can wait on storage and block predicates, then land or expire on a simulated AMM, using Vibenet RPC and the existing faucet. Co-authored-by: Cursor --- .env.example | 8 + AGENTS.md | 1 + app/analytics/events.ts | 7 + app/api/vibenet/validity/config.test.ts | 31 + app/api/vibenet/validity/config.ts | 52 + app/api/vibenet/validity/forward.ts | 56 ++ app/api/vibenet/validity/rpc/route.ts | 25 + app/api/vibenet/validity/status/route.ts | 83 ++ app/sitemap.ts | 1 + app/vibenet/demos/catalogue.test.ts | 4 +- app/vibenet/demos/catalogue.ts | 13 + app/vibenet/demos/validity/ValidityDemo.tsx | 896 ++++++++++++++++++ .../demos/validity/components/OrderList.tsx | 160 ++++ .../demos/validity/components/OrderTicket.tsx | 150 +++ .../validity/components/PriceCandles.test.ts | 59 ++ .../validity/components/PriceCandles.tsx | 315 ++++++ .../validity/components/ReserveChart.tsx | 265 ++++++ .../validity/components/ValidityJson.tsx | 80 ++ app/vibenet/demos/validity/layout.tsx | 12 + app/vibenet/demos/validity/lib/amm.test.ts | 33 + app/vibenet/demos/validity/lib/amm.ts | 529 +++++++++++ .../validity/lib/artifacts/MintableERC20.json | 1 + .../validity/lib/artifacts/SwapHelper.json | 1 + .../lib/artifacts/UniswapV2Factory.json | 1 + .../validity/lib/artifacts/UniswapV2Pair.json | 1 + app/vibenet/demos/validity/lib/bots.test.ts | 53 ++ app/vibenet/demos/validity/lib/bots.ts | 250 +++++ app/vibenet/demos/validity/lib/constants.ts | 56 ++ app/vibenet/demos/validity/lib/faucet.ts | 38 + app/vibenet/demos/validity/lib/fees.test.ts | 32 + app/vibenet/demos/validity/lib/fees.ts | 44 + app/vibenet/demos/validity/lib/orders.test.ts | 68 ++ app/vibenet/demos/validity/lib/orders.ts | 69 ++ .../demos/validity/lib/predicates.test.ts | 100 ++ app/vibenet/demos/validity/lib/predicates.ts | 181 ++++ app/vibenet/demos/validity/lib/quote.test.ts | 89 ++ app/vibenet/demos/validity/lib/quote.ts | 105 ++ app/vibenet/demos/validity/lib/rpc.test.ts | 33 + app/vibenet/demos/validity/lib/rpc.ts | 93 ++ app/vibenet/demos/validity/lib/store.ts | 117 +++ app/vibenet/demos/validity/lib/types.ts | 106 +++ app/vibenet/demos/validity/page.tsx | 5 + 42 files changed, 4221 insertions(+), 2 deletions(-) create mode 100644 app/api/vibenet/validity/config.test.ts create mode 100644 app/api/vibenet/validity/config.ts create mode 100644 app/api/vibenet/validity/forward.ts create mode 100644 app/api/vibenet/validity/rpc/route.ts create mode 100644 app/api/vibenet/validity/status/route.ts create mode 100644 app/vibenet/demos/validity/ValidityDemo.tsx create mode 100644 app/vibenet/demos/validity/components/OrderList.tsx create mode 100644 app/vibenet/demos/validity/components/OrderTicket.tsx create mode 100644 app/vibenet/demos/validity/components/PriceCandles.test.ts create mode 100644 app/vibenet/demos/validity/components/PriceCandles.tsx create mode 100644 app/vibenet/demos/validity/components/ReserveChart.tsx create mode 100644 app/vibenet/demos/validity/components/ValidityJson.tsx create mode 100644 app/vibenet/demos/validity/layout.tsx create mode 100644 app/vibenet/demos/validity/lib/amm.test.ts create mode 100644 app/vibenet/demos/validity/lib/amm.ts create mode 100644 app/vibenet/demos/validity/lib/artifacts/MintableERC20.json create mode 100644 app/vibenet/demos/validity/lib/artifacts/SwapHelper.json create mode 100644 app/vibenet/demos/validity/lib/artifacts/UniswapV2Factory.json create mode 100644 app/vibenet/demos/validity/lib/artifacts/UniswapV2Pair.json create mode 100644 app/vibenet/demos/validity/lib/bots.test.ts create mode 100644 app/vibenet/demos/validity/lib/bots.ts create mode 100644 app/vibenet/demos/validity/lib/constants.ts create mode 100644 app/vibenet/demos/validity/lib/faucet.ts create mode 100644 app/vibenet/demos/validity/lib/fees.test.ts create mode 100644 app/vibenet/demos/validity/lib/fees.ts create mode 100644 app/vibenet/demos/validity/lib/orders.test.ts create mode 100644 app/vibenet/demos/validity/lib/orders.ts create mode 100644 app/vibenet/demos/validity/lib/predicates.test.ts create mode 100644 app/vibenet/demos/validity/lib/predicates.ts create mode 100644 app/vibenet/demos/validity/lib/quote.test.ts create mode 100644 app/vibenet/demos/validity/lib/quote.ts create mode 100644 app/vibenet/demos/validity/lib/rpc.test.ts create mode 100644 app/vibenet/demos/validity/lib/rpc.ts create mode 100644 app/vibenet/demos/validity/lib/store.ts create mode 100644 app/vibenet/demos/validity/lib/types.ts create mode 100644 app/vibenet/demos/validity/page.tsx diff --git a/.env.example b/.env.example index 18a9b3d..6143a9d 100644 --- a/.env.example +++ b/.env.example @@ -51,3 +51,11 @@ NEXT_PUBLIC_VIBENET_RPC_URL=https://rpc.vibes.base.org # /benchmark to load any data; the section throws a configuration error without # it. No credentials belong here: this value is inlined into the client bundle. # NEXT_PUBLIC_BENCHMARK_API_BASE_URL= + +# Validity demo (/vibenet/demos/validity). Server-side RPC proxy only. +# Defaults to the public Vibenet RPC (`NEXT_PUBLIC_VIBENET_RPC_URL`). ETH comes +# from the Vibenet faucet — do not set a funder key. Override only for a local +# node with --enable-experimental-validity-transactions. +# VALIDITY_DEMO_RPC_URL=https://rpc.vibes.base.org +# Local Anvil / just devnet: +# VALIDITY_DEMO_RPC_URL=http://127.0.0.1:8545 diff --git a/AGENTS.md b/AGENTS.md index 3b90fc3..6971364 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,6 +124,7 @@ This app uses Vercel Web Analytics. Two things must stay in place: | `trackB20PromptCopy(module, prompt)` | `app/vibenet/demos/b20/components/CopyPromptButton.tsx` — copy AI prompt | | `trackExplorerChainSelect(chain)` | `app/internal-explorer/components/ChainToggle.tsx` — chain toggle | | `trackExplorerActiveBlockJump(chain, jump)` | `app/internal-explorer/components/ActiveBlockButton.tsx` — zeronet latest/previous active block | + | `trackValidityOrder(side, status)` | `app/vibenet/demos/validity/ValidityDemo.tsx` — conditional swap submit / include / expiry / replace | Add a helper (and a row here) for a new key journey; remove the helper if you remove its surface. Confirm the wiring with `grep -rn "analytics/events" app`. diff --git a/app/analytics/events.ts b/app/analytics/events.ts index d838376..531e6fa 100644 --- a/app/analytics/events.ts +++ b/app/analytics/events.ts @@ -77,3 +77,10 @@ export function trackExplorerChainSelect(chain: string): void { export function trackExplorerActiveBlockJump(chain: string, jump: 'latest' | 'previous'): void { track('explorer_active_block_jump', { chain, jump }); } + +export function trackValidityOrder( + side: string, + status: 'submitted' | 'filled' | 'expired' | 'replaced' | 'error', +): void { + track('validity_order', { side, status }); +} diff --git a/app/api/vibenet/validity/config.test.ts b/app/api/vibenet/validity/config.test.ts new file mode 100644 index 0000000..2d6a099 --- /dev/null +++ b/app/api/vibenet/validity/config.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { VIBENET_RPC_URL } from '../../../vibenet/library/config'; +import { getReadRpcUrl, getSubmitRpcUrl } from './config'; + +const originalRead = process.env.VALIDITY_DEMO_RPC_URL; +const originalSubmit = process.env.VALIDITY_DEMO_SUBMIT_RPC_URL; + +afterEach(() => { + if (originalRead === undefined) delete process.env.VALIDITY_DEMO_RPC_URL; + else process.env.VALIDITY_DEMO_RPC_URL = originalRead; + if (originalSubmit === undefined) delete process.env.VALIDITY_DEMO_SUBMIT_RPC_URL; + else process.env.VALIDITY_DEMO_SUBMIT_RPC_URL = originalSubmit; +}); + +describe('validity demo RPC config', () => { + it('defaults to the public Vibenet RPC for reads and submits', () => { + delete process.env.VALIDITY_DEMO_RPC_URL; + delete process.env.VALIDITY_DEMO_SUBMIT_RPC_URL; + expect(getReadRpcUrl()).toBe(VIBENET_RPC_URL); + expect(getSubmitRpcUrl()).toBe(VIBENET_RPC_URL); + }); + + it('uses a single custom RPC for both when submit is unset', () => { + process.env.VALIDITY_DEMO_RPC_URL = 'http://127.0.0.1:8545'; + delete process.env.VALIDITY_DEMO_SUBMIT_RPC_URL; + expect(getReadRpcUrl()).toBe('http://127.0.0.1:8545'); + expect(getSubmitRpcUrl()).toBe('http://127.0.0.1:8545'); + delete process.env.VALIDITY_DEMO_RPC_URL; + }); +}); diff --git a/app/api/vibenet/validity/config.ts b/app/api/vibenet/validity/config.ts new file mode 100644 index 0000000..c796faf --- /dev/null +++ b/app/api/vibenet/validity/config.ts @@ -0,0 +1,52 @@ +// Server-only config for the validity demo's RPC proxy. +// Defaults to the public Vibenet RPC; override with VALIDITY_DEMO_* in `.env.local`. + +import { VIBENET_RPC_URL } from '../../../vibenet/library/config'; + +function trimEnv(name: string): string | undefined { + const value = process.env[name]?.trim(); + return value && value.length > 0 ? value : undefined; +} + +export function getReadRpcUrl(): string { + return trimEnv('VALIDITY_DEMO_RPC_URL') ?? VIBENET_RPC_URL; +} + +export function getSubmitRpcUrl(): string { + return trimEnv('VALIDITY_DEMO_SUBMIT_RPC_URL') ?? getReadRpcUrl(); +} + +export function rpcHost(url: string): string { + try { + return new URL(url).host; + } catch { + return 'invalid-rpc-url'; + } +} + +export const SUBMIT_METHODS = new Set([ + 'eth_sendRawTransaction', + 'eth_sendRawTransactionSync', + 'base_sendRawTransactionValidity', +]); + +export const ALLOWED_METHODS = new Set([ + ...SUBMIT_METHODS, + 'eth_chainId', + 'eth_blockNumber', + 'eth_getBlockByNumber', + 'eth_getBlockByHash', + 'eth_getCode', + 'eth_call', + 'eth_estimateGas', + 'eth_gasPrice', + 'eth_maxPriorityFeePerGas', + 'eth_feeHistory', + 'eth_getBalance', + 'eth_getTransactionCount', + 'eth_getTransactionReceipt', + 'eth_getTransactionByHash', + 'eth_getStorageAt', + 'eth_getLogs', + 'eth_blobBaseFee', +]); diff --git a/app/api/vibenet/validity/forward.ts b/app/api/vibenet/validity/forward.ts new file mode 100644 index 0000000..3593827 --- /dev/null +++ b/app/api/vibenet/validity/forward.ts @@ -0,0 +1,56 @@ +import { ALLOWED_METHODS, SUBMIT_METHODS, getReadRpcUrl, getSubmitRpcUrl } from './config'; + +type JsonRpcRequest = { + jsonrpc?: string; + id?: unknown; + method?: string; + params?: unknown; +}; + +type JsonRpcError = { code: number; message: string }; + +function methodNotAllowed(id: unknown, method: string) { + return { + jsonrpc: '2.0', + id: id ?? null, + error: { code: -32601, message: `Method not allowed: ${method}` } satisfies JsonRpcError, + }; +} + +async function forwardOne(request: JsonRpcRequest): Promise { + const method = request.method ?? ''; + if (!ALLOWED_METHODS.has(method)) { + return methodNotAllowed(request.id, method); + } + const url = SUBMIT_METHODS.has(method) ? getSubmitRpcUrl() : getReadRpcUrl(); + const response = await fetch(url, { + method: 'POST', + cache: 'no-store', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: request.jsonrpc ?? '2.0', + id: request.id ?? 1, + method, + params: request.params ?? [], + }), + }); + const body: unknown = await response.json().catch(() => null); + if (!response.ok) { + return { + jsonrpc: '2.0', + id: request.id ?? null, + error: { + code: -32603, + message: `Upstream RPC HTTP ${response.status}`, + }, + }; + } + return body; +} + +export async function forwardJsonRpc(payload: unknown): Promise { + if (Array.isArray(payload)) { + return Promise.all(payload.map((item) => forwardOne(item as JsonRpcRequest))); + } + return forwardOne(payload as JsonRpcRequest); +} diff --git a/app/api/vibenet/validity/rpc/route.ts b/app/api/vibenet/validity/rpc/route.ts new file mode 100644 index 0000000..ce9b2e3 --- /dev/null +++ b/app/api/vibenet/validity/rpc/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server'; + +import { forwardJsonRpc } from '../forward'; + +export async function POST(request: Request) { + let payload: unknown; + try { + payload = await request.json(); + } catch { + return NextResponse.json( + { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }, + { status: 400 }, + ); + } + try { + const result = await forwardJsonRpc(payload); + return NextResponse.json(result); + } catch (error) { + const message = error instanceof Error ? error.message : 'RPC proxy failed'; + return NextResponse.json( + { jsonrpc: '2.0', id: null, error: { code: -32603, message } }, + { status: 502 }, + ); + } +} diff --git a/app/api/vibenet/validity/status/route.ts b/app/api/vibenet/validity/status/route.ts new file mode 100644 index 0000000..a6c4b49 --- /dev/null +++ b/app/api/vibenet/validity/status/route.ts @@ -0,0 +1,83 @@ +import { NextResponse } from 'next/server'; + +import { getReadRpcUrl, getSubmitRpcUrl, rpcHost } from '../config'; +import { forwardJsonRpc } from '../forward'; + +type JsonRpcResponse = { + result?: unknown; + error?: { code?: number; message?: string }; +}; + +async function rpcCall(method: string, params: unknown[]): Promise { + const body = await forwardJsonRpc({ jsonrpc: '2.0', id: 1, method, params }); + return (body ?? {}) as JsonRpcResponse; +} + +function methodExists(response: JsonRpcResponse): boolean { + const code = response.error?.code; + const message = (response.error?.message ?? '').toLowerCase(); + if (code === -32601) return false; + if (message.includes('method not found') || message.includes('method is not available')) { + return false; + } + if (message.includes('unsupported') && message.includes('method')) return false; + return true; +} + +function typeAccepted(response: JsonRpcResponse): boolean { + const message = (response.error?.message ?? '').toLowerCase(); + if (!response.error) return true; + if (message.includes('unknown variant') || message.includes('unknown type') || message.includes('invalid type')) { + return false; + } + if (message.includes('deny_unknown') || message.includes('did not match any variant')) return false; + return true; +} + +const DUMMY_TX = '0x00'; +const DUMMY_BALANCE = { + type: 'balance', + params: { + address: '0x0000000000000000000000000000000000000001', + op: '>=', + value: '0x0', + }, +}; +const DUMMY_BLOCK = { + type: 'block_number', + params: { op: '<=', value: '0x1' }, +}; + +export async function GET() { + const readHost = rpcHost(getReadRpcUrl()); + const submitHost = rpcHost(getSubmitRpcUrl()); + + const chain = await rpcCall('eth_chainId', []); + const genesis = await rpcCall('eth_getBlockByNumber', ['0x0', false]); + const validity = await rpcCall('base_sendRawTransactionValidity', [ + { tx: DUMMY_TX, validity: [DUMMY_BALANCE] }, + ]); + const validitySupported = methodExists(validity); + let blockNumberPredicate = false; + if (validitySupported) { + const blockProbe = await rpcCall('base_sendRawTransactionValidity', [ + { tx: DUMMY_TX, validity: [DUMMY_BLOCK] }, + ]); + blockNumberPredicate = typeAccepted(blockProbe); + } + + const genesisHash = + genesis.result && typeof genesis.result === 'object' && genesis.result !== null && 'hash' in genesis.result + ? String((genesis.result as { hash: unknown }).hash) + : null; + + return NextResponse.json({ + chainId: typeof chain.result === 'string' ? Number.parseInt(chain.result, 16) : null, + genesisHash, + readHost, + submitHost, + validitySupported, + blockNumberPredicate, + validityError: validity.error?.message ?? null, + }); +} diff --git a/app/sitemap.ts b/app/sitemap.ts index 6908f5e..a87908a 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -25,6 +25,7 @@ export default function sitemap(): MetadataRoute.Sitemap { { path: '/vibenet/faucet', priority: 0.5, changeFrequency: 'monthly' }, { path: '/vibenet/demos/account', priority: 0.5, changeFrequency: 'weekly' }, { path: '/vibenet/demos/b20', priority: 0.5, changeFrequency: 'weekly' }, + { path: '/vibenet/demos/validity', priority: 0.5, changeFrequency: 'weekly' }, ]; return routes.map(({ path, priority, changeFrequency }) => ({ diff --git a/app/vibenet/demos/catalogue.test.ts b/app/vibenet/demos/catalogue.test.ts index e7c6025..121d136 100644 --- a/app/vibenet/demos/catalogue.test.ts +++ b/app/vibenet/demos/catalogue.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it } from 'vitest'; import { DEMOS, demoLabel } from './catalogue'; describe('demoLabel', () => { - it('uses the catalogue entry so the crumb matches the demo name', () => { - expect(demoLabel('account')).toBe('Account'); + it('prefers shortTitle for the validity demo', () => { + expect(demoLabel('validity')).toBe('Validity'); }); it('prefers shortTitle over title when both are set', () => { diff --git a/app/vibenet/demos/catalogue.ts b/app/vibenet/demos/catalogue.ts index 65c8c37..31ae82a 100644 --- a/app/vibenet/demos/catalogue.ts +++ b/app/vibenet/demos/catalogue.ts @@ -46,6 +46,19 @@ export const DEMOS: DemoEntry[] = [ ], available: true, }, + { + href: '/vibenet/demos/validity', + title: 'Validity', + shortTitle: 'Validity', + summary: + 'Attach conditions to a transaction so the sequencer includes it only while they hold. A simulated pool shows a swap waiting on price, then landing or expiring.', + points: [ + 'Add storage and block-number conditions to an ordinary swap', + 'A simulated AMM makes those conditions visible on a moving mid', + 'Optional 5s / 15s / 60s bound so a stale condition cannot fire later', + ], + available: true, + }, ]; /** `smart-wallet` -> `Smart Wallet`. Fallback for a route with no catalogue entry. */ diff --git a/app/vibenet/demos/validity/ValidityDemo.tsx b/app/vibenet/demos/validity/ValidityDemo.tsx new file mode 100644 index 0000000..cc6b04a --- /dev/null +++ b/app/vibenet/demos/validity/ValidityDemo.tsx @@ -0,0 +1,896 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { Account, PublicClient, WalletClient } from 'viem'; +import { formatEther } from 'viem'; + +import { trackValidityOrder } from '../../../analytics/events'; +import { Button } from '../../../components/ui/Button'; +import { Card } from '../../../components/ui/Card'; +import { cn } from '../../../components/ui/cn'; +import { Text } from '../../../components/ui/Text'; +import { CopyableValue } from '../../components/CopyableValue'; +import { DemoHeader } from '../_components/DemoHeader'; +import { OrderList } from './components/OrderList'; +import { OrderTicket } from './components/OrderTicket'; +import { PriceCandles, type FillMark, type PriceLevel, type PriceSample } from './components/PriceCandles'; +import { ReserveChart } from './components/ReserveChart'; +import { ValidityJson } from './components/ValidityJson'; +import { + amountOutAtLimit, + deployAmm, + encodeHelperSwap, + fillQuoteFromSwapReceipt, + getReserves, + signCall, + tokenBalance, +} from './lib/amm'; +import { startBots, allNeedGas, botNeedsGas, refuelValue } from './lib/bots'; +import { MAX_EXPIRY_SECONDS } from './lib/constants'; +import { faucetErrorMessage, seedEthFromFaucet } from './lib/faucet'; +import { + maxBlockForExpiry, + occupyingOrder, + orderBlockExpired, + orderWallClockExpired, + restingOrderToReplace, + tapeCrossedAt, +} from './lib/orders'; +import { bumpReplacementFees, isReplacementUnderpriced, padFees } from './lib/fees'; +import { applyOffsetBps, blockExpiryPredicate, formatPrice, prettyValidity, priceValidity, spotPastTarget } from './lib/predicates'; +import { + ammPriceFromQuote, + ammSide, + clampToCondition, + quoteWad, + swapOuts, + tokenInFor, + vibeIsToken0, +} from './lib/quote'; +import { + chainFromId, + describeValidityError, + fetchChainStatus, + makePublicClient, + makeWalletClient, + sendValidityTransaction, +} from './lib/rpc'; +import { accountsFrom, createState, dropDeployment, loadState, saveState, type StoredState } from './lib/store'; +import type { ChainStatus, PlacedOrder, Rectangle, Reserves, Side } from './lib/types'; + +const POLL_MS = 400; +/** L2 blocks are ~2s. viem's default 4s block cache made this skip 2–3 heads. */ +const BLOCK_POLL_MS = 1_000; +const DEFAULT_SIZE_FRACTION = 50n; // 1/50 of inventory + +function wadToNumber(wad: bigint): number { + return Number(wad) / 1e18; +} + +function newId(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +export function ValidityDemo() { + const [status, setStatus] = useState(null); + const [statusError, setStatusError] = useState(null); + const [state, setState] = useState(null); + const [hydrated, setHydrated] = useState(false); + const [ethBalance, setEthBalance] = useState(null); + const [reserves, setReserves] = useState(null); + const [progress, setProgress] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [botsOn, setBotsOn] = useState(true); + const [hoverPrice, setHoverPrice] = useState(null); + const [side, setSide] = useState('buy'); + const [offsetBps, setOffsetBps] = useState(100); + const [expirySeconds, setExpirySeconds] = useState(60); + const [orders, setOrders] = useState([]); + const [hoveredOrderId, setHoveredOrderId] = useState(null); + const [samples, setSamples] = useState([]); + const [makerError, setMakerError] = useState(null); + const [makersDry, setMakersDry] = useState(false); + const [blockNumber, setBlockNumber] = useState(null); + + const publicRef = useRef(null); + const userWalletRef = useRef(null); + const userAccountRef = useRef(null); + const botsEnabledRef = useRef(true); + botsEnabledRef.current = botsOn; + const busyRef = useRef(false); + busyRef.current = busy; + const refuelInFlightRef = useRef(false); + const lastMakerPriceAtRef = useRef(0); + const autoFaucetRef = useRef(false); + + const ordersRef = useRef([]); + ordersRef.current = orders; + const reservesRef = useRef(null); + reservesRef.current = reserves; + const samplesRef = useRef([]); + samplesRef.current = samples; + + const persist = useCallback((next: StoredState) => { + saveState(next); + setState(next); + }, []); + + const pushSample = useCallback((price: number) => { + if (!Number.isFinite(price) || price <= 0) return; + setSamples((prev) => { + const next = [...prev, { t: Date.now(), price }]; + return next.length > 500 ? next.slice(-500) : next; + }); + }, []); + + useEffect(() => { + let cancelled = false; + fetchChainStatus() + .then((next) => { + if (cancelled) return; + setStatus(next); + if (!next.chainId || !next.genesisHash) { + setStatusError('RPC did not return a chain id / genesis hash.'); + return; + } + const existing = loadState(); + if (existing && existing.chainId === next.chainId && existing.genesisHash === next.genesisHash) { + setState(existing); + } else { + const created = createState(next.chainId, next.genesisHash); + persist(created); + } + const chain = chainFromId(next.chainId); + publicRef.current = makePublicClient(chain); + }) + .catch((err: unknown) => { + if (!cancelled) setStatusError(err instanceof Error ? err.message : 'Could not reach the validity RPC proxy.'); + }) + .finally(() => { + if (!cancelled) setHydrated(true); + }); + return () => { + cancelled = true; + }; + }, [persist]); + + const accounts = useMemo(() => (state ? accountsFrom(state) : null), [state]); + + useEffect(() => { + if (!status?.chainId || !accounts) return; + const chain = chainFromId(status.chainId); + publicRef.current = makePublicClient(chain); + userAccountRef.current = accounts.user; + userWalletRef.current = makeWalletClient(chain, accounts.user); + }, [accounts, status?.chainId]); + + const refreshBalances = useCallback(async () => { + const client = publicRef.current; + const account = userAccountRef.current; + if (!client || !account) return; + const [eth, latestReserves] = await Promise.all([ + client.getBalance({ address: account.address }), + state?.deployment ? getReserves(client, state.deployment.pair).catch(() => null) : Promise.resolve(null), + ]); + setEthBalance(eth); + if (latestReserves && state?.deployment) { + setReserves(latestReserves); + const quote = quoteWad(latestReserves.reserve0, latestReserves.reserve1, vibeIsToken0(state.deployment)); + pushSample(Number(quote) / 1e18); + } + }, [pushSample, state?.deployment]); + + useEffect(() => { + if (!hydrated || !accounts) return; + void refreshBalances().catch(() => {}); + const id = window.setInterval(() => { + void refreshBalances().catch(() => {}); + }, POLL_MS); + return () => window.clearInterval(id); + }, [accounts, hydrated, refreshBalances]); + + const refuelBots = useCallback(async (): Promise => { + const publicClient = publicRef.current; + const wallet = userWalletRef.current; + const account = userAccountRef.current; + if (!publicClient || !accounts || busyRef.current || refuelInFlightRef.current) return true; + refuelInFlightRef.current = true; + let needed = false; + let refilled = false; + try { + let userBal = await publicClient.getBalance({ address: accounts.user.address }); + for (const bot of accounts.bots) { + const bal = await publicClient.getBalance({ address: bot.address }); + if (!botNeedsGas(bal)) continue; + needed = true; + const value = refuelValue(bal, userBal); + if (value === 0n || !wallet || !account) continue; + await wallet.sendTransaction({ + account, + chain: wallet.chain, + to: bot.address, + value, + }); + userBal -= value; + refilled = true; + } + } finally { + refuelInFlightRef.current = false; + } + return !needed || refilled; + }, [accounts]); + + useEffect(() => { + if (!hydrated || !accounts || !state?.deployment) return; + const id = window.setInterval(() => { + void refuelBots(); + }, 8_000); + return () => window.clearInterval(id); + }, [accounts, hydrated, refuelBots, state?.deployment]); + + useEffect(() => { + if (!hydrated || !status?.chainId) return; + let cancelled = false; + let inFlight = false; + const tick = async () => { + const client = publicRef.current; + if (!client || inFlight) return; + inFlight = true; + try { + const block = await client.getBlockNumber({ cacheTime: 0 }); + if (!cancelled) setBlockNumber((prev) => (prev === block ? prev : block)); + } catch { + // keep the last block we saw + } finally { + inFlight = false; + } + }; + void tick(); + const id = window.setInterval(() => { + void tick(); + }, BLOCK_POLL_MS); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, [hydrated, status?.chainId]); + + useEffect(() => { + if (!status?.chainId || !state?.deployment || !accounts) return; + const chain = chainFromId(status.chainId); + const publicClient = makePublicClient(chain); + const wallets = accounts.bots.map((bot) => makeWalletClient(chain, bot)); + const stop = startBots({ + publicClient, + wallets, + accounts: [...accounts.bots], + deployment: state.deployment, + enabled: () => botsEnabledRef.current, + onPrice: (price) => { + lastMakerPriceAtRef.current = Date.now(); + pushSample(price); + setMakerError(null); + setMakersDry(false); + }, + onError: setMakerError, + onGasLow: () => { + void (async () => { + const ok = await refuelBots(); + if (Date.now() - lastMakerPriceAtRef.current < 2_500) return; + const client = publicRef.current; + if (!client || !accounts) return; + const balances = await Promise.all( + accounts.bots.map((bot) => client.getBalance({ address: bot.address })), + ); + if (!allNeedGas(balances)) return; + setMakersDry(true); + if (!ok) setMakerError('need ETH'); + })(); + }, + }); + return stop; + }, [accounts, pushSample, refuelBots, state?.deployment, status?.chainId]); + + // Watch pending orders for inclusion / expiry. Refs so reserve polling cannot + // reset the interval before it ever fires. Wall-clock expiry does not wait on RPC. + useEffect(() => { + let cancelled = false; + let inFlight = false; + + const withTimeout = (promise: Promise, ms: number): Promise => + new Promise((resolve, reject) => { + const timer = window.setTimeout(() => reject(new Error('rpc timeout')), ms); + promise.then( + (value) => { + window.clearTimeout(timer); + resolve(value); + }, + (err: unknown) => { + window.clearTimeout(timer); + reject(err); + }, + ); + }); + + const patchOrders = (patch: (order: PlacedOrder) => PlacedOrder) => { + let changed = false; + const next = ordersRef.current.map((order) => { + const updated = patch(order); + if (updated !== order) changed = true; + return updated; + }); + if (!changed) return; + ordersRef.current = next; + setOrders(next); + }; + + const tick = async () => { + if (inFlight || cancelled) return; + inFlight = true; + try { + const latest = ordersRef.current; + if (latest.length === 0) return; + + const client = publicRef.current; + if (!client) return; + + const reservesNow = reservesRef.current; + const deployment = state?.deployment; + const spot = + reservesNow && deployment + ? quoteWad(reservesNow.reserve0, reservesNow.reserve1, vibeIsToken0(deployment)) + : null; + + for (const order of ordersRef.current) { + if (!order.txHash || (order.status !== 'pending' && order.status !== 'expired')) continue; + const receipt = await withTimeout( + client.getTransactionReceipt({ hash: order.txHash }), + 2_500, + ).catch(() => null); + if (cancelled) return; + if (!receipt) continue; + const filled = receipt.status === 'success'; + const vibeToken0Now = Boolean(deployment && vibeIsToken0(deployment)); + const observed = filled + ? (deployment + ? fillQuoteFromSwapReceipt(receipt, deployment.pair, vibeToken0Now) + : undefined) + : undefined; + const fillPriceWad = filled + ? clampToCondition(order.side, observed ?? order.targetPriceWad, order.targetPriceWad) + : undefined; + const target = wadToNumber(order.targetPriceWad); + const crossed = tapeCrossedAt(samplesRef.current, order.submittedAt, target, order.side); + let filledAt = crossed; + if (filled && filledAt === undefined) { + const header = await withTimeout( + client.getBlock({ blockNumber: receipt.blockNumber }), + 2_500, + ).catch(() => null); + filledAt = header ? Number(header.timestamp) * 1000 : Date.now(); + } + const wasPending = order.status === 'pending'; + patchOrders((item) => + item.id === order.id + ? { + ...item, + status: filled ? 'filled' : 'error', + filledAt: filled ? (item.filledAt ?? filledAt) : item.filledAt, + fillPriceWad: filled ? (item.fillPriceWad ?? fillPriceWad) : item.fillPriceWad, + } + : item, + ); + if (wasPending) { + trackValidityOrder(order.side, filled ? 'filled' : 'error'); + } + } + + const now = Date.now(); + const wallExpired = ordersRef.current.filter((order) => orderWallClockExpired(order, now)); + if (wallExpired.length > 0) { + const ids = new Set(wallExpired.map((order) => order.id)); + patchOrders((item) => + ids.has(item.id) && item.status === 'pending' ? { ...item, status: 'expired' } : item, + ); + for (const order of wallExpired) { + trackValidityOrder(order.side, 'expired'); + } + } + + const block = await withTimeout(client.getBlockNumber({ cacheTime: 0 }), 2_500).catch(() => null); + if (cancelled) return; + if (block !== null) { + const blockExpired = ordersRef.current.filter((order) => orderBlockExpired(order, block)); + if (blockExpired.length > 0) { + const ids = new Set(blockExpired.map((order) => order.id)); + patchOrders((item) => + ids.has(item.id) && item.status === 'pending' ? { ...item, status: 'expired' } : item, + ); + for (const order of blockExpired) { + trackValidityOrder(order.side, 'expired'); + } + } + } + + if (spot) { + patchOrders((order) => + order.status === 'expired' && + !order.crossedAfterExpiry && + spotPastTarget(spot, order.targetPriceWad, order.side) + ? { ...order, crossedAfterExpiry: true } + : order, + ); + } + } finally { + inFlight = false; + } + }; + + const id = window.setInterval(() => { + void tick(); + }, 700); + void tick(); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, [state?.deployment, status?.chainId]); + + const vibeToken0 = Boolean(state?.deployment && vibeIsToken0(state.deployment)); + const k = reserves ? reserves.reserve0 * reserves.reserve1 : 0n; + const spot = reserves && state?.deployment + ? quoteWad(reserves.reserve0, reserves.reserve1, vibeToken0) + : 0n; + const draft = useMemo(() => { + if (!state?.deployment || k === 0n || spot === 0n) return null; + try { + const price = applyOffsetBps(spot, side, offsetBps); + const ammPrice = ammPriceFromQuote(price, vibeToken0); + const built = priceValidity(state.deployment.pair, k, ammPrice, ammSide(side, vibeToken0)); + return { + priceWad: price, + side, + offsetBps, + rectangle: built.rectangle as Rectangle, + predicates: built.predicates, + }; + } catch { + return null; + } + }, [k, offsetBps, side, spot, state?.deployment, vibeToken0]); + + const jsonOrder = + orders.find((order) => order.id === hoveredOrderId && order.validity.length > 0) ?? null; + + const hoodPredicates = useMemo(() => { + if (jsonOrder) return jsonOrder.validity; + if (!draft) return []; + if (blockNumber === null || !status?.blockNumberPredicate) return draft.predicates; + const seconds = Math.min(MAX_EXPIRY_SECONDS, expirySeconds); + const maxBlock = maxBlockForExpiry(blockNumber, seconds); + return [...draft.predicates, blockExpiryPredicate(maxBlock)]; + }, [blockNumber, draft, expirySeconds, jsonOrder, status?.blockNumberPredicate]); + + const chartLevels = useMemo((): PriceLevel[] => { + const levels: PriceLevel[] = []; + if (draft) { + levels.push({ + id: 'draft', + price: wadToNumber(draft.priceWad), + side: draft.side, + kind: 'draft', + }); + } + for (const order of orders) { + if (order.status !== 'pending' && order.id !== hoveredOrderId) continue; + levels.push({ + id: order.id, + price: wadToNumber(order.targetPriceWad), + side: order.side, + kind: 'resting', + highlighted: order.id === hoveredOrderId, + }); + } + return levels; + }, [draft, hoveredOrderId, orders]); + + const fillMarks = useMemo((): FillMark[] => { + const marks: FillMark[] = []; + for (const order of orders) { + if (order.status !== 'filled' || order.filledAt === undefined) continue; + const price = wadToNumber(order.fillPriceWad ?? order.targetPriceWad); + if (!Number.isFinite(price) || price <= 0) continue; + marks.push({ + id: order.id, + t: order.filledAt, + price, + target: wadToNumber(order.targetPriceWad), + side: order.side, + highlighted: order.id === hoveredOrderId, + }); + } + return marks; + }, [hoveredOrderId, orders]); + + const fund = useCallback(async () => { + if (!accounts) return; + const publicClient = publicRef.current; + if (!publicClient) return; + setBusy(true); + setError(null); + try { + setProgress('Requesting ETH from the faucet'); + await seedEthFromFaucet(accounts.user.address, () => + publicClient.getBalance({ address: accounts.user.address }), + ); + await refreshBalances(); + } catch (err) { + setError(faucetErrorMessage(err)); + } finally { + setBusy(false); + setProgress(null); + } + }, [accounts, refreshBalances]); + + useEffect(() => { + if (!hydrated || !accounts || busy || autoFaucetRef.current) return; + if (ethBalance === null) return; + if (ethBalance > 0n) { + autoFaucetRef.current = true; + return; + } + autoFaucetRef.current = true; + void fund(); + }, [accounts, busy, ethBalance, fund, hydrated]); + + const deploy = async () => { + if (!accounts || !status?.chainId) return; + const wallet = userWalletRef.current; + const publicClient = publicRef.current; + const account = userAccountRef.current; + if (!wallet || !publicClient || !account || !state) return; + setBusy(true); + setError(null); + try { + const deployment = await deployAmm({ + wallet, + publicClient, + account, + extraRecipients: accounts.bots.map((bot) => bot.address), + onProgress: setProgress, + }); + persist({ ...state, deployment }); + setProgress('Seeding bot gas'); + for (const bot of accounts.bots) { + const userBal = await publicClient.getBalance({ address: account.address }); + const value = refuelValue(0n, userBal); + if (value === 0n) continue; + const hash = await wallet.sendTransaction({ + account, + chain: wallet.chain, + to: bot.address, + value, + }); + await publicClient.waitForTransactionReceipt({ hash }); + } + await refreshBalances(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Deploy failed'); + } finally { + setBusy(false); + setProgress(null); + } + }; + + const placeOrder = async () => { + if (!draft || !accounts || !state?.deployment || !reserves) return; + const wallet = userWalletRef.current; + const publicClient = publicRef.current; + const account = userAccountRef.current; + if (!wallet || !publicClient || !account) return; + setBusy(true); + setError(null); + const side: Side = draft.side; + const tokenIn = tokenInFor(state.deployment, side === 'sell'); + try { + const inventory = await tokenBalance(publicClient, tokenIn, account.address); + const amountIn = inventory / DEFAULT_SIZE_FRACTION; + if (amountIn === 0n) throw new Error('Not enough token inventory to swap.'); + const outExact = amountOutAtLimit(amountIn, side, k, draft.priceWad); + const out = outExact > 1n ? outExact - 1n : outExact; + if (out === 0n) throw new Error('Swap size is too small.'); + const { amount0Out, amount1Out } = swapOuts({ + vibeToken0, + sellVibe: side === 'sell', + amountOut: out, + }); + const call = encodeHelperSwap({ + helper: state.deployment.helper, + tokenIn, + pair: state.deployment.pair, + amountIn, + amount0Out, + amount1Out, + }); + const confirmedNonce = await publicClient.getTransactionCount({ + address: account.address, + blockTag: 'latest', + }); + const occupant = occupyingOrder(ordersRef.current, confirmedNonce); + const replaced = restingOrderToReplace(ordersRef.current, confirmedNonce); + const estimated = await publicClient.estimateFeesPerGas().catch(() => null); + const padded = + estimated?.maxFeePerGas !== undefined && estimated.maxPriorityFeePerGas !== undefined + ? padFees({ + maxFeePerGas: estimated.maxFeePerGas, + maxPriorityFeePerGas: estimated.maxPriorityFeePerGas, + }) + : null; + let fees = padded; + if (occupant?.maxFeePerGas !== undefined && occupant.maxPriorityFeePerGas !== undefined) { + fees = bumpReplacementFees( + { + maxFeePerGas: occupant.maxFeePerGas, + maxPriorityFeePerGas: occupant.maxPriorityFeePerGas, + }, + padded, + ); + } + const sign = (nextFees: typeof fees) => + signCall({ + wallet, + publicClient, + account, + to: call.to, + data: call.data, + nonce: confirmedNonce, + fees: nextFees, + }); + let signedResult = await sign(fees); + const seconds = Math.min(MAX_EXPIRY_SECONDS, expirySeconds); + const block = await publicClient.getBlockNumber({ cacheTime: 0 }); + const maxBlock = maxBlockForExpiry(block, seconds); + const validity = [...draft.predicates]; + if (status?.blockNumberPredicate) { + validity.push(blockExpiryPredicate(maxBlock)); + } + trackValidityOrder(side, 'submitted'); + let hash; + try { + hash = await sendValidityTransaction(publicClient, signedResult.signed, validity); + } catch (err) { + if (!isReplacementUnderpriced(err) || !signedResult.fees) throw err; + signedResult = await sign(bumpReplacementFees(signedResult.fees, padded)); + hash = await sendValidityTransaction(publicClient, signedResult.signed, validity); + } + const order: PlacedOrder = { + id: newId(), + side, + targetPriceWad: draft.priceWad, + size: amountIn, + expirySeconds: seconds, + maxBlock: status?.blockNumberPredicate ? maxBlock : undefined, + submittedAt: Date.now(), + txHash: hash, + nonce: signedResult.nonce, + maxFeePerGas: signedResult.fees?.maxFeePerGas, + maxPriorityFeePerGas: signedResult.fees?.maxPriorityFeePerGas, + status: 'pending', + rectangle: draft.rectangle, + validity, + }; + setOrders((prev) => { + const next = replaced + ? prev.map((item) => + item.id === replaced.id && item.status === 'pending' + ? { ...item, status: 'replaced' as const } + : item, + ) + : prev; + return [order, ...next]; + }); + if (replaced) trackValidityOrder(replaced.side, 'replaced'); + } catch (err) { + const message = describeValidityError(err); + setError(message); + trackValidityOrder(side, 'error'); + setOrders((prev) => [ + { + id: newId(), + side, + targetPriceWad: draft.priceWad, + size: 0n, + expirySeconds, + submittedAt: Date.now(), + status: 'error', + error: message, + rectangle: draft.rectangle, + validity: draft.predicates, + }, + ...prev, + ]); + } finally { + setBusy(false); + } + }; + + const resetDemo = () => { + if (!state) return; + setOrders([]); + setSamples([]); + setReserves(null); + setMakerError(null); + setMakersDry(false); + setHoveredOrderId(null); + setError(null); + setProgress(null); + setBotsOn(true); + lastMakerPriceAtRef.current = 0; + persist(dropDeployment(state)); + }; + + const address = accounts?.user.address; + const funded = (ethBalance ?? 0n) > 0n; + const deployed = Boolean(state?.deployment); + + if (!hydrated) return
; + + return ( +
+ + +
+
+ {status?.readHost ?? 'no rpc'} + validity {status?.validitySupported ? 'on' : 'unavailable'} + {status?.blockNumberPredicate ? block bounds on : client-side expiry only} + simulation {botsOn ? (makersDry ? 'out of ETH' : 'live') : 'paused'} + {makerError && !makersDry ? simulation {makerError} : null} + {deployed || makersDry ? ( + + ) : null} +
+ {makersDry ? ( + + Simulated flow ran out of ETH. Reset drops this pool so you can top up and deploy again. + + ) : null} +
+ + {statusError ? ( + {statusError} + ) : null} + + {!deployed ? ( + + Simulated pool + + A local EOA (not the Vibenet 8130 account) signs the swaps. The faucet + funds it, then you deploy a VIBE/USDV pool. Simulated flow moves the mid + so you can see a price condition fire — or expire unused. + + {address ? ( +
+ + Address + + +
+ ) : null} +
+ + ETH + + {ethBalance === null ? '…' : formatEther(ethBalance)} +
+ {error ? {error} : null} + {progress ? {progress} : null} +
+ + +
+
+ ) : ( +
+
+ +
+ + Spot {spot === 0n ? '—' : `$${formatPrice(spot)}`} USDV · simulated flow moves the mid + + +
+
+
+
+ {draft ? ( + void placeOrder()} + /> + ) : ( + + Conditional swap + + Waiting for a live mid from the simulated pool. + + + )} + {error ? {error} : null} +
+
+ +
+
+ +
+
+ + Under the hood + + + The condition is a hatched reserve rectangle on x·y = k, encoded as four + storage predicates on Uni v2 slot 0x8, plus a block-number expiry. + +
+ + {draft || jsonOrder ? ( + predicate.type === 'block_number')} + /> + ) : null} +
+
+ )} +
+ ); +} diff --git a/app/vibenet/demos/validity/components/OrderList.tsx b/app/vibenet/demos/validity/components/OrderList.tsx new file mode 100644 index 0000000..1474c6b --- /dev/null +++ b/app/vibenet/demos/validity/components/OrderList.tsx @@ -0,0 +1,160 @@ +'use client'; + +import Link from 'next/link'; +import type { CSSProperties } from 'react'; + +import { cn } from '../../../../components/ui/cn'; +import { CheckIcon } from '../../../../components/ui/icons'; +import { Text } from '../../../../components/ui/Text'; +import { VIBENET_EXPLORER_PATH } from '../../../library/config'; +import { formatPrice } from '../lib/predicates'; +import type { PlacedOrder } from '../lib/types'; + +const STATUS_LABEL: Record = { + pending: 'pending', + filled: 'included', + expired: 'expired · not included', + replaced: 'replaced', + error: 'rejected', +}; + +const CELEBRATE_MS = 2_400; + +const CONFETTI_PIECES = [ + { x: -42, y: 36, r: -48, c: 'bg-bds-green-50', d: 0 }, + { x: -18, y: 52, r: 32, c: 'bg-bds-orange-50', d: 40 }, + { x: 8, y: 28, r: -18, c: 'bg-base-blue', d: 20 }, + { x: 28, y: 48, r: 54, c: 'bg-bds-green-40', d: 70 }, + { x: 52, y: 22, r: -36, c: 'bg-bds-orange-40', d: 30 }, + { x: 74, y: 44, r: 22, c: 'bg-bds-green-50', d: 90 }, +] as const; + +function formatClock(ts: number): string { + return new Date(ts).toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + }); +} + +function FillConfetti() { + return ( + + ); +} + +type Props = { + orders: PlacedOrder[]; + highlightedOrderId: string | null; + onHighlight: (id: string | null) => void; +}; + +export function OrderList({ orders, highlightedOrderId, onHighlight }: Props) { + if (orders.length === 0) { + return ( +
+ Submitted + + Conditional swaps land here. They also draw as a dashed line on the tape. + +
+ ); + } + const now = Date.now(); + return ( +
+ Submitted +
    + {orders.map((order) => { + const filled = order.status === 'filled'; + const celebrating = filled && order.filledAt !== undefined && now - order.filledAt < CELEBRATE_MS; + const highlighted = order.id === highlightedOrderId; + return ( +
  • onHighlight(order.id)} + onMouseLeave={() => onHighlight(null)} + onFocus={() => onHighlight(order.id)} + onBlur={() => onHighlight(null)} + tabIndex={0} + > + {celebrating ? : null} +
    + + {order.side} VIBE ${formatPrice(order.targetPriceWad)} + + + {filled ? : null} + {filled ? 'included!' : STATUS_LABEL[order.status]} + +
    + + {formatClock(order.submittedAt)} + {order.filledAt ? ` → ${formatClock(order.filledAt)}` : null} + {filled && order.fillPriceWad !== undefined + ? ` · ${formatPrice(order.fillPriceWad)}` + : null} + + {filled && order.txHash ? ( + event.stopPropagation()} + > + View transaction + + ) : null} + {order.status === 'expired' && order.crossedAfterExpiry ? ( + + Spot later crossed this price. The expired transaction was not included. + + ) : null} + {order.error ? ( + + {order.error.length > 240 ? `${order.error.slice(0, 237)}…` : order.error} + + ) : null} +
  • + ); + })} +
+
+ ); +} diff --git a/app/vibenet/demos/validity/components/OrderTicket.tsx b/app/vibenet/demos/validity/components/OrderTicket.tsx new file mode 100644 index 0000000..d4b048b --- /dev/null +++ b/app/vibenet/demos/validity/components/OrderTicket.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { Button } from '../../../../components/ui/Button'; +import { Text } from '../../../../components/ui/Text'; +import { applyOffsetBps, formatPrice } from '../lib/predicates'; +import type { Side } from '../lib/types'; + +const EXPIRIES = [5, 15, 60] as const; +const OFFSETS = [0, 50, 100, 200, 500] as const; + +type Props = { + spotWad: bigint; + side: Side; + offsetBps: number; + expirySeconds: number; + busy: boolean; + validitySupported: boolean; + onSide: (side: Side) => void; + onOffset: (bps: number) => void; + onExpiry: (seconds: number) => void; + onSubmit: () => void; +}; + +function formatBps(bps: number): string { + const pct = bps / 100; + return Number.isInteger(pct) ? `${pct}%` : `${pct.toFixed(1)}%`; +} + +export function OrderTicket({ + spotWad, + side, + offsetBps, + expirySeconds, + busy, + validitySupported, + onSide, + onOffset, + onExpiry, + onSubmit, +}: Props) { + const target = applyOffsetBps(spotWad, side, offsetBps); + const signed = offsetBps === 0 ? '±0%' : side === 'buy' ? `−${formatBps(offsetBps)}` : `+${formatBps(offsetBps)}`; + + return ( +
+
+ Conditional swap + + mid ${formatPrice(spotWad)} + +
+
+ + +
+
+ + {offsetBps === 0 ? 'At mid' : side === 'buy' ? 'Below mid' : 'Above mid'} + +
+ {OFFSETS.map((bps) => ( + + ))} +
+
+
+ + Include when price is {side === 'buy' ? '≤' : '≥'} + + + ${formatPrice(target)} + + + mid {signed} + +
+
+ + Expiry + +
+ {EXPIRIES.map((seconds) => ( + + ))} +
+
+ {!validitySupported ? ( + + This RPC does not expose base_sendRawTransactionValidity. The swap will + still be signed; submission will fail until you point at a node with the + flag enabled. + + ) : null} + +
+ ); +} diff --git a/app/vibenet/demos/validity/components/PriceCandles.test.ts b/app/vibenet/demos/validity/components/PriceCandles.test.ts new file mode 100644 index 0000000..62a573e --- /dev/null +++ b/app/vibenet/demos/validity/components/PriceCandles.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { isUpCandle, toCandles, type PriceSample } from './PriceCandles'; + +describe('toCandles', () => { + it('builds a wick when price reverses inside a 2s bucket', () => { + const t0 = 1_000_000; + const samples: PriceSample[] = [ + { t: t0, price: 1.0 }, + { t: t0 + 400, price: 1.03 }, + { t: t0 + 800, price: 0.98 }, + { t: t0 + 1_200, price: 1.01 }, + ]; + const [candle] = toCandles(samples); + expect(candle.o).toBe(1.0); + expect(candle.c).toBe(1.01); + expect(candle.h).toBe(1.03); + expect(candle.l).toBe(0.98); + expect(candle.h).toBeGreaterThan(Math.max(candle.o, candle.c)); + expect(candle.l).toBeLessThan(Math.min(candle.o, candle.c)); + }); + + it('stays a doji when every sample is the same price', () => { + const t0 = 1_000_000; + const samples: PriceSample[] = [ + { t: t0, price: 1.008 }, + { t: t0 + 400, price: 1.008 }, + { t: t0 + 800, price: 1.008 }, + ]; + const [candle] = toCandles(samples); + expect(candle.o).toBe(candle.h); + expect(candle.h).toBe(candle.l); + expect(candle.l).toBe(candle.c); + }); + + it('opens each bucket at the previous close so a dump is red', () => { + const t0 = 2_000_000; + const samples: PriceSample[] = [ + { t: t0, price: 0.08 }, + { t: t0 + 2_000, price: 0.078 }, + { t: t0 + 2_400, price: 0.0784 }, + ]; + const candles = toCandles(samples); + expect(candles).toHaveLength(2); + expect(candles[1].o).toBe(0.08); + expect(candles[1].c).toBe(0.0784); + expect(isUpCandle(candles[1], candles[0])).toBe(false); + }); +}); + +describe('isUpCandle', () => { + it('colors a flat candle from the prior close, not as a default green', () => { + const prev = { t: 0, o: 0.08, h: 0.08, l: 0.08, c: 0.079 }; + const flat = { t: 2_000, o: 0.079, h: 0.079, l: 0.079, c: 0.079 }; + expect(isUpCandle(flat, prev)).toBe(true); + const lower = { t: 4_000, o: 0.078, h: 0.078, l: 0.078, c: 0.078 }; + expect(isUpCandle(lower, flat)).toBe(false); + }); +}); diff --git a/app/vibenet/demos/validity/components/PriceCandles.tsx b/app/vibenet/demos/validity/components/PriceCandles.tsx new file mode 100644 index 0000000..1f1633b --- /dev/null +++ b/app/vibenet/demos/validity/components/PriceCandles.tsx @@ -0,0 +1,315 @@ +'use client'; + +import { scaleLinear } from 'd3'; +import { useMemo } from 'react'; + +import type { Side } from '../lib/types'; + +const BUY_PLOT = '#22ad73'; +const SELL_PLOT = '#ed5966'; +const TICKER = '#c8ff4a'; +const BUCKET_MS = 2_000; +const WINDOW_MS = 120_000; +const WIDTH = 960; +const HEIGHT = 440; +const PAD = { top: 20, right: 20, bottom: 40, left: 68 }; + +export type PriceSample = { t: number; price: number }; + +export type PriceLevel = { + id: string; + price: number; + side: Side; + kind: 'draft' | 'resting'; + highlighted?: boolean; +}; + +export type Candle = { t: number; o: number; h: number; l: number; c: number }; + +export function toCandles(samples: PriceSample[]): Candle[] { + if (!samples || samples.length === 0) return []; + const lastT = samples[samples.length - 1].t; + const start = lastT - WINDOW_MS; + const buckets = new Map(); + for (const sample of samples) { + if (sample.t < start || !Number.isFinite(sample.price) || sample.price <= 0) continue; + const bucket = Math.floor(sample.t / BUCKET_MS) * BUCKET_MS; + const existing = buckets.get(bucket); + if (!existing) { + buckets.set(bucket, { t: bucket, o: sample.price, h: sample.price, l: sample.price, c: sample.price }); + continue; + } + existing.h = Math.max(existing.h, sample.price); + existing.l = Math.min(existing.l, sample.price); + existing.c = sample.price; + } + const raw = [...buckets.values()].sort((a, b) => a.t - b.t); + const stitched: Candle[] = []; + for (const candle of raw) { + const open = stitched.length === 0 ? candle.o : stitched[stitched.length - 1].c; + stitched.push({ + t: candle.t, + o: open, + h: Math.max(candle.h, open), + l: Math.min(candle.l, open), + c: candle.c, + }); + } + return stitched; +} + +export function isUpCandle(candle: Candle, prev?: Candle): boolean { + if (candle.c > candle.o) return true; + if (candle.c < candle.o) return false; + if (!prev) return true; + return candle.c >= prev.c; +} + +function formatAxisPrice(price: number): string { + if (price >= 1) return `$${price.toFixed(2)}`; + if (price >= 0.1) return `$${price.toFixed(3)}`; + return `$${price.toFixed(4)}`; +} + +function formatAxisTime(ts: number): string { + return new Date(ts).toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + }); +} + +function VibeMark() { + return ( + + ); +} + +export type FillMark = { + id: string; + t: number; + price: number; + target: number; + side: Side; + highlighted?: boolean; +}; + +type Props = { + samples: PriceSample[]; + levels?: PriceLevel[]; + fills?: FillMark[]; +}; + +export function PriceCandles({ samples, levels = [], fills = [] }: Props) { + const candles = useMemo(() => toCandles(samples ?? []), [samples]); + const innerW = WIDTH - PAD.left - PAD.right; + const innerH = HEIGHT - PAD.top - PAD.bottom; + const visibleLevels = levels.filter((level) => Number.isFinite(level.price) && level.price > 0); + const visibleFills = fills.filter((fill) => Number.isFinite(fill.price) && fill.price > 0 && fill.t > 0); + const focusFill = visibleFills.find((fill) => fill.highlighted) ?? null; + + const layout = useMemo(() => { + if (candles.length === 0) return null; + let lo = candles[0].l; + let hi = candles[0].h; + for (const candle of candles) { + lo = Math.min(lo, candle.l); + hi = Math.max(hi, candle.h); + } + for (const level of visibleLevels) { + lo = Math.min(lo, level.price); + hi = Math.max(hi, level.price); + } + for (const fill of visibleFills) { + lo = Math.min(lo, fill.price, fill.target); + hi = Math.max(hi, fill.price, fill.target); + } + const last = candles[candles.length - 1].c; + const minSpan = Math.max(last * 0.06, 0.002); + if (hi - lo < minSpan) { + const mid = (hi + lo) / 2; + lo = mid - minSpan / 2; + hi = mid + minSpan / 2; + } + const pad = (hi - lo) * 0.08; + const yMin = Math.max(lo - pad, 0); + const yMax = hi + pad; + let t0 = candles[0].t; + let t1 = Math.max(candles[candles.length - 1].t + BUCKET_MS, t0 + BUCKET_MS); + if (focusFill) { + t0 = Math.min(t0, focusFill.t - BUCKET_MS); + t1 = Math.max(t1, focusFill.t + BUCKET_MS); + } + const x = scaleLinear().domain([t0, t1]).range([0, innerW]); + const y = scaleLinear().domain([yMin, yMax]).range([innerH, 0]); + const yTicks = y.ticks(6); + const xTicks = x.ticks(5); + return { x, y, yMin, yMax, yTicks, xTicks, last, slot: innerW / Math.max(candles.length, 1) }; + }, [candles, focusFill, innerH, innerW, visibleFills, visibleLevels]); + + const firstOpen = candles[0]?.o; + const lastClose = layout?.last; + const change = + firstOpen && lastClose ? ((lastClose - firstOpen) / firstOpen) * 100 : 0; + const up = change >= 0; + + return ( +
+
+
+ +
+
VIBE / USDV
+
simulated pool · 2s candles
+
+
+
+
+ {layout ? formatAxisPrice(layout.last) : '—'} +
+
+ {layout ? `${up ? '+' : ''}${change.toFixed(2)}%` : ''} +
+
+
+ {layout ? ( + + + {layout.yTicks.map((tick) => ( + + + + {formatAxisPrice(tick)} + + + ))} + {layout.xTicks.map((tick) => ( + + {formatAxisTime(tick)} + + ))} + + USDV + + {candles.map((candle, index) => { + const color = isUpCandle(candle, candles[index - 1]) ? BUY_PLOT : SELL_PLOT; + const cx = layout.x(candle.t + BUCKET_MS / 2); + const highY = layout.y(candle.h); + const lowY = layout.y(candle.l); + const bodyTop = layout.y(Math.max(candle.o, candle.c)); + const bodyBot = layout.y(Math.min(candle.o, candle.c)); + const rawBody = Math.max(bodyBot - bodyTop, 0); + const doji = rawBody < 0.8; + const bodyH = doji ? 1.6 : Math.max(rawBody, 2); + const bodyW = Math.min(Math.max(layout.slot * 0.55, 4), 14); + return ( + + + + + ); + })} + {visibleLevels.map((level) => { + const y = layout.y(level.price); + const color = level.side === 'buy' ? BUY_PLOT : SELL_PLOT; + const draft = level.kind === 'draft'; + return ( + + + + {draft ? 'draft' : level.side} {formatAxisPrice(level.price)} + + + ); + })} + {visibleFills.map((fill) => { + const cx = layout.x(fill.t); + const cy = layout.y(fill.price); + if (cx < -8 || cx > innerW + 8) return null; + const color = fill.side === 'buy' ? BUY_PLOT : SELL_PLOT; + const r = fill.highlighted ? 7 : 4.5; + return ( + + {fill.highlighted ? ( + + ) : null} + + + {fill.highlighted ? ( + innerW * 0.62 ? cx - 10 : cx + 10} + y={cy - 10} + textAnchor={cx > innerW * 0.62 ? 'end' : 'start'} + fill={color} + fontSize={10} + fontFamily="ui-monospace, monospace" + > + included {formatAxisPrice(fill.price)} + + ) : null} + + ); + })} + + + ) : ( +

+ Tape starts once the simulated pool prints a mid. +

+ )} +
+ ); +} diff --git a/app/vibenet/demos/validity/components/ReserveChart.tsx b/app/vibenet/demos/validity/components/ReserveChart.tsx new file mode 100644 index 0000000..50669e9 --- /dev/null +++ b/app/vibenet/demos/validity/components/ReserveChart.tsx @@ -0,0 +1,265 @@ +'use client'; + +import { scaleLinear } from 'd3'; +import { useMemo, useRef } from 'react'; + +import { WAD } from '../lib/constants'; +import { formatPrice, priceWad } from '../lib/predicates'; +import { ammPriceFromQuote, USDV_SYMBOL, VIBE_SYMBOL } from '../lib/quote'; +import type { PlacedOrder, Rectangle, Reserves, Side } from '../lib/types'; + +// Plot is always dark; pin light-theme greens/reds so they stay vivid on #0c1117. +const BUY_PLOT = '#22ad73'; +const SELL_PLOT = '#ed5966'; + +function sidePlotColor(side: Side): string { + return side === 'buy' ? BUY_PLOT : SELL_PLOT; +} + +type Props = { + reserves: Reserves | null; + hoverPriceWad: bigint | null; + draft: { priceWad: bigint; side: Side; rectangle: Rectangle } | null; + orders: PlacedOrder[]; + highlightedOrderId: string | null; + vibeToken0: boolean; + onHover: (priceWad: bigint | null) => void; +}; + +function hyperbolaPoints(r0: number, r1: number, xMin: number, xMax: number, count = 80): Array<[number, number]> { + const k = r0 * r1; + const points: Array<[number, number]> = []; + for (let i = 0; i <= count; i += 1) { + const t = i / count; + const x = xMin * Math.pow(xMax / xMin, t); + const y = k / x; + if (Number.isFinite(y) && y > 0) points.push([x, y]); + } + return points; +} + +function toTokens(amount: bigint): number { + return Number(amount) / 1e18; +} + +function recencyOpacity(orders: PlacedOrder[], order: PlacedOrder): number { + const ranked = [...orders].sort((a, b) => a.submittedAt - b.submittedAt); + const index = ranked.findIndex((item) => item.id === order.id); + const newest = ranked.length <= 1 ? 1 : Math.max(index, 0) / Math.max(ranked.length - 1, 1); + const statusMul = order.status === 'pending' ? 1 : order.status === 'filled' ? 0.78 : 0.48; + return (0.2 + 0.8 * newest) * statusMul; +} + +export function ReserveChart({ + reserves, + hoverPriceWad, + draft, + orders, + highlightedOrderId, + vibeToken0, + onHover, +}: Props) { + const svgRef = useRef(null); + const width = 960; + const height = 560; + const pad = { top: 28, right: 24, bottom: 56, left: 64 }; + const innerW = width - pad.left - pad.right; + const innerH = height - pad.top - pad.bottom; + + const layout = useMemo(() => { + if (!reserves || reserves.reserve0 === 0n || reserves.reserve1 === 0n) return null; + const r0 = toTokens(reserves.reserve0); + const r1 = toTokens(reserves.reserve1); + const xMin = r0 * 0.45; + const xMax = r0 * 1.7; + const yMin = r1 * 0.45; + const yMax = r1 * 1.7; + const x = scaleLinear().domain([xMin, xMax]).range([0, innerW]); + const y = scaleLinear().domain([yMin, yMax]).range([innerH, 0]); + const curve = hyperbolaPoints(r0, r1, xMin, xMax) + .map(([px, py]) => `${x(px).toFixed(1)},${y(py).toFixed(1)}`) + .join(' '); + const spot = priceWad(reserves.reserve0, reserves.reserve1); + const quote = vibeToken0 || spot === 0n ? spot : (WAD * WAD) / spot; + return { r0, r1, x, y, curve, spot, quote }; + }, [innerH, innerW, reserves, vibeToken0]); + + const priceAt = (clientX: number, clientY: number): bigint | null => { + if (!layout || !svgRef.current) return null; + const rect = svgRef.current.getBoundingClientRect(); + const sx = ((clientX - rect.left) / rect.width) * width; + const sy = ((clientY - rect.top) / rect.height) * height; + const dx = sx - pad.left; + const dy = sy - pad.top; + if (dx < 0 || dy < 0 || dx > innerW || dy > innerH) return null; + const rx = layout.x.invert(dx); + const ry = layout.y.invert(dy); + if (rx <= 0 || ry <= 0) return null; + const amm = BigInt(Math.round((ry / rx) * 1e18)); + if (!vibeToken0) { + if (amm === 0n) return null; + return (WAD * WAD) / amm; + } + return amm; + }; + + const rectanglePath = (rect: Rectangle) => { + if (!layout) return ''; + const [x0, x1] = layout.x.domain() as [number, number]; + const [y0, y1] = layout.y.domain() as [number, number]; + const left = Math.max(toTokens(rect.r0Min), x0); + const right = Math.min(toTokens(rect.r0Max), x1); + const bottom = Math.max(toTokens(rect.r1Min), y0); + const top = Math.min(toTokens(rect.r1Max), y1); + if (left >= right || bottom >= top) return ''; + return `M ${layout.x(left)} ${layout.y(bottom)} H ${layout.x(right)} V ${layout.y(top)} H ${layout.x(left)} Z`; + }; + + const painted = [...orders].sort((a, b) => { + if (a.id === highlightedOrderId) return 1; + if (b.id === highlightedOrderId) return -1; + return a.submittedAt - b.submittedAt; + }); + + const hoverAmmWad = hoverPriceWad + ? ammPriceFromQuote(hoverPriceWad, vibeToken0) + : null; + const hoverIsBuy = hoverPriceWad !== null && layout !== null && hoverPriceWad <= layout.quote; + + return ( +
+ onHover(null)} + onMouseMove={(event) => onHover(priceAt(event.clientX, event.clientY))} + > + + + + + + + + + + + + + + + + + {layout ? ( + + {Array.from({ length: 6 }, (_, i) => { + const gx = (innerW / 5) * i; + const gy = (innerH / 5) * i; + return ( + + + + + ); + })} + {painted.map((order) => { + const highlighted = order.id === highlightedOrderId; + const opacity = highlighted ? 1 : recencyOpacity(orders, order); + return ( + + ); + })} + {draft ? ( + + ) : null} + + + + {hoverAmmWad ? ( + + ) : null} + + {vibeToken0 ? USDV_SYMBOL : VIBE_SYMBOL} + + + {vibeToken0 ? VIBE_SYMBOL : USDV_SYMBOL} + + + ) : ( + + Deploy the pool to see the curve + + )} + +
+ + x · y = k + + + {layout ? `$${formatPrice(layout.quote)}` : '—'} + + + USDV per VIBE · condition box + +
+ {hoverPriceWad ? ( +
+ {hoverIsBuy ? 'buy VIBE <= ' : 'sell VIBE >= '} + ${formatPrice(hoverPriceWad)} +
+ ) : null} +
+ ); +} diff --git a/app/vibenet/demos/validity/components/ValidityJson.tsx b/app/vibenet/demos/validity/components/ValidityJson.tsx new file mode 100644 index 0000000..c023adb --- /dev/null +++ b/app/vibenet/demos/validity/components/ValidityJson.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { Text } from '../../../../components/ui/Text'; + +type TokenKind = 'key' | 'string' | 'number' | 'literal' | 'punct'; + +function tokenizeJson(source: string): Array<{ kind: TokenKind; text: string }> { + const tokens: Array<{ kind: TokenKind; text: string }> = []; + const pattern = + /("(?:\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(?:\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?|[{}[\]:,])/g; + let last = 0; + for (const hit of source.matchAll(pattern)) { + const text = hit[0]; + const index = hit.index ?? 0; + if (index > last) { + tokens.push({ kind: 'punct', text: source.slice(last, index) }); + } + let kind: TokenKind = 'punct'; + if (text.startsWith('"')) { + kind = text.endsWith(':') ? 'key' : 'string'; + } else if (text === 'true' || text === 'false' || text === 'null') { + kind = 'literal'; + } else if (/^-?\d/.test(text)) { + kind = 'number'; + } + tokens.push({ kind, text }); + last = index + text.length; + } + if (last < source.length) tokens.push({ kind: 'punct', text: source.slice(last) }); + return tokens; +} + +const KIND_CLASS: Record = { + key: 'text-[#7eb8ff]', + string: 'text-[#7ee0a8]', + number: 'text-[#f5c542]', + literal: 'text-[#ed9a6c]', + punct: 'text-[#8b98a5]', +}; + +export function ValidityJson({ + source, + frozen, + hasBlockBound, +}: { + source: string; + frozen?: boolean; + hasBlockBound?: boolean; +}) { + const tokens = tokenizeJson(source); + const footnote = frozen + ? hasBlockBound + ? 'Frozen at submit. The block bound does not walk with the live chain.' + : 'Frozen at submit.' + : hasBlockBound + ? 'Four storage predicates on Uni v2 slot 0x8, plus a block-number expiry. The sequencer includes the swap only while this box holds.' + : 'Four storage predicates on Uni v2 slot 0x8. The sequencer includes the swap only while this box holds.'; + return ( + + ); +} diff --git a/app/vibenet/demos/validity/layout.tsx b/app/vibenet/demos/validity/layout.tsx new file mode 100644 index 0000000..3ad36c7 --- /dev/null +++ b/app/vibenet/demos/validity/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import type { ReactNode } from 'react'; + +export const metadata: Metadata = { + title: 'Validity · Vibenet', + description: + 'Attach conditions to a transaction. A simulated pool shows how a swap waits, lands, or expires.', +}; + +export default function ValidityDemoLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/app/vibenet/demos/validity/lib/amm.test.ts b/app/vibenet/demos/validity/lib/amm.test.ts new file mode 100644 index 0000000..ff0312b --- /dev/null +++ b/app/vibenet/demos/validity/lib/amm.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { amountOut, amountOutAtLimit } from './amm'; +import { SEED_USDV, SEED_VIBE, WAD } from './constants'; + +describe('amountOut', () => { + it('uses a 0% fee so k is conserved', () => { + expect(amountOut(100n, 1000n, 2000n)).toBe(181n); + expect(amountOut(10n ** 18n, 100n * 10n ** 18n, 100n * 10n ** 18n)).toBe( + (10n ** 18n * 100n * 10n ** 18n) / (101n * 10n ** 18n), + ); + }); + + it('returns 0 when any leg is empty', () => { + expect(amountOut(0n, 1000n, 2000n)).toBe(0n); + expect(amountOut(100n, 0n, 2000n)).toBe(0n); + }); +}); + +describe('amountOutAtLimit', () => { + it('sizes a resting buy on the limit curve, not submit-time spot', () => { + const k = SEED_VIBE * SEED_USDV; + const spot = (SEED_USDV * WAD) / SEED_VIBE; + const limit = (spot * 98n) / 100n; + const amountIn = 800n * WAD; + const atSpot = amountOut(amountIn, SEED_USDV, SEED_VIBE); + const atLimit = amountOutAtLimit(amountIn, 'buy', k, limit); + expect(atLimit).toBeGreaterThan(atSpot); + const fill = (amountIn * WAD) / atLimit; + expect(fill).toBeLessThan((amountIn * WAD) / atSpot); + expect(((fill - limit) * 10_000n) / limit).toBeLessThan(100n); + }); +}); diff --git a/app/vibenet/demos/validity/lib/amm.ts b/app/vibenet/demos/validity/lib/amm.ts new file mode 100644 index 0000000..8d35d1e --- /dev/null +++ b/app/vibenet/demos/validity/lib/amm.ts @@ -0,0 +1,529 @@ +import { + encodeFunctionData, + parseEventLogs, + zeroAddress, + type Account, + type Address, + type Hex, + type PublicClient, + type TransactionReceipt, + type WalletClient, +} from 'viem'; + +import { + SEED_USDV, + SEED_VIBE, + TRADER_USDV, + TRADER_VIBE, + WAD, + erc20Abi, + erc20Bytecode, + factoryAbi, + factoryBytecode, + helperAbi, + helperBytecode, + pairAbi, +} from './constants'; +import { padFees, type FeeFields } from './fees'; +import { sqrt } from './predicates'; +import { quoteFromPreSwapReserves, USDV_NAME, USDV_SYMBOL, VIBE_NAME, VIBE_SYMBOL } from './quote'; +import type { Deployment, Reserves, Side } from './types'; + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function wait( + publicClient: PublicClient, + hash: Hex, +): Promise { + const receipt = await publicClient.waitForTransactionReceipt({ + hash, + timeout: 120_000, + pollingInterval: 250, + }); + if (receipt.status === 'reverted') { + throw new Error(`Transaction reverted (${hash})`); + } + return receipt; +} + +/** Zeronet query RPC is load-balanced; a receipt can land before bytecode is visible. */ +async function waitForBytecode( + publicClient: PublicClient, + address: Address, + label: string, +): Promise { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + const code = await publicClient.getCode({ address }).catch(() => undefined); + if (code && code !== '0x') return; + await sleep(400); + } + throw new Error(`${label} bytecode not visible on the read RPC yet (${address}).`); +} + +function pairFromCreateReceipt(receipt: TransactionReceipt): Address | null { + const logs = parseEventLogs({ + abi: factoryAbi, + eventName: 'PairCreated', + logs: receipt.logs, + }); + const pair = logs[0]?.args?.pair; + return typeof pair === 'string' ? pair : null; +} + +async function readPair( + publicClient: PublicClient, + factory: Address, + tokenA: Address, + tokenB: Address, +): Promise
{ + const deadline = Date.now() + 60_000; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const pair = (await publicClient.readContract({ + address: factory, + abi: factoryAbi, + functionName: 'getPair', + args: [tokenA, tokenB], + })) as Address; + if (pair && pair !== zeroAddress) return pair; + } catch (err) { + lastError = err; + } + await sleep(400); + } + if (lastError instanceof Error) throw lastError; + throw new Error('Factory returned no pair.'); +} + +async function send( + wallet: WalletClient, + publicClient: PublicClient, + account: Account, + request: { + to?: Address; + data: Hex; + gas?: bigint; + }, +): Promise { + const hash = await wallet.sendTransaction({ + account, + chain: wallet.chain, + ...request, + }); + return wait(publicClient, hash); +} + +export async function getReserves(publicClient: PublicClient, pair: Address): Promise { + const result = (await publicClient.readContract({ + address: pair, + abi: pairAbi, + functionName: 'getReserves', + })) as [bigint, bigint, number]; + return { + reserve0: result[0], + reserve1: result[1], + blockTimestampLast: Number(result[2]), + }; +} + +export function amountOut(amountIn: bigint, reserveIn: bigint, reserveOut: bigint): bigint { + if (amountIn === 0n || reserveIn === 0n || reserveOut === 0n) return 0n; + // 0% swap fee so k stays put; the validity rectangle is a patch on one hyperbola. + const numerator = amountIn * reserveOut; + const denominator = reserveIn + amountIn; + return numerator / denominator; +} + +/** Reserves on the current hyperbola at a USDV-per-VIBE quote. */ +export function reservesAtQuote(k: bigint, quoteWad: bigint): { vibe: bigint; usdv: bigint } { + if (k === 0n || quoteWad <= 0n) { + throw new Error('Need a live pool and a positive target price.'); + } + const vibe = sqrt((k * WAD) / quoteWad); + if (vibe === 0n) throw new Error('Degenerate reserve bound.'); + const usdv = (vibe * quoteWad) / WAD || 1n; + return { vibe, usdv }; +} + +/** + * Output sized at the limit, not at submit-time spot. Resting buys locked against + * the then-current (worse) curve would fill above the line once the box hit. + */ +export function amountOutAtLimit( + amountIn: bigint, + side: Side, + k: bigint, + targetQuoteWad: bigint, +): bigint { + const { vibe, usdv } = reservesAtQuote(k, targetQuoteWad); + return side === 'buy' ? amountOut(amountIn, usdv, vibe) : amountOut(amountIn, vibe, usdv); +} + +export function fillQuoteFromSwapReceipt( + receipt: TransactionReceipt, + pair: Address, + vibeToken0: boolean, +): bigint | undefined { + try { + const wanted = pair.toLowerCase(); + const swaps = parseEventLogs({ + abi: pairAbi, + eventName: 'Swap', + logs: receipt.logs, + }); + const syncs = parseEventLogs({ + abi: pairAbi, + eventName: 'Sync', + logs: receipt.logs, + }); + const swap = [...swaps].reverse().find((ev) => ev.address.toLowerCase() === wanted); + const sync = [...syncs].reverse().find((ev) => ev.address.toLowerCase() === wanted); + if ( + !swap || + swap.args.amount0In === undefined || + swap.args.amount1In === undefined || + swap.args.amount0Out === undefined || + swap.args.amount1Out === undefined + ) { + return undefined; + } + if (sync?.args.reserve0 !== undefined && sync.args.reserve1 !== undefined) { + return quoteFromPreSwapReserves({ + vibeToken0, + postReserve0: sync.args.reserve0, + postReserve1: sync.args.reserve1, + amount0In: swap.args.amount0In, + amount1In: swap.args.amount1In, + amount0Out: swap.args.amount0Out, + amount1Out: swap.args.amount1Out, + }); + } + return undefined; + } catch { + return undefined; + } +} + +export async function deployAmm(args: { + wallet: WalletClient; + publicClient: PublicClient; + account: Account; + extraRecipients: Address[]; + onProgress?: (label: string) => void; +}): Promise { + const { wallet, publicClient, account, extraRecipients, onProgress } = args; + const note = (label: string) => onProgress?.(label); + + note('Deploying VIBE'); + const tokenAHash = await wallet.deployContract({ + abi: erc20Abi, + bytecode: erc20Bytecode, + args: [VIBE_NAME, VIBE_SYMBOL], + account, + chain: wallet.chain, + }); + const tokenAReceipt = await wait(publicClient, tokenAHash); + const tokenA = tokenAReceipt.contractAddress; + if (!tokenA) throw new Error('VIBE deploy returned no address.'); + await waitForBytecode(publicClient, tokenA, 'VIBE'); + + note('Deploying USDV'); + const tokenBHash = await wallet.deployContract({ + abi: erc20Abi, + bytecode: erc20Bytecode, + args: [USDV_NAME, USDV_SYMBOL], + account, + chain: wallet.chain, + }); + const tokenBReceipt = await wait(publicClient, tokenBHash); + const tokenB = tokenBReceipt.contractAddress; + if (!tokenB) throw new Error('USDV deploy returned no address.'); + await waitForBytecode(publicClient, tokenB, 'USDV'); + + note('Deploying Uniswap V2 factory'); + const factoryHash = await wallet.deployContract({ + abi: factoryAbi, + bytecode: factoryBytecode, + args: [account.address], + account, + chain: wallet.chain, + }); + const factoryReceipt = await wait(publicClient, factoryHash); + const factory = factoryReceipt.contractAddress; + if (!factory) throw new Error('Factory deploy returned no address.'); + await waitForBytecode(publicClient, factory, 'Factory'); + + note('Creating the pair'); + const createReceipt = await send(wallet, publicClient, account, { + to: factory, + data: encodeFunctionData({ + abi: factoryAbi, + functionName: 'createPair', + args: [tokenA, tokenB], + }), + gas: 5_000_000n, + }); + const pair = + pairFromCreateReceipt(createReceipt) ?? (await readPair(publicClient, factory, tokenA, tokenB)); + await waitForBytecode(publicClient, pair, 'Pair'); + + const token0 = (await publicClient.readContract({ + address: pair, + abi: pairAbi, + functionName: 'token0', + })) as Address; + const token1 = (await publicClient.readContract({ + address: pair, + abi: pairAbi, + functionName: 'token1', + })) as Address; + + note('Seeding VIBE/USDV (~$0.07)'); + const mintTo = (token: Address, to: Address, amount: bigint) => + send(wallet, publicClient, account, { + to: token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'mint', + args: [to, amount], + }), + }); + + await mintTo(tokenA, account.address, SEED_VIBE); + await mintTo(tokenB, account.address, SEED_USDV); + await send(wallet, publicClient, account, { + to: tokenA, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [pair, SEED_VIBE], + }), + }); + await send(wallet, publicClient, account, { + to: tokenB, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [pair, SEED_USDV], + }), + }); + await send(wallet, publicClient, account, { + to: pair, + data: encodeFunctionData({ + abi: pairAbi, + functionName: 'mint', + args: [account.address], + }), + gas: 500_000n, + }); + + note('Deploying swap helper'); + const helperHash = await wallet.deployContract({ + abi: helperAbi, + bytecode: helperBytecode, + account, + chain: wallet.chain, + }); + const helperReceipt = await wait(publicClient, helperHash); + const helper = helperReceipt.contractAddress; + if (!helper) throw new Error('Swap helper deploy returned no address.'); + await waitForBytecode(publicClient, helper, 'Swap helper'); + + note('Minting trader inventory'); + const recipients = [account.address, ...extraRecipients]; + for (const recipient of recipients) { + await mintTo(tokenA, recipient, TRADER_VIBE); + await mintTo(tokenB, recipient, TRADER_USDV); + } + + note('Approving the helper'); + const max = 2n ** 256n - 1n; + for (const token of [token0, token1] as const) { + await send(wallet, publicClient, account, { + to: token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [helper, max], + }), + }); + } + + return { tokenA, tokenB, token0, token1, factory, pair, helper }; +} + +export function encodeHelperSwap(args: { + helper: Address; + tokenIn: Address; + pair: Address; + amountIn: bigint; + amount0Out: bigint; + amount1Out: bigint; +}): { to: Address; data: Hex } { + return { + to: args.helper, + data: encodeFunctionData({ + abi: helperAbi, + functionName: 'swap', + args: [args.tokenIn, args.pair, args.amountIn, args.amount0Out, args.amount1Out], + }), + }; +} + +export async function swapExactIn(args: { + wallet: WalletClient; + publicClient: PublicClient; + account: Account; + pair: Address; + tokenIn: Address; + amountIn: bigint; + amount0Out: bigint; + amount1Out: bigint; + nonce?: number; + waitForReceipt?: boolean; + fees?: FeeFields | null; +}): Promise<{ hash: Hex; nextNonce: number; fees: FeeFields | null }> { + const { wallet, publicClient, account, pair, tokenIn, amountIn, amount0Out, amount1Out } = args; + const [nonce, estimated] = await Promise.all([ + args.nonce !== undefined + ? Promise.resolve(args.nonce) + : publicClient.getTransactionCount({ address: account.address, blockTag: 'pending' }), + args.fees ? Promise.resolve(null) : publicClient.estimateFeesPerGas().catch(() => null), + ]); + const fees: FeeFields | null = args.fees + ?? (estimated?.maxFeePerGas !== undefined && estimated.maxPriorityFeePerGas !== undefined + ? padFees({ maxFeePerGas: estimated.maxFeePerGas, maxPriorityFeePerGas: estimated.maxPriorityFeePerGas }) + : null); + const feeFields = fees ?? {}; + await wallet.sendTransaction({ + account, + chain: wallet.chain, + to: tokenIn, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [pair, amountIn], + }), + nonce, + ...feeFields, + }); + const hash = await wallet.sendTransaction({ + account, + chain: wallet.chain, + to: pair, + data: encodeFunctionData({ + abi: pairAbi, + functionName: 'swap', + args: [amount0Out, amount1Out, account.address, '0x'], + }), + nonce: nonce + 1, + gas: 300_000n, + ...feeFields, + }); + if (args.waitForReceipt !== false) await wait(publicClient, hash); + return { hash, nextNonce: nonce + 2, fees }; +} + +export async function tokenAllowance( + publicClient: PublicClient, + token: Address, + owner: Address, + spender: Address, +): Promise { + return (await publicClient.readContract({ + address: token, + abi: erc20Abi, + functionName: 'allowance', + args: [owner, spender], + })) as bigint; +} + +export async function approveMax(args: { + wallet: WalletClient; + publicClient: PublicClient; + account: Account; + token: Address; + spender: Address; +}): Promise { + const { wallet, publicClient, account, token, spender } = args; + await send(wallet, publicClient, account, { + to: token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [spender, 2n ** 256n - 1n], + }), + }); +} + +/** One-tx swap through SwapHelper (bots need a prior approve). */ +export async function swapExactInHelper(args: { + wallet: WalletClient; + publicClient: PublicClient; + account: Account; + helper: Address; + pair: Address; + tokenIn: Address; + amountIn: bigint; + amount0Out: bigint; + amount1Out: bigint; +}): Promise { + const call = encodeHelperSwap(args); + const receipt = await send(args.wallet, args.publicClient, args.account, { + to: call.to, + data: call.data, + gas: 400_000n, + }); + return receipt.transactionHash; +} + +export async function tokenBalance( + publicClient: PublicClient, + token: Address, + owner: Address, +): Promise { + return (await publicClient.readContract({ + address: token, + abi: erc20Abi, + functionName: 'balanceOf', + args: [owner], + })) as bigint; +} + +export async function signCall(args: { + wallet: WalletClient; + publicClient: PublicClient; + account: Account; + to: Address; + data: Hex; + nonce?: number; + fees?: FeeFields | null; +}): Promise<{ signed: Hex; nonce: number; fees: FeeFields | null }> { + const { wallet, publicClient, account, to, data } = args; + const [nonce, estimated] = await Promise.all([ + args.nonce !== undefined + ? Promise.resolve(args.nonce) + : publicClient.getTransactionCount({ address: account.address, blockTag: 'pending' }), + args.fees ? Promise.resolve(null) : publicClient.estimateFeesPerGas().catch(() => null), + ]); + const fees: FeeFields | null = args.fees + ?? (estimated?.maxFeePerGas !== undefined && estimated.maxPriorityFeePerGas !== undefined + ? padFees({ maxFeePerGas: estimated.maxFeePerGas, maxPriorityFeePerGas: estimated.maxPriorityFeePerGas }) + : null); + const signed = await wallet.signTransaction({ + account, + chain: wallet.chain, + to, + data, + nonce, + gas: 400_000n, + ...(fees ?? {}), + }); + return { signed, nonce, fees }; +} diff --git a/app/vibenet/demos/validity/lib/artifacts/MintableERC20.json b/app/vibenet/demos/validity/lib/artifacts/MintableERC20.json new file mode 100644 index 0000000..b601f79 --- /dev/null +++ b/app/vibenet/demos/validity/lib/artifacts/MintableERC20.json @@ -0,0 +1 @@ +{"abi":[{"type":"constructor","inputs":[{"name":"name_","type":"string","internalType":"string"},{"name":"symbol_","type":"string","internalType":"string"}],"stateMutability":"nonpayable"},{"type":"function","name":"allowance","inputs":[{"name":"","type":"address","internalType":"address"},{"name":"","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"approve","inputs":[{"name":"spender","type":"address","internalType":"address"},{"name":"amount","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"nonpayable"},{"type":"function","name":"balanceOf","inputs":[{"name":"","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"decimals","inputs":[],"outputs":[{"name":"","type":"uint8","internalType":"uint8"}],"stateMutability":"view"},{"type":"function","name":"mint","inputs":[{"name":"to","type":"address","internalType":"address"},{"name":"amount","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"name","inputs":[],"outputs":[{"name":"","type":"string","internalType":"string"}],"stateMutability":"view"},{"type":"function","name":"symbol","inputs":[],"outputs":[{"name":"","type":"string","internalType":"string"}],"stateMutability":"view"},{"type":"function","name":"totalSupply","inputs":[],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"transfer","inputs":[{"name":"to","type":"address","internalType":"address"},{"name":"amount","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"nonpayable"},{"type":"function","name":"transferFrom","inputs":[{"name":"from","type":"address","internalType":"address"},{"name":"to","type":"address","internalType":"address"},{"name":"amount","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"nonpayable"},{"type":"event","name":"Approval","inputs":[{"name":"owner","type":"address","indexed":true,"internalType":"address"},{"name":"spender","type":"address","indexed":true,"internalType":"address"},{"name":"value","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"Transfer","inputs":[{"name":"from","type":"address","indexed":true,"internalType":"address"},{"name":"to","type":"address","indexed":true,"internalType":"address"},{"name":"value","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false}],"bytecode":"0x608060405234801562000010575f80fd5b50604051620009c6380380620009c6833981016040819052620000339162000116565b5f62000040838262000208565b5060016200004f828262000208565b505050620002d0565b634e487b7160e01b5f52604160045260245ffd5b5f82601f8301126200007c575f80fd5b81516001600160401b038082111562000099576200009962000058565b604051601f8301601f19908116603f01168101908282118183101715620000c457620000c462000058565b81604052838152602092508683858801011115620000e0575f80fd5b5f91505b83821015620001035785820183015181830184015290820190620000e4565b5f93810190920192909252949350505050565b5f806040838503121562000128575f80fd5b82516001600160401b03808211156200013f575f80fd5b6200014d868387016200006c565b9350602085015191508082111562000163575f80fd5b5062000172858286016200006c565b9150509250929050565b600181811c908216806200019157607f821691505b602082108103620001b057634e487b7160e01b5f52602260045260245ffd5b50919050565b601f82111562000203575f81815260208120601f850160051c81016020861015620001de5750805b601f850160051c820191505b81811015620001ff57828155600101620001ea565b5050505b505050565b81516001600160401b0381111562000224576200022462000058565b6200023c816200023584546200017c565b84620001b6565b602080601f83116001811462000272575f84156200025a5750858301515b5f19600386901b1c1916600185901b178555620001ff565b5f85815260208120601f198616915b82811015620002a25788860151825594840194600190910190840162000281565b5085821015620002c057878501515f19600388901b60f8161c191681555b5050505050600190811b01905550565b6106e880620002de5f395ff3fe608060405234801561000f575f80fd5b506004361061009b575f3560e01c806340c10f191161006357806340c10f191461012457806370a082311461013957806395d89b4114610158578063a9059cbb14610160578063dd62ed3e14610173575f80fd5b806306fdde031461009f578063095ea7b3146100bd57806318160ddd146100e057806323b872dd146100f7578063313ce5671461010a575b5f80fd5b6100a761019d565b6040516100b49190610528565b60405180910390f35b6100d06100cb36600461058e565b610228565b60405190151581526020016100b4565b6100e960025481565b6040519081526020016100b4565b6100d06101053660046105b6565b610294565b610112601281565b60405160ff90911681526020016100b4565b61013761013236600461058e565b610344565b005b6100e96101473660046105ef565b60036020525f908152604090205481565b6100a76103ca565b6100d061016e36600461058e565b6103d7565b6100e961018136600461060f565b600460209081525f928352604080842090915290825290205481565b5f80546101a990610640565b80601f01602080910402602001604051908101604052809291908181526020018280546101d590610640565b80156102205780601f106101f757610100808354040283529160200191610220565b820191905f5260205f20905b81548152906001019060200180831161020357829003601f168201915b505050505081565b335f8181526004602090815260408083206001600160a01b038716808552925280832085905551919290917f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925906102829086815260200190565b60405180910390a35060015b92915050565b6001600160a01b0383165f9081526004602090815260408083203384529091528120545f19811461032e57828110156103005760405162461bcd60e51b8152602060048201526009602482015268414c4c4f57414e434560b81b60448201526064015b60405180910390fd5b61030a838261068c565b6001600160a01b0386165f9081526004602090815260408083203384529091529020555b6103398585856103ec565b506001949350505050565b8060025f828254610355919061069f565b90915550506001600160a01b0382165f908152600360205260408120805483929061038190849061069f565b90915550506040518181526001600160a01b038316905f907fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9060200160405180910390a35050565b600180546101a990610640565b5f6103e33384846103ec565b50600192915050565b6001600160a01b03821661042b5760405162461bcd60e51b81526004016102f7906020808252600490820152635a45524f60e01b604082015260600190565b6001600160a01b0383165f9081526003602052604090205481111561047c5760405162461bcd60e51b815260206004820152600760248201526642414c414e434560c81b60448201526064016102f7565b6001600160a01b0383165f90815260036020526040812080548392906104a390849061068c565b90915550506001600160a01b0382165f90815260036020526040812080548392906104cf90849061069f565b92505081905550816001600160a01b0316836001600160a01b03167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef8360405161051b91815260200190565b60405180910390a3505050565b5f6020808352835180828501525f5b8181101561055357858101830151858201604001528201610537565b505f604082860101526040601f19601f8301168501019250505092915050565b80356001600160a01b0381168114610589575f80fd5b919050565b5f806040838503121561059f575f80fd5b6105a883610573565b946020939093013593505050565b5f805f606084860312156105c8575f80fd5b6105d184610573565b92506105df60208501610573565b9150604084013590509250925092565b5f602082840312156105ff575f80fd5b61060882610573565b9392505050565b5f8060408385031215610620575f80fd5b61062983610573565b915061063760208401610573565b90509250929050565b600181811c9082168061065457607f821691505b60208210810361067257634e487b7160e01b5f52602260045260245ffd5b50919050565b634e487b7160e01b5f52601160045260245ffd5b8181038181111561028e5761028e610678565b8082018082111561028e5761028e61067856fea2646970667358221220b964cf8c676498398f7437a6da85d426afbb81592388a93aa134bd95594bbf5364736f6c63430008140033"} \ No newline at end of file diff --git a/app/vibenet/demos/validity/lib/artifacts/SwapHelper.json b/app/vibenet/demos/validity/lib/artifacts/SwapHelper.json new file mode 100644 index 0000000..ce1fcf6 --- /dev/null +++ b/app/vibenet/demos/validity/lib/artifacts/SwapHelper.json @@ -0,0 +1 @@ +{"abi":[{"type":"function","name":"swap","inputs":[{"name":"tokenIn","type":"address","internalType":"address"},{"name":"pair","type":"address","internalType":"address"},{"name":"amountIn","type":"uint256","internalType":"uint256"},{"name":"amount0Out","type":"uint256","internalType":"uint256"},{"name":"amount1Out","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"}],"bytecode":"0x608060405234801561000f575f80fd5b506102298061001d5f395ff3fe608060405234801561000f575f80fd5b5060043610610029575f3560e01c80637a950f991461002d575b5f80fd5b61004061003b366004610184565b610042565b005b6040516323b872dd60e01b81523360048201526001600160a01b038581166024830152604482018590528616906323b872dd906064016020604051808303815f875af1158015610094573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906100b891906101cd565b6100f35760405162461bcd60e51b81526020600482015260086024820152672a2920a729a322a960c11b604482015260640160405180910390fd5b60405163022c0d9f60e01b81526004810183905260248101829052336044820152608060648201525f60848201526001600160a01b0385169063022c0d9f9060a4015f604051808303815f87803b15801561014c575f80fd5b505af115801561015e573d5f803e3d5ffd5b505050505050505050565b80356001600160a01b038116811461017f575f80fd5b919050565b5f805f805f60a08688031215610198575f80fd5b6101a186610169565b94506101af60208701610169565b94979496505050506040830135926060810135926080909101359150565b5f602082840312156101dd575f80fd5b815180151581146101ec575f80fd5b939250505056fea2646970667358221220543bb5afcba8324e91fa3066ed0aabc3ba64dc9364a70aec6466d23a776947e264736f6c63430008140033"} \ No newline at end of file diff --git a/app/vibenet/demos/validity/lib/artifacts/UniswapV2Factory.json b/app/vibenet/demos/validity/lib/artifacts/UniswapV2Factory.json new file mode 100644 index 0000000..dc38db7 --- /dev/null +++ b/app/vibenet/demos/validity/lib/artifacts/UniswapV2Factory.json @@ -0,0 +1 @@ +{"abi":[{"inputs":[{"internalType":"address","name":"_feeToSetter","type":"address"}],"payable":false,"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"token0","type":"address"},{"indexed":true,"internalType":"address","name":"token1","type":"address"},{"indexed":false,"internalType":"address","name":"pair","type":"address"},{"indexed":false,"internalType":"uint256","name":"","type":"uint256"}],"name":"PairCreated","type":"event"},{"constant":true,"inputs":[{"internalType":"uint256","name":"","type":"uint256"}],"name":"allPairs","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"allPairsLength","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"tokenA","type":"address"},{"internalType":"address","name":"tokenB","type":"address"}],"name":"createPair","outputs":[{"internalType":"address","name":"pair","type":"address"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"feeTo","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"feeToSetter","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"address","name":"","type":"address"},{"internalType":"address","name":"","type":"address"}],"name":"getPair","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"_feeTo","type":"address"}],"name":"setFeeTo","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"_feeToSetter","type":"address"}],"name":"setFeeToSetter","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"}],"bytecode":"0x608060405234801561001057600080fd5b506040516136863803806136868339818101604052602081101561003357600080fd5b5051600180546001600160a01b0319166001600160a01b03909216919091179055613623806100636000396000f3fe608060405234801561001057600080fd5b50600436106100885760003560e01c8063a2e74af61161005b578063a2e74af6146100fd578063c9c6539614610132578063e6a439051461016d578063f46901ed146101a857610088565b8063017e7e581461008d578063094b7415146100be5780631e3dd18b146100c6578063574f2ba3146100e3575b600080fd5b6100956101db565b6040805173ffffffffffffffffffffffffffffffffffffffff9092168252519081900360200190f35b6100956101f7565b610095600480360360208110156100dc57600080fd5b5035610213565b6100eb610247565b60408051918252519081900360200190f35b6101306004803603602081101561011357600080fd5b503573ffffffffffffffffffffffffffffffffffffffff1661024d565b005b6100956004803603604081101561014857600080fd5b5073ffffffffffffffffffffffffffffffffffffffff8135811691602001351661031a565b6100956004803603604081101561018357600080fd5b5073ffffffffffffffffffffffffffffffffffffffff8135811691602001351661076d565b610130600480360360208110156101be57600080fd5b503573ffffffffffffffffffffffffffffffffffffffff166107a0565b60005473ffffffffffffffffffffffffffffffffffffffff1681565b60015473ffffffffffffffffffffffffffffffffffffffff1681565b6003818154811061022057fe5b60009182526020909120015473ffffffffffffffffffffffffffffffffffffffff16905081565b60035490565b60015473ffffffffffffffffffffffffffffffffffffffff1633146102d357604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f556e697377617056323a20464f5242494444454e000000000000000000000000604482015290519081900360640190fd5b600180547fffffffffffffffffffffffff00000000000000000000000000000000000000001673ffffffffffffffffffffffffffffffffffffffff92909216919091179055565b60008173ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff1614156103b757604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601e60248201527f556e697377617056323a204944454e544943414c5f4144445245535345530000604482015290519081900360640190fd5b6000808373ffffffffffffffffffffffffffffffffffffffff168573ffffffffffffffffffffffffffffffffffffffff16106103f45783856103f7565b84845b909250905073ffffffffffffffffffffffffffffffffffffffff821661047e57604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f556e697377617056323a205a45524f5f41444452455353000000000000000000604482015290519081900360640190fd5b73ffffffffffffffffffffffffffffffffffffffff82811660009081526002602090815260408083208585168452909152902054161561051f57604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601660248201527f556e697377617056323a20504149525f45584953545300000000000000000000604482015290519081900360640190fd5b6060604051806020016105319061086d565b6020820181038252601f19601f82011660405250905060008383604051602001808373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b81526014018273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b815260140192505050604051602081830303815290604052805190602001209050808251602084016000f5604080517f485cc95500000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff8781166004830152868116602483015291519297509087169163485cc9559160448082019260009290919082900301818387803b15801561065e57600080fd5b505af1158015610672573d6000803e3d6000fd5b5050505073ffffffffffffffffffffffffffffffffffffffff84811660008181526002602081815260408084208987168086529083528185208054978d167fffffffffffffffffffffffff000000000000000000000000000000000000000098891681179091559383528185208686528352818520805488168517905560038054600181018255958190527fc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b90950180549097168417909655925483519283529082015281517f0d3648bd0f6ba80134a33ba9275ac585d9d315f0ad8355cddefde31afa28d0e9929181900390910190a35050505092915050565b600260209081526000928352604080842090915290825290205473ffffffffffffffffffffffffffffffffffffffff1681565b60015473ffffffffffffffffffffffffffffffffffffffff16331461082657604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f556e697377617056323a20464f5242494444454e000000000000000000000000604482015290519081900360640190fd5b600080547fffffffffffffffffffffffff00000000000000000000000000000000000000001673ffffffffffffffffffffffffffffffffffffffff92909216919091179055565b612d748061087b8339019056fe60806040526001600c5534801561001557600080fd5b506040514690806052612d228239604080519182900360520182208282018252600a8352692ab734b9bbb0b8102b1960b11b6020938401528151808301835260018152603160f81b908401528151808401919091527fbfcc8ef98ffbf7b6c3fec7bf5185b566b9863e35a9d83acd49ad6824b5969738818301527fc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc6606082015260808101949094523060a0808601919091528151808603909101815260c09094019052825192019190912060035550600580546001600160a01b03191633179055612c1d806101056000396000f3fe608060405234801561001057600080fd5b50600436106101b95760003560e01c80636a627842116100f9578063ba9a7a5611610097578063d21220a711610071578063d21220a7146105da578063d505accf146105e2578063dd62ed3e14610640578063fff6cae91461067b576101b9565b8063ba9a7a5614610597578063bc25cf771461059f578063c45a0155146105d2576101b9565b80637ecebe00116100d35780637ecebe00146104d757806389afcb441461050a57806395d89b4114610556578063a9059cbb1461055e576101b9565b80636a6278421461046957806370a082311461049c5780637464fc3d146104cf576101b9565b806323b872dd116101665780633644e515116101405780633644e51514610416578063485cc9551461041e5780635909c0d5146104595780635a3d549314610461576101b9565b806323b872dd146103ad57806330adf81f146103f0578063313ce567146103f8576101b9565b8063095ea7b311610197578063095ea7b3146103155780630dfe16811461036257806318160ddd14610393576101b9565b8063022c0d9f146101be57806306fdde03146102595780630902f1ac146102d6575b600080fd5b610257600480360360808110156101d457600080fd5b81359160208101359173ffffffffffffffffffffffffffffffffffffffff604083013516919081019060808101606082013564010000000081111561021857600080fd5b82018360208201111561022a57600080fd5b8035906020019184600183028401116401000000008311171561024c57600080fd5b509092509050610683565b005b610261610d57565b6040805160208082528351818301528351919283929083019185019080838360005b8381101561029b578181015183820152602001610283565b50505050905090810190601f1680156102c85780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6102de610d90565b604080516dffffffffffffffffffffffffffff948516815292909316602083015263ffffffff168183015290519081900360600190f35b61034e6004803603604081101561032b57600080fd5b5073ffffffffffffffffffffffffffffffffffffffff8135169060200135610de5565b604080519115158252519081900360200190f35b61036a610dfc565b6040805173ffffffffffffffffffffffffffffffffffffffff9092168252519081900360200190f35b61039b610e18565b60408051918252519081900360200190f35b61034e600480360360608110156103c357600080fd5b5073ffffffffffffffffffffffffffffffffffffffff813581169160208101359091169060400135610e1e565b61039b610efd565b610400610f21565b6040805160ff9092168252519081900360200190f35b61039b610f26565b6102576004803603604081101561043457600080fd5b5073ffffffffffffffffffffffffffffffffffffffff81358116916020013516610f2c565b61039b611005565b61039b61100b565b61039b6004803603602081101561047f57600080fd5b503573ffffffffffffffffffffffffffffffffffffffff16611011565b61039b600480360360208110156104b257600080fd5b503573ffffffffffffffffffffffffffffffffffffffff166113cb565b61039b6113dd565b61039b600480360360208110156104ed57600080fd5b503573ffffffffffffffffffffffffffffffffffffffff166113e3565b61053d6004803603602081101561052057600080fd5b503573ffffffffffffffffffffffffffffffffffffffff166113f5565b6040805192835260208301919091528051918290030190f35b610261611892565b61034e6004803603604081101561057457600080fd5b5073ffffffffffffffffffffffffffffffffffffffff81351690602001356118cb565b61039b6118d8565b610257600480360360208110156105b557600080fd5b503573ffffffffffffffffffffffffffffffffffffffff166118de565b61036a611ad4565b61036a611af0565b610257600480360360e08110156105f857600080fd5b5073ffffffffffffffffffffffffffffffffffffffff813581169160208101359091169060408101359060608101359060ff6080820135169060a08101359060c00135611b0c565b61039b6004803603604081101561065657600080fd5b5073ffffffffffffffffffffffffffffffffffffffff81358116916020013516611dd8565b610257611df5565b600c546001146106f457604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f556e697377617056323a204c4f434b4544000000000000000000000000000000604482015290519081900360640190fd5b6000600c55841515806107075750600084115b61075c576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526025815260200180612b2f6025913960400191505060405180910390fd5b600080610767610d90565b5091509150816dffffffffffffffffffffffffffff168710801561079a5750806dffffffffffffffffffffffffffff1686105b6107ef576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526021815260200180612b786021913960400191505060405180910390fd5b600654600754600091829173ffffffffffffffffffffffffffffffffffffffff91821691908116908916821480159061085457508073ffffffffffffffffffffffffffffffffffffffff168973ffffffffffffffffffffffffffffffffffffffff1614155b6108bf57604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601560248201527f556e697377617056323a20494e56414c49445f544f0000000000000000000000604482015290519081900360640190fd5b8a156108d0576108d0828a8d611fdb565b89156108e1576108e1818a8c611fdb565b86156109c3578873ffffffffffffffffffffffffffffffffffffffff166310d1e85c338d8d8c8c6040518663ffffffff1660e01b8152600401808673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001858152602001848152602001806020018281038252848482818152602001925080828437600081840152601f19601f8201169050808301925050509650505050505050600060405180830381600087803b1580156109aa57600080fd5b505af11580156109be573d6000803e3d6000fd5b505050505b604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905173ffffffffffffffffffffffffffffffffffffffff8416916370a08231916024808301926020929190829003018186803b158015610a2f57600080fd5b505afa158015610a43573d6000803e3d6000fd5b505050506040513d6020811015610a5957600080fd5b5051604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905191955073ffffffffffffffffffffffffffffffffffffffff8316916370a0823191602480820192602092909190829003018186803b158015610acb57600080fd5b505afa158015610adf573d6000803e3d6000fd5b505050506040513d6020811015610af557600080fd5b5051925060009150506dffffffffffffffffffffffffffff85168a90038311610b1f576000610b35565b89856dffffffffffffffffffffffffffff160383035b9050600089856dffffffffffffffffffffffffffff16038311610b59576000610b6f565b89856dffffffffffffffffffffffffffff160383035b90506000821180610b805750600081115b610bd5576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526024815260200180612b546024913960400191505060405180910390fd5b6000610c09610beb84600063ffffffff6121e816565b610bfd876103e863ffffffff6121e816565b9063ffffffff61226e16565b90506000610c21610beb84600063ffffffff6121e816565b9050610c59620f4240610c4d6dffffffffffffffffffffffffffff8b8116908b1663ffffffff6121e816565b9063ffffffff6121e816565b610c69838363ffffffff6121e816565b1015610cd657604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152600c60248201527f556e697377617056323a204b0000000000000000000000000000000000000000604482015290519081900360640190fd5b5050610ce4848488886122e0565b60408051838152602081018390528082018d9052606081018c9052905173ffffffffffffffffffffffffffffffffffffffff8b169133917fd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d8229181900360800190a350506001600c55505050505050505050565b6040518060400160405280600a81526020017f556e69737761702056320000000000000000000000000000000000000000000081525081565b6008546dffffffffffffffffffffffffffff808216926e0100000000000000000000000000008304909116917c0100000000000000000000000000000000000000000000000000000000900463ffffffff1690565b6000610df233848461259c565b5060015b92915050565b60065473ffffffffffffffffffffffffffffffffffffffff1681565b60005481565b73ffffffffffffffffffffffffffffffffffffffff831660009081526002602090815260408083203384529091528120547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff14610ee85773ffffffffffffffffffffffffffffffffffffffff84166000908152600260209081526040808320338452909152902054610eb6908363ffffffff61226e16565b73ffffffffffffffffffffffffffffffffffffffff851660009081526002602090815260408083203384529091529020555b610ef384848461260b565b5060019392505050565b7f6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c981565b601281565b60035481565b60055473ffffffffffffffffffffffffffffffffffffffff163314610fb257604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f556e697377617056323a20464f5242494444454e000000000000000000000000604482015290519081900360640190fd5b6006805473ffffffffffffffffffffffffffffffffffffffff9384167fffffffffffffffffffffffff00000000000000000000000000000000000000009182161790915560078054929093169116179055565b60095481565b600a5481565b6000600c5460011461108457604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f556e697377617056323a204c4f434b4544000000000000000000000000000000604482015290519081900360640190fd5b6000600c81905580611094610d90565b50600654604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905193955091935060009273ffffffffffffffffffffffffffffffffffffffff909116916370a08231916024808301926020929190829003018186803b15801561110e57600080fd5b505afa158015611122573d6000803e3d6000fd5b505050506040513d602081101561113857600080fd5b5051600754604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905192935060009273ffffffffffffffffffffffffffffffffffffffff909216916370a0823191602480820192602092909190829003018186803b1580156111b157600080fd5b505afa1580156111c5573d6000803e3d6000fd5b505050506040513d60208110156111db57600080fd5b505190506000611201836dffffffffffffffffffffffffffff871663ffffffff61226e16565b90506000611225836dffffffffffffffffffffffffffff871663ffffffff61226e16565b9050600061123387876126ec565b600054909150806112705761125c6103e8610bfd611257878763ffffffff6121e816565b612878565b985061126b60006103e86128ca565b6112cd565b6112ca6dffffffffffffffffffffffffffff8916611294868463ffffffff6121e816565b8161129b57fe5b046dffffffffffffffffffffffffffff89166112bd868563ffffffff6121e816565b816112c457fe5b0461297a565b98505b60008911611326576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526028815260200180612bc16028913960400191505060405180910390fd5b6113308a8a6128ca565b61133c86868a8a6122e0565b811561137e5760085461137a906dffffffffffffffffffffffffffff808216916e01000000000000000000000000000090041663ffffffff6121e816565b600b555b6040805185815260208101859052815133927f4c209b5fc8ad50758f13e2e1088ba56a560dff690a1c6fef26394f4c03821c4f928290030190a250506001600c5550949695505050505050565b60016020526000908152604090205481565b600b5481565b60046020526000908152604090205481565b600080600c5460011461146957604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f556e697377617056323a204c4f434b4544000000000000000000000000000000604482015290519081900360640190fd5b6000600c81905580611479610d90565b50600654600754604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905194965092945073ffffffffffffffffffffffffffffffffffffffff9182169391169160009184916370a08231916024808301926020929190829003018186803b1580156114fb57600080fd5b505afa15801561150f573d6000803e3d6000fd5b505050506040513d602081101561152557600080fd5b5051604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905191925060009173ffffffffffffffffffffffffffffffffffffffff8516916370a08231916024808301926020929190829003018186803b15801561159957600080fd5b505afa1580156115ad573d6000803e3d6000fd5b505050506040513d60208110156115c357600080fd5b5051306000908152600160205260408120549192506115e288886126ec565b600054909150806115f9848763ffffffff6121e816565b8161160057fe5b049a5080611614848663ffffffff6121e816565b8161161b57fe5b04995060008b11801561162e575060008a115b611683576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526028815260200180612b996028913960400191505060405180910390fd5b61168d3084612992565b611698878d8d611fdb565b6116a3868d8c611fdb565b604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905173ffffffffffffffffffffffffffffffffffffffff8916916370a08231916024808301926020929190829003018186803b15801561170f57600080fd5b505afa158015611723573d6000803e3d6000fd5b505050506040513d602081101561173957600080fd5b5051604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905191965073ffffffffffffffffffffffffffffffffffffffff8816916370a0823191602480820192602092909190829003018186803b1580156117ab57600080fd5b505afa1580156117bf573d6000803e3d6000fd5b505050506040513d60208110156117d557600080fd5b505193506117e585858b8b6122e0565b811561182757600854611823906dffffffffffffffffffffffffffff808216916e01000000000000000000000000000090041663ffffffff6121e816565b600b555b604080518c8152602081018c9052815173ffffffffffffffffffffffffffffffffffffffff8f169233927fdccd412f0b1252819cb1fd330b93224ca42612892bb3f4f789976e6d81936496929081900390910190a35050505050505050506001600c81905550915091565b6040518060400160405280600681526020017f554e492d5632000000000000000000000000000000000000000000000000000081525081565b6000610df233848461260b565b6103e881565b600c5460011461194f57604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f556e697377617056323a204c4f434b4544000000000000000000000000000000604482015290519081900360640190fd5b6000600c55600654600754600854604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905173ffffffffffffffffffffffffffffffffffffffff9485169490931692611a2b9285928792611a26926dffffffffffffffffffffffffffff169185916370a0823191602480820192602092909190829003018186803b1580156119ee57600080fd5b505afa158015611a02573d6000803e3d6000fd5b505050506040513d6020811015611a1857600080fd5b50519063ffffffff61226e16565b611fdb565b600854604080517f70a082310000000000000000000000000000000000000000000000000000000081523060048201529051611aca9284928792611a26926e01000000000000000000000000000090046dffffffffffffffffffffffffffff169173ffffffffffffffffffffffffffffffffffffffff8616916370a0823191602480820192602092909190829003018186803b1580156119ee57600080fd5b50506001600c5550565b60055473ffffffffffffffffffffffffffffffffffffffff1681565b60075473ffffffffffffffffffffffffffffffffffffffff1681565b42841015611b7b57604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601260248201527f556e697377617056323a20455850495245440000000000000000000000000000604482015290519081900360640190fd5b60035473ffffffffffffffffffffffffffffffffffffffff80891660008181526004602090815260408083208054600180820190925582517f6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c98186015280840196909652958d166060860152608085018c905260a085019590955260c08085018b90528151808603909101815260e0850182528051908301207f19010000000000000000000000000000000000000000000000000000000000006101008601526101028501969096526101228085019690965280518085039096018652610142840180825286519683019690962095839052610162840180825286905260ff89166101828501526101a284018890526101c28401879052519193926101e2808201937fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe081019281900390910190855afa158015611cdc573d6000803e3d6000fd5b50506040517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0015191505073ffffffffffffffffffffffffffffffffffffffff811615801590611d5757508873ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16145b611dc257604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601c60248201527f556e697377617056323a20494e56414c49445f5349474e415455524500000000604482015290519081900360640190fd5b611dcd89898961259c565b505050505050505050565b600260209081526000928352604080842090915290825290205481565b600c54600114611e6657604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f556e697377617056323a204c4f434b4544000000000000000000000000000000604482015290519081900360640190fd5b6000600c55600654604080517f70a082310000000000000000000000000000000000000000000000000000000081523060048201529051611fd49273ffffffffffffffffffffffffffffffffffffffff16916370a08231916024808301926020929190829003018186803b158015611edd57600080fd5b505afa158015611ef1573d6000803e3d6000fd5b505050506040513d6020811015611f0757600080fd5b5051600754604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905173ffffffffffffffffffffffffffffffffffffffff909216916370a0823191602480820192602092909190829003018186803b158015611f7a57600080fd5b505afa158015611f8e573d6000803e3d6000fd5b505050506040513d6020811015611fa457600080fd5b50516008546dffffffffffffffffffffffffffff808216916e0100000000000000000000000000009004166122e0565b6001600c55565b604080518082018252601981527f7472616e7366657228616464726573732c75696e743235362900000000000000602091820152815173ffffffffffffffffffffffffffffffffffffffff85811660248301526044808301869052845180840390910181526064909201845291810180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff167fa9059cbb000000000000000000000000000000000000000000000000000000001781529251815160009460609489169392918291908083835b602083106120e157805182527fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe090920191602091820191016120a4565b6001836020036101000a0380198251168184511680821785525050505050509050019150506000604051808303816000865af19150503d8060008114612143576040519150601f19603f3d011682016040523d82523d6000602084013e612148565b606091505b5091509150818015612176575080511580612176575080806020019051602081101561217357600080fd5b50515b6121e157604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601a60248201527f556e697377617056323a205452414e534645525f4641494c4544000000000000604482015290519081900360640190fd5b5050505050565b60008115806122035750508082028282828161220057fe5b04145b610df657604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f64732d6d6174682d6d756c2d6f766572666c6f77000000000000000000000000604482015290519081900360640190fd5b80820382811115610df657604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601560248201527f64732d6d6174682d7375622d756e646572666c6f770000000000000000000000604482015290519081900360640190fd5b6dffffffffffffffffffffffffffff841180159061230c57506dffffffffffffffffffffffffffff8311155b61237757604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601360248201527f556e697377617056323a204f564552464c4f5700000000000000000000000000604482015290519081900360640190fd5b60085463ffffffff428116917c0100000000000000000000000000000000000000000000000000000000900481168203908116158015906123c757506dffffffffffffffffffffffffffff841615155b80156123e257506dffffffffffffffffffffffffffff831615155b15612492578063ffffffff16612425856123fb86612a57565b7bffffffffffffffffffffffffffffffffffffffffffffffffffffffff169063ffffffff612a7b16565b600980547bffffffffffffffffffffffffffffffffffffffffffffffffffffffff929092169290920201905563ffffffff8116612465846123fb87612a57565b600a80547bffffffffffffffffffffffffffffffffffffffffffffffffffffffff92909216929092020190555b600880547fffffffffffffffffffffffffffffffffffff0000000000000000000000000000166dffffffffffffffffffffffffffff888116919091177fffffffff0000000000000000000000000000ffffffffffffffffffffffffffff166e0100000000000000000000000000008883168102919091177bffffffffffffffffffffffffffffffffffffffffffffffffffffffff167c010000000000000000000000000000000000000000000000000000000063ffffffff871602179283905560408051848416815291909304909116602082015281517f1c411e9a96e071241c2f21f7726b17ae89e3cab4c78be50e062b03a9fffbbad1929181900390910190a1505050505050565b73ffffffffffffffffffffffffffffffffffffffff808416600081815260026020908152604080832094871680845294825291829020859055815185815291517f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9259281900390910190a3505050565b73ffffffffffffffffffffffffffffffffffffffff8316600090815260016020526040902054612641908263ffffffff61226e16565b73ffffffffffffffffffffffffffffffffffffffff8085166000908152600160205260408082209390935590841681522054612683908263ffffffff612abc16565b73ffffffffffffffffffffffffffffffffffffffff80841660008181526001602090815260409182902094909455805185815290519193928716927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef92918290030190a3505050565b600080600560009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1663017e7e586040518163ffffffff1660e01b815260040160206040518083038186803b15801561275757600080fd5b505afa15801561276b573d6000803e3d6000fd5b505050506040513d602081101561278157600080fd5b5051600b5473ffffffffffffffffffffffffffffffffffffffff821615801594509192509061286457801561285f5760006127d86112576dffffffffffffffffffffffffffff88811690881663ffffffff6121e816565b905060006127e583612878565b90508082111561285c576000612813612804848463ffffffff61226e16565b6000549063ffffffff6121e816565b905060006128388361282c86600563ffffffff6121e816565b9063ffffffff612abc16565b9050600081838161284557fe5b04905080156128585761285887826128ca565b5050505b50505b612870565b8015612870576000600b555b505092915050565b600060038211156128bb575080600160028204015b818110156128b5578091506002818285816128a457fe5b0401816128ad57fe5b04905061288d565b506128c5565b81156128c5575060015b919050565b6000546128dd908263ffffffff612abc16565b600090815573ffffffffffffffffffffffffffffffffffffffff8316815260016020526040902054612915908263ffffffff612abc16565b73ffffffffffffffffffffffffffffffffffffffff831660008181526001602090815260408083209490945583518581529351929391927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9281900390910190a35050565b6000818310612989578161298b565b825b9392505050565b73ffffffffffffffffffffffffffffffffffffffff82166000908152600160205260409020546129c8908263ffffffff61226e16565b73ffffffffffffffffffffffffffffffffffffffff831660009081526001602052604081209190915554612a02908263ffffffff61226e16565b600090815560408051838152905173ffffffffffffffffffffffffffffffffffffffff8516917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef919081900360200190a35050565b6dffffffffffffffffffffffffffff166e0100000000000000000000000000000290565b60006dffffffffffffffffffffffffffff82167bffffffffffffffffffffffffffffffffffffffffffffffffffffffff841681612ab457fe5b049392505050565b80820182811015610df657604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f64732d6d6174682d6164642d6f766572666c6f77000000000000000000000000604482015290519081900360640190fdfe556e697377617056323a20494e53554646494349454e545f4f55545055545f414d4f554e54556e697377617056323a20494e53554646494349454e545f494e5055545f414d4f554e54556e697377617056323a20494e53554646494349454e545f4c4951554944495459556e697377617056323a20494e53554646494349454e545f4c49515549444954595f4255524e4544556e697377617056323a20494e53554646494349454e545f4c49515549444954595f4d494e544544a265627a7a723158207dca18479e58487606bf70c79e44d8dee62353c9ee6d01f9a9d70885b8765f2264736f6c63430005100032454950373132446f6d61696e28737472696e67206e616d652c737472696e672076657273696f6e2c75696e7432353620636861696e49642c6164647265737320766572696679696e67436f6e747261637429a265627a7a723158202760f92d7fa1db6f5aa16307bad65df4ebcc8550c4b1f03755ab8dfd830c178f64736f6c63430005100032"} \ No newline at end of file diff --git a/app/vibenet/demos/validity/lib/artifacts/UniswapV2Pair.json b/app/vibenet/demos/validity/lib/artifacts/UniswapV2Pair.json new file mode 100644 index 0000000..c2ccc0f --- /dev/null +++ b/app/vibenet/demos/validity/lib/artifacts/UniswapV2Pair.json @@ -0,0 +1 @@ +{"abi":[{"inputs":[],"payable":false,"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"owner","type":"address"},{"indexed":true,"internalType":"address","name":"spender","type":"address"},{"indexed":false,"internalType":"uint256","name":"value","type":"uint256"}],"name":"Approval","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"sender","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount0","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"amount1","type":"uint256"},{"indexed":true,"internalType":"address","name":"to","type":"address"}],"name":"Burn","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"sender","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount0","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"amount1","type":"uint256"}],"name":"Mint","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"sender","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount0In","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"amount1In","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"amount0Out","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"amount1Out","type":"uint256"},{"indexed":true,"internalType":"address","name":"to","type":"address"}],"name":"Swap","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"uint112","name":"reserve0","type":"uint112"},{"indexed":false,"internalType":"uint112","name":"reserve1","type":"uint112"}],"name":"Sync","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":false,"internalType":"uint256","name":"value","type":"uint256"}],"name":"Transfer","type":"event"},{"constant":true,"inputs":[],"name":"DOMAIN_SEPARATOR","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"MINIMUM_LIQUIDITY","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"PERMIT_TYPEHASH","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"address","name":"","type":"address"},{"internalType":"address","name":"","type":"address"}],"name":"allowance","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"value","type":"uint256"}],"name":"approve","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"balanceOf","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"to","type":"address"}],"name":"burn","outputs":[{"internalType":"uint256","name":"amount0","type":"uint256"},{"internalType":"uint256","name":"amount1","type":"uint256"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"decimals","outputs":[{"internalType":"uint8","name":"","type":"uint8"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"factory","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"getReserves","outputs":[{"internalType":"uint112","name":"_reserve0","type":"uint112"},{"internalType":"uint112","name":"_reserve1","type":"uint112"},{"internalType":"uint32","name":"_blockTimestampLast","type":"uint32"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"_token0","type":"address"},{"internalType":"address","name":"_token1","type":"address"}],"name":"initialize","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"kLast","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"to","type":"address"}],"name":"mint","outputs":[{"internalType":"uint256","name":"liquidity","type":"uint256"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"name","outputs":[{"internalType":"string","name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"nonces","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"owner","type":"address"},{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"value","type":"uint256"},{"internalType":"uint256","name":"deadline","type":"uint256"},{"internalType":"uint8","name":"v","type":"uint8"},{"internalType":"bytes32","name":"r","type":"bytes32"},{"internalType":"bytes32","name":"s","type":"bytes32"}],"name":"permit","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"price0CumulativeLast","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"price1CumulativeLast","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"to","type":"address"}],"name":"skim","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"uint256","name":"amount0Out","type":"uint256"},{"internalType":"uint256","name":"amount1Out","type":"uint256"},{"internalType":"address","name":"to","type":"address"},{"internalType":"bytes","name":"data","type":"bytes"}],"name":"swap","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"symbol","outputs":[{"internalType":"string","name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[],"name":"sync","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"token0","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"token1","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"totalSupply","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"value","type":"uint256"}],"name":"transfer","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"value","type":"uint256"}],"name":"transferFrom","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"}],"bytecode":"0x60806040526001600c5534801561001557600080fd5b506040514690806052612d228239604080519182900360520182208282018252600a8352692ab734b9bbb0b8102b1960b11b6020938401528151808301835260018152603160f81b908401528151808401919091527fbfcc8ef98ffbf7b6c3fec7bf5185b566b9863e35a9d83acd49ad6824b5969738818301527fc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc6606082015260808101949094523060a0808601919091528151808603909101815260c09094019052825192019190912060035550600580546001600160a01b03191633179055612c1d806101056000396000f3fe608060405234801561001057600080fd5b50600436106101b95760003560e01c80636a627842116100f9578063ba9a7a5611610097578063d21220a711610071578063d21220a7146105da578063d505accf146105e2578063dd62ed3e14610640578063fff6cae91461067b576101b9565b8063ba9a7a5614610597578063bc25cf771461059f578063c45a0155146105d2576101b9565b80637ecebe00116100d35780637ecebe00146104d757806389afcb441461050a57806395d89b4114610556578063a9059cbb1461055e576101b9565b80636a6278421461046957806370a082311461049c5780637464fc3d146104cf576101b9565b806323b872dd116101665780633644e515116101405780633644e51514610416578063485cc9551461041e5780635909c0d5146104595780635a3d549314610461576101b9565b806323b872dd146103ad57806330adf81f146103f0578063313ce567146103f8576101b9565b8063095ea7b311610197578063095ea7b3146103155780630dfe16811461036257806318160ddd14610393576101b9565b8063022c0d9f146101be57806306fdde03146102595780630902f1ac146102d6575b600080fd5b610257600480360360808110156101d457600080fd5b81359160208101359173ffffffffffffffffffffffffffffffffffffffff604083013516919081019060808101606082013564010000000081111561021857600080fd5b82018360208201111561022a57600080fd5b8035906020019184600183028401116401000000008311171561024c57600080fd5b509092509050610683565b005b610261610d57565b6040805160208082528351818301528351919283929083019185019080838360005b8381101561029b578181015183820152602001610283565b50505050905090810190601f1680156102c85780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6102de610d90565b604080516dffffffffffffffffffffffffffff948516815292909316602083015263ffffffff168183015290519081900360600190f35b61034e6004803603604081101561032b57600080fd5b5073ffffffffffffffffffffffffffffffffffffffff8135169060200135610de5565b604080519115158252519081900360200190f35b61036a610dfc565b6040805173ffffffffffffffffffffffffffffffffffffffff9092168252519081900360200190f35b61039b610e18565b60408051918252519081900360200190f35b61034e600480360360608110156103c357600080fd5b5073ffffffffffffffffffffffffffffffffffffffff813581169160208101359091169060400135610e1e565b61039b610efd565b610400610f21565b6040805160ff9092168252519081900360200190f35b61039b610f26565b6102576004803603604081101561043457600080fd5b5073ffffffffffffffffffffffffffffffffffffffff81358116916020013516610f2c565b61039b611005565b61039b61100b565b61039b6004803603602081101561047f57600080fd5b503573ffffffffffffffffffffffffffffffffffffffff16611011565b61039b600480360360208110156104b257600080fd5b503573ffffffffffffffffffffffffffffffffffffffff166113cb565b61039b6113dd565b61039b600480360360208110156104ed57600080fd5b503573ffffffffffffffffffffffffffffffffffffffff166113e3565b61053d6004803603602081101561052057600080fd5b503573ffffffffffffffffffffffffffffffffffffffff166113f5565b6040805192835260208301919091528051918290030190f35b610261611892565b61034e6004803603604081101561057457600080fd5b5073ffffffffffffffffffffffffffffffffffffffff81351690602001356118cb565b61039b6118d8565b610257600480360360208110156105b557600080fd5b503573ffffffffffffffffffffffffffffffffffffffff166118de565b61036a611ad4565b61036a611af0565b610257600480360360e08110156105f857600080fd5b5073ffffffffffffffffffffffffffffffffffffffff813581169160208101359091169060408101359060608101359060ff6080820135169060a08101359060c00135611b0c565b61039b6004803603604081101561065657600080fd5b5073ffffffffffffffffffffffffffffffffffffffff81358116916020013516611dd8565b610257611df5565b600c546001146106f457604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f556e697377617056323a204c4f434b4544000000000000000000000000000000604482015290519081900360640190fd5b6000600c55841515806107075750600084115b61075c576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526025815260200180612b2f6025913960400191505060405180910390fd5b600080610767610d90565b5091509150816dffffffffffffffffffffffffffff168710801561079a5750806dffffffffffffffffffffffffffff1686105b6107ef576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526021815260200180612b786021913960400191505060405180910390fd5b600654600754600091829173ffffffffffffffffffffffffffffffffffffffff91821691908116908916821480159061085457508073ffffffffffffffffffffffffffffffffffffffff168973ffffffffffffffffffffffffffffffffffffffff1614155b6108bf57604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601560248201527f556e697377617056323a20494e56414c49445f544f0000000000000000000000604482015290519081900360640190fd5b8a156108d0576108d0828a8d611fdb565b89156108e1576108e1818a8c611fdb565b86156109c3578873ffffffffffffffffffffffffffffffffffffffff166310d1e85c338d8d8c8c6040518663ffffffff1660e01b8152600401808673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001858152602001848152602001806020018281038252848482818152602001925080828437600081840152601f19601f8201169050808301925050509650505050505050600060405180830381600087803b1580156109aa57600080fd5b505af11580156109be573d6000803e3d6000fd5b505050505b604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905173ffffffffffffffffffffffffffffffffffffffff8416916370a08231916024808301926020929190829003018186803b158015610a2f57600080fd5b505afa158015610a43573d6000803e3d6000fd5b505050506040513d6020811015610a5957600080fd5b5051604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905191955073ffffffffffffffffffffffffffffffffffffffff8316916370a0823191602480820192602092909190829003018186803b158015610acb57600080fd5b505afa158015610adf573d6000803e3d6000fd5b505050506040513d6020811015610af557600080fd5b5051925060009150506dffffffffffffffffffffffffffff85168a90038311610b1f576000610b35565b89856dffffffffffffffffffffffffffff160383035b9050600089856dffffffffffffffffffffffffffff16038311610b59576000610b6f565b89856dffffffffffffffffffffffffffff160383035b90506000821180610b805750600081115b610bd5576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526024815260200180612b546024913960400191505060405180910390fd5b6000610c09610beb84600063ffffffff6121e816565b610bfd876103e863ffffffff6121e816565b9063ffffffff61226e16565b90506000610c21610beb84600063ffffffff6121e816565b9050610c59620f4240610c4d6dffffffffffffffffffffffffffff8b8116908b1663ffffffff6121e816565b9063ffffffff6121e816565b610c69838363ffffffff6121e816565b1015610cd657604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152600c60248201527f556e697377617056323a204b0000000000000000000000000000000000000000604482015290519081900360640190fd5b5050610ce4848488886122e0565b60408051838152602081018390528082018d9052606081018c9052905173ffffffffffffffffffffffffffffffffffffffff8b169133917fd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d8229181900360800190a350506001600c55505050505050505050565b6040518060400160405280600a81526020017f556e69737761702056320000000000000000000000000000000000000000000081525081565b6008546dffffffffffffffffffffffffffff808216926e0100000000000000000000000000008304909116917c0100000000000000000000000000000000000000000000000000000000900463ffffffff1690565b6000610df233848461259c565b5060015b92915050565b60065473ffffffffffffffffffffffffffffffffffffffff1681565b60005481565b73ffffffffffffffffffffffffffffffffffffffff831660009081526002602090815260408083203384529091528120547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff14610ee85773ffffffffffffffffffffffffffffffffffffffff84166000908152600260209081526040808320338452909152902054610eb6908363ffffffff61226e16565b73ffffffffffffffffffffffffffffffffffffffff851660009081526002602090815260408083203384529091529020555b610ef384848461260b565b5060019392505050565b7f6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c981565b601281565b60035481565b60055473ffffffffffffffffffffffffffffffffffffffff163314610fb257604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f556e697377617056323a20464f5242494444454e000000000000000000000000604482015290519081900360640190fd5b6006805473ffffffffffffffffffffffffffffffffffffffff9384167fffffffffffffffffffffffff00000000000000000000000000000000000000009182161790915560078054929093169116179055565b60095481565b600a5481565b6000600c5460011461108457604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f556e697377617056323a204c4f434b4544000000000000000000000000000000604482015290519081900360640190fd5b6000600c81905580611094610d90565b50600654604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905193955091935060009273ffffffffffffffffffffffffffffffffffffffff909116916370a08231916024808301926020929190829003018186803b15801561110e57600080fd5b505afa158015611122573d6000803e3d6000fd5b505050506040513d602081101561113857600080fd5b5051600754604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905192935060009273ffffffffffffffffffffffffffffffffffffffff909216916370a0823191602480820192602092909190829003018186803b1580156111b157600080fd5b505afa1580156111c5573d6000803e3d6000fd5b505050506040513d60208110156111db57600080fd5b505190506000611201836dffffffffffffffffffffffffffff871663ffffffff61226e16565b90506000611225836dffffffffffffffffffffffffffff871663ffffffff61226e16565b9050600061123387876126ec565b600054909150806112705761125c6103e8610bfd611257878763ffffffff6121e816565b612878565b985061126b60006103e86128ca565b6112cd565b6112ca6dffffffffffffffffffffffffffff8916611294868463ffffffff6121e816565b8161129b57fe5b046dffffffffffffffffffffffffffff89166112bd868563ffffffff6121e816565b816112c457fe5b0461297a565b98505b60008911611326576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526028815260200180612bc16028913960400191505060405180910390fd5b6113308a8a6128ca565b61133c86868a8a6122e0565b811561137e5760085461137a906dffffffffffffffffffffffffffff808216916e01000000000000000000000000000090041663ffffffff6121e816565b600b555b6040805185815260208101859052815133927f4c209b5fc8ad50758f13e2e1088ba56a560dff690a1c6fef26394f4c03821c4f928290030190a250506001600c5550949695505050505050565b60016020526000908152604090205481565b600b5481565b60046020526000908152604090205481565b600080600c5460011461146957604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f556e697377617056323a204c4f434b4544000000000000000000000000000000604482015290519081900360640190fd5b6000600c81905580611479610d90565b50600654600754604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905194965092945073ffffffffffffffffffffffffffffffffffffffff9182169391169160009184916370a08231916024808301926020929190829003018186803b1580156114fb57600080fd5b505afa15801561150f573d6000803e3d6000fd5b505050506040513d602081101561152557600080fd5b5051604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905191925060009173ffffffffffffffffffffffffffffffffffffffff8516916370a08231916024808301926020929190829003018186803b15801561159957600080fd5b505afa1580156115ad573d6000803e3d6000fd5b505050506040513d60208110156115c357600080fd5b5051306000908152600160205260408120549192506115e288886126ec565b600054909150806115f9848763ffffffff6121e816565b8161160057fe5b049a5080611614848663ffffffff6121e816565b8161161b57fe5b04995060008b11801561162e575060008a115b611683576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526028815260200180612b996028913960400191505060405180910390fd5b61168d3084612992565b611698878d8d611fdb565b6116a3868d8c611fdb565b604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905173ffffffffffffffffffffffffffffffffffffffff8916916370a08231916024808301926020929190829003018186803b15801561170f57600080fd5b505afa158015611723573d6000803e3d6000fd5b505050506040513d602081101561173957600080fd5b5051604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905191965073ffffffffffffffffffffffffffffffffffffffff8816916370a0823191602480820192602092909190829003018186803b1580156117ab57600080fd5b505afa1580156117bf573d6000803e3d6000fd5b505050506040513d60208110156117d557600080fd5b505193506117e585858b8b6122e0565b811561182757600854611823906dffffffffffffffffffffffffffff808216916e01000000000000000000000000000090041663ffffffff6121e816565b600b555b604080518c8152602081018c9052815173ffffffffffffffffffffffffffffffffffffffff8f169233927fdccd412f0b1252819cb1fd330b93224ca42612892bb3f4f789976e6d81936496929081900390910190a35050505050505050506001600c81905550915091565b6040518060400160405280600681526020017f554e492d5632000000000000000000000000000000000000000000000000000081525081565b6000610df233848461260b565b6103e881565b600c5460011461194f57604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f556e697377617056323a204c4f434b4544000000000000000000000000000000604482015290519081900360640190fd5b6000600c55600654600754600854604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905173ffffffffffffffffffffffffffffffffffffffff9485169490931692611a2b9285928792611a26926dffffffffffffffffffffffffffff169185916370a0823191602480820192602092909190829003018186803b1580156119ee57600080fd5b505afa158015611a02573d6000803e3d6000fd5b505050506040513d6020811015611a1857600080fd5b50519063ffffffff61226e16565b611fdb565b600854604080517f70a082310000000000000000000000000000000000000000000000000000000081523060048201529051611aca9284928792611a26926e01000000000000000000000000000090046dffffffffffffffffffffffffffff169173ffffffffffffffffffffffffffffffffffffffff8616916370a0823191602480820192602092909190829003018186803b1580156119ee57600080fd5b50506001600c5550565b60055473ffffffffffffffffffffffffffffffffffffffff1681565b60075473ffffffffffffffffffffffffffffffffffffffff1681565b42841015611b7b57604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601260248201527f556e697377617056323a20455850495245440000000000000000000000000000604482015290519081900360640190fd5b60035473ffffffffffffffffffffffffffffffffffffffff80891660008181526004602090815260408083208054600180820190925582517f6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c98186015280840196909652958d166060860152608085018c905260a085019590955260c08085018b90528151808603909101815260e0850182528051908301207f19010000000000000000000000000000000000000000000000000000000000006101008601526101028501969096526101228085019690965280518085039096018652610142840180825286519683019690962095839052610162840180825286905260ff89166101828501526101a284018890526101c28401879052519193926101e2808201937fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe081019281900390910190855afa158015611cdc573d6000803e3d6000fd5b50506040517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0015191505073ffffffffffffffffffffffffffffffffffffffff811615801590611d5757508873ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16145b611dc257604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601c60248201527f556e697377617056323a20494e56414c49445f5349474e415455524500000000604482015290519081900360640190fd5b611dcd89898961259c565b505050505050505050565b600260209081526000928352604080842090915290825290205481565b600c54600114611e6657604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f556e697377617056323a204c4f434b4544000000000000000000000000000000604482015290519081900360640190fd5b6000600c55600654604080517f70a082310000000000000000000000000000000000000000000000000000000081523060048201529051611fd49273ffffffffffffffffffffffffffffffffffffffff16916370a08231916024808301926020929190829003018186803b158015611edd57600080fd5b505afa158015611ef1573d6000803e3d6000fd5b505050506040513d6020811015611f0757600080fd5b5051600754604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905173ffffffffffffffffffffffffffffffffffffffff909216916370a0823191602480820192602092909190829003018186803b158015611f7a57600080fd5b505afa158015611f8e573d6000803e3d6000fd5b505050506040513d6020811015611fa457600080fd5b50516008546dffffffffffffffffffffffffffff808216916e0100000000000000000000000000009004166122e0565b6001600c55565b604080518082018252601981527f7472616e7366657228616464726573732c75696e743235362900000000000000602091820152815173ffffffffffffffffffffffffffffffffffffffff85811660248301526044808301869052845180840390910181526064909201845291810180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff167fa9059cbb000000000000000000000000000000000000000000000000000000001781529251815160009460609489169392918291908083835b602083106120e157805182527fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe090920191602091820191016120a4565b6001836020036101000a0380198251168184511680821785525050505050509050019150506000604051808303816000865af19150503d8060008114612143576040519150601f19603f3d011682016040523d82523d6000602084013e612148565b606091505b5091509150818015612176575080511580612176575080806020019051602081101561217357600080fd5b50515b6121e157604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601a60248201527f556e697377617056323a205452414e534645525f4641494c4544000000000000604482015290519081900360640190fd5b5050505050565b60008115806122035750508082028282828161220057fe5b04145b610df657604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f64732d6d6174682d6d756c2d6f766572666c6f77000000000000000000000000604482015290519081900360640190fd5b80820382811115610df657604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601560248201527f64732d6d6174682d7375622d756e646572666c6f770000000000000000000000604482015290519081900360640190fd5b6dffffffffffffffffffffffffffff841180159061230c57506dffffffffffffffffffffffffffff8311155b61237757604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601360248201527f556e697377617056323a204f564552464c4f5700000000000000000000000000604482015290519081900360640190fd5b60085463ffffffff428116917c0100000000000000000000000000000000000000000000000000000000900481168203908116158015906123c757506dffffffffffffffffffffffffffff841615155b80156123e257506dffffffffffffffffffffffffffff831615155b15612492578063ffffffff16612425856123fb86612a57565b7bffffffffffffffffffffffffffffffffffffffffffffffffffffffff169063ffffffff612a7b16565b600980547bffffffffffffffffffffffffffffffffffffffffffffffffffffffff929092169290920201905563ffffffff8116612465846123fb87612a57565b600a80547bffffffffffffffffffffffffffffffffffffffffffffffffffffffff92909216929092020190555b600880547fffffffffffffffffffffffffffffffffffff0000000000000000000000000000166dffffffffffffffffffffffffffff888116919091177fffffffff0000000000000000000000000000ffffffffffffffffffffffffffff166e0100000000000000000000000000008883168102919091177bffffffffffffffffffffffffffffffffffffffffffffffffffffffff167c010000000000000000000000000000000000000000000000000000000063ffffffff871602179283905560408051848416815291909304909116602082015281517f1c411e9a96e071241c2f21f7726b17ae89e3cab4c78be50e062b03a9fffbbad1929181900390910190a1505050505050565b73ffffffffffffffffffffffffffffffffffffffff808416600081815260026020908152604080832094871680845294825291829020859055815185815291517f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9259281900390910190a3505050565b73ffffffffffffffffffffffffffffffffffffffff8316600090815260016020526040902054612641908263ffffffff61226e16565b73ffffffffffffffffffffffffffffffffffffffff8085166000908152600160205260408082209390935590841681522054612683908263ffffffff612abc16565b73ffffffffffffffffffffffffffffffffffffffff80841660008181526001602090815260409182902094909455805185815290519193928716927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef92918290030190a3505050565b600080600560009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1663017e7e586040518163ffffffff1660e01b815260040160206040518083038186803b15801561275757600080fd5b505afa15801561276b573d6000803e3d6000fd5b505050506040513d602081101561278157600080fd5b5051600b5473ffffffffffffffffffffffffffffffffffffffff821615801594509192509061286457801561285f5760006127d86112576dffffffffffffffffffffffffffff88811690881663ffffffff6121e816565b905060006127e583612878565b90508082111561285c576000612813612804848463ffffffff61226e16565b6000549063ffffffff6121e816565b905060006128388361282c86600563ffffffff6121e816565b9063ffffffff612abc16565b9050600081838161284557fe5b04905080156128585761285887826128ca565b5050505b50505b612870565b8015612870576000600b555b505092915050565b600060038211156128bb575080600160028204015b818110156128b5578091506002818285816128a457fe5b0401816128ad57fe5b04905061288d565b506128c5565b81156128c5575060015b919050565b6000546128dd908263ffffffff612abc16565b600090815573ffffffffffffffffffffffffffffffffffffffff8316815260016020526040902054612915908263ffffffff612abc16565b73ffffffffffffffffffffffffffffffffffffffff831660008181526001602090815260408083209490945583518581529351929391927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9281900390910190a35050565b6000818310612989578161298b565b825b9392505050565b73ffffffffffffffffffffffffffffffffffffffff82166000908152600160205260409020546129c8908263ffffffff61226e16565b73ffffffffffffffffffffffffffffffffffffffff831660009081526001602052604081209190915554612a02908263ffffffff61226e16565b600090815560408051838152905173ffffffffffffffffffffffffffffffffffffffff8516917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef919081900360200190a35050565b6dffffffffffffffffffffffffffff166e0100000000000000000000000000000290565b60006dffffffffffffffffffffffffffff82167bffffffffffffffffffffffffffffffffffffffffffffffffffffffff841681612ab457fe5b049392505050565b80820182811015610df657604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f64732d6d6174682d6164642d6f766572666c6f77000000000000000000000000604482015290519081900360640190fdfe556e697377617056323a20494e53554646494349454e545f4f55545055545f414d4f554e54556e697377617056323a20494e53554646494349454e545f494e5055545f414d4f554e54556e697377617056323a20494e53554646494349454e545f4c4951554944495459556e697377617056323a20494e53554646494349454e545f4c49515549444954595f4255524e4544556e697377617056323a20494e53554646494349454e545f4c49515549444954595f4d494e544544a265627a7a723158207dca18479e58487606bf70c79e44d8dee62353c9ee6d01f9a9d70885b8765f2264736f6c63430005100032454950373132446f6d61696e28737472696e67206e616d652c737472696e672076657273696f6e2c75696e7432353620636861696e49642c6164647265737320766572696679696e67436f6e747261637429"} \ No newline at end of file diff --git a/app/vibenet/demos/validity/lib/bots.test.ts b/app/vibenet/demos/validity/lib/bots.test.ts new file mode 100644 index 0000000..6fa03c9 --- /dev/null +++ b/app/vibenet/demos/validity/lib/bots.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { BOT_GAS_FLOOR, BOT_GAS_REFILL, USER_GAS_RESERVE, allNeedGas, botNeedsGas, fractionForPriceMove, makerTargetPrice, planSwap, refuelValue } from './bots'; + +describe('fractionForPriceMove', () => { + it('sizes a 1% price step at about half a percent of reserves', () => { + const fraction = fractionForPriceMove(0.01); + expect(fraction).toBeGreaterThan(0.0045); + expect(fraction).toBeLessThan(0.0056); + }); +}); + +describe('makerTargetPrice', () => { + it('wanders around the VIBE/USDV anchor inside $0.01–$1', () => { + const prices = Array.from({ length: 120 }, (_, i) => makerTargetPrice(i * 250, 0.07)); + expect(Math.max(...prices) / Math.min(...prices)).toBeGreaterThan(1.02); + expect(Math.min(...prices)).toBeGreaterThan(0.01); + expect(Math.max(...prices)).toBeLessThan(1); + expect(prices.some((price) => price < 0.07)).toBe(true); + expect(prices.some((price) => price > 0.07)).toBe(true); + }); +}); + +describe('planSwap', () => { + it('sizes near a 1% price impact', () => { + const plan = planSwap(0.08, 0.07, 0); + expect(plan.fraction).toBeGreaterThan(0.0045); + expect(plan.fraction).toBeLessThan(0.0056); + }); + + it('buys VIBE when the quote is stretched cheap', () => { + expect(planSwap(0.012, 0.07, 0).sellVibe).toBe(false); + }); +}); + +describe('refuelValue', () => { + it('tops a dry maker up to the refill target without taking the trader reserve', () => { + expect(botNeedsGas(0n)).toBe(true); + expect(botNeedsGas(BOT_GAS_FLOOR)).toBe(false); + expect(refuelValue(0n, BOT_GAS_REFILL + USER_GAS_RESERVE)).toBe(BOT_GAS_REFILL); + expect(refuelValue(BOT_GAS_FLOOR, 10n ** 18n)).toBe(0n); + expect(refuelValue(0n, USER_GAS_RESERVE)).toBe(0n); + expect(refuelValue(0n, USER_GAS_RESERVE + 1_000n)).toBe(1_000n); + }); +}); + +describe('allNeedGas', () => { + it('is only true when every maker is below the floor', () => { + expect(allNeedGas([])).toBe(false); + expect(allNeedGas([0n, BOT_GAS_FLOOR])).toBe(false); + expect(allNeedGas([0n, BOT_GAS_FLOOR - 1n])).toBe(true); + }); +}); diff --git a/app/vibenet/demos/validity/lib/bots.ts b/app/vibenet/demos/validity/lib/bots.ts new file mode 100644 index 0000000..91d3d3e --- /dev/null +++ b/app/vibenet/demos/validity/lib/bots.ts @@ -0,0 +1,250 @@ +import { parseEther, type Account, type Address, type PublicClient, type WalletClient } from 'viem'; + +import { amountOut, getReserves, swapExactIn, tokenBalance } from './amm'; +import { + bumpReplacementFees, + isInsufficientFunds, + isNonceTooLow, + isReplacementUnderpriced, + type FeeFields, +} from './fees'; +import { + quoteWad, + swapOuts, + tokenInFor, + usdvReserve, + vibeIsToken0, + vibeReserve, +} from './quote'; +import type { Deployment } from './types'; + +const ANCHOR = 0.07; +const SLOW_PERIOD_MS = 24_000; +const SLOW_AMPLITUDE = 0.05; +const FAST_PERIOD_MS = 3_000; +const FAST_AMPLITUDE = 0.012; +const PRICE_MOVE = 0.01; +const HARD_LO = 0.01; +const HARD_HI = 1; +const TICK_MS = 240; +export const BOT_GAS_FLOOR = parseEther('0.002'); +export const BOT_GAS_REFILL = parseEther('0.03'); +export const USER_GAS_RESERVE = parseEther('0.008'); +const GAS_LOW_MS = 4_000; + +function clamp(n: number, lo: number, hi: number): number { + return Math.min(hi, Math.max(lo, n)); +} + +export function botNeedsGas(balance: bigint, floor = BOT_GAS_FLOOR): boolean { + return balance < floor; +} + +export function allNeedGas(balances: readonly bigint[]): boolean { + return balances.length > 0 && balances.every((balance) => botNeedsGas(balance)); +} + +/** ETH the trader can send a dry maker without stranding their own swaps. */ +export function refuelValue(botBalance: bigint, userBalance: bigint): bigint { + if (!botNeedsGas(botBalance)) return 0n; + const room = userBalance > USER_GAS_RESERVE ? userBalance - USER_GAS_RESERVE : 0n; + if (room === 0n) return 0n; + const target = botBalance >= BOT_GAS_REFILL ? 0n : BOT_GAS_REFILL - botBalance; + if (target === 0n) return 0n; + return target < room ? target : room; +} + +/** + * Reserve-in fraction that moves Uni v2 mid by `move` (0.01 = 1%). + * Because p ∝ 1/r0², a 1% price step is about 0.5% of the input reserve. + */ +export function fractionForPriceMove(move: number): number { + const abs = clamp(Math.abs(move), 0.002, 0.2); + return 1 / Math.sqrt(1 - abs) - 1; +} + +/** Slow ±5% wander around the VIBE/USDV anchor, plus a faster ±1.2% wobble. */ +export function makerTargetPrice(nowMs: number, anchor = ANCHOR): number { + const slow = SLOW_AMPLITUDE * Math.sin((2 * Math.PI * nowMs) / SLOW_PERIOD_MS); + const fast = FAST_AMPLITUDE * Math.sin((2 * Math.PI * nowMs) / FAST_PERIOD_MS + 0.6); + return clamp(anchor * (1 + slow + fast), HARD_LO, HARD_HI); +} + +export function planSwap( + spot: number, + desired: number, + noise: number, +): { sellVibe: boolean; fraction: number } { + const towardSellVibe = desired < spot; + const stretched = + spot <= HARD_LO * 1.2 || spot >= HARD_HI * 0.85 || Math.abs(spot - desired) / Math.max(desired, 1e-9) > 0.07; + let sellVibe: boolean; + if (stretched) { + sellVibe = spot > desired; + } else if (Math.random() < 0.78) { + sellVibe = towardSellVibe; + } else { + sellVibe = !towardSellVibe; + } + const move = PRICE_MOVE * (1 + noise); + return { sellVibe, fraction: fractionForPriceMove(move) }; +} + +/** + * One ~1% swap per block toward a shared USDV/VIBE target. + */ +export function startBots(args: { + publicClient: PublicClient; + wallets: WalletClient[]; + accounts: Account[]; + deployment: Deployment; + enabled: () => boolean; + onPrice?: (price: number) => void; + onError?: (message: string) => void; + onGasLow?: () => void; +}): () => void { + const { publicClient, wallets, accounts, deployment, enabled, onPrice, onError, onGasLow } = args; + let stopped = false; + let timer: ReturnType | undefined; + let turn = 0; + let anchor = ANCHOR; + let anchored = false; + let lastGasLow = 0; + const nonces: Array = []; + const fees: Array = []; + const vibeToken0 = vibeIsToken0(deployment); + + const signalGasLow = () => { + const now = Date.now(); + if (now - lastGasLow < GAS_LOW_MS) return; + lastGasLow = now; + onGasLow?.(); + }; + + const sendSwap = async ( + index: number, + tokenIn: Address, + used: bigint, + sellVibe: boolean, + out: bigint, + ) => { + const outs = swapOuts({ vibeToken0, sellVibe, amountOut: out }); + const attempt = async (nextFees: FeeFields | null | undefined) => + swapExactIn({ + wallet: wallets[index], + publicClient, + account: accounts[index], + pair: deployment.pair, + tokenIn, + amountIn: used, + amount0Out: outs.amount0Out, + amount1Out: outs.amount1Out, + nonce: nonces[index], + fees: nextFees, + waitForReceipt: false, + }); + + try { + return await attempt(fees[index]); + } catch (err) { + if (isReplacementUnderpriced(err)) { + const base = fees[index] ?? { + maxFeePerGas: 3_000_000_000n, + maxPriorityFeePerGas: 1_500_000_000n, + }; + const bumped = bumpReplacementFees(base); + fees[index] = bumped; + return await attempt(bumped); + } + throw err; + } + }; + + const tick = async (index: number) => { + if (stopped || !enabled()) return; + const account = accounts[index]; + const eth = await publicClient.getBalance({ address: account.address }); + if (botNeedsGas(eth)) { + signalGasLow(); + return; + } + const { reserve0, reserve1 } = await getReserves(publicClient, deployment.pair); + if (reserve0 === 0n || reserve1 === 0n) return; + const spot = Number(quoteWad(reserve0, reserve1, vibeToken0)) / 1e18; + if (!Number.isFinite(spot) || spot <= 0) return; + if (!anchored) { + anchor = spot; + anchored = true; + } + const noise = (Math.random() - 0.5) * 0.4; + const { sellVibe, fraction } = planSwap(spot, makerTargetPrice(Date.now(), anchor), noise); + const poolIn = sellVibe + ? vibeReserve(reserve0, reserve1, vibeToken0) + : usdvReserve(reserve0, reserve1, vibeToken0); + const tokenIn = tokenInFor(deployment, sellVibe); + const amountIn = (poolIn * BigInt(Math.floor(fraction * 10_000))) / 10_000n; + if (amountIn === 0n) return; + const bal = await tokenBalance(publicClient, tokenIn, account.address); + const used = amountIn <= bal ? amountIn : (bal * 8n) / 10n; + if (used === 0n) throw new Error('maker inventory empty'); + const reserveIn = poolIn; + const reserveOut = sellVibe + ? usdvReserve(reserve0, reserve1, vibeToken0) + : vibeReserve(reserve0, reserve1, vibeToken0); + const exactOut = amountOut(used, reserveIn, reserveOut); + // 1 wei slack on a 0% fee pair so k stays on the validity hyperbola. + const out = exactOut > 1n ? exactOut - 1n : exactOut; + if (out === 0n) return; + if (nonces[index] === undefined) { + nonces[index] = await publicClient.getTransactionCount({ + address: account.address, + blockTag: 'pending', + }); + } + try { + const result = await sendSwap(index, tokenIn, used, sellVibe, out); + nonces[index] = result.nextNonce; + fees[index] = null; + } catch (err) { + if (isNonceTooLow(err)) nonces[index] = undefined; + if (isInsufficientFunds(err)) { + fees[index] = null; + signalGasLow(); + return; + } + throw err; + } + const nextVibe = sellVibe + ? vibeReserve(reserve0, reserve1, vibeToken0) + used + : vibeReserve(reserve0, reserve1, vibeToken0) - exactOut; + const nextUsdv = sellVibe + ? usdvReserve(reserve0, reserve1, vibeToken0) - exactOut + : usdvReserve(reserve0, reserve1, vibeToken0) + used; + if (nextVibe > 0n && nextUsdv > 0n) { + const next0 = vibeToken0 ? nextVibe : nextUsdv; + const next1 = vibeToken0 ? nextUsdv : nextVibe; + const next = Number(quoteWad(next0, next1, vibeToken0)) / 1e18; + if (Number.isFinite(next) && next > 0) onPrice?.(next); + } + }; + + const loop = async () => { + if (stopped) return; + try { + if (enabled() && accounts.length > 0) await tick(turn % accounts.length); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'maker swap failed'; + onError?.(message.split('\n')[0] ?? message); + } + turn += 1; + if (!stopped) timer = setTimeout(loop, TICK_MS); + }; + timer = setTimeout(loop, 200); + + return () => { + stopped = true; + if (timer) clearTimeout(timer); + }; +} + +export type { Address }; diff --git a/app/vibenet/demos/validity/lib/constants.ts b/app/vibenet/demos/validity/lib/constants.ts new file mode 100644 index 0000000..397cda0 --- /dev/null +++ b/app/vibenet/demos/validity/lib/constants.ts @@ -0,0 +1,56 @@ +import type { Abi, Address, Hex } from 'viem'; + +import erc20Artifact from './artifacts/MintableERC20.json'; +import helperArtifact from './artifacts/SwapHelper.json'; +import factoryArtifact from './artifacts/UniswapV2Factory.json'; +import pairArtifact from './artifacts/UniswapV2Pair.json'; + +function with0x(value: string): Hex { + return (value.startsWith('0x') ? value : `0x${value}`) as Hex; +} + +export const erc20Abi = erc20Artifact.abi as Abi; +export const erc20Bytecode = with0x(erc20Artifact.bytecode); + +export const factoryAbi = factoryArtifact.abi as Abi; +export const factoryBytecode = with0x(factoryArtifact.bytecode); + +export const pairAbi = pairArtifact.abi as Abi; + +export const helperAbi = helperArtifact.abi as Abi; +export const helperBytecode = with0x(helperArtifact.bytecode); + +export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Address; + +export const RPC_PATH = '/api/vibenet/validity/rpc'; +export const STATUS_PATH = '/api/vibenet/validity/status'; + +export const STORAGE_KEY = 'vibenet.validity.v3'; +export const LEGACY_STORAGE_KEYS = ['vibenet.validity.v2', 'vibenet.validity.v1'] as const; + +export const WAD = 10n ** 18n; +/** ~$0.07 USDV per VIBE so the tape has room to move, not a 1:1 peg. */ +export const SEED_VIBE = 2_000_000n * WAD; +export const SEED_USDV = 140_000n * WAD; +export const TRADER_VIBE = 400_000n * WAD; +export const TRADER_USDV = 40_000n * WAD; +export const PAIR_RESERVES_SLOT = 8n; +export const RESERVE_BITS = 112n; +export const RESERVE0_MASK = (1n << RESERVE_BITS) - 1n; +export const RESERVE1_MASK = RESERVE0_MASK << RESERVE_BITS; + +export const MAX_EXPIRY_SECONDS = 60; +/** + * Canonical L2 block time. `block_number` predicates and mempool eviction + * (`expire_by_block`) are on committed L2 blocks, not 250ms flashblocks. + * Using 0.25s here made a 60s UI timer last ~8 minutes in the pool. + */ +export const BLOCK_SECONDS = 2; + +/** + * Finite box span around the target point on the current hyperbola. + * Far edge is this multiple of the near edge (6%). The pair is 0% fee so k + * does not walk off this patch while makers move price through the line. + */ +export const BOX_SPAN_NUM = 53n; +export const BOX_SPAN_DEN = 50n; diff --git a/app/vibenet/demos/validity/lib/faucet.ts b/app/vibenet/demos/validity/lib/faucet.ts new file mode 100644 index 0000000..bd2aed5 --- /dev/null +++ b/app/vibenet/demos/validity/lib/faucet.ts @@ -0,0 +1,38 @@ +import type { Address } from 'viem'; + +import { VibenetApiError, vibenetApi } from '../../../library/client'; + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +export function faucetErrorMessage(err: unknown): string { + if (err instanceof VibenetApiError) { + if (err.status === 429) return 'Faucet rate limited — wait a minute and try again.'; + return err.message; + } + return err instanceof Error ? err.message : 'Faucet request failed.'; +} + +/** Drip Vibenet ETH and wait until the address shows a balance. */ +export async function seedEthFromFaucet( + address: Address, + getBalance: () => Promise, +): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + await vibenetApi.faucet.drip({ address }); + break; + } catch (err) { + if (attempt >= 3) throw err; + await sleep(11_000); + } + } + for (let i = 0; i < 30; i += 1) { + if ((await getBalance()) > 0n) return; + await sleep(2_000); + } + throw new Error('Faucet drip submitted, but ETH has not landed yet. Try again in a minute.'); +} diff --git a/app/vibenet/demos/validity/lib/fees.test.ts b/app/vibenet/demos/validity/lib/fees.test.ts new file mode 100644 index 0000000..64b9541 --- /dev/null +++ b/app/vibenet/demos/validity/lib/fees.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { bumpReplacementFees, isInsufficientFunds, isReplacementUnderpriced } from './fees'; + +describe('bumpReplacementFees', () => { + it('raises tip and fee cap by at least 10%', () => { + const prev = { maxFeePerGas: 1_000n, maxPriorityFeePerGas: 100n }; + const next = bumpReplacementFees(prev); + expect(next.maxFeePerGas * 10n).toBeGreaterThanOrEqual(prev.maxFeePerGas * 11n); + expect(next.maxPriorityFeePerGas * 10n).toBeGreaterThanOrEqual(prev.maxPriorityFeePerGas * 11n); + }); + + it('takes the higher of the bump and the latest network fees', () => { + const prev = { maxFeePerGas: 1_000n, maxPriorityFeePerGas: 100n }; + const latest = { maxFeePerGas: 5_000n, maxPriorityFeePerGas: 800n }; + expect(bumpReplacementFees(prev, latest)).toEqual(latest); + }); +}); + +describe('isReplacementUnderpriced', () => { + it('matches geth-style replacement errors', () => { + expect(isReplacementUnderpriced(new Error('replacement transaction underpriced'))).toBe(true); + expect(isReplacementUnderpriced(new Error('nonce too low'))).toBe(false); + }); +}); + +describe('isInsufficientFunds', () => { + it('matches common eth_sendRawTransaction balance errors', () => { + expect(isInsufficientFunds(new Error('insufficient funds for gas * price + value'))).toBe(true); + expect(isInsufficientFunds(new Error('nonce too low'))).toBe(false); + }); +}); diff --git a/app/vibenet/demos/validity/lib/fees.ts b/app/vibenet/demos/validity/lib/fees.ts new file mode 100644 index 0000000..6678cf6 --- /dev/null +++ b/app/vibenet/demos/validity/lib/fees.ts @@ -0,0 +1,44 @@ +export type FeeFields = { + maxFeePerGas: bigint; + maxPriorityFeePerGas: bigint; +}; + +/** Geth/OP mempools require ≥10% higher tip and fee cap to replace. 12.5% + 1 wei. */ +const BUMP_NUM = 9n; +const BUMP_DEN = 8n; + +function bump(value: bigint): bigint { + return (value * BUMP_NUM) / BUMP_DEN + 1n; +} + +export function bumpReplacementFees(previous: FeeFields, latest?: FeeFields | null): FeeFields { + const tipFloor = bump(previous.maxPriorityFeePerGas); + const maxFloor = bump(previous.maxFeePerGas); + const maxPriorityFeePerGas = + latest && latest.maxPriorityFeePerGas > tipFloor ? latest.maxPriorityFeePerGas : tipFloor; + let maxFeePerGas = latest && latest.maxFeePerGas > maxFloor ? latest.maxFeePerGas : maxFloor; + if (maxFeePerGas < maxPriorityFeePerGas) maxFeePerGas = maxPriorityFeePerGas; + return { maxFeePerGas, maxPriorityFeePerGas }; +} + +export function isReplacementUnderpriced(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return /replacement transaction underpriced|underpriced replacement/i.test(message); +} + +export function isNonceTooLow(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return /nonce too low/i.test(message); +} + +export function isInsufficientFunds(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return /insufficient funds|insufficient balance|exceeds the balance/i.test(message); +} + +export function padFees(fees: FeeFields, mul = 3n): FeeFields { + return { + maxFeePerGas: fees.maxFeePerGas * mul, + maxPriorityFeePerGas: fees.maxPriorityFeePerGas * mul, + }; +} diff --git a/app/vibenet/demos/validity/lib/orders.test.ts b/app/vibenet/demos/validity/lib/orders.test.ts new file mode 100644 index 0000000..a14b03d --- /dev/null +++ b/app/vibenet/demos/validity/lib/orders.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { occupyingOrder, maxBlockForExpiry, orderBlockExpired, orderWallClockExpired, restingOrderToReplace, tapeCrossedAt } from './orders'; + +describe('orderWallClockExpired', () => { + it('expires a resting order after the window plus grace', () => { + const order = { status: 'pending' as const, submittedAt: 1_000, expirySeconds: 5 }; + expect(orderWallClockExpired(order, 1_000 + 5_000 + 1_000)).toBe(false); + expect(orderWallClockExpired(order, 1_000 + 5_000 + 2_001)).toBe(true); + }); + + it('does not expire fills', () => { + const order = { status: 'filled' as const, submittedAt: 1_000, expirySeconds: 5 }; + expect(orderWallClockExpired(order, 1_000 + 60_000)).toBe(false); + }); +}); + +describe('orderBlockExpired', () => { + it('expires once the chain is past maxBlock', () => { + const order = { status: 'pending' as const, maxBlock: 100n }; + expect(orderBlockExpired(order, 100n)).toBe(false); + expect(orderBlockExpired(order, 101n)).toBe(true); + }); +}); + +describe('maxBlockForExpiry', () => { + it('uses ~2s L2 blocks, not flashblock cadence', () => { + expect(maxBlockForExpiry(1_000n, 60)).toBe(1_030n); + expect(maxBlockForExpiry(1_000n, 5)).toBe(1_003n); + }); +}); + +describe('tapeCrossedAt', () => { + it('uses the first print on the fill side, not a later wick', () => { + const samples = [ + { t: 1_000, price: 0.083 }, + { t: 2_000, price: 0.0816 }, + { t: 3_000, price: 0.0828 }, + ]; + expect(tapeCrossedAt(samples, 1_500, 0.0816, 'buy')).toBe(2_000); + }); + + it('ignores prints before submit', () => { + const samples = [ + { t: 1_000, price: 0.08 }, + { t: 3_000, price: 0.082 }, + ]; + expect(tapeCrossedAt(samples, 2_000, 0.081, 'sell')).toBe(3_000); + }); +}); + +const fees = { nonce: 3, maxFeePerGas: 1n, maxPriorityFeePerGas: 1n }; + +describe('occupyingOrder', () => { + it('finds an expired order that may still hold the nonce', () => { + const expired = { id: 'e', status: 'expired' as const, ...fees }; + expect(occupyingOrder([expired], 3)?.id).toBe('e'); + }); +}); + +describe('restingOrderToReplace', () => { + it('replaces only an active resting order', () => { + const pending = { id: 'p', status: 'pending' as const, ...fees }; + const expired = { id: 'e', status: 'expired' as const, ...fees }; + expect(restingOrderToReplace([pending], 3)?.id).toBe('p'); + expect(restingOrderToReplace([expired], 3)).toBeUndefined(); + }); +}); diff --git a/app/vibenet/demos/validity/lib/orders.ts b/app/vibenet/demos/validity/lib/orders.ts new file mode 100644 index 0000000..8ef3169 --- /dev/null +++ b/app/vibenet/demos/validity/lib/orders.ts @@ -0,0 +1,69 @@ +import { BLOCK_SECONDS } from './constants'; +import type { PlacedOrder, Side } from './types'; + +const WALL_CLOCK_GRACE_MS = 2_000; + +export function orderWallClockExpired( + order: Pick, + now = Date.now(), +): boolean { + if (order.status !== 'pending') return false; + return now > order.submittedAt + order.expirySeconds * 1000 + WALL_CLOCK_GRACE_MS; +} + +export function orderBlockExpired( + order: Pick, + block: bigint, +): boolean { + return order.status === 'pending' && order.maxBlock !== undefined && block > order.maxBlock; +} + +/** Inclusive last L2 block the mempool will still hold this validity tx. */ +export function maxBlockForExpiry(currentBlock: bigint, expirySeconds: number): bigint { + const seconds = Math.max(1, expirySeconds); + const blocks = Math.max(1, Math.ceil(seconds / BLOCK_SECONDS)); + return currentBlock + BigInt(blocks); +} + +/** First tape print on the fill side of the limit after submit — not when the receipt lagged in. */ +export function tapeCrossedAt( + samples: { t: number; price: number }[], + submittedAt: number, + target: number, + side: Side, +): number | undefined { + if (!Number.isFinite(target) || target <= 0) return undefined; + for (const sample of samples) { + if (sample.t < submittedAt) continue; + const hit = side === 'buy' ? sample.price <= target : sample.price >= target; + if (hit) return sample.t; + } + return undefined; +} + +export function occupyingOrder( + orders: Pick[], + nonce: number, +): (typeof orders)[number] | undefined { + return orders.find( + (order) => + order.nonce === nonce && + (order.status === 'pending' || order.status === 'expired') && + order.maxFeePerGas !== undefined && + order.maxPriorityFeePerGas !== undefined, + ); +} + +/** UI replacement only. Expired stays expired even if we bump fees over its pooled nonce. */ +export function restingOrderToReplace( + orders: Pick[], + nonce: number, +): (typeof orders)[number] | undefined { + return orders.find( + (order) => + order.nonce === nonce && + order.status === 'pending' && + order.maxFeePerGas !== undefined && + order.maxPriorityFeePerGas !== undefined, + ); +} diff --git a/app/vibenet/demos/validity/lib/predicates.test.ts b/app/vibenet/demos/validity/lib/predicates.test.ts new file mode 100644 index 0000000..7bb42be --- /dev/null +++ b/app/vibenet/demos/validity/lib/predicates.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; + +import { PAIR_RESERVES_SLOT, RESERVE0_MASK, RESERVE1_MASK, RESERVE_BITS, WAD } from './constants'; +import { + applyOffsetBps, + formatCompactHex, + formatPrice, + prettyValidity, + priceValidity, + priceWad, + rectangleForTarget, + sqrt, + toWord, +} from './predicates'; + +const PAIR = '0x1111111111111111111111111111111111111111'; + +describe('predicates', () => { + it('integer-square-roots perfect and imperfect squares', () => { + expect(sqrt(0n)).toBe(0n); + expect(sqrt(1n)).toBe(1n); + expect(sqrt(9n)).toBe(3n); + expect(sqrt(10n)).toBe(3n); + expect(sqrt(100n * WAD * WAD)).toBe(10n * WAD); + }); + + it('formats wad prices', () => { + expect(formatPrice(WAD)).toBe('1.0000'); + expect(formatPrice(99n * 10n ** 16n)).toBe('0.9900'); + }); + + it('formats compact hex without leading zeros', () => { + expect(formatCompactHex(0n)).toBe('0x0'); + expect(formatCompactHex(PAIR_RESERVES_SLOT)).toBe('0x8'); + expect(formatCompactHex(255n)).toBe('0xff'); + }); + + it('offsets spot in basis points for buy and sell', () => { + expect(applyOffsetBps(WAD, 'buy', 100)).toBe((99n * WAD) / 100n); + expect(applyOffsetBps(WAD, 'sell', 100)).toBe((101n * WAD) / 100n); + expect(applyOffsetBps(WAD, 'buy', 0)).toBe(WAD); + expect(applyOffsetBps(WAD, 'sell', 0)).toBe(WAD); + }); + + it('buy box implies every corner has price ≤ P', () => { + const k = 1_000n * WAD * (1_000n * WAD); + const target = (99n * WAD) / 100n; + const box = rectangleForTarget(k, target, 'buy'); + const worst = (box.r1Max * WAD) / box.r0Min; + expect(worst).toBeLessThanOrEqual(target); + expect(box.r0Max).toBeGreaterThan(box.r0Min); + expect(box.r1Max).toBeGreaterThan(box.r1Min); + expect((box.r0Max * 1000n) / box.r0Min).toBeGreaterThanOrEqual(1050n); + expect((box.r0Max * 1000n) / box.r0Min).toBeLessThanOrEqual(1070n); + + const { predicates } = priceValidity(PAIR, k, target, 'buy'); + expect(predicates).toHaveLength(4); + expect(predicates[0]).toMatchObject({ + type: 'storage', + params: { address: PAIR, op: '>=', mask: toWord(RESERVE0_MASK) }, + }); + expect(predicates[1].params.op).toBe('<='); + expect(predicates[1].params.mask).toBe(toWord(RESERVE0_MASK)); + expect(predicates[2].params.op).toBe('>='); + expect(predicates[2].params.mask).toBe(toWord(RESERVE1_MASK)); + expect(predicates[3].params.op).toBe('<='); + const r1MaxValue = BigInt(predicates[3].params.value); + expect(r1MaxValue).toBe(box.r1Max << RESERVE_BITS); + expect((r1MaxValue & ~RESERVE1_MASK) === 0n).toBe(true); + }); + + it('sell box implies every corner has price ≥ P', () => { + const k = 1_000n * WAD * (1_000n * WAD); + const target = (101n * WAD) / 100n; + const box = rectangleForTarget(k, target, 'sell'); + const worst = (box.r1Min * WAD) / box.r0Max; + expect(worst).toBeGreaterThanOrEqual(target); + expect(box.r0Max).toBeGreaterThan(box.r0Min); + expect(box.r1Max).toBeGreaterThan(box.r1Min); + + const { predicates } = priceValidity(PAIR, k, target, 'sell'); + expect(predicates).toHaveLength(4); + expect(predicates[0].params.op).toBe('>='); + expect(predicates[1].params.op).toBe('<='); + expect(predicates[2].params.op).toBe('>='); + expect(predicates[3].params.op).toBe('<='); + }); + + it('pretty-prints validity JSON with compact hex', () => { + const k = 1_000n * WAD * (1_000n * WAD); + const { predicates } = priceValidity(PAIR, k, WAD, 'buy'); + const pretty = prettyValidity(predicates); + expect(pretty).toContain('"slot": "0x8"'); + expect(pretty).not.toContain('0x00000000'); + }); + + it('spot price is reserve1/reserve0 in wad', () => { + expect(priceWad(100n, 99n)).toBe((99n * WAD) / 100n); + }); +}); diff --git a/app/vibenet/demos/validity/lib/predicates.ts b/app/vibenet/demos/validity/lib/predicates.ts new file mode 100644 index 0000000..3f005a2 --- /dev/null +++ b/app/vibenet/demos/validity/lib/predicates.ts @@ -0,0 +1,181 @@ +import type { Address, Hex } from 'viem'; + +import { + BOX_SPAN_DEN, + BOX_SPAN_NUM, + PAIR_RESERVES_SLOT, + RESERVE0_MASK, + RESERVE1_MASK, + RESERVE_BITS, + WAD, +} from './constants'; +import type { + Rectangle, + Side, + StoragePredicate, + ValidityOperator, + ValidityPredicate, +} from './types'; + +export function toWord(value: bigint): Hex { + if (value < 0n) throw new Error('toWord: negative value'); + const hex = value.toString(16); + if (hex.length > 64) throw new Error('toWord: value exceeds 32 bytes'); + return `0x${hex.padStart(64, '0')}` as Hex; +} + +export function sqrt(n: bigint): bigint { + if (n < 0n) throw new Error('sqrt of negative'); + if (n < 2n) return n; + let x0 = n; + let x1 = (n >> 1n) + 1n; + while (x1 < x0) { + x0 = x1; + x1 = (x1 + n / x1) >> 1n; + } + return x0; +} + +export function priceWad(reserve0: bigint, reserve1: bigint): bigint { + if (reserve0 === 0n) return 0n; + return (reserve1 * WAD) / reserve0; +} + +export function formatPrice(wad: bigint, digits = 4): string { + const negative = wad < 0n; + const abs = negative ? -wad : wad; + const int = abs / WAD; + const frac = (abs % WAD).toString().padStart(18, '0').slice(0, digits); + return `${negative ? '-' : ''}${int.toString()}.${frac}`; +} + +/** Apply a basis-point offset to spot. Buy is below (`-bps`), sell is above (`+bps`). 0 is at mid. */ +export function applyOffsetBps(spotWad: bigint, side: Side, offsetBps: number): bigint { + if (spotWad <= 0n) throw new Error('Need a live mid price.'); + if (!Number.isInteger(offsetBps) || offsetBps < 0 || offsetBps >= 10_000) { + throw new Error('Offset must be inside [0, 100%).'); + } + if (offsetBps === 0) return spotWad; + const bps = BigInt(offsetBps); + if (side === 'buy') return (spotWad * (10_000n - bps)) / 10_000n || 1n; + return (spotWad * (10_000n + bps)) / 10_000n; +} + +export function formatCompactHex(value: bigint): string { + if (value < 0n) throw new Error('formatCompactHex: negative value'); + return `0x${value.toString(16)}`; +} + +export function compactHexString(hex: string): string { + if (!/^0x[0-9a-fA-F]+$/i.test(hex)) return hex; + // Only collapse padded 32-byte words. Leave addresses and other hex alone. + if (hex.length !== 66) return hex; + const body = hex.slice(2).replace(/^0+/, ''); + return `0x${body.length ? body.toLowerCase() : '0'}`; +} + +export function prettyValidity(predicates: ValidityPredicate[]): string { + const walk = (value: unknown): unknown => { + if (typeof value === 'string' && /^0x[0-9a-fA-F]+$/.test(value)) return compactHexString(value); + if (Array.isArray(value)) return value.map(walk); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, walk(nested)])); + } + return value; + }; + return JSON.stringify(walk(predicates), null, 2); +} + +/** + * Finite reserve box around the target point on the current hyperbola. + * + * buy (price ≤ P): A ≤ r0 ≤ A·s ∧ B/s ≤ r1 ≤ B with B/A ≤ P + * sell (price ≥ P): A/s ≤ r0 ≤ A ∧ B ≤ r1 ≤ B·s with B/A ≥ P + * + * Four storage predicates, so a drained or wildly expanded pool cannot fill. + */ +export function rectangleForTarget(k: bigint, targetPriceWad: bigint, side: Side): Rectangle { + if (k === 0n || targetPriceWad <= 0n) { + throw new Error('Need a live pool and a positive target price.'); + } + const a = sqrt((k * WAD) / targetPriceWad); + if (a === 0n) throw new Error('Degenerate reserve bound.'); + if (side === 'buy') { + const b = (a * targetPriceWad) / WAD || 1n; + const r0Max = (a * BOX_SPAN_NUM) / BOX_SPAN_DEN; + const r1Min = (b * BOX_SPAN_DEN) / BOX_SPAN_NUM; + return { + r0Min: a, + r0Max: r0Max > a ? r0Max : a + 1n, + r1Min: r1Min < b ? r1Min : 1n, + r1Max: b, + side, + }; + } + const b = (a * targetPriceWad + WAD - 1n) / WAD; + const r0Min = (a * BOX_SPAN_DEN) / BOX_SPAN_NUM; + const r1Max = (b * BOX_SPAN_NUM) / BOX_SPAN_DEN; + return { + r0Min: r0Min < a ? r0Min : 1n, + r0Max: a, + r1Min: b, + r1Max: r1Max > b ? r1Max : b + 1n, + side, + }; +} + +export function storagePredicate( + address: Address, + slot: bigint, + mask: bigint, + op: ValidityOperator, + value: bigint, +): StoragePredicate { + if ((value & ~mask) !== 0n) { + throw new Error('Storage predicate value has bits outside its mask.'); + } + return { + type: 'storage', + params: { + address, + slot: toWord(slot), + mask: toWord(mask), + op, + value: toWord(value), + }, + }; +} + +export function priceValidity( + pair: Address, + k: bigint, + targetPriceWad: bigint, + side: Side, +): { rectangle: Rectangle; predicates: ValidityPredicate[] } { + const rectangle = rectangleForTarget(k, targetPriceWad, side); + const r1MinValue = rectangle.r1Min << RESERVE_BITS; + const r1MaxValue = rectangle.r1Max << RESERVE_BITS; + const predicates: ValidityPredicate[] = [ + storagePredicate(pair, PAIR_RESERVES_SLOT, RESERVE0_MASK, '>=', rectangle.r0Min), + storagePredicate(pair, PAIR_RESERVES_SLOT, RESERVE0_MASK, '<=', rectangle.r0Max), + storagePredicate(pair, PAIR_RESERVES_SLOT, RESERVE1_MASK, '>=', r1MinValue), + storagePredicate(pair, PAIR_RESERVES_SLOT, RESERVE1_MASK, '<=', r1MaxValue), + ]; + return { rectangle, predicates }; +} + +export function blockExpiryPredicate(maxBlock: bigint): ValidityPredicate { + return { + type: 'block_number', + params: { op: '<=', value: toWord(maxBlock) }, + }; +} + +export function sideFromPrices(spotWad: bigint, targetWad: bigint): Side { + return targetWad <= spotWad ? 'buy' : 'sell'; +} + +/** True when current spot is on the fill side of the target (inclusive). */ +export function spotPastTarget(spotWad: bigint, targetWad: bigint, side: Side): boolean { + return side === 'buy' ? spotWad <= targetWad : spotWad >= targetWad; +} diff --git a/app/vibenet/demos/validity/lib/quote.test.ts b/app/vibenet/demos/validity/lib/quote.test.ts new file mode 100644 index 0000000..c57fc48 --- /dev/null +++ b/app/vibenet/demos/validity/lib/quote.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { WAD } from './constants'; +import { ammPriceFromQuote, ammSide, clampToCondition, quoteFromPreSwapReserves, quoteFromSwapAmounts, quoteWad, swapOuts, vibeIsToken0 } from './quote'; + +const deployment = { + tokenA: '0x000000000000000000000000000000000000000a' as const, + token0: '0x000000000000000000000000000000000000000a' as const, + token1: '0x000000000000000000000000000000000000000b' as const, +}; + +describe('quote', () => { + it('treats tokenA as VIBE', () => { + expect(vibeIsToken0(deployment)).toBe(true); + expect(vibeIsToken0({ ...deployment, token0: deployment.token1 })).toBe(false); + }); + + it('quotes USDV per VIBE regardless of Uni v2 sort', () => { + const vibe = 2_000_000n; + const usdv = 140_000n; + expect(quoteWad(vibe, usdv, true)).toBe((usdv * WAD) / vibe); + expect(quoteWad(usdv, vibe, false)).toBe((usdv * WAD) / vibe); + }); + + it('round-trips quote ↔ AMM price when VIBE is token1', () => { + const quote = (7n * WAD) / 100n; + const amm = ammPriceFromQuote(quote, false); + expect(ammPriceFromQuote(amm, false)).toBe(quote); + expect(ammSide('buy', false)).toBe('sell'); + }); + + it('sends USDV out when dumping VIBE and VIBE is token0', () => { + expect(swapOuts({ vibeToken0: true, sellVibe: true, amountOut: 5n })).toEqual({ + amount0Out: 0n, + amount1Out: 5n, + }); + }); + + it('quotes a buy from Swap in/out amounts', () => { + // Pay 816 USDV, receive 10_000 VIBE → $0.0816 + expect( + quoteFromSwapAmounts({ + vibeToken0: true, + amount0In: 0n, + amount1In: 816n, + amount0Out: 10_000n, + amount1Out: 0n, + }), + ).toBe((816n * WAD) / 10_000n); + }); + + it('quotes a sell when VIBE is token1', () => { + expect( + quoteFromSwapAmounts({ + vibeToken0: false, + amount0In: 0n, + amount1In: 10_000n, + amount0Out: 816n, + amount1Out: 0n, + }), + ).toBe((816n * WAD) / 10_000n); + }); + + it('reconstructs the pre-swap mid from Sync + Swap amounts', () => { + const pre0 = 1_000n; + const pre1 = 70n; + const amount0Out = 10n; + const amount1In = 8n; + const quote = quoteFromPreSwapReserves({ + vibeToken0: true, + postReserve0: pre0 - amount0Out, + postReserve1: pre1 + amount1In, + amount0In: 0n, + amount1In, + amount0Out, + amount1Out: 0n, + }); + expect(quote).toBe((pre1 * WAD) / pre0); + }); + + it('clamps a buy to the condition so impact cannot plot above the line', () => { + const target = 703n * 10n ** 15n; + const worse = 707n * 10n ** 15n; + const better = 700n * 10n ** 15n; + expect(clampToCondition('buy', worse, target)).toBe(target); + expect(clampToCondition('buy', better, target)).toBe(better); + expect(clampToCondition('sell', 690n * 10n ** 15n, target)).toBe(target); + }); +}); diff --git a/app/vibenet/demos/validity/lib/quote.ts b/app/vibenet/demos/validity/lib/quote.ts new file mode 100644 index 0000000..7a1fd05 --- /dev/null +++ b/app/vibenet/demos/validity/lib/quote.ts @@ -0,0 +1,105 @@ +import type { Address } from 'viem'; + +import { WAD } from './constants'; +import { priceWad } from './predicates'; +import type { Deployment, Side } from './types'; + +export const VIBE_NAME = 'VIBE'; +export const VIBE_SYMBOL = 'VIBE'; +export const USDV_NAME = 'Vibe USD'; +export const USDV_SYMBOL = 'USDV'; + +export function vibeIsToken0(deployment: Pick): boolean { + return deployment.token0.toLowerCase() === deployment.tokenA.toLowerCase(); +} + +/** USDV per VIBE. tokenA is always VIBE, tokenB is always USDV. */ +export function quoteWad( + reserve0: bigint, + reserve1: bigint, + vibeToken0: boolean, +): bigint { + return vibeToken0 ? priceWad(reserve0, reserve1) : priceWad(reserve1, reserve0); +} + +export function ammPriceFromQuote(quote: bigint, vibeToken0: boolean): bigint { + if (vibeToken0) return quote; + if (quote === 0n) return 0n; + return (WAD * WAD) / quote; +} + +export function ammSide(side: Side, vibeToken0: boolean): Side { + if (vibeToken0) return side; + return side === 'buy' ? 'sell' : 'buy'; +} + +export function quoteFromAmmPrice(amm: bigint, vibeToken0: boolean): bigint { + return ammPriceFromQuote(amm, vibeToken0); +} + +export function vibeReserve(reserve0: bigint, reserve1: bigint, vibeToken0: boolean): bigint { + return vibeToken0 ? reserve0 : reserve1; +} + +export function usdvReserve(reserve0: bigint, reserve1: bigint, vibeToken0: boolean): bigint { + return vibeToken0 ? reserve1 : reserve0; +} + +export function swapOuts(args: { + vibeToken0: boolean; + sellVibe: boolean; + amountOut: bigint; +}): { amount0Out: bigint; amount1Out: bigint } { + const { vibeToken0, sellVibe, amountOut } = args; + if (sellVibe) { + return vibeToken0 + ? { amount0Out: 0n, amount1Out: amountOut } + : { amount0Out: amountOut, amount1Out: 0n }; + } + return vibeToken0 + ? { amount0Out: amountOut, amount1Out: 0n } + : { amount0Out: 0n, amount1Out: amountOut }; +} + +export function tokenInFor(deployment: Deployment, sellVibe: boolean): Address { + return sellVibe ? deployment.tokenA : deployment.tokenB; +} + +/** USDV per VIBE from a Uni v2 Swap's in/out amounts. */ +export function quoteFromSwapAmounts(args: { + vibeToken0: boolean; + amount0In: bigint; + amount1In: bigint; + amount0Out: bigint; + amount1Out: bigint; +}): bigint | undefined { + const vibeIn = args.vibeToken0 ? args.amount0In : args.amount1In; + const vibeOut = args.vibeToken0 ? args.amount0Out : args.amount1Out; + const usdvIn = args.vibeToken0 ? args.amount1In : args.amount0In; + const usdvOut = args.vibeToken0 ? args.amount1Out : args.amount0Out; + if (vibeOut > 0n && usdvIn > 0n) return (usdvIn * WAD) / vibeOut; + if (vibeIn > 0n && usdvOut > 0n) return (usdvOut * WAD) / vibeIn; + return undefined; +} + +/** Mid before a Swap, reconstructed from post-swap Sync + Swap amounts. */ +export function quoteFromPreSwapReserves(args: { + vibeToken0: boolean; + postReserve0: bigint; + postReserve1: bigint; + amount0In: bigint; + amount1In: bigint; + amount0Out: bigint; + amount1Out: bigint; +}): bigint | undefined { + const r0 = args.postReserve0 + args.amount0Out - args.amount0In; + const r1 = args.postReserve1 + args.amount1Out - args.amount1In; + if (r0 <= 0n || r1 <= 0n) return undefined; + return quoteWad(r0, r1, args.vibeToken0); +} + +/** Never plot a buy above the condition or a sell below it. */ +export function clampToCondition(side: Side, quote: bigint, target: bigint): bigint { + if (side === 'buy') return quote <= target ? quote : target; + return quote >= target ? quote : target; +} diff --git a/app/vibenet/demos/validity/lib/rpc.test.ts b/app/vibenet/demos/validity/lib/rpc.test.ts new file mode 100644 index 0000000..ddafa4c --- /dev/null +++ b/app/vibenet/demos/validity/lib/rpc.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { describeValidityError } from './rpc'; + +describe('describeValidityError', () => { + it('collapses viem method-not-found dumps into one sentence', () => { + const dump = [ + 'The method "base_sendRawTransactionValidity" does not exist / is not available.', + '', + 'URL: /api/vibenet/validity/rpc', + 'Request body: {"method":"base_sendRawTransactionValidity","params":[{"tx":"0x02"}]}', + 'Details: Method not found', + ].join('\n'); + expect(describeValidityError(new Error(dump))).toMatch(/does not expose base_sendRawTransactionValidity/); + expect(describeValidityError(new Error(dump))).not.toMatch(/Request body/); + }); + + it('keeps a short unrelated error', () => { + expect(describeValidityError(new Error('Not enough token inventory to swap.'))).toBe( + 'Not enough token inventory to swap.', + ); + }); + + it('unwraps viem Missing or invalid parameters to the RPC details', () => { + const err = Object.assign(new Error('Missing or invalid parameters.\n\nURL: /rpc\nDetails: storage predicate at index 2 has value bits set outside its mask'), { + shortMessage: 'Missing or invalid parameters', + details: 'storage predicate at index 2 has value bits set outside its mask', + }); + expect(describeValidityError(err)).toBe( + 'storage predicate at index 2 has value bits set outside its mask', + ); + }); +}); diff --git a/app/vibenet/demos/validity/lib/rpc.ts b/app/vibenet/demos/validity/lib/rpc.ts new file mode 100644 index 0000000..8339c4c --- /dev/null +++ b/app/vibenet/demos/validity/lib/rpc.ts @@ -0,0 +1,93 @@ +import { + createPublicClient, + createWalletClient, + http, + type Account, + type Chain, + type Hex, + type PublicClient, + type WalletClient, +} from 'viem'; + +import { RPC_PATH, STATUS_PATH } from './constants'; +import type { ChainStatus, ValidityPredicate } from './types'; + +export const PROXY_TRANSPORT = http(RPC_PATH); + +export function chainFromId(id: number): Chain { + const name = + id === 84538453 ? 'Vibenet' : id === 763360 ? 'Base Zeronet' : id === 1337 ? 'Local devnet' : `Chain ${id}`; + return { + id, + name, + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: [typeof window === 'undefined' ? 'http://127.0.0.1:8545' : RPC_PATH] } }, + }; +} + +export function makePublicClient(chain: Chain): PublicClient { + return createPublicClient({ chain, transport: PROXY_TRANSPORT, cacheTime: 0 }); +} + +export function makeWalletClient(chain: Chain, account: Account): WalletClient { + return createWalletClient({ chain, account, transport: PROXY_TRANSPORT }); +} + +export async function fetchChainStatus(): Promise { + const response = await fetch(STATUS_PATH, { cache: 'no-store' }); + if (!response.ok) { + throw new Error(`Status ${response.status}`); + } + return (await response.json()) as ChainStatus; +} + +export function describeValidityError(err: unknown): string { + const record = err as { + shortMessage?: string; + details?: string; + message?: string; + cause?: { shortMessage?: string; details?: string; message?: string }; + }; + const message = err instanceof Error ? err.message : String(err); + if (/does not exist|not available|Method not found/i.test(message)) { + return 'This node does not expose base_sendRawTransactionValidity. Vibenet must have --enable-experimental-validity-transactions.'; + } + const short = record.shortMessage?.trim(); + const generic = Boolean(short && /^Missing or invalid parameters/i.test(short)); + const details = + (generic ? record.details : undefined) ?? + record.details ?? + short ?? + record.cause?.details ?? + record.cause?.shortMessage ?? + record.cause?.message; + if (details && !/^Missing or invalid parameters/i.test(details)) { + const line = details.split('\n')[0]?.trim() || details; + return line.length > 240 ? `${line.slice(0, 237)}…` : line; + } + const detailLine = message.match(/Details:\s*(.+)/i)?.[1]?.trim(); + if (detailLine) return detailLine.length > 240 ? `${detailLine.slice(0, 237)}…` : detailLine; + const first = message.split('\n')[0]?.trim() || 'Submit failed'; + return first.length > 240 ? `${first.slice(0, 237)}…` : first; +} + +export async function sendValidityTransaction( + _client: PublicClient, + tx: Hex, + validity: ValidityPredicate[], +): Promise { + const response = await fetch(RPC_PATH, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'base_sendRawTransactionValidity', + params: [{ tx, validity }], + }), + }); + const body = (await response.json()) as { result?: Hex; error?: { message?: string } }; + if (body.error?.message) throw new Error(body.error.message); + if (!body.result) throw new Error('Validity submit returned no hash.'); + return body.result; +} diff --git a/app/vibenet/demos/validity/lib/store.ts b/app/vibenet/demos/validity/lib/store.ts new file mode 100644 index 0000000..a5d131e --- /dev/null +++ b/app/vibenet/demos/validity/lib/store.ts @@ -0,0 +1,117 @@ +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; +import type { Hex } from 'viem'; + +import { LEGACY_STORAGE_KEYS, STORAGE_KEY } from './constants'; +import type { Deployment } from './types'; + +export type StoredState = { + v: 1; + chainId: number; + genesisHash: string; + userKey: Hex; + botKeys: [Hex, Hex]; + deployment?: Deployment; +}; + +function isHexKey(value: unknown): value is Hex { + return typeof value === 'string' && /^0x[0-9a-fA-F]{64}$/.test(value); +} + +function isAddress(value: unknown): value is `0x${string}` { + return typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value); +} + +function parseDeployment(value: unknown): Deployment | undefined { + if (!value || typeof value !== 'object') return undefined; + const d = value as Record; + if ( + !isAddress(d.tokenA) || + !isAddress(d.tokenB) || + !isAddress(d.token0) || + !isAddress(d.token1) || + !isAddress(d.factory) || + !isAddress(d.pair) || + !isAddress(d.helper) + ) { + return undefined; + } + return { + tokenA: d.tokenA, + tokenB: d.tokenB, + token0: d.token0, + token1: d.token1, + factory: d.factory, + pair: d.pair, + helper: d.helper, + }; +} + +export function loadState(): StoredState | null { + if (typeof window === 'undefined') return null; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = parseStored(raw); + if (parsed) return parsed; + } + for (const key of LEGACY_STORAGE_KEYS) { + const legacy = window.localStorage.getItem(key); + if (!legacy) continue; + const migrated = parseStored(legacy); + if (!migrated) continue; + const next = { ...migrated, deployment: undefined }; + saveState(next); + return next; + } + return null; + } catch { + return null; + } +} + +function parseStored(raw: string): StoredState | null { + const parsed = JSON.parse(raw) as Partial; + if (parsed.v !== 1) return null; + if (typeof parsed.chainId !== 'number' || typeof parsed.genesisHash !== 'string') return null; + if (!isHexKey(parsed.userKey) || !Array.isArray(parsed.botKeys)) return null; + if (!isHexKey(parsed.botKeys[0]) || !isHexKey(parsed.botKeys[1])) return null; + return { + v: 1, + chainId: parsed.chainId, + genesisHash: parsed.genesisHash, + userKey: parsed.userKey, + botKeys: [parsed.botKeys[0], parsed.botKeys[1]], + deployment: parseDeployment(parsed.deployment), + }; +} + +export function saveState(state: StoredState): void { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); +} + +export function dropDeployment(state: StoredState): StoredState { + return { + v: 1, + chainId: state.chainId, + genesisHash: state.genesisHash, + userKey: state.userKey, + botKeys: state.botKeys, + }; +} + +export function createState(chainId: number, genesisHash: string): StoredState { + return { + v: 1, + chainId, + genesisHash, + userKey: generatePrivateKey(), + botKeys: [generatePrivateKey(), generatePrivateKey()], + }; +} + +export function accountsFrom(state: StoredState) { + return { + user: privateKeyToAccount(state.userKey), + bots: [privateKeyToAccount(state.botKeys[0]), privateKeyToAccount(state.botKeys[1])] as const, + }; +} diff --git a/app/vibenet/demos/validity/lib/types.ts b/app/vibenet/demos/validity/lib/types.ts new file mode 100644 index 0000000..20aeedb --- /dev/null +++ b/app/vibenet/demos/validity/lib/types.ts @@ -0,0 +1,106 @@ +import type { Address, Hex } from 'viem'; + +export type ValidityOperator = '<' | '<=' | '=' | '!=' | '>' | '>='; + +export type StoragePredicate = { + type: 'storage'; + params: { + address: Address; + slot: Hex; + mask: Hex; + op: ValidityOperator; + value: Hex; + }; +}; + +export type BalancePredicate = { + type: 'balance'; + params: { + address: Address; + op: ValidityOperator; + value: Hex; + }; +}; + +export type BlockNumberPredicate = { + type: 'block_number'; + params: { + op: ValidityOperator; + value: Hex; + }; +}; + +export type FlashblockIndexPredicate = { + type: 'flashblock_index'; + params: { + op: ValidityOperator; + value: Hex; + }; +}; + +export type ValidityPredicate = + | StoragePredicate + | BalancePredicate + | BlockNumberPredicate + | FlashblockIndexPredicate; + +export type Side = 'buy' | 'sell'; + +export type Rectangle = { + r0Min: bigint; + r0Max: bigint; + r1Min: bigint; + r1Max: bigint; + side: Side; +}; + +export type Reserves = { + reserve0: bigint; + reserve1: bigint; + blockTimestampLast: number; +}; + +export type Deployment = { + tokenA: Address; + tokenB: Address; + token0: Address; + token1: Address; + factory: Address; + pair: Address; + helper: Address; +}; + +export type OrderStatus = 'pending' | 'filled' | 'expired' | 'replaced' | 'error'; + +export type PlacedOrder = { + id: string; + side: Side; + targetPriceWad: bigint; + size: bigint; + expirySeconds: number; + maxBlock?: bigint; + submittedAt: number; + txHash?: Hex; + nonce?: number; + maxFeePerGas?: bigint; + maxPriorityFeePerGas?: bigint; + status: OrderStatus; + error?: string; + rectangle: Rectangle; + validity: ValidityPredicate[]; + /** True once spot has crossed the target after this order expired. */ + crossedAfterExpiry?: boolean; + filledAt?: number; + /** Mid when the condition matched (pre-swap), never worse than the named price. */ + fillPriceWad?: bigint; +}; + +export type ChainStatus = { + chainId: number | null; + genesisHash: string | null; + readHost: string; + submitHost: string; + validitySupported: boolean; + blockNumberPredicate: boolean; + validityError: string | null; +}; diff --git a/app/vibenet/demos/validity/page.tsx b/app/vibenet/demos/validity/page.tsx new file mode 100644 index 0000000..42a8089 --- /dev/null +++ b/app/vibenet/demos/validity/page.tsx @@ -0,0 +1,5 @@ +import { ValidityDemo } from './ValidityDemo'; + +export default function ValidityDemoPage() { + return ; +} From dc7330d63d3f82d4ac616a149e18d5ce6feb17db Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Thu, 27 Aug 2026 16:23:33 -0700 Subject: [PATCH 2/6] fix(vibenet): type Validity event logs and replacement side Unwidened artifact ABIs so parseEventLogs can see Swap/Sync/PairCreated args, and include side on occupying-order picks so replacement analytics typecheck. Co-authored-by: Cursor --- app/vibenet/demos/validity/lib/amm.ts | 19 ++++++++++++++----- app/vibenet/demos/validity/lib/orders.test.ts | 2 +- app/vibenet/demos/validity/lib/orders.ts | 4 ++-- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/app/vibenet/demos/validity/lib/amm.ts b/app/vibenet/demos/validity/lib/amm.ts index 8d35d1e..64ee189 100644 --- a/app/vibenet/demos/validity/lib/amm.ts +++ b/app/vibenet/demos/validity/lib/amm.ts @@ -1,5 +1,6 @@ import { encodeFunctionData, + parseAbi, parseEventLogs, zeroAddress, type Account, @@ -10,6 +11,15 @@ import { type WalletClient, } from 'viem'; +const factoryEvents = parseAbi([ + 'event PairCreated(address indexed token0, address indexed token1, address pair, uint256 allPairsLength)', +]); + +const pairEvents = parseAbi([ + 'event Swap(address indexed sender, uint256 amount0In, uint256 amount1In, uint256 amount0Out, uint256 amount1Out, address indexed to)', + 'event Sync(uint112 reserve0, uint112 reserve1)', +]); + import { SEED_USDV, SEED_VIBE, @@ -67,12 +77,11 @@ async function waitForBytecode( function pairFromCreateReceipt(receipt: TransactionReceipt): Address | null { const logs = parseEventLogs({ - abi: factoryAbi, + abi: factoryEvents, eventName: 'PairCreated', logs: receipt.logs, }); - const pair = logs[0]?.args?.pair; - return typeof pair === 'string' ? pair : null; + return logs[0]?.args.pair ?? null; } async function readPair( @@ -173,12 +182,12 @@ export function fillQuoteFromSwapReceipt( try { const wanted = pair.toLowerCase(); const swaps = parseEventLogs({ - abi: pairAbi, + abi: pairEvents, eventName: 'Swap', logs: receipt.logs, }); const syncs = parseEventLogs({ - abi: pairAbi, + abi: pairEvents, eventName: 'Sync', logs: receipt.logs, }); diff --git a/app/vibenet/demos/validity/lib/orders.test.ts b/app/vibenet/demos/validity/lib/orders.test.ts index a14b03d..0a6815a 100644 --- a/app/vibenet/demos/validity/lib/orders.test.ts +++ b/app/vibenet/demos/validity/lib/orders.test.ts @@ -49,7 +49,7 @@ describe('tapeCrossedAt', () => { }); }); -const fees = { nonce: 3, maxFeePerGas: 1n, maxPriorityFeePerGas: 1n }; +const fees = { nonce: 3, maxFeePerGas: 1n, maxPriorityFeePerGas: 1n, side: 'buy' as const }; describe('occupyingOrder', () => { it('finds an expired order that may still hold the nonce', () => { diff --git a/app/vibenet/demos/validity/lib/orders.ts b/app/vibenet/demos/validity/lib/orders.ts index 8ef3169..cbbcbf3 100644 --- a/app/vibenet/demos/validity/lib/orders.ts +++ b/app/vibenet/demos/validity/lib/orders.ts @@ -42,7 +42,7 @@ export function tapeCrossedAt( } export function occupyingOrder( - orders: Pick[], + orders: Pick[], nonce: number, ): (typeof orders)[number] | undefined { return orders.find( @@ -56,7 +56,7 @@ export function occupyingOrder( /** UI replacement only. Expired stays expired even if we bump fees over its pooled nonce. */ export function restingOrderToReplace( - orders: Pick[], + orders: Pick[], nonce: number, ): (typeof orders)[number] | undefined { return orders.find( From f09c92a15f81c7b48e399d40cc725561b93f5b90 Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Thu, 27 Aug 2026 16:23:45 -0700 Subject: [PATCH 3/6] chore: refresh llms.txt for the Validity demo route Co-authored-by: Cursor --- public/AGENTS.md | 3 ++- public/llms-full.txt | 1 + public/llms.txt | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/public/AGENTS.md b/public/AGENTS.md index 70e7a18..f007d1b 100644 --- a/public/AGENTS.md +++ b/public/AGENTS.md @@ -27,7 +27,7 @@ Machine-readable entry point for agents working with Base Chain network state. | /upgrades/changelog | per release | re-fetch before stating an activation status | | /vibenet/faucet | monthly | stable within a session | | /api/snapshots | daily | re-fetch every session; never cache across sessions | -| /, /vibenet, /vibenet/demos/account, /vibenet/demos/b20 | infrequent | stable within a session | +| /, /vibenet, /vibenet/demos/account, /vibenet/demos/b20, /vibenet/demos/validity | infrequent | stable within a session | ## Machine-readable endpoints @@ -88,6 +88,7 @@ Discovered from the Next.js app directory. - [/vibenet](https://chain.base.org/vibenet) — Explore Vibenet, the Base devnet for testing in-flight protocol features. - [/vibenet/demos/account](https://chain.base.org/vibenet/demos/account) — Create native account abstraction accounts from in-browser keys, fund them from the faucet, and inspect balances on Vibenet. - [/vibenet/demos/b20](https://chain.base.org/vibenet/demos/b20) — Explore, configure, and issue Base-native B20 tokens on Vibenet. +- [/vibenet/demos/validity](https://chain.base.org/vibenet/demos/validity) — Attach conditions to a transaction. A simulated pool shows how a swap waits, lands, or expires. - [/vibenet/explorer](https://chain.base.org/vibenet/explorer) — Browse blocks, transactions, and addresses on the Vibenet devnet. - [/vibenet/faucet](https://chain.base.org/vibenet/faucet) — Request testnet tokens on Vibenet to fund accounts and try in-flight Base features. diff --git a/public/llms-full.txt b/public/llms-full.txt index 540e390..2567977 100644 --- a/public/llms-full.txt +++ b/public/llms-full.txt @@ -21,6 +21,7 @@ - [Vibenet · Base Chain](https://chain.base.org/vibenet): Explore Vibenet, the Base devnet for testing in-flight protocol features. - [Accounts · Vibenet](https://chain.base.org/vibenet/demos/account): Create native account abstraction accounts from in-browser keys, fund them from the faucet, and inspect balances on Vibenet. - [Tokens · Vibenet](https://chain.base.org/vibenet/demos/b20): Explore, configure, and issue Base-native B20 tokens on Vibenet. +- [Validity · Vibenet](https://chain.base.org/vibenet/demos/validity): Attach conditions to a transaction. A simulated pool shows how a swap waits, lands, or expires. - [Explorer · Vibenet](https://chain.base.org/vibenet/explorer): Browse blocks, transactions, and addresses on the Vibenet devnet. (changes daily; re-fetch before relying on it) - [Faucet · Vibenet](https://chain.base.org/vibenet/faucet): Request testnet tokens on Vibenet to fund accounts and try in-flight Base features. (changes monthly; re-fetch before relying on it) diff --git a/public/llms.txt b/public/llms.txt index 77223d3..80acc38 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -19,6 +19,7 @@ Freshness: /snapshots and /vibenet/explorer change daily. /vibenet/faucet change - [Vibenet · Base Chain](https://chain.base.org/vibenet): Explore Vibenet, the Base devnet for testing in-flight protocol features. - [Accounts · Vibenet](https://chain.base.org/vibenet/demos/account): Create native account abstraction accounts from in-browser keys, fund them from the faucet, and inspect balances on Vibenet. - [Tokens · Vibenet](https://chain.base.org/vibenet/demos/b20): Explore, configure, and issue Base-native B20 tokens on Vibenet. +- [Validity · Vibenet](https://chain.base.org/vibenet/demos/validity): Attach conditions to a transaction. A simulated pool shows how a swap waits, lands, or expires. - [Explorer · Vibenet](https://chain.base.org/vibenet/explorer): Browse blocks, transactions, and addresses on the Vibenet devnet. - [Faucet · Vibenet](https://chain.base.org/vibenet/faucet): Request testnet tokens on Vibenet to fund accounts and try in-flight Base features. From 31ed1519221bb9f44c2813573ba52fd29db1e92a Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Thu, 27 Aug 2026 16:34:04 -0700 Subject: [PATCH 4/6] feat(vibenet): annotate Validity predicates in plain English Pair each submitted JSON field with a meaning column so address, slot, mask, and bounds read as reserve and block checks. Co-authored-by: Cursor --- app/vibenet/demos/validity/ValidityDemo.tsx | 11 +- .../validity/components/ValidityJson.tsx | 54 ++++++-- .../demos/validity/lib/annotate.test.ts | 43 ++++++ app/vibenet/demos/validity/lib/annotate.ts | 129 ++++++++++++++++++ 4 files changed, 218 insertions(+), 19 deletions(-) create mode 100644 app/vibenet/demos/validity/lib/annotate.test.ts create mode 100644 app/vibenet/demos/validity/lib/annotate.ts diff --git a/app/vibenet/demos/validity/ValidityDemo.tsx b/app/vibenet/demos/validity/ValidityDemo.tsx index cc6b04a..f03fb66 100644 --- a/app/vibenet/demos/validity/ValidityDemo.tsx +++ b/app/vibenet/demos/validity/ValidityDemo.tsx @@ -37,7 +37,7 @@ import { tapeCrossedAt, } from './lib/orders'; import { bumpReplacementFees, isReplacementUnderpriced, padFees } from './lib/fees'; -import { applyOffsetBps, blockExpiryPredicate, formatPrice, prettyValidity, priceValidity, spotPastTarget } from './lib/predicates'; +import { applyOffsetBps, blockExpiryPredicate, formatPrice, priceValidity, spotPastTarget } from './lib/predicates'; import { ammPriceFromQuote, ammSide, @@ -868,8 +868,9 @@ export function ValidityDemo() { Under the hood - The condition is a hatched reserve rectangle on x·y = k, encoded as four - storage predicates on Uni v2 slot 0x8, plus a block-number expiry. + The hatched box on the curve is four storage reads on the pair's + reserves word, plus an optional block expiry. The payload on the left + is what gets submitted; the right column is what each field means.
{draft || jsonOrder ? ( predicate.type === 'block_number')} + vibeToken0={vibeToken0} /> ) : null} diff --git a/app/vibenet/demos/validity/components/ValidityJson.tsx b/app/vibenet/demos/validity/components/ValidityJson.tsx index c023adb..f50526e 100644 --- a/app/vibenet/demos/validity/components/ValidityJson.tsx +++ b/app/vibenet/demos/validity/components/ValidityJson.tsx @@ -1,6 +1,8 @@ 'use client'; import { Text } from '../../../../components/ui/Text'; +import { annotatedValidity } from '../lib/annotate'; +import type { ValidityPredicate } from '../lib/types'; type TokenKind = 'key' | 'string' | 'number' | 'literal' | 'punct'; @@ -39,22 +41,21 @@ const KIND_CLASS: Record = { }; export function ValidityJson({ - source, + predicates, frozen, - hasBlockBound, + vibeToken0, }: { - source: string; + predicates: ValidityPredicate[]; frozen?: boolean; - hasBlockBound?: boolean; + vibeToken0: boolean; }) { - const tokens = tokenizeJson(source); + const rows = annotatedValidity(predicates, vibeToken0); + const hasBlockBound = predicates.some((predicate) => predicate.type === 'block_number'); const footnote = frozen ? hasBlockBound ? 'Frozen at submit. The block bound does not walk with the live chain.' : 'Frozen at submit.' - : hasBlockBound - ? 'Four storage predicates on Uni v2 slot 0x8, plus a block-number expiry. The sequencer includes the swap only while this box holds.' - : 'Four storage predicates on Uni v2 slot 0x8. The sequencer includes the swap only while this box holds.'; + : 'Hover a field. The right column is what the sequencer is actually checking.'; return ( ); } diff --git a/app/vibenet/demos/validity/lib/annotate.test.ts b/app/vibenet/demos/validity/lib/annotate.test.ts new file mode 100644 index 0000000..5e0f604 --- /dev/null +++ b/app/vibenet/demos/validity/lib/annotate.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { annotatedValidity } from './annotate'; +import { WAD } from './constants'; +import { blockExpiryPredicate, priceValidity } from './predicates'; + +const PAIR = '0x1111111111111111111111111111111111111111'; + +describe('annotatedValidity', () => { + it('explains each storage field and decodes reserve bounds', () => { + const k = 2_000_000n * WAD * (140_000n * WAD); + const { predicates } = priceValidity(PAIR, k, (7n * WAD) / 100n, 'buy'); + const rows = annotatedValidity(predicates, true); + const notes = rows.map((row) => row.note).filter(Boolean); + + expect(notes[0]).toMatch(/every clause/i); + expect(notes).toContain('The simulated VIBE/USDV pair'); + expect(notes.some((note) => note?.includes('packed reserves'))).toBe(true); + expect(notes.some((note) => note?.includes('low 112 bits') && note.includes('VIBE'))).toBe(true); + expect(notes.some((note) => note?.includes('high 112 bits') && note.includes('USDV'))).toBe(true); + expect(notes.some((note) => note?.includes('Floor') && note.includes('VIBE'))).toBe(true); + expect(notes.some((note) => note?.includes('Ceiling') && note.includes('USDV'))).toBe(true); + expect(notes.filter((note) => /VIBE$/.test(note ?? '')).length).toBeGreaterThanOrEqual(2); + }); + + it('labels token0 as USDV when VIBE is token1', () => { + const k = 1_000n * WAD * (1_000n * WAD); + const { predicates } = priceValidity(PAIR, k, WAD, 'buy'); + const notes = annotatedValidity(predicates, false) + .map((row) => row.note) + .filter(Boolean); + expect(notes.some((note) => note?.includes('low 112 bits') && note.includes('USDV'))).toBe(true); + expect(notes.some((note) => note?.includes('high 112 bits') && note.includes('VIBE'))).toBe(true); + }); + + it('decodes a block-number expiry as an L2 head bound', () => { + const rows = annotatedValidity([blockExpiryPredicate(18_422_105n)]); + const notes = rows.map((row) => row.note).filter(Boolean); + expect(notes).toContain('Block-number expiry'); + expect(notes).toContain('L2 block 18422105'); + expect(notes.some((note) => note?.includes('at most'))).toBe(true); + }); +}); diff --git a/app/vibenet/demos/validity/lib/annotate.ts b/app/vibenet/demos/validity/lib/annotate.ts new file mode 100644 index 0000000..a0f8331 --- /dev/null +++ b/app/vibenet/demos/validity/lib/annotate.ts @@ -0,0 +1,129 @@ +import { PAIR_RESERVES_SLOT, RESERVE0_MASK, RESERVE1_MASK, RESERVE_BITS, WAD } from './constants'; +import { prettyValidity } from './predicates'; +import { USDV_SYMBOL, VIBE_SYMBOL } from './quote'; +import type { StoragePredicate, ValidityOperator, ValidityPredicate } from './types'; + +export type AnnotatedJsonLine = { + text: string; + note?: string; +}; + +function tokenForReserve(reserve: 0 | 1, vibeToken0: boolean): string { + if (reserve === 0) return vibeToken0 ? VIBE_SYMBOL : USDV_SYMBOL; + return vibeToken0 ? USDV_SYMBOL : VIBE_SYMBOL; +} + +function reserveFromMask(mask: bigint): 0 | 1 | null { + if (mask === RESERVE0_MASK) return 0; + if (mask === RESERVE1_MASK) return 1; + return null; +} + +function decodeReserve(value: bigint, mask: bigint): bigint { + return mask === RESERVE1_MASK ? value >> RESERVE_BITS : value; +} + +function formatAmount(wad: bigint): string { + const negative = wad < 0n; + const abs = negative ? -wad : wad; + const whole = abs / WAD; + const frac = ((abs % WAD) * 100n) / WAD; + const grouped = whole.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); + const body = frac === 0n ? grouped : `${grouped}.${frac.toString().padStart(2, '0')}`; + return negative ? `-${body}` : body; +} + +function comparePhrase(op: ValidityOperator): string { + switch (op) { + case '>=': + return 'at least'; + case '<=': + return 'at most'; + case '>': + return 'above'; + case '<': + return 'below'; + case '=': + return 'exactly'; + case '!=': + return 'anything but'; + default: + return op; + } +} + +function boundWord(op: ValidityOperator): string { + if (op === '>=' || op === '>') return 'Floor'; + if (op === '<=' || op === '<') return 'Ceiling'; + return 'Check'; +} + +function storageNotes(predicate: StoragePredicate, vibeToken0: boolean): Record { + const mask = BigInt(predicate.params.mask); + const slot = BigInt(predicate.params.slot); + const value = BigInt(predicate.params.value); + const reserve = reserveFromMask(mask); + const symbol = reserve === null ? 'reserve' : tokenForReserve(reserve, vibeToken0); + const half = reserve === 0 ? 'low 112 bits' : reserve === 1 ? 'high 112 bits' : 'selected bits'; + const amount = reserve === null ? value.toString() : formatAmount(decodeReserve(value, mask)); + return { + type: `${boundWord(predicate.params.op)} on the ${symbol} reserve`, + address: 'The simulated VIBE/USDV pair', + slot: + slot === PAIR_RESERVES_SLOT + ? 'Uni v2 packed reserves (reserve0 | reserve1 << 112)' + : `Storage slot ${slot.toString()}`, + mask: `Keep the ${half} — ${symbol}`, + op: `Include only if that reserve is ${comparePhrase(predicate.params.op)}`, + value: `${amount} ${symbol}`, + }; +} + +function notesFor(predicate: ValidityPredicate, vibeToken0: boolean): Record { + if (predicate.type === 'storage') return storageNotes(predicate, vibeToken0); + if (predicate.type === 'block_number') { + const block = BigInt(predicate.params.value); + return { + type: 'Block-number expiry', + op: `Include only while the head is ${comparePhrase(predicate.params.op)}`, + value: `L2 block ${block.toString()}`, + }; + } + if (predicate.type === 'balance') { + return { + type: 'Balance check', + address: 'Account whose ETH balance is read', + op: `Include only if the balance is ${comparePhrase(predicate.params.op)}`, + value: `${formatAmount(BigInt(predicate.params.value))} ETH`, + }; + } + return { + type: 'Flashblock-index bound', + op: `Include only if the flashblock index is ${comparePhrase(predicate.params.op)}`, + value: BigInt(predicate.params.value).toString(), + }; +} + +/** Pretty JSON plus a plain-English note for each field the sequencer actually reads. */ +export function annotatedValidity( + predicates: ValidityPredicate[], + vibeToken0 = true, +): AnnotatedJsonLine[] { + const lines = prettyValidity(predicates).split('\n'); + let index = -1; + let fields: Record = {}; + return lines.map((text, lineIndex) => { + if (lineIndex === 0 && text.trim() === '[') { + return { text, note: 'Every clause must hold for the swap to land' }; + } + const key = text.match(/^\s*"([^"]+)":/)?.[1]; + if (!key) return { text }; + if (key === 'type') { + index += 1; + const predicate = predicates[index]; + fields = predicate ? notesFor(predicate, vibeToken0) : {}; + } + const note = fields[key]; + return note ? { text, note } : { text }; + }); +} From 0ea534c064074e6f234d59c2c1461aa4629eaf15 Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Thu, 27 Aug 2026 20:02:43 -0700 Subject: [PATCH 5/6] feat(vibenet): rest concurrent Validity conditions on 8130 Denim is 200ms, so predicates and the tape need that clock. Nonceless 8130 lets several conditions sit in the Vibenet mempool without replacing each other. Co-authored-by: Cursor --- .env.example | 9 +- app/vibenet/demos/catalogue.ts | 2 +- app/vibenet/demos/validity/ValidityDemo.tsx | 137 +++++++++++------- .../demos/validity/components/OrderList.tsx | 4 +- .../demos/validity/components/OrderTicket.tsx | 76 ++++++++-- .../validity/components/PriceCandles.test.ts | 16 +- .../validity/components/PriceCandles.tsx | 7 +- app/vibenet/demos/validity/lib/aa.test.ts | 32 ++++ app/vibenet/demos/validity/lib/aa.ts | 63 ++++++++ app/vibenet/demos/validity/lib/constants.ts | 15 +- app/vibenet/demos/validity/lib/orders.test.ts | 10 +- app/vibenet/demos/validity/lib/orders.ts | 2 +- app/vibenet/demos/validity/lib/rpc.ts | 2 +- app/vibenet/demos/validity/lib/types.ts | 3 + 14 files changed, 283 insertions(+), 95 deletions(-) create mode 100644 app/vibenet/demos/validity/lib/aa.test.ts create mode 100644 app/vibenet/demos/validity/lib/aa.ts diff --git a/.env.example b/.env.example index 6143a9d..dfd4f74 100644 --- a/.env.example +++ b/.env.example @@ -53,9 +53,10 @@ NEXT_PUBLIC_VIBENET_RPC_URL=https://rpc.vibes.base.org # NEXT_PUBLIC_BENCHMARK_API_BASE_URL= # Validity demo (/vibenet/demos/validity). Server-side RPC proxy only. -# Defaults to the public Vibenet RPC (`NEXT_PUBLIC_VIBENET_RPC_URL`). ETH comes -# from the Vibenet faucet — do not set a funder key. Override only for a local -# node with --enable-experimental-validity-transactions. +# Defaults to the public Vibenet RPC (`NEXT_PUBLIC_VIBENET_RPC_URL`) for both +# reads and `base_sendRawTransactionValidity` submits. ETH comes from the +# Vibenet faucet — do not set a funder key. # VALIDITY_DEMO_RPC_URL=https://rpc.vibes.base.org -# Local Anvil / just devnet: +# VALIDITY_DEMO_SUBMIT_RPC_URL=https://rpc.vibes.base.org +# Local node with --enable-experimental-validity-transactions: # VALIDITY_DEMO_RPC_URL=http://127.0.0.1:8545 diff --git a/app/vibenet/demos/catalogue.ts b/app/vibenet/demos/catalogue.ts index 31ae82a..a94c1f9 100644 --- a/app/vibenet/demos/catalogue.ts +++ b/app/vibenet/demos/catalogue.ts @@ -55,7 +55,7 @@ export const DEMOS: DemoEntry[] = [ points: [ 'Add storage and block-number conditions to an ordinary swap', 'A simulated AMM makes those conditions visible on a moving mid', - 'Optional 5s / 15s / 60s bound so a stale condition cannot fire later', + 'Stack several 8130 conditions at once, or replace the resting one', ], available: true, }, diff --git a/app/vibenet/demos/validity/ValidityDemo.tsx b/app/vibenet/demos/validity/ValidityDemo.tsx index f03fb66..24f533f 100644 --- a/app/vibenet/demos/validity/ValidityDemo.tsx +++ b/app/vibenet/demos/validity/ValidityDemo.tsx @@ -1,7 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { Account, PublicClient, WalletClient } from 'viem'; +import type { Account, Hex, PublicClient, WalletClient } from 'viem'; import { formatEther } from 'viem'; import { trackValidityOrder } from '../../../analytics/events'; @@ -25,8 +25,9 @@ import { signCall, tokenBalance, } from './lib/amm'; +import { clampNoncelessExpiry, signNoncelessCall } from './lib/aa'; import { startBots, allNeedGas, botNeedsGas, refuelValue } from './lib/bots'; -import { MAX_EXPIRY_SECONDS } from './lib/constants'; +import { BLOCK_MS, MAX_EXPIRY_SECONDS, MAX_NONCELESS_SECONDS } from './lib/constants'; import { faucetErrorMessage, seedEthFromFaucet } from './lib/faucet'; import { maxBlockForExpiry, @@ -56,11 +57,11 @@ import { sendValidityTransaction, } from './lib/rpc'; import { accountsFrom, createState, dropDeployment, loadState, saveState, type StoredState } from './lib/store'; -import type { ChainStatus, PlacedOrder, Rectangle, Reserves, Side } from './lib/types'; +import type { ChainStatus, PlacedOrder, Rectangle, Reserves, Side, SubmitMode } from './lib/types'; -const POLL_MS = 400; -/** L2 blocks are ~2s. viem's default 4s block cache made this skip 2–3 heads. */ -const BLOCK_POLL_MS = 1_000; +const POLL_MS = BLOCK_MS; +/** Denim heads are 200ms. viem's default 4s block cache would skip dozens. */ +const BLOCK_POLL_MS = BLOCK_MS; const DEFAULT_SIZE_FRACTION = 50n; // 1/50 of inventory function wadToNumber(wad: bigint): number { @@ -85,7 +86,8 @@ export function ValidityDemo() { const [hoverPrice, setHoverPrice] = useState(null); const [side, setSide] = useState('buy'); const [offsetBps, setOffsetBps] = useState(100); - const [expirySeconds, setExpirySeconds] = useState(60); + const [expirySeconds, setExpirySeconds] = useState(15); + const [submitMode, setSubmitMode] = useState('concurrent'); const [orders, setOrders] = useState([]); const [hoveredOrderId, setHoveredOrderId] = useState(null); const [samples, setSamples] = useState([]); @@ -429,7 +431,7 @@ export function ValidityDemo() { const id = window.setInterval(() => { void tick(); - }, 700); + }, 250); void tick(); return () => { cancelled = true; @@ -613,12 +615,16 @@ export function ValidityDemo() { amount0Out, amount1Out, }); - const confirmedNonce = await publicClient.getTransactionCount({ - address: account.address, - blockTag: 'latest', - }); - const occupant = occupyingOrder(ordersRef.current, confirmedNonce); - const replaced = restingOrderToReplace(ordersRef.current, confirmedNonce); + const seconds = + submitMode === 'concurrent' + ? clampNoncelessExpiry(expirySeconds) + : Math.min(MAX_EXPIRY_SECONDS, expirySeconds); + const block = await publicClient.getBlockNumber({ cacheTime: 0 }); + const maxBlock = maxBlockForExpiry(block, seconds); + const validity = [...draft.predicates]; + if (status?.blockNumberPredicate) { + validity.push(blockExpiryPredicate(maxBlock)); + } const estimated = await publicClient.estimateFeesPerGas().catch(() => null); const padded = estimated?.maxFeePerGas !== undefined && estimated.maxPriorityFeePerGas !== undefined @@ -627,42 +633,59 @@ export function ValidityDemo() { maxPriorityFeePerGas: estimated.maxPriorityFeePerGas, }) : null; + trackValidityOrder(side, 'submitted'); + let hash: Hex; + let nonce: number | undefined; let fees = padded; - if (occupant?.maxFeePerGas !== undefined && occupant.maxPriorityFeePerGas !== undefined) { - fees = bumpReplacementFees( - { - maxFeePerGas: occupant.maxFeePerGas, - maxPriorityFeePerGas: occupant.maxPriorityFeePerGas, - }, - padded, - ); - } - const sign = (nextFees: typeof fees) => - signCall({ - wallet, - publicClient, - account, + let replaced: ReturnType; + if (submitMode === 'concurrent') { + replaced = undefined; + const signed = await signNoncelessCall({ + privateKey: state.userKey, + chainId: status?.chainId ?? state.chainId, to: call.to, data: call.data, - nonce: confirmedNonce, - fees: nextFees, + expiresIn: seconds, + fees: padded, + publicClient, }); - let signedResult = await sign(fees); - const seconds = Math.min(MAX_EXPIRY_SECONDS, expirySeconds); - const block = await publicClient.getBlockNumber({ cacheTime: 0 }); - const maxBlock = maxBlockForExpiry(block, seconds); - const validity = [...draft.predicates]; - if (status?.blockNumberPredicate) { - validity.push(blockExpiryPredicate(maxBlock)); - } - trackValidityOrder(side, 'submitted'); - let hash; - try { - hash = await sendValidityTransaction(publicClient, signedResult.signed, validity); - } catch (err) { - if (!isReplacementUnderpriced(err) || !signedResult.fees) throw err; - signedResult = await sign(bumpReplacementFees(signedResult.fees, padded)); - hash = await sendValidityTransaction(publicClient, signedResult.signed, validity); + hash = await sendValidityTransaction(publicClient, signed.signed, validity); + } else { + const confirmedNonce = await publicClient.getTransactionCount({ + address: account.address, + blockTag: 'latest', + }); + const occupant = occupyingOrder(ordersRef.current, confirmedNonce); + replaced = restingOrderToReplace(ordersRef.current, confirmedNonce); + if (occupant?.maxFeePerGas !== undefined && occupant.maxPriorityFeePerGas !== undefined) { + fees = bumpReplacementFees( + { + maxFeePerGas: occupant.maxFeePerGas, + maxPriorityFeePerGas: occupant.maxPriorityFeePerGas, + }, + padded, + ); + } + const sign = (nextFees: typeof fees) => + signCall({ + wallet, + publicClient, + account, + to: call.to, + data: call.data, + nonce: confirmedNonce, + fees: nextFees, + }); + let signedResult = await sign(fees); + try { + hash = await sendValidityTransaction(publicClient, signedResult.signed, validity); + } catch (err) { + if (!isReplacementUnderpriced(err) || !signedResult.fees) throw err; + signedResult = await sign(bumpReplacementFees(signedResult.fees, padded)); + hash = await sendValidityTransaction(publicClient, signedResult.signed, validity); + } + nonce = signedResult.nonce; + fees = signedResult.fees; } const order: PlacedOrder = { id: newId(), @@ -670,12 +693,13 @@ export function ValidityDemo() { targetPriceWad: draft.priceWad, size: amountIn, expirySeconds: seconds, + submitMode, maxBlock: status?.blockNumberPredicate ? maxBlock : undefined, submittedAt: Date.now(), txHash: hash, - nonce: signedResult.nonce, - maxFeePerGas: signedResult.fees?.maxFeePerGas, - maxPriorityFeePerGas: signedResult.fees?.maxPriorityFeePerGas, + nonce, + maxFeePerGas: fees?.maxFeePerGas, + maxPriorityFeePerGas: fees?.maxPriorityFeePerGas, status: 'pending', rectangle: draft.rectangle, validity, @@ -747,6 +771,7 @@ export function ValidityDemo() {
{status?.readHost ?? 'no rpc'} + 200ms blocks validity {status?.validitySupported ? 'on' : 'unavailable'} {status?.blockNumberPredicate ? block bounds on : client-side expiry only} simulation {botsOn ? (makersDry ? 'out of ETH' : 'live') : 'paused'} @@ -782,9 +807,10 @@ export function ValidityDemo() { Simulated pool - A local EOA (not the Vibenet 8130 account) signs the swaps. The faucet - funds it, then you deploy a VIBE/USDV pool. Simulated flow moves the mid - so you can see a price condition fire — or expire unused. + A local key signs the swaps — type-2 replacements, or 8130 nonceless + txs so several conditions can rest at once. The faucet funds it, then + you deploy a VIBE/USDV pool. Simulated flow moves the mid so you can + see a price condition fire — or expire unused. {address ? (
@@ -836,11 +862,18 @@ export function ValidityDemo() { side={side} offsetBps={offsetBps} expirySeconds={expirySeconds} + submitMode={submitMode} busy={busy} validitySupported={Boolean(status?.validitySupported)} onSide={setSide} onOffset={setOffsetBps} onExpiry={setExpirySeconds} + onSubmitMode={(mode) => { + setSubmitMode(mode); + if (mode === 'concurrent' && expirySeconds > MAX_NONCELESS_SECONDS) { + setExpirySeconds(15); + } + }} onSubmit={() => void placeOrder()} /> ) : ( diff --git a/app/vibenet/demos/validity/components/OrderList.tsx b/app/vibenet/demos/validity/components/OrderList.tsx index 1474c6b..1a94be2 100644 --- a/app/vibenet/demos/validity/components/OrderList.tsx +++ b/app/vibenet/demos/validity/components/OrderList.tsx @@ -74,7 +74,8 @@ export function OrderList({ orders, highlightedOrderId, onHighlight }: Props) {
Submitted - Conditional swaps land here. They also draw as a dashed line on the tape. + Conditional swaps land here. Concurrent 8130 orders stack; replace + mode bumps the last nonce.
); @@ -125,6 +126,7 @@ export function OrderList({ orders, highlightedOrderId, onHighlight }: Props) {
{formatClock(order.submittedAt)} + {order.submitMode === 'concurrent' ? ' · 8130' : order.submitMode === 'replace' ? ' · replace' : null} {order.filledAt ? ` → ${formatClock(order.filledAt)}` : null} {filled && order.fillPriceWad !== undefined ? ` · ${formatPrice(order.fillPriceWad)}` diff --git a/app/vibenet/demos/validity/components/OrderTicket.tsx b/app/vibenet/demos/validity/components/OrderTicket.tsx index d4b048b..d7b4c33 100644 --- a/app/vibenet/demos/validity/components/OrderTicket.tsx +++ b/app/vibenet/demos/validity/components/OrderTicket.tsx @@ -2,8 +2,9 @@ import { Button } from '../../../../components/ui/Button'; import { Text } from '../../../../components/ui/Text'; +import { MAX_NONCELESS_SECONDS } from '../lib/constants'; import { applyOffsetBps, formatPrice } from '../lib/predicates'; -import type { Side } from '../lib/types'; +import type { Side, SubmitMode } from '../lib/types'; const EXPIRIES = [5, 15, 60] as const; const OFFSETS = [0, 50, 100, 200, 500] as const; @@ -13,11 +14,13 @@ type Props = { side: Side; offsetBps: number; expirySeconds: number; + submitMode: SubmitMode; busy: boolean; validitySupported: boolean; onSide: (side: Side) => void; onOffset: (bps: number) => void; onExpiry: (seconds: number) => void; + onSubmitMode: (mode: SubmitMode) => void; onSubmit: () => void; }; @@ -31,11 +34,13 @@ export function OrderTicket({ side, offsetBps, expirySeconds, + submitMode, busy, validitySupported, onSide, onOffset, onExpiry, + onSubmitMode, onSubmit, }: Props) { const target = applyOffsetBps(spotWad, side, offsetBps); @@ -114,25 +119,66 @@ export function OrderTicket({ mid {signed}
+
+ + Mempool + +
+ + +
+ + {submitMode === 'replace' + ? 'Same nonce, fee bump. The new swap takes the resting slot.' + : `8130 nonceless — stack several at once. Envelope max ${MAX_NONCELESS_SECONDS}s.`} + +
Expiry
- {EXPIRIES.map((seconds) => ( - - ))} + {EXPIRIES.map((seconds) => { + const blocked = submitMode === 'concurrent' && seconds > MAX_NONCELESS_SECONDS; + return ( + + ); + })}
{!validitySupported ? ( diff --git a/app/vibenet/demos/validity/components/PriceCandles.test.ts b/app/vibenet/demos/validity/components/PriceCandles.test.ts index 62a573e..e7a0385 100644 --- a/app/vibenet/demos/validity/components/PriceCandles.test.ts +++ b/app/vibenet/demos/validity/components/PriceCandles.test.ts @@ -3,13 +3,13 @@ import { describe, expect, it } from 'vitest'; import { isUpCandle, toCandles, type PriceSample } from './PriceCandles'; describe('toCandles', () => { - it('builds a wick when price reverses inside a 2s bucket', () => { + it('builds a wick when price reverses inside a 200ms bucket', () => { const t0 = 1_000_000; const samples: PriceSample[] = [ { t: t0, price: 1.0 }, - { t: t0 + 400, price: 1.03 }, - { t: t0 + 800, price: 0.98 }, - { t: t0 + 1_200, price: 1.01 }, + { t: t0 + 40, price: 1.03 }, + { t: t0 + 80, price: 0.98 }, + { t: t0 + 120, price: 1.01 }, ]; const [candle] = toCandles(samples); expect(candle.o).toBe(1.0); @@ -24,8 +24,8 @@ describe('toCandles', () => { const t0 = 1_000_000; const samples: PriceSample[] = [ { t: t0, price: 1.008 }, - { t: t0 + 400, price: 1.008 }, - { t: t0 + 800, price: 1.008 }, + { t: t0 + 40, price: 1.008 }, + { t: t0 + 80, price: 1.008 }, ]; const [candle] = toCandles(samples); expect(candle.o).toBe(candle.h); @@ -37,8 +37,8 @@ describe('toCandles', () => { const t0 = 2_000_000; const samples: PriceSample[] = [ { t: t0, price: 0.08 }, - { t: t0 + 2_000, price: 0.078 }, - { t: t0 + 2_400, price: 0.0784 }, + { t: t0 + 200, price: 0.078 }, + { t: t0 + 240, price: 0.0784 }, ]; const candles = toCandles(samples); expect(candles).toHaveLength(2); diff --git a/app/vibenet/demos/validity/components/PriceCandles.tsx b/app/vibenet/demos/validity/components/PriceCandles.tsx index 1f1633b..2c2fb98 100644 --- a/app/vibenet/demos/validity/components/PriceCandles.tsx +++ b/app/vibenet/demos/validity/components/PriceCandles.tsx @@ -3,13 +3,14 @@ import { scaleLinear } from 'd3'; import { useMemo } from 'react'; +import { CANDLE_BUCKET_MS, CANDLE_WINDOW_MS } from '../lib/constants'; import type { Side } from '../lib/types'; const BUY_PLOT = '#22ad73'; const SELL_PLOT = '#ed5966'; const TICKER = '#c8ff4a'; -const BUCKET_MS = 2_000; -const WINDOW_MS = 120_000; +const BUCKET_MS = CANDLE_BUCKET_MS; +const WINDOW_MS = CANDLE_WINDOW_MS; const WIDTH = 960; const HEIGHT = 440; const PAD = { top: 20, right: 20, bottom: 40, left: 68 }; @@ -165,7 +166,7 @@ export function PriceCandles({ samples, levels = [], fills = [] }: Props) {
VIBE / USDV
-
simulated pool · 2s candles
+
simulated pool · 200ms candles
diff --git a/app/vibenet/demos/validity/lib/aa.test.ts b/app/vibenet/demos/validity/lib/aa.test.ts new file mode 100644 index 0000000..00ab8e8 --- /dev/null +++ b/app/vibenet/demos/validity/lib/aa.test.ts @@ -0,0 +1,32 @@ +import { generatePrivateKey, nonceKeyMax } from '@aa'; +import { describe, expect, it } from 'vitest'; + +import { clampNoncelessExpiry, noncelessFields, signNoncelessCall } from './aa'; + +describe('noncelessFields', () => { + it('uses nonceKeyMax and no sequence so concurrent txs do not replace', () => { + const fields = noncelessFields(15, 1_700_000_000_000); + expect(fields.nonceKey).toBe(nonceKeyMax); + expect(fields.nonceSequence).toBe(0n); + expect(fields.validBefore).toBe(1_700_000_015_000n); + }); + + it('clamps to the 20s nonce-free window', () => { + expect(clampNoncelessExpiry(60)).toBe(20); + expect(noncelessFields(60, 1_000).validBefore).toBe(21_000n); + }); +}); + +describe('signNoncelessCall', () => { + it('signs a type-0x79 envelope so validity can wrap an 8130 tx', async () => { + const { signed } = await signNoncelessCall({ + privateKey: generatePrivateKey(), + chainId: 84538453, + to: '0x1111111111111111111111111111111111111111', + data: '0x', + expiresIn: 15, + publicClient: { getCode: async () => '0x' } as never, + }); + expect(signed.slice(0, 4).toLowerCase()).toBe('0x79'); + }); +}); diff --git a/app/vibenet/demos/validity/lib/aa.ts b/app/vibenet/demos/validity/lib/aa.ts new file mode 100644 index 0000000..1786afb --- /dev/null +++ b/app/vibenet/demos/validity/lib/aa.ts @@ -0,0 +1,63 @@ +import { + defaultAccountAddress, + encodeWalletCalls, + nonceFreeMaxExpiryWindow, + nonceKeyMax, + privateKeyToAccount, + toEoaAccount, + type Hex, +} from '@aa'; +import type { Address, PublicClient } from 'viem'; + +import { MAX_NONCELESS_SECONDS } from './constants'; +import type { FeeFields } from './fees'; + +export function clampNoncelessExpiry(seconds: number): number { + return Math.min(Math.max(1, seconds), MAX_NONCELESS_SECONDS); +} + +export function noncelessFields(expiresIn: number, now = Date.now()) { + const seconds = clampNoncelessExpiry(expiresIn); + return { + nonceKey: nonceKeyMax, + nonceSequence: 0n, + validBefore: BigInt(now + seconds * 1000), + }; +} + +const FALLBACK_FEES = { + maxFeePerGas: 1_000_000_000n, + maxPriorityFeePerGas: 1_000_000n, +} as const; + +/** Sign the helper swap as an EIP-8130 nonceless tx from the demo EOA. */ +export async function signNoncelessCall(args: { + privateKey: Hex; + chainId: number; + to: Address; + data: Hex; + expiresIn: number; + fees?: FeeFields | null; + publicClient: PublicClient; +}): Promise<{ signed: Hex; validBefore: bigint }> { + const signer = privateKeyToAccount(args.privateKey); + const account = toEoaAccount(signer); + const code = await args.publicClient.getCode({ address: account.address }); + const accountChanges = !code || code === '0x' ? [account.delegate(defaultAccountAddress)] : []; + const calls = encodeWalletCalls({ + account: account.address, + calls: [[{ to: args.to, data: args.data, value: 0n }]], + }); + const fields = noncelessFields(args.expiresIn); + const signed = await account.signTransaction({ + chainId: args.chainId, + accountChanges, + calls, + ...fields, + gas: accountChanges.length > 0 ? 800_000n : 400_000n, + ...(args.fees ?? FALLBACK_FEES), + }); + return { signed, validBefore: fields.validBefore }; +} + +export const NONCELESS_WINDOW_MS = Number(nonceFreeMaxExpiryWindow); diff --git a/app/vibenet/demos/validity/lib/constants.ts b/app/vibenet/demos/validity/lib/constants.ts index 397cda0..45cfe35 100644 --- a/app/vibenet/demos/validity/lib/constants.ts +++ b/app/vibenet/demos/validity/lib/constants.ts @@ -41,11 +41,18 @@ export const RESERVE1_MASK = RESERVE0_MASK << RESERVE_BITS; export const MAX_EXPIRY_SECONDS = 60; /** - * Canonical L2 block time. `block_number` predicates and mempool eviction - * (`expire_by_block`) are on committed L2 blocks, not 250ms flashblocks. - * Using 0.25s here made a 60s UI timer last ~8 minutes in the pool. + * EIP-8130 nonce-free (`nonceKeyMax`) txs are capped at a 20s `validBefore`. + * Concurrent mode uses that envelope, so the ticket snaps to this ceiling. */ -export const BLOCK_SECONDS = 2; +export const MAX_NONCELESS_SECONDS = 20; +/** + * Denim-native L2 block time. `block_number` predicates and mempool eviction + * are on committed 200ms blocks, not 2s pre-Denim heads or 250ms flashblocks. + */ +export const BLOCK_SECONDS = 0.2; +export const BLOCK_MS = 200; +export const CANDLE_BUCKET_MS = 200; +export const CANDLE_WINDOW_MS = 30_000; /** * Finite box span around the target point on the current hyperbola. diff --git a/app/vibenet/demos/validity/lib/orders.test.ts b/app/vibenet/demos/validity/lib/orders.test.ts index 0a6815a..be4d5dd 100644 --- a/app/vibenet/demos/validity/lib/orders.test.ts +++ b/app/vibenet/demos/validity/lib/orders.test.ts @@ -5,8 +5,8 @@ import { occupyingOrder, maxBlockForExpiry, orderBlockExpired, orderWallClockExp describe('orderWallClockExpired', () => { it('expires a resting order after the window plus grace', () => { const order = { status: 'pending' as const, submittedAt: 1_000, expirySeconds: 5 }; - expect(orderWallClockExpired(order, 1_000 + 5_000 + 1_000)).toBe(false); - expect(orderWallClockExpired(order, 1_000 + 5_000 + 2_001)).toBe(true); + expect(orderWallClockExpired(order, 1_000 + 5_000 + 400)).toBe(false); + expect(orderWallClockExpired(order, 1_000 + 5_000 + 401)).toBe(true); }); it('does not expire fills', () => { @@ -24,9 +24,9 @@ describe('orderBlockExpired', () => { }); describe('maxBlockForExpiry', () => { - it('uses ~2s L2 blocks, not flashblock cadence', () => { - expect(maxBlockForExpiry(1_000n, 60)).toBe(1_030n); - expect(maxBlockForExpiry(1_000n, 5)).toBe(1_003n); + it('uses 200ms Denim blocks, not 2s pre-Denim heads', () => { + expect(maxBlockForExpiry(1_000n, 60)).toBe(1_300n); + expect(maxBlockForExpiry(1_000n, 5)).toBe(1_025n); }); }); diff --git a/app/vibenet/demos/validity/lib/orders.ts b/app/vibenet/demos/validity/lib/orders.ts index cbbcbf3..3133c80 100644 --- a/app/vibenet/demos/validity/lib/orders.ts +++ b/app/vibenet/demos/validity/lib/orders.ts @@ -1,7 +1,7 @@ import { BLOCK_SECONDS } from './constants'; import type { PlacedOrder, Side } from './types'; -const WALL_CLOCK_GRACE_MS = 2_000; +const WALL_CLOCK_GRACE_MS = 400; export function orderWallClockExpired( order: Pick, diff --git a/app/vibenet/demos/validity/lib/rpc.ts b/app/vibenet/demos/validity/lib/rpc.ts index 8339c4c..6eaeacc 100644 --- a/app/vibenet/demos/validity/lib/rpc.ts +++ b/app/vibenet/demos/validity/lib/rpc.ts @@ -21,7 +21,7 @@ export function chainFromId(id: number): Chain { id, name, nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, - rpcUrls: { default: { http: [typeof window === 'undefined' ? 'http://127.0.0.1:8545' : RPC_PATH] } }, + rpcUrls: { default: { http: [RPC_PATH] } }, }; } diff --git a/app/vibenet/demos/validity/lib/types.ts b/app/vibenet/demos/validity/lib/types.ts index 20aeedb..5645186 100644 --- a/app/vibenet/demos/validity/lib/types.ts +++ b/app/vibenet/demos/validity/lib/types.ts @@ -46,6 +46,8 @@ export type ValidityPredicate = export type Side = 'buy' | 'sell'; +export type SubmitMode = 'replace' | 'concurrent'; + export type Rectangle = { r0Min: bigint; r0Max: bigint; @@ -78,6 +80,7 @@ export type PlacedOrder = { targetPriceWad: bigint; size: bigint; expirySeconds: number; + submitMode?: SubmitMode; maxBlock?: bigint; submittedAt: number; txHash?: Hex; From e66a505bc64e898940fd2f0688a553d41ea24367 Mon Sep 17 00:00:00 2001 From: Brian Bland Date: Fri, 28 Aug 2026 16:41:28 -0700 Subject: [PATCH 6/6] feat(vibenet): run Validity on shared accounts and live heads Use the Account engine for the trader and makers, subscribe to Vibenet /ws for heads and pair logs, and keep validity submits on HTTP. Co-authored-by: Cursor --- .env.example | 7 +- app/api/vibenet/validity/config.test.ts | 19 +- app/api/vibenet/validity/config.ts | 17 + app/api/vibenet/validity/status/route.ts | 3 +- .../demos/account/useAccountEngine.tsx | 109 ++- app/vibenet/demos/validity/ValidityDemo.tsx | 899 +++++++++++------- .../demos/validity/components/OrderList.tsx | 5 - app/vibenet/demos/validity/lib/aa.test.ts | 18 +- app/vibenet/demos/validity/lib/aa.ts | 49 +- app/vibenet/demos/validity/lib/amm.test.ts | 44 +- app/vibenet/demos/validity/lib/amm.ts | 221 ++--- app/vibenet/demos/validity/lib/bots.test.ts | 10 +- app/vibenet/demos/validity/lib/bots.ts | 145 +-- app/vibenet/demos/validity/lib/constants.ts | 5 +- app/vibenet/demos/validity/lib/faucet.ts | 31 +- app/vibenet/demos/validity/lib/fees.test.ts | 16 +- app/vibenet/demos/validity/lib/fees.ts | 25 +- app/vibenet/demos/validity/lib/makers.test.ts | 43 + app/vibenet/demos/validity/lib/makers.ts | 48 + app/vibenet/demos/validity/lib/predicates.ts | 9 - app/vibenet/demos/validity/lib/quote.test.ts | 27 +- app/vibenet/demos/validity/lib/quote.ts | 21 - app/vibenet/demos/validity/lib/rpc.test.ts | 25 +- app/vibenet/demos/validity/lib/rpc.ts | 58 +- app/vibenet/demos/validity/lib/store.test.ts | 39 + app/vibenet/demos/validity/lib/store.ts | 81 +- app/vibenet/demos/validity/lib/stream.test.ts | 13 + app/vibenet/demos/validity/lib/stream.ts | 126 +++ app/vibenet/demos/validity/lib/types.ts | 4 +- 29 files changed, 1242 insertions(+), 875 deletions(-) create mode 100644 app/vibenet/demos/validity/lib/makers.test.ts create mode 100644 app/vibenet/demos/validity/lib/makers.ts create mode 100644 app/vibenet/demos/validity/lib/store.test.ts create mode 100644 app/vibenet/demos/validity/lib/stream.test.ts create mode 100644 app/vibenet/demos/validity/lib/stream.ts diff --git a/.env.example b/.env.example index dfd4f74..5fb4e16 100644 --- a/.env.example +++ b/.env.example @@ -52,11 +52,12 @@ NEXT_PUBLIC_VIBENET_RPC_URL=https://rpc.vibes.base.org # it. No credentials belong here: this value is inlined into the client bundle. # NEXT_PUBLIC_BENCHMARK_API_BASE_URL= -# Validity demo (/vibenet/demos/validity). Server-side RPC proxy only. -# Defaults to the public Vibenet RPC (`NEXT_PUBLIC_VIBENET_RPC_URL`) for both -# reads and `base_sendRawTransactionValidity` submits. ETH comes from the +# Validity demo (/vibenet/demos/validity). Server-side RPC proxy for HTTP +# reads and `base_sendRawTransactionValidity` submits. WebSocket is for +# eth_subscribe (defaults to the read host + /ws). ETH comes from the # Vibenet faucet — do not set a funder key. # VALIDITY_DEMO_RPC_URL=https://rpc.vibes.base.org # VALIDITY_DEMO_SUBMIT_RPC_URL=https://rpc.vibes.base.org +# VALIDITY_DEMO_WS_URL=wss://rpc.vibes.base.org/ws # Local node with --enable-experimental-validity-transactions: # VALIDITY_DEMO_RPC_URL=http://127.0.0.1:8545 diff --git a/app/api/vibenet/validity/config.test.ts b/app/api/vibenet/validity/config.test.ts index 2d6a099..fc0cfca 100644 --- a/app/api/vibenet/validity/config.test.ts +++ b/app/api/vibenet/validity/config.test.ts @@ -1,16 +1,19 @@ import { afterEach, describe, expect, it } from 'vitest'; import { VIBENET_RPC_URL } from '../../../vibenet/library/config'; -import { getReadRpcUrl, getSubmitRpcUrl } from './config'; +import { getReadRpcUrl, getSubmitRpcUrl, getWsRpcUrl, wsUrlFromHttp } from './config'; const originalRead = process.env.VALIDITY_DEMO_RPC_URL; const originalSubmit = process.env.VALIDITY_DEMO_SUBMIT_RPC_URL; +const originalWs = process.env.VALIDITY_DEMO_WS_URL; afterEach(() => { if (originalRead === undefined) delete process.env.VALIDITY_DEMO_RPC_URL; else process.env.VALIDITY_DEMO_RPC_URL = originalRead; if (originalSubmit === undefined) delete process.env.VALIDITY_DEMO_SUBMIT_RPC_URL; else process.env.VALIDITY_DEMO_SUBMIT_RPC_URL = originalSubmit; + if (originalWs === undefined) delete process.env.VALIDITY_DEMO_WS_URL; + else process.env.VALIDITY_DEMO_WS_URL = originalWs; }); describe('validity demo RPC config', () => { @@ -28,4 +31,18 @@ describe('validity demo RPC config', () => { expect(getSubmitRpcUrl()).toBe('http://127.0.0.1:8545'); delete process.env.VALIDITY_DEMO_RPC_URL; }); + + it('derives the public Vibenet /ws URL from HTTPS RPC', () => { + delete process.env.VALIDITY_DEMO_WS_URL; + expect(wsUrlFromHttp('https://rpc.vibes.base.org')).toBe('wss://rpc.vibes.base.org/ws'); + process.env.VALIDITY_DEMO_RPC_URL = 'https://rpc.vibes.base.org'; + expect(getWsRpcUrl()).toBe('wss://rpc.vibes.base.org/ws'); + delete process.env.VALIDITY_DEMO_RPC_URL; + }); + + it('lets VALIDITY_DEMO_WS_URL win', () => { + process.env.VALIDITY_DEMO_WS_URL = 'wss://example.test/ws'; + expect(getWsRpcUrl()).toBe('wss://example.test/ws'); + delete process.env.VALIDITY_DEMO_WS_URL; + }); }); diff --git a/app/api/vibenet/validity/config.ts b/app/api/vibenet/validity/config.ts index c796faf..0adfe68 100644 --- a/app/api/vibenet/validity/config.ts +++ b/app/api/vibenet/validity/config.ts @@ -24,6 +24,23 @@ export function rpcHost(url: string): string { } } +/** Map an HTTP JSON-RPC URL to the usual `/ws` WebSocket path. */ +export function wsUrlFromHttp(httpUrl: string): string | null { + try { + const url = new URL(httpUrl); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + if (url.pathname === '/' || url.pathname === '') url.pathname = '/ws'; + return url.toString(); + } catch { + return null; + } +} + +export function getWsRpcUrl(): string | null { + return trimEnv('VALIDITY_DEMO_WS_URL') ?? wsUrlFromHttp(getReadRpcUrl()); +} + export const SUBMIT_METHODS = new Set([ 'eth_sendRawTransaction', 'eth_sendRawTransactionSync', diff --git a/app/api/vibenet/validity/status/route.ts b/app/api/vibenet/validity/status/route.ts index a6c4b49..f547057 100644 --- a/app/api/vibenet/validity/status/route.ts +++ b/app/api/vibenet/validity/status/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; -import { getReadRpcUrl, getSubmitRpcUrl, rpcHost } from '../config'; +import { getReadRpcUrl, getSubmitRpcUrl, getWsRpcUrl, rpcHost } from '../config'; import { forwardJsonRpc } from '../forward'; type JsonRpcResponse = { @@ -76,6 +76,7 @@ export async function GET() { genesisHash, readHost, submitHost, + wsUrl: getWsRpcUrl(), validitySupported, blockNumberPredicate, validityError: validity.error?.message ?? null, diff --git a/app/vibenet/demos/account/useAccountEngine.tsx b/app/vibenet/demos/account/useAccountEngine.tsx index 3f19ec8..e317790 100644 --- a/app/vibenet/demos/account/useAccountEngine.tsx +++ b/app/vibenet/demos/account/useAccountEngine.tsx @@ -887,8 +887,12 @@ function useAccountEngineCore() { // pins them here instead. seqOpt?: { nonceSequence?: bigint; + nonceKey?: bigint; + validBefore?: bigint; assumeDeployed?: boolean; estimateRevert?: 'fallback' | 'throw' | 'force'; + maxFeePerGas?: bigint; + maxPriorityFeePerGas?: bigint; }, ): Promise<{ serialized: Hex; nextSeq: number }> => { const signer = await buildSigner(signerWS); @@ -956,11 +960,12 @@ function useAccountEngineCore() { const plainCallCount = Math.max(totalCalls - heavyCallCount, 1); const wire = encodeWalletCalls({ account: account.address, calls: phases }); + const nonceKey = seqOpt?.nonceKey ?? 0n; const nonceSequence = seqOpt?.nonceSequence ?? (await getTransactionCount(makeRpcClient(), { address: account.address as Address, - nonceKey: 0n, + nonceKey, })); // Authenticator hint so estimateGas shapes the senderAuth stub for the @@ -1055,10 +1060,11 @@ function useAccountEngineCore() { accountChanges, calls: wire, metadata: meta, - nonceKey: 0n, + nonceKey, nonceSequence, - maxFeePerGas: 1_000_000_000n, - maxPriorityFeePerGas: 1_000_000n, + ...(seqOpt?.validBefore !== undefined ? { validBefore: seqOpt.validBefore } : {}), + maxFeePerGas: seqOpt?.maxFeePerGas ?? 1_000_000_000n, + maxPriorityFeePerGas: seqOpt?.maxPriorityFeePerGas ?? 1_000_000n, gas: gasLimit, // A local payer signs `payerAuth` here, so don't stub it out. ...(payerOpt ? { payer: payerOpt.address, ...(payerOpt.localSigner ? {} : { payerAuth: '0x' as Hex }) } : {}), @@ -1148,6 +1154,72 @@ function useAccountEngineCore() { return { hash, serialized, mode: tokenGas ? 'token' : 'self' }; }; + // Sign + broadcast from a specific stored account (not necessarily the active + // one). Validity's simulated makers are delegated sub-accounts; switching + // `activeAccountId` to send from them would steal the user's selection. + const signerForAccount = (account: StoredAccount): WalletSigner => { + const parent = account.parentId ? (accounts.find((item) => item.id === account.parentId) ?? null) : null; + const ownerIds = new Set(); + for (const owner of account.owners) if (owner.signerId) ownerIds.add(owner.signerId); + if (parent) for (const owner of parent.owners) if (owner.signerId) ownerIds.add(owner.signerId); + const candidates = signers.filter((signer) => ownerIds.has(signer.id)); + const spare = candidates.find( + (signer) => + signer.kind === 'k1' && + signer.privateKey && + account.owners.some((owner) => owner.signerId === signer.id), + ); + const signer = spare ?? candidates[0]; + if (!signer) throw new Error(`No local owner key found for ${account.label}.`); + return signer; + }; + + const sendAccountCalls = async ({ + account, + calls, + wait = true, + seqOpt, + metadata, + }: { + account: StoredAccount; + calls: { to: Address; data: Hex; value?: string }[]; + wait?: boolean; + seqOpt?: { + nonceSequence?: bigint; + nonceKey?: bigint; + validBefore?: bigint; + assumeDeployed?: boolean; + maxFeePerGas?: bigint; + maxPriorityFeePerGas?: bigint; + }; + metadata?: string; + }): Promise<{ hash: Hex; serialized: Hex; nextSeq: number }> => { + if (!calls.length) throw new Error('No calls to send.'); + const signer = signerForAccount(account); + const { serialized, nextSeq } = await signComposed( + account, + signer, + calls.map((call) => newCallRow({ to: call.to, data: call.data, value: call.value ?? '0' })), + [], + null, + metadata?.trim() ? toHex(metadata.trim()) : undefined, + undefined, + undefined, + seqOpt, + ); + if (wait) { + const hash = await broadcast8130(serialized); + applyLandedBundle(account, nextSeq, []); + return { hash, serialized, nextSeq }; + } + const hash = (await makeRpcClient().request({ + method: 'eth_sendRawTransaction', + params: [serialized], + })) as Hex; + applyLandedBundle(account, nextSeq, []); + return { hash, serialized, nextSeq }; + }; + /** * Run several transactions from the active account back to back. * @@ -1871,10 +1943,14 @@ function useAccountEngineCore() { // Derive + store a delegated sub-account (its own address, controlled by this // account via key.delegate). `withSpareKey` also mints a fresh owner key you // hold, so you can spend from the sub-account without your main keys. - const doCreateSubAccount = (label: string, opts?: { withSpareKey?: boolean }): AppSubAccount | null => { - if (!acct) return null; + const doCreateSubAccount = ( + label: string, + opts?: { withSpareKey?: boolean; parent?: StoredAccount }, + ): { sub: AppSubAccount; account: StoredAccount } | null => { + const parent = opts?.parent ?? acct; + if (!parent) return null; const subSalt = randomHex32() as Hex; - const actors = [key.delegate(acct.address)]; + const actors = [key.delegate(parent.address)]; const signerIds: string[] = []; let spare: WalletSigner | null = null; if (opts?.withSpareKey) { @@ -1892,11 +1968,11 @@ function useAccountEngineCore() { }); const sub: AppSubAccount = { id: crypto.randomUUID(), - label: label.trim() || `Sub-account ${acct.subAccounts.length + 1}`, + label: label.trim() || `Sub-account ${parent.subAccounts.length + 1}`, salt: subSalt, address: subAddress, signerIds, - delegateTo: acct.address, + delegateTo: parent.address, createdAt: Date.now(), }; // Selectable account record for the sub. The on-chain owner is the parent (via @@ -1906,11 +1982,11 @@ function useAccountEngineCore() { // owner and stays selectable on its own. const delegateActor: StoredActor = { signerId: '', - actorId: key.delegate(acct.address).actorId, + actorId: key.delegate(parent.address).actorId, authenticator: canonicalAuthenticators.delegate, kind: 'k1', - label: `${acct.label} (delegate)`, - identity: acct.address, + label: `${parent.label} (delegate)`, + identity: parent.address, scope: 0, }; const subStoredActors = sortActors([delegateActor, ...(spare ? [toStoredActor(spare)] : [])]); @@ -1918,7 +1994,7 @@ function useAccountEngineCore() { id: crypto.randomUUID(), label: sub.label, type: 'smart', - parentId: acct.id, + parentId: parent.id, saltField: '', salt: subSalt, address: subAddress, @@ -1930,17 +2006,17 @@ function useAccountEngineCore() { subAccounts: [], createdAt: Date.now(), }; - updateAccount(acct.id, (a) => ({ ...a, subAccounts: [...a.subAccounts, sub] })); + updateAccount(parent.id, (a) => ({ ...a, subAccounts: [...a.subAccounts, sub] })); setAccounts((prev) => [...prev, subRecord]); pushActivity({ kind: 'subaccount', title: `Sub-account created · ${sub.label}`, - detail: `Delegates to ${short(acct.address)}`, + detail: `Delegates to ${short(parent.address)}`, changes: ['owner: this account', ...(spare ? [`owner: ${spare.label}`] : [])], account: subAddress, }); autoFundNewAccount(subAddress); - return sub; + return { sub, account: subRecord }; }; return { @@ -2004,6 +2080,7 @@ function useAccountEngineCore() { broadcast8130, signComposed, sendActiveCalls, + sendAccountCalls, sendActiveCallsBatches, applyLandedBundle, pendingBundleFor, diff --git a/app/vibenet/demos/validity/ValidityDemo.tsx b/app/vibenet/demos/validity/ValidityDemo.tsx index 24f533f..753afab 100644 --- a/app/vibenet/demos/validity/ValidityDemo.tsx +++ b/app/vibenet/demos/validity/ValidityDemo.tsx @@ -1,8 +1,8 @@ 'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { Account, Hex, PublicClient, WalletClient } from 'viem'; -import { formatEther } from 'viem'; +import { formatEther, parseEther, type Hex, type PublicClient } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; import { trackValidityOrder } from '../../../analytics/events'; import { Button } from '../../../components/ui/Button'; @@ -10,7 +10,12 @@ import { Card } from '../../../components/ui/Card'; import { cn } from '../../../components/ui/cn'; import { Text } from '../../../components/ui/Text'; import { CopyableValue } from '../../components/CopyableValue'; +import { AccountDemoShell } from '../_components/AccountDemoShell'; import { DemoHeader } from '../_components/DemoHeader'; +import { newCallRow } from '../account/library/calls'; +import type { StoredAccount } from '../account/library/model'; +import { ActivityLog } from '../account/components/ActivityLog'; +import { AccountEngineProvider, useAccountEngine } from '../account/useAccountEngine'; import { OrderList } from './components/OrderList'; import { OrderTicket } from './components/OrderTicket'; import { PriceCandles, type FillMark, type PriceLevel, type PriceSample } from './components/PriceCandles'; @@ -19,16 +24,19 @@ import { ValidityJson } from './components/ValidityJson'; import { amountOutAtLimit, deployAmm, + encodeApprove, encodeHelperSwap, + fillQuoteFromPairLogs, fillQuoteFromSwapReceipt, getReserves, - signCall, + reservesFromSyncLog, tokenBalance, } from './lib/amm'; -import { clampNoncelessExpiry, signNoncelessCall } from './lib/aa'; -import { startBots, allNeedGas, botNeedsGas, refuelValue } from './lib/bots'; -import { BLOCK_MS, MAX_EXPIRY_SECONDS, MAX_NONCELESS_SECONDS } from './lib/constants'; -import { faucetErrorMessage, seedEthFromFaucet } from './lib/faucet'; +import { clampNoncelessExpiry, noncelessFields } from './lib/aa'; +import { startBots, allNeedGas } from './lib/bots'; +import { MAX_EXPIRY_SECONDS, MAX_NONCELESS_SECONDS } from './lib/constants'; +import { faucetErrorMessage } from './lib/faucet'; +import { ensureMakers, rootAccount } from './lib/makers'; import { maxBlockForExpiry, occupyingOrder, @@ -37,8 +45,8 @@ import { restingOrderToReplace, tapeCrossedAt, } from './lib/orders'; -import { bumpReplacementFees, isReplacementUnderpriced, padFees } from './lib/fees'; -import { applyOffsetBps, blockExpiryPredicate, formatPrice, priceValidity, spotPastTarget } from './lib/predicates'; +import { bumpReplacementFees, feesFromHead, isReplacementUnderpriced, padFees } from './lib/fees'; +import { applyOffsetBps, blockExpiryPredicate, formatPrice, priceValidity } from './lib/predicates'; import { ammPriceFromQuote, ammSide, @@ -55,14 +63,19 @@ import { makePublicClient, makeWalletClient, sendValidityTransaction, + type RpcSend, } from './lib/rpc'; -import { accountsFrom, createState, dropDeployment, loadState, saveState, type StoredState } from './lib/store'; +import { connectJsonRpcStream, headNumber, type StreamHead, type StreamLog } from './lib/stream'; +import { createState, dropDeployment, loadState, saveState, type StoredState } from './lib/store'; import type { ChainStatus, PlacedOrder, Rectangle, Reserves, Side, SubmitMode } from './lib/types'; -const POLL_MS = BLOCK_MS; -/** Denim heads are 200ms. viem's default 4s block cache would skip dozens. */ -const BLOCK_POLL_MS = BLOCK_MS; +/** HTTP fallback when the read host has no `/ws`. Submit is always HTTP. + * The socket carries heads, pair logs, and remaining reads (balances, receipts). */ +const SYNC_MS = 1_000; +const BALANCE_MS = 5_000; const DEFAULT_SIZE_FRACTION = 50n; // 1/50 of inventory +const OWNER_DEPLOY_GAS = parseEther('0.05'); +const OWNER_DEPLOY_SEND = '0.06'; function wadToNumber(wad: bigint): number { return Number(wad) / 1e18; @@ -73,6 +86,17 @@ function newId(): string { } export function ValidityDemo() { + return ( + + + + ); +} + +function ValidityDemoInner() { + const engine = useAccountEngine(); + const acct = engine.acct; + const [status, setStatus] = useState(null); const [statusError, setStatusError] = useState(null); const [state, setState] = useState(null); @@ -94,17 +118,20 @@ export function ValidityDemo() { const [makerError, setMakerError] = useState(null); const [makersDry, setMakersDry] = useState(false); const [blockNumber, setBlockNumber] = useState(null); + const [streamLive, setStreamLive] = useState(false); const publicRef = useRef(null); - const userWalletRef = useRef(null); - const userAccountRef = useRef(null); + const rpcSendRef = useRef(null); + const headFeesRef = useRef>(null); + const makerNonceRef = useRef<(bigint | null)[]>([]); + const engineRef = useRef(engine); + engineRef.current = engine; const botsEnabledRef = useRef(true); botsEnabledRef.current = botsOn; - const busyRef = useRef(false); - busyRef.current = busy; - const refuelInFlightRef = useRef(false); const lastMakerPriceAtRef = useRef(0); - const autoFaucetRef = useRef(false); + const makersRef = useRef([]); + const makerEthRef = useRef<(bigint | null)[]>([]); + const makerTokenRef = useRef>({}); const ordersRef = useRef([]); ordersRef.current = orders; @@ -112,6 +139,8 @@ export function ValidityDemo() { reservesRef.current = reserves; const samplesRef = useRef([]); samplesRef.current = samples; + const stateRef = useRef(null); + stateRef.current = state; const persist = useCallback((next: StoredState) => { saveState(next); @@ -126,6 +155,29 @@ export function ValidityDemo() { }); }, []); + const parent = useMemo( + () => (acct ? rootAccount(acct, engine.accounts) : null), + [acct, engine.accounts], + ); + + const makers = useMemo(() => { + if (!parent) return [] as StoredAccount[]; + const ids = state?.makerAccountIds; + const resolved = (ids ?? []) + .map((id) => engine.accounts.find((item) => item.id === id)) + .filter((item): item is StoredAccount => Boolean(item)); + if (resolved.length === 2) return resolved; + return engine.accounts.filter((item) => item.parentId === parent.id && item.label.startsWith('Validity maker')); + }, [engine.accounts, parent, state?.makerAccountIds]); + makersRef.current = makers; + + const poolForThisAccount = Boolean( + state?.deployment && + (!state.accountId || + parent?.id === state.accountId || + (acct && state.makerAccountIds?.includes(acct.id))), + ); + useEffect(() => { let cancelled = false; fetchChainStatus() @@ -143,8 +195,7 @@ export function ValidityDemo() { const created = createState(next.chainId, next.genesisHash); persist(created); } - const chain = chainFromId(next.chainId); - publicRef.current = makePublicClient(chain); + publicRef.current = makePublicClient(chainFromId(next.chainId), () => rpcSendRef.current); }) .catch((err: unknown) => { if (!cancelled) setStatusError(err instanceof Error ? err.message : 'Could not reach the validity RPC proxy.'); @@ -157,287 +208,304 @@ export function ValidityDemo() { }; }, [persist]); - const accounts = useMemo(() => (state ? accountsFrom(state) : null), [state]); - useEffect(() => { - if (!status?.chainId || !accounts) return; - const chain = chainFromId(status.chainId); - publicRef.current = makePublicClient(chain); - userAccountRef.current = accounts.user; - userWalletRef.current = makeWalletClient(chain, accounts.user); - }, [accounts, status?.chainId]); - - const refreshBalances = useCallback(async () => { - const client = publicRef.current; - const account = userAccountRef.current; - if (!client || !account) return; - const [eth, latestReserves] = await Promise.all([ - client.getBalance({ address: account.address }), - state?.deployment ? getReserves(client, state.deployment.pair).catch(() => null) : Promise.resolve(null), - ]); - setEthBalance(eth); - if (latestReserves && state?.deployment) { - setReserves(latestReserves); - const quote = quoteWad(latestReserves.reserve0, latestReserves.reserve1, vibeIsToken0(state.deployment)); - pushSample(Number(quote) / 1e18); - } - }, [pushSample, state?.deployment]); + if (!status?.chainId) return; + publicRef.current = makePublicClient(chainFromId(status.chainId), () => rpcSendRef.current); + }, [status?.chainId]); - useEffect(() => { - if (!hydrated || !accounts) return; - void refreshBalances().catch(() => {}); - const id = window.setInterval(() => { - void refreshBalances().catch(() => {}); - }, POLL_MS); - return () => window.clearInterval(id); - }, [accounts, hydrated, refreshBalances]); - - const refuelBots = useCallback(async (): Promise => { - const publicClient = publicRef.current; - const wallet = userWalletRef.current; - const account = userAccountRef.current; - if (!publicClient || !accounts || busyRef.current || refuelInFlightRef.current) return true; - refuelInFlightRef.current = true; - let needed = false; - let refilled = false; - try { - let userBal = await publicClient.getBalance({ address: accounts.user.address }); - for (const bot of accounts.bots) { - const bal = await publicClient.getBalance({ address: bot.address }); - if (!botNeedsGas(bal)) continue; - needed = true; - const value = refuelValue(bal, userBal); - if (value === 0n || !wallet || !account) continue; - await wallet.sendTransaction({ - account, - chain: wallet.chain, - to: bot.address, - value, - }); - userBal -= value; - refilled = true; + const patchOrders = useCallback((patch: (order: PlacedOrder) => PlacedOrder) => { + let changed = false; + const next = ordersRef.current.map((order) => { + const updated = patch(order); + if (updated !== order) changed = true; + return updated; + }); + if (!changed) return; + ordersRef.current = next; + setOrders(next); + }, []); + + const expireOrders = useCallback( + (block: bigint | null) => { + const now = Date.now(); + const wallExpired = ordersRef.current.filter((order) => orderWallClockExpired(order, now)); + if (wallExpired.length > 0) { + const ids = new Set(wallExpired.map((order) => order.id)); + patchOrders((item) => + ids.has(item.id) && item.status === 'pending' ? { ...item, status: 'expired' } : item, + ); + for (const order of wallExpired) trackValidityOrder(order.side, 'expired'); } - } finally { - refuelInFlightRef.current = false; - } - return !needed || refilled; - }, [accounts]); + if (block === null) return; + const blockExpired = ordersRef.current.filter((order) => orderBlockExpired(order, block)); + if (blockExpired.length === 0) return; + const ids = new Set(blockExpired.map((order) => order.id)); + patchOrders((item) => + ids.has(item.id) && item.status === 'pending' ? { ...item, status: 'expired' } : item, + ); + for (const order of blockExpired) trackValidityOrder(order.side, 'expired'); + }, + [patchOrders], + ); - useEffect(() => { - if (!hydrated || !accounts || !state?.deployment) return; - const id = window.setInterval(() => { - void refuelBots(); - }, 8_000); - return () => window.clearInterval(id); - }, [accounts, hydrated, refuelBots, state?.deployment]); + const markOrderLanded = useCallback( + (txHash: Hex, filled: boolean, fillPriceWad?: bigint) => { + const wanted = txHash.toLowerCase(); + const order = ordersRef.current.find((item) => item.txHash?.toLowerCase() === wanted); + if (!order || (order.status !== 'pending' && order.status !== 'expired')) return; + const target = wadToNumber(order.targetPriceWad); + const crossed = tapeCrossedAt(samplesRef.current, order.submittedAt, target, order.side); + const filledAt = filled ? (crossed ?? Date.now()) : undefined; + const clamped = filled + ? clampToCondition(order.side, fillPriceWad ?? order.targetPriceWad, order.targetPriceWad) + : undefined; + const wasPending = order.status === 'pending'; + patchOrders((item) => + item.id === order.id + ? { + ...item, + status: filled ? 'filled' : 'error', + filledAt: filled ? (item.filledAt ?? filledAt) : item.filledAt, + fillPriceWad: filled ? (item.fillPriceWad ?? clamped) : item.fillPriceWad, + } + : item, + ); + if (filled) trackValidityOrder(order.side, 'filled'); + else if (wasPending) trackValidityOrder(order.side, 'error'); + }, + [patchOrders], + ); + + const applyReceipts = useCallback( + async (client: PublicClient, pending: PlacedOrder[], block: bigint | null) => { + const withTimeout = (promise: Promise, ms: number): Promise => + new Promise((resolve, reject) => { + const timer = window.setTimeout(() => reject(new Error('rpc timeout')), ms); + promise.then( + (value) => { + window.clearTimeout(timer); + resolve(value); + }, + (err: unknown) => { + window.clearTimeout(timer); + reject(err); + }, + ); + }); + + const receipts = await Promise.all( + pending.map((order) => + order.txHash + ? withTimeout(client.getTransactionReceipt({ hash: order.txHash }), 2_500).catch(() => null) + : Promise.resolve(null), + ), + ); + const deployment = stateRef.current?.deployment; + const vibeToken0Now = Boolean(deployment && vibeIsToken0(deployment)); + + for (let i = 0; i < pending.length; i += 1) { + const order = pending[i]; + const receipt = receipts[i]; + if (!receipt || !order.txHash) continue; + const filled = receipt.status === 'success'; + const observed = filled && deployment + ? fillQuoteFromSwapReceipt(receipt, deployment.pair, vibeToken0Now) + : undefined; + markOrderLanded(order.txHash, filled, observed); + } + expireOrders(block); + }, + [expireOrders, markOrderLanded], + ); useEffect(() => { - if (!hydrated || !status?.chainId) return; + if (!hydrated || !acct || !status?.chainId) return; + const client = publicRef.current; + if (!client) return; let cancelled = false; let inFlight = false; - const tick = async () => { - const client = publicRef.current; - if (!client || inFlight) return; + let pollId: number | undefined; + let balanceId: number | undefined; + let stream: ReturnType | undefined; + const logsByTx = new Map(); + + const applyMakerParts = (deployment: StoredState['deployment'], makerParts: unknown[]) => { + const makerList = makersRef.current; + const stride = deployment ? 3 : 1; + makerEthRef.current = makerList.map((_, index) => { + const value = makerParts[index * stride]; + return typeof value === 'bigint' ? value : null; + }); + const tokens: Record = {}; + if (deployment) { + makerList.forEach((maker, index) => { + const vibe = makerParts[index * stride + 1]; + const usdv = makerParts[index * stride + 2]; + if (typeof vibe === 'bigint') tokens[`${maker.address}:${deployment.tokenA}`] = vibe; + if (typeof usdv === 'bigint') tokens[`${maker.address}:${deployment.tokenB}`] = usdv; + }); + } + makerTokenRef.current = tokens; + }; + + const pullBalances = async (includeReserves: boolean) => { + const deployment = stateRef.current?.deployment; + const makerList = makersRef.current; + const jobs: Promise[] = [client.getBalance({ address: acct.address })]; + if (includeReserves) { + jobs.push(deployment ? getReserves(client, deployment.pair).catch(() => null) : Promise.resolve(null)); + } + for (const maker of makerList) { + jobs.push(client.getBalance({ address: maker.address }).catch(() => null)); + if (deployment) { + jobs.push(tokenBalance(client, deployment.tokenA, maker.address).catch(() => null)); + jobs.push(tokenBalance(client, deployment.tokenB, maker.address).catch(() => null)); + } + } + const [eth, ...rest] = await Promise.all(jobs); + if (cancelled) return; + if (typeof eth === 'bigint') setEthBalance(eth); + if (includeReserves) { + const latestReserves = rest[0]; + const makerParts = rest.slice(1); + if (latestReserves && deployment) { + const latest = latestReserves as Reserves; + setReserves(latest); + const quote = quoteWad(latest.reserve0, latest.reserve1, vibeIsToken0(deployment)); + pushSample(Number(quote) / 1e18); + } + applyMakerParts(deployment, makerParts); + return; + } + applyMakerParts(deployment, rest); + }; + + const pollTick = async () => { + if (cancelled || inFlight) return; inFlight = true; try { + const pending = pendingWithHash(); const block = await client.getBlockNumber({ cacheTime: 0 }); - if (!cancelled) setBlockNumber((prev) => (prev === block ? prev : block)); + if (cancelled) return; + setBlockNumber((prev) => (prev === block ? prev : block)); + await pullBalances(true); + if (pending.length > 0) await applyReceipts(client, pending, block); } catch { - // keep the last block we saw + // keep last snapshot } finally { inFlight = false; } }; - void tick(); - const id = window.setInterval(() => { - void tick(); - }, BLOCK_POLL_MS); - return () => { - cancelled = true; - window.clearInterval(id); - }; - }, [hydrated, status?.chainId]); - useEffect(() => { - if (!status?.chainId || !state?.deployment || !accounts) return; - const chain = chainFromId(status.chainId); - const publicClient = makePublicClient(chain); - const wallets = accounts.bots.map((bot) => makeWalletClient(chain, bot)); - const stop = startBots({ - publicClient, - wallets, - accounts: [...accounts.bots], - deployment: state.deployment, - enabled: () => botsEnabledRef.current, - onPrice: (price) => { - lastMakerPriceAtRef.current = Date.now(); - pushSample(price); - setMakerError(null); - setMakersDry(false); - }, - onError: setMakerError, - onGasLow: () => { - void (async () => { - const ok = await refuelBots(); - if (Date.now() - lastMakerPriceAtRef.current < 2_500) return; - const client = publicRef.current; - if (!client || !accounts) return; - const balances = await Promise.all( - accounts.bots.map((bot) => client.getBalance({ address: bot.address })), - ); - if (!allNeedGas(balances)) return; - setMakersDry(true); - if (!ok) setMakerError('need ETH'); - })(); - }, - }); - return stop; - }, [accounts, pushSample, refuelBots, state?.deployment, status?.chainId]); - - // Watch pending orders for inclusion / expiry. Refs so reserve polling cannot - // reset the interval before it ever fires. Wall-clock expiry does not wait on RPC. - useEffect(() => { - let cancelled = false; - let inFlight = false; - - const withTimeout = (promise: Promise, ms: number): Promise => - new Promise((resolve, reject) => { - const timer = window.setTimeout(() => reject(new Error('rpc timeout')), ms); - promise.then( - (value) => { - window.clearTimeout(timer); - resolve(value); - }, - (err: unknown) => { - window.clearTimeout(timer); - reject(err); - }, - ); - }); + const pendingWithHash = () => + ordersRef.current.filter( + (order) => order.txHash && (order.status === 'pending' || order.status === 'expired'), + ); - const patchOrders = (patch: (order: PlacedOrder) => PlacedOrder) => { - let changed = false; - const next = ordersRef.current.map((order) => { - const updated = patch(order); - if (updated !== order) changed = true; - return updated; - }); - if (!changed) return; - ordersRef.current = next; - setOrders(next); + const stopBalances = () => { + if (balanceId === undefined) return; + window.clearInterval(balanceId); + balanceId = undefined; }; - const tick = async () => { - if (inFlight || cancelled) return; - inFlight = true; - try { - const latest = ordersRef.current; - if (latest.length === 0) return; - - const client = publicRef.current; - if (!client) return; - - const reservesNow = reservesRef.current; - const deployment = state?.deployment; - const spot = - reservesNow && deployment - ? quoteWad(reservesNow.reserve0, reservesNow.reserve1, vibeIsToken0(deployment)) - : null; - - for (const order of ordersRef.current) { - if (!order.txHash || (order.status !== 'pending' && order.status !== 'expired')) continue; - const receipt = await withTimeout( - client.getTransactionReceipt({ hash: order.txHash }), - 2_500, - ).catch(() => null); - if (cancelled) return; - if (!receipt) continue; - const filled = receipt.status === 'success'; - const vibeToken0Now = Boolean(deployment && vibeIsToken0(deployment)); - const observed = filled - ? (deployment - ? fillQuoteFromSwapReceipt(receipt, deployment.pair, vibeToken0Now) - : undefined) - : undefined; - const fillPriceWad = filled - ? clampToCondition(order.side, observed ?? order.targetPriceWad, order.targetPriceWad) - : undefined; - const target = wadToNumber(order.targetPriceWad); - const crossed = tapeCrossedAt(samplesRef.current, order.submittedAt, target, order.side); - let filledAt = crossed; - if (filled && filledAt === undefined) { - const header = await withTimeout( - client.getBlock({ blockNumber: receipt.blockNumber }), - 2_500, - ).catch(() => null); - filledAt = header ? Number(header.timestamp) * 1000 : Date.now(); - } - const wasPending = order.status === 'pending'; - patchOrders((item) => - item.id === order.id - ? { - ...item, - status: filled ? 'filled' : 'error', - filledAt: filled ? (item.filledAt ?? filledAt) : item.filledAt, - fillPriceWad: filled ? (item.fillPriceWad ?? fillPriceWad) : item.fillPriceWad, - } - : item, - ); - if (wasPending) { - trackValidityOrder(order.side, filled ? 'filled' : 'error'); - } - } - - const now = Date.now(); - const wallExpired = ordersRef.current.filter((order) => orderWallClockExpired(order, now)); - if (wallExpired.length > 0) { - const ids = new Set(wallExpired.map((order) => order.id)); - patchOrders((item) => - ids.has(item.id) && item.status === 'pending' ? { ...item, status: 'expired' } : item, - ); - for (const order of wallExpired) { - trackValidityOrder(order.side, 'expired'); - } - } + const startPoll = () => { + if (pollId !== undefined) return; + stopBalances(); + rpcSendRef.current = null; + setStreamLive(false); + void pollTick(); + pollId = window.setInterval(() => { + void pollTick(); + }, SYNC_MS); + }; - const block = await withTimeout(client.getBlockNumber({ cacheTime: 0 }), 2_500).catch(() => null); - if (cancelled) return; - if (block !== null) { - const blockExpired = ordersRef.current.filter((order) => orderBlockExpired(order, block)); - if (blockExpired.length > 0) { - const ids = new Set(blockExpired.map((order) => order.id)); - patchOrders((item) => - ids.has(item.id) && item.status === 'pending' ? { ...item, status: 'expired' } : item, - ); - for (const order of blockExpired) { - trackValidityOrder(order.side, 'expired'); - } - } - } + const handleLog = (raw: unknown) => { + const log = raw as StreamLog; + if (!log?.address || !log.topics?.length || !log.data) return; + const deployment = stateRef.current?.deployment; + if (!deployment) return; + const tx = log.transactionHash?.toLowerCase(); + const pending = tx + ? ordersRef.current.find( + (order) => + order.txHash?.toLowerCase() === tx && (order.status === 'pending' || order.status === 'expired'), + ) + : undefined; + if (tx && pending) { + const bucket = logsByTx.get(tx) ?? []; + bucket.push(log); + logsByTx.set(tx, bucket); + if (bucket.length > 8) logsByTx.delete(tx); + } + const sync = reservesFromSyncLog(log); + if (sync) { + setReserves(sync); + const quote = quoteWad(sync.reserve0, sync.reserve1, vibeIsToken0(deployment)); + pushSample(Number(quote) / 1e18); + } + if (!tx || !pending) return; + const observed = fillQuoteFromPairLogs(logsByTx.get(tx) ?? [log], deployment.pair, vibeIsToken0(deployment)); + if (observed === undefined) return; + logsByTx.delete(tx); + markOrderLanded(pending.txHash!, true, observed); + }; - if (spot) { - patchOrders((order) => - order.status === 'expired' && - !order.crossedAfterExpiry && - spotPastTarget(spot, order.targetPriceWad, order.side) - ? { ...order, crossedAfterExpiry: true } - : order, - ); - } - } finally { - inFlight = false; + const startStream = async (wsUrl: string) => { + stream = connectJsonRpcStream(wsUrl); + stream.setOnClose(() => { + rpcSendRef.current = null; + if (!cancelled) startPoll(); + }); + await stream.ready; + rpcSendRef.current = (method, params) => stream!.request(method, params); + let lastReceiptAt = 0; + await stream.subscribe(['newHeads'], (raw) => { + const head = raw as StreamHead; + const number = headNumber(head); + const fees = feesFromHead(head); + if (fees) headFeesRef.current = fees; + if (number === null) return; + setBlockNumber((prev) => (prev === number ? prev : number)); + expireOrders(number); + const pending = pendingWithHash(); + if (pending.length === 0 || Date.now() - lastReceiptAt < SYNC_MS) return; + lastReceiptAt = Date.now(); + void applyReceipts(client, pending, number).catch(() => {}); + }); + const pair = stateRef.current?.deployment?.pair; + if (pair) { + await stream.subscribe(['logs', { address: pair }], handleLog); } + if (cancelled) { + stream.close(); + return; + } + setStreamLive(true); + void pullBalances(true); + balanceId = window.setInterval(() => { + void pullBalances(false).catch(() => {}); + }, BALANCE_MS); }; - const id = window.setInterval(() => { - void tick(); - }, 250); - void tick(); + if (status.wsUrl) { + void startStream(status.wsUrl).catch(() => { + rpcSendRef.current = null; + stream?.close(); + if (!cancelled) startPoll(); + }); + } else { + startPoll(); + } + return () => { cancelled = true; - window.clearInterval(id); + rpcSendRef.current = null; + if (pollId !== undefined) window.clearInterval(pollId); + if (balanceId !== undefined) window.clearInterval(balanceId); + stream?.close(); + setStreamLive(false); }; - }, [state?.deployment, status?.chainId]); + }, [acct, applyReceipts, expireOrders, hydrated, markOrderLanded, pushSample, status?.chainId, status?.wsUrl, state?.deployment?.pair]); const vibeToken0 = Boolean(state?.deployment && vibeIsToken0(state.deployment)); const k = reserves ? reserves.reserve0 * reserves.reserve1 : 0n; @@ -516,67 +584,90 @@ export function ValidityDemo() { }, [hoveredOrderId, orders]); const fund = useCallback(async () => { - if (!accounts) return; - const publicClient = publicRef.current; - if (!publicClient) return; setBusy(true); setError(null); try { setProgress('Requesting ETH from the faucet'); - await seedEthFromFaucet(accounts.user.address, () => - publicClient.getBalance({ address: accounts.user.address }), - ); - await refreshBalances(); + await engine.requestFaucet(); } catch (err) { setError(faucetErrorMessage(err)); } finally { setBusy(false); setProgress(null); } - }, [accounts, refreshBalances]); - - useEffect(() => { - if (!hydrated || !accounts || busy || autoFaucetRef.current) return; - if (ethBalance === null) return; - if (ethBalance > 0n) { - autoFaucetRef.current = true; - return; - } - autoFaucetRef.current = true; - void fund(); - }, [accounts, busy, ethBalance, fund, hydrated]); + }, [engine]); const deploy = async () => { - if (!accounts || !status?.chainId) return; - const wallet = userWalletRef.current; + if (!acct || !parent || !status?.chainId) return; const publicClient = publicRef.current; - const account = userAccountRef.current; - if (!wallet || !publicClient || !account || !state) return; + if (!publicClient) return; + const k1 = engine.ownerSigners.find((signer) => signer.kind === 'k1' && signer.privateKey); + if (!k1?.privateKey) { + setError('Pool deploy needs a K1 owner key on this account. Add one in Accounts.'); + return; + } setBusy(true); setError(null); try { + const [makerA, makerB] = ensureMakers( + parent, + engine.accounts, + state?.makerAccountIds, + engine.doCreateSubAccount, + ); + persist({ + ...(state ?? createState(status.chainId, status.genesisHash ?? '')), + accountId: parent.id, + makerAccountIds: [makerA.id, makerB.id], + }); + + const eoa = privateKeyToAccount(k1.privateKey); + const eoaBal = await publicClient.getBalance({ address: eoa.address }); + if (eoaBal < OWNER_DEPLOY_GAS) { + setProgress('Sending ETH to the owner key for contract creates'); + await engine.sendActiveCalls({ + calls: [{ to: eoa.address, data: '0x', value: OWNER_DEPLOY_SEND }], + metadata: 'Validity deploy gas', + }); + } + + const chain = chainFromId(status.chainId); + const wallet = makeWalletClient(chain, eoa); + const traders = [acct.address, makerA.address, makerB.address]; const deployment = await deployAmm({ wallet, publicClient, - account, - extraRecipients: accounts.bots.map((bot) => bot.address), + account: eoa, + traders, onProgress: setProgress, }); - persist({ ...state, deployment }); - setProgress('Seeding bot gas'); - for (const bot of accounts.bots) { - const userBal = await publicClient.getBalance({ address: account.address }); - const value = refuelValue(0n, userBal); - if (value === 0n) continue; - const hash = await wallet.sendTransaction({ - account, - chain: wallet.chain, - to: bot.address, - value, - }); - await publicClient.waitForTransactionReceipt({ hash }); - } - await refreshBalances(); + + setProgress('Approving the swap helper'); + await engine.sendActiveCalls({ + calls: [ + encodeApprove(deployment.token0, deployment.helper), + encodeApprove(deployment.token1, deployment.helper), + ], + metadata: 'Validity helper approve', + }); + + persist({ + ...(state ?? createState(status.chainId, status.genesisHash ?? '')), + v: 2, + chainId: status.chainId, + genesisHash: status.genesisHash ?? '', + accountId: parent.id, + makerAccountIds: [makerA.id, makerB.id], + deployment, + }); + engine.pushActivity({ + kind: 'transact', + title: 'Validity pool deployed', + detail: `Pair ${deployment.pair}`, + account: acct.address, + network: engine.chain.name, + mode: engine.chain.mode, + }); } catch (err) { setError(err instanceof Error ? err.message : 'Deploy failed'); } finally { @@ -585,18 +676,75 @@ export function ValidityDemo() { } }; + const makerKey = makers.map((maker) => maker.id).join(','); + + useEffect(() => { + if (!hydrated || !status?.chainId || !state?.deployment || makersRef.current.length !== 2) return; + makerNonceRef.current = []; + const deployment = state.deployment; + const stop = startBots({ + addresses: makersRef.current.map((maker) => maker.address), + deployment, + reserves: () => reservesRef.current, + ethBalance: (index) => makerEthRef.current[index] ?? null, + tokenBalance: (index, token) => { + const maker = makersRef.current[index]; + if (!maker) return null; + return makerTokenRef.current[`${maker.address}:${token}`] ?? null; + }, + sendSwap: async (index, calls) => { + const maker = makersRef.current[index]; + if (!maker) throw new Error('maker missing'); + const client = publicRef.current; + let nonce = makerNonceRef.current[index] ?? null; + if (nonce === null && client) { + nonce = BigInt(await client.getTransactionCount({ address: maker.address })); + } + const nonceSequence = nonce ?? 0n; + try { + await engineRef.current.sendAccountCalls({ + account: maker, + calls: calls.map((call) => ({ ...call, value: '0' })), + wait: false, + seqOpt: { assumeDeployed: true, nonceSequence }, + }); + makerNonceRef.current[index] = nonceSequence + 1n; + } catch (err) { + makerNonceRef.current[index] = null; + throw err; + } + }, + enabled: () => botsEnabledRef.current, + onPrice: (price) => { + lastMakerPriceAtRef.current = Date.now(); + pushSample(price); + setMakerError(null); + setMakersDry(false); + }, + onError: setMakerError, + onGasLow: () => { + for (const maker of makersRef.current) engineRef.current.autoFundNewAccount(maker.address); + if (Date.now() - lastMakerPriceAtRef.current < 2_500) return; + const balances = makerEthRef.current.filter((value): value is bigint => value !== null); + if (balances.length === makersRef.current.length && allNeedGas(balances)) { + setMakersDry(true); + setMakerError('need ETH'); + } + }, + }); + return stop; + }, [hydrated, makerKey, pushSample, state?.deployment, status?.chainId]); + const placeOrder = async () => { - if (!draft || !accounts || !state?.deployment || !reserves) return; - const wallet = userWalletRef.current; + if (!draft || !acct || !state?.deployment || !reserves || !engine.activeSigner) return; const publicClient = publicRef.current; - const account = userAccountRef.current; - if (!wallet || !publicClient || !account) return; + if (!publicClient) return; setBusy(true); setError(null); const side: Side = draft.side; const tokenIn = tokenInFor(state.deployment, side === 'sell'); try { - const inventory = await tokenBalance(publicClient, tokenIn, account.address); + const inventory = await tokenBalance(publicClient, tokenIn, acct.address); const amountIn = inventory / DEFAULT_SIZE_FRACTION; if (amountIn === 0n) throw new Error('Not enough token inventory to swap.'); const outExact = amountOutAtLimit(amountIn, side, k, draft.priceWad); @@ -619,13 +767,16 @@ export function ValidityDemo() { submitMode === 'concurrent' ? clampNoncelessExpiry(expirySeconds) : Math.min(MAX_EXPIRY_SECONDS, expirySeconds); - const block = await publicClient.getBlockNumber({ cacheTime: 0 }); + const block = blockNumber ?? (await publicClient.getBlockNumber({ cacheTime: 0 })); const maxBlock = maxBlockForExpiry(block, seconds); const validity = [...draft.predicates]; if (status?.blockNumberPredicate) { validity.push(blockExpiryPredicate(maxBlock)); } - const estimated = await publicClient.estimateFeesPerGas().catch(() => null); + const fromHead = headFeesRef.current; + const estimated = + fromHead ?? + (await publicClient.estimateFeesPerGas().catch(() => null)); const padded = estimated?.maxFeePerGas !== undefined && estimated.maxPriorityFeePerGas !== undefined ? padFees({ @@ -638,23 +789,35 @@ export function ValidityDemo() { let nonce: number | undefined; let fees = padded; let replaced: ReturnType; + const rows = [newCallRow({ to: call.to, data: call.data, value: '0' })]; if (submitMode === 'concurrent') { replaced = undefined; - const signed = await signNoncelessCall({ - privateKey: state.userKey, - chainId: status?.chainId ?? state.chainId, - to: call.to, - data: call.data, - expiresIn: seconds, - fees: padded, - publicClient, - }); - hash = await sendValidityTransaction(publicClient, signed.signed, validity); + const fields = noncelessFields(seconds); + const { serialized } = await engine.signComposed( + acct, + engine.activeSigner, + rows, + [], + null, + undefined, + undefined, + undefined, + { + nonceKey: fields.nonceKey, + nonceSequence: 0n, + validBefore: fields.validBefore, + maxFeePerGas: padded?.maxFeePerGas, + maxPriorityFeePerGas: padded?.maxPriorityFeePerGas, + }, + ); + hash = await sendValidityTransaction(serialized, validity); } else { - const confirmedNonce = await publicClient.getTransactionCount({ - address: account.address, - blockTag: 'latest', - }); + const confirmedNonce = Number( + await publicClient.getTransactionCount({ + address: acct.address, + blockTag: 'latest', + }), + ); const occupant = occupyingOrder(ordersRef.current, confirmedNonce); replaced = restingOrderToReplace(ordersRef.current, confirmedNonce); if (occupant?.maxFeePerGas !== undefined && occupant.maxPriorityFeePerGas !== undefined) { @@ -667,25 +830,21 @@ export function ValidityDemo() { ); } const sign = (nextFees: typeof fees) => - signCall({ - wallet, - publicClient, - account, - to: call.to, - data: call.data, - nonce: confirmedNonce, - fees: nextFees, + engine.signComposed(acct, engine.activeSigner!, rows, [], null, undefined, undefined, undefined, { + nonceSequence: BigInt(confirmedNonce), + maxFeePerGas: nextFees?.maxFeePerGas, + maxPriorityFeePerGas: nextFees?.maxPriorityFeePerGas, }); let signedResult = await sign(fees); try { - hash = await sendValidityTransaction(publicClient, signedResult.signed, validity); + hash = await sendValidityTransaction(signedResult.serialized, validity); } catch (err) { - if (!isReplacementUnderpriced(err) || !signedResult.fees) throw err; - signedResult = await sign(bumpReplacementFees(signedResult.fees, padded)); - hash = await sendValidityTransaction(publicClient, signedResult.signed, validity); + if (!isReplacementUnderpriced(err) || !fees) throw err; + fees = bumpReplacementFees(fees, padded); + signedResult = await sign(fees); + hash = await sendValidityTransaction(signedResult.serialized, validity); } - nonce = signedResult.nonce; - fees = signedResult.fees; + nonce = confirmedNonce; } const order: PlacedOrder = { id: newId(), @@ -715,6 +874,15 @@ export function ValidityDemo() { return [order, ...next]; }); if (replaced) trackValidityOrder(replaced.side, 'replaced'); + engine.pushActivity({ + kind: 'transact', + title: `Validity ${side} submitted`, + detail: submitMode === 'concurrent' ? '8130 concurrent' : '8130 replace', + account: acct.address, + txHash: hash, + network: engine.chain.name, + mode: engine.chain.mode, + }); } catch (err) { const message = describeValidityError(err); setError(message); @@ -754,14 +922,20 @@ export function ValidityDemo() { persist(dropDeployment(state)); }; - const address = accounts?.user.address; + const address = acct?.address; const funded = (ethBalance ?? 0n) > 0n; - const deployed = Boolean(state?.deployment); - - if (!hydrated) return
; + const deployed = Boolean(poolForThisAccount); return ( -
+ } + activityCount={engine.activity.length} + activityEmptyMessage="Nothing has happened yet." + > + {!hydrated || !engine.hydrated ? ( +
+ ) : ( +
{status?.readHost ?? 'no rpc'} - 200ms blocks + {streamLive ? '200ms heads' : '200ms blocks'} validity {status?.validitySupported ? 'on' : 'unavailable'} {status?.blockNumberPredicate ? block bounds on : client-side expiry only} simulation {botsOn ? (makersDry ? 'out of ETH' : 'live') : 'paused'} @@ -807,10 +981,9 @@ export function ValidityDemo() { Simulated pool - A local key signs the swaps — type-2 replacements, or 8130 nonceless - txs so several conditions can rest at once. The faucet funds it, then - you deploy a VIBE/USDV pool. Simulated flow moves the mid so you can - see a price condition fire — or expire unused. + Your Vibenet account signs the swaps — several 8130 conditions can rest + at once, or one sequenced replacement. Deploy creates two maker + subaccounts that move the simulated mid. {address ? (
@@ -829,8 +1002,8 @@ export function ValidityDemo() { {error ? {error} : null} {progress ? {progress} : null}
-
)} -
+
+ )} + ); } diff --git a/app/vibenet/demos/validity/components/OrderList.tsx b/app/vibenet/demos/validity/components/OrderList.tsx index 1a94be2..1d18a10 100644 --- a/app/vibenet/demos/validity/components/OrderList.tsx +++ b/app/vibenet/demos/validity/components/OrderList.tsx @@ -143,11 +143,6 @@ export function OrderList({ orders, highlightedOrderId, onHighlight }: Props) { View transaction ) : null} - {order.status === 'expired' && order.crossedAfterExpiry ? ( - - Spot later crossed this price. The expired transaction was not included. - - ) : null} {order.error ? ( {order.error.length > 240 ? `${order.error.slice(0, 237)}…` : order.error} diff --git a/app/vibenet/demos/validity/lib/aa.test.ts b/app/vibenet/demos/validity/lib/aa.test.ts index 00ab8e8..fa22155 100644 --- a/app/vibenet/demos/validity/lib/aa.test.ts +++ b/app/vibenet/demos/validity/lib/aa.test.ts @@ -1,7 +1,7 @@ -import { generatePrivateKey, nonceKeyMax } from '@aa'; +import { nonceKeyMax } from '@aa'; import { describe, expect, it } from 'vitest'; -import { clampNoncelessExpiry, noncelessFields, signNoncelessCall } from './aa'; +import { clampNoncelessExpiry, noncelessFields } from './aa'; describe('noncelessFields', () => { it('uses nonceKeyMax and no sequence so concurrent txs do not replace', () => { @@ -16,17 +16,3 @@ describe('noncelessFields', () => { expect(noncelessFields(60, 1_000).validBefore).toBe(21_000n); }); }); - -describe('signNoncelessCall', () => { - it('signs a type-0x79 envelope so validity can wrap an 8130 tx', async () => { - const { signed } = await signNoncelessCall({ - privateKey: generatePrivateKey(), - chainId: 84538453, - to: '0x1111111111111111111111111111111111111111', - data: '0x', - expiresIn: 15, - publicClient: { getCode: async () => '0x' } as never, - }); - expect(signed.slice(0, 4).toLowerCase()).toBe('0x79'); - }); -}); diff --git a/app/vibenet/demos/validity/lib/aa.ts b/app/vibenet/demos/validity/lib/aa.ts index 1786afb..a006920 100644 --- a/app/vibenet/demos/validity/lib/aa.ts +++ b/app/vibenet/demos/validity/lib/aa.ts @@ -1,16 +1,6 @@ -import { - defaultAccountAddress, - encodeWalletCalls, - nonceFreeMaxExpiryWindow, - nonceKeyMax, - privateKeyToAccount, - toEoaAccount, - type Hex, -} from '@aa'; -import type { Address, PublicClient } from 'viem'; +import { nonceKeyMax } from '@aa'; import { MAX_NONCELESS_SECONDS } from './constants'; -import type { FeeFields } from './fees'; export function clampNoncelessExpiry(seconds: number): number { return Math.min(Math.max(1, seconds), MAX_NONCELESS_SECONDS); @@ -24,40 +14,3 @@ export function noncelessFields(expiresIn: number, now = Date.now()) { validBefore: BigInt(now + seconds * 1000), }; } - -const FALLBACK_FEES = { - maxFeePerGas: 1_000_000_000n, - maxPriorityFeePerGas: 1_000_000n, -} as const; - -/** Sign the helper swap as an EIP-8130 nonceless tx from the demo EOA. */ -export async function signNoncelessCall(args: { - privateKey: Hex; - chainId: number; - to: Address; - data: Hex; - expiresIn: number; - fees?: FeeFields | null; - publicClient: PublicClient; -}): Promise<{ signed: Hex; validBefore: bigint }> { - const signer = privateKeyToAccount(args.privateKey); - const account = toEoaAccount(signer); - const code = await args.publicClient.getCode({ address: account.address }); - const accountChanges = !code || code === '0x' ? [account.delegate(defaultAccountAddress)] : []; - const calls = encodeWalletCalls({ - account: account.address, - calls: [[{ to: args.to, data: args.data, value: 0n }]], - }); - const fields = noncelessFields(args.expiresIn); - const signed = await account.signTransaction({ - chainId: args.chainId, - accountChanges, - calls, - ...fields, - gas: accountChanges.length > 0 ? 800_000n : 400_000n, - ...(args.fees ?? FALLBACK_FEES), - }); - return { signed, validBefore: fields.validBefore }; -} - -export const NONCELESS_WINDOW_MS = Number(nonceFreeMaxExpiryWindow); diff --git a/app/vibenet/demos/validity/lib/amm.test.ts b/app/vibenet/demos/validity/lib/amm.test.ts index ff0312b..1539cb5 100644 --- a/app/vibenet/demos/validity/lib/amm.test.ts +++ b/app/vibenet/demos/validity/lib/amm.test.ts @@ -1,6 +1,7 @@ +import { encodeAbiParameters, encodeEventTopics, parseAbi, zeroAddress } from 'viem'; import { describe, expect, it } from 'vitest'; -import { amountOut, amountOutAtLimit } from './amm'; +import { amountOut, amountOutAtLimit, reservesFromSyncLog } from './amm'; import { SEED_USDV, SEED_VIBE, WAD } from './constants'; describe('amountOut', () => { @@ -31,3 +32,44 @@ describe('amountOutAtLimit', () => { expect(((fill - limit) * 10_000n) / limit).toBeLessThan(100n); }); }); + +describe('reservesFromSyncLog', () => { + it('decodes Uni v2 Sync reserves', () => { + const abi = parseAbi(['event Sync(uint112 reserve0, uint112 reserve1)']); + const [topic] = encodeEventTopics({ abi, eventName: 'Sync' }); + const log = { + address: zeroAddress, + topics: [topic], + data: encodeAbiParameters( + [{ type: 'uint112' }, { type: 'uint112' }], + [1_000n * WAD, 70n * WAD], + ), + }; + expect(reservesFromSyncLog(log)).toEqual({ + reserve0: 1_000n * WAD, + reserve1: 70n * WAD, + blockTimestampLast: 0, + }); + }); + + it('ignores a Swap topic', () => { + const abi = parseAbi([ + 'event Swap(address indexed sender, uint256 amount0In, uint256 amount1In, uint256 amount0Out, uint256 amount1Out, address indexed to)', + ]); + const topics = encodeEventTopics({ + abi, + eventName: 'Swap', + args: { sender: zeroAddress, to: zeroAddress }, + }); + expect( + reservesFromSyncLog({ + address: zeroAddress, + topics, + data: encodeAbiParameters( + [{ type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' }], + [1n, 0n, 0n, 1n], + ), + }), + ).toBeUndefined(); + }); +}); diff --git a/app/vibenet/demos/validity/lib/amm.ts b/app/vibenet/demos/validity/lib/amm.ts index 64ee189..d10a108 100644 --- a/app/vibenet/demos/validity/lib/amm.ts +++ b/app/vibenet/demos/validity/lib/amm.ts @@ -34,7 +34,6 @@ import { helperBytecode, pairAbi, } from './constants'; -import { padFees, type FeeFields } from './fees'; import { sqrt } from './predicates'; import { quoteFromPreSwapReserves, USDV_NAME, USDV_SYMBOL, VIBE_NAME, VIBE_SYMBOL } from './quote'; import type { Deployment, Reserves, Side } from './types'; @@ -52,7 +51,7 @@ async function wait( const receipt = await publicClient.waitForTransactionReceipt({ hash, timeout: 120_000, - pollingInterval: 250, + pollingInterval: 1_000, }); if (receipt.status === 'reverted') { throw new Error(`Transaction reverted (${hash})`); @@ -174,8 +173,27 @@ export function amountOutAtLimit( return side === 'buy' ? amountOut(amountIn, usdv, vibe) : amountOut(amountIn, vibe, usdv); } -export function fillQuoteFromSwapReceipt( - receipt: TransactionReceipt, +export function reservesFromSyncLog(log: { + address: Address; + topics: Hex[]; + data: Hex; +}): Reserves | undefined { + try { + const syncs = parseEventLogs({ + abi: pairEvents, + eventName: 'Sync', + logs: [log as never], + }); + const sync = syncs[0]; + if (sync?.args.reserve0 === undefined || sync.args.reserve1 === undefined) return undefined; + return { reserve0: sync.args.reserve0, reserve1: sync.args.reserve1, blockTimestampLast: 0 }; + } catch { + return undefined; + } +} + +export function fillQuoteFromPairLogs( + logs: { address: Address; topics: Hex[]; data: Hex }[], pair: Address, vibeToken0: boolean, ): bigint | undefined { @@ -184,12 +202,12 @@ export function fillQuoteFromSwapReceipt( const swaps = parseEventLogs({ abi: pairEvents, eventName: 'Swap', - logs: receipt.logs, + logs: logs as never, }); const syncs = parseEventLogs({ abi: pairEvents, eventName: 'Sync', - logs: receipt.logs, + logs: logs as never, }); const swap = [...swaps].reverse().find((ev) => ev.address.toLowerCase() === wanted); const sync = [...syncs].reverse().find((ev) => ev.address.toLowerCase() === wanted); @@ -219,14 +237,22 @@ export function fillQuoteFromSwapReceipt( } } +export function fillQuoteFromSwapReceipt( + receipt: TransactionReceipt, + pair: Address, + vibeToken0: boolean, +): bigint | undefined { + return fillQuoteFromPairLogs(receipt.logs, pair, vibeToken0); +} + export async function deployAmm(args: { wallet: WalletClient; publicClient: PublicClient; account: Account; - extraRecipients: Address[]; + traders: Address[]; onProgress?: (label: string) => void; }): Promise { - const { wallet, publicClient, account, extraRecipients, onProgress } = args; + const { wallet, publicClient, account, traders, onProgress } = args; const note = (label: string) => onProgress?.(label); note('Deploying VIBE'); @@ -345,8 +371,7 @@ export async function deployAmm(args: { await waitForBytecode(publicClient, helper, 'Swap helper'); note('Minting trader inventory'); - const recipients = [account.address, ...extraRecipients]; - for (const recipient of recipients) { + for (const recipient of traders) { await mintTo(tokenA, recipient, TRADER_VIBE); await mintTo(tokenB, recipient, TRADER_USDV); } @@ -367,129 +392,61 @@ export async function deployAmm(args: { return { tokenA, tokenB, token0, token1, factory, pair, helper }; } -export function encodeHelperSwap(args: { - helper: Address; - tokenIn: Address; - pair: Address; - amountIn: bigint; - amount0Out: bigint; - amount1Out: bigint; -}): { to: Address; data: Hex } { +export function encodeApprove(token: Address, spender: Address): { to: Address; data: Hex } { return { - to: args.helper, + to: token, data: encodeFunctionData({ - abi: helperAbi, - functionName: 'swap', - args: [args.tokenIn, args.pair, args.amountIn, args.amount0Out, args.amount1Out], + abi: erc20Abi, + functionName: 'approve', + args: [spender, 2n ** 256n - 1n], }), }; } -export async function swapExactIn(args: { - wallet: WalletClient; - publicClient: PublicClient; - account: Account; - pair: Address; +export function encodeSwapLegs(args: { tokenIn: Address; + pair: Address; + recipient: Address; amountIn: bigint; amount0Out: bigint; amount1Out: bigint; - nonce?: number; - waitForReceipt?: boolean; - fees?: FeeFields | null; -}): Promise<{ hash: Hex; nextNonce: number; fees: FeeFields | null }> { - const { wallet, publicClient, account, pair, tokenIn, amountIn, amount0Out, amount1Out } = args; - const [nonce, estimated] = await Promise.all([ - args.nonce !== undefined - ? Promise.resolve(args.nonce) - : publicClient.getTransactionCount({ address: account.address, blockTag: 'pending' }), - args.fees ? Promise.resolve(null) : publicClient.estimateFeesPerGas().catch(() => null), - ]); - const fees: FeeFields | null = args.fees - ?? (estimated?.maxFeePerGas !== undefined && estimated.maxPriorityFeePerGas !== undefined - ? padFees({ maxFeePerGas: estimated.maxFeePerGas, maxPriorityFeePerGas: estimated.maxPriorityFeePerGas }) - : null); - const feeFields = fees ?? {}; - await wallet.sendTransaction({ - account, - chain: wallet.chain, - to: tokenIn, - data: encodeFunctionData({ - abi: erc20Abi, - functionName: 'transfer', - args: [pair, amountIn], - }), - nonce, - ...feeFields, - }); - const hash = await wallet.sendTransaction({ - account, - chain: wallet.chain, - to: pair, - data: encodeFunctionData({ - abi: pairAbi, - functionName: 'swap', - args: [amount0Out, amount1Out, account.address, '0x'], - }), - nonce: nonce + 1, - gas: 300_000n, - ...feeFields, - }); - if (args.waitForReceipt !== false) await wait(publicClient, hash); - return { hash, nextNonce: nonce + 2, fees }; -} - -export async function tokenAllowance( - publicClient: PublicClient, - token: Address, - owner: Address, - spender: Address, -): Promise { - return (await publicClient.readContract({ - address: token, - abi: erc20Abi, - functionName: 'allowance', - args: [owner, spender], - })) as bigint; -} - -export async function approveMax(args: { - wallet: WalletClient; - publicClient: PublicClient; - account: Account; - token: Address; - spender: Address; -}): Promise { - const { wallet, publicClient, account, token, spender } = args; - await send(wallet, publicClient, account, { - to: token, - data: encodeFunctionData({ - abi: erc20Abi, - functionName: 'approve', - args: [spender, 2n ** 256n - 1n], - }), - }); +}): { to: Address; data: Hex }[] { + return [ + { + to: args.tokenIn, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [args.pair, args.amountIn], + }), + }, + { + to: args.pair, + data: encodeFunctionData({ + abi: pairAbi, + functionName: 'swap', + args: [args.amount0Out, args.amount1Out, args.recipient, '0x'], + }), + }, + ]; } -/** One-tx swap through SwapHelper (bots need a prior approve). */ -export async function swapExactInHelper(args: { - wallet: WalletClient; - publicClient: PublicClient; - account: Account; +export function encodeHelperSwap(args: { helper: Address; - pair: Address; tokenIn: Address; + pair: Address; amountIn: bigint; amount0Out: bigint; amount1Out: bigint; -}): Promise { - const call = encodeHelperSwap(args); - const receipt = await send(args.wallet, args.publicClient, args.account, { - to: call.to, - data: call.data, - gas: 400_000n, - }); - return receipt.transactionHash; +}): { to: Address; data: Hex } { + return { + to: args.helper, + data: encodeFunctionData({ + abi: helperAbi, + functionName: 'swap', + args: [args.tokenIn, args.pair, args.amountIn, args.amount0Out, args.amount1Out], + }), + }; } export async function tokenBalance( @@ -504,35 +461,3 @@ export async function tokenBalance( args: [owner], })) as bigint; } - -export async function signCall(args: { - wallet: WalletClient; - publicClient: PublicClient; - account: Account; - to: Address; - data: Hex; - nonce?: number; - fees?: FeeFields | null; -}): Promise<{ signed: Hex; nonce: number; fees: FeeFields | null }> { - const { wallet, publicClient, account, to, data } = args; - const [nonce, estimated] = await Promise.all([ - args.nonce !== undefined - ? Promise.resolve(args.nonce) - : publicClient.getTransactionCount({ address: account.address, blockTag: 'pending' }), - args.fees ? Promise.resolve(null) : publicClient.estimateFeesPerGas().catch(() => null), - ]); - const fees: FeeFields | null = args.fees - ?? (estimated?.maxFeePerGas !== undefined && estimated.maxPriorityFeePerGas !== undefined - ? padFees({ maxFeePerGas: estimated.maxFeePerGas, maxPriorityFeePerGas: estimated.maxPriorityFeePerGas }) - : null); - const signed = await wallet.signTransaction({ - account, - chain: wallet.chain, - to, - data, - nonce, - gas: 400_000n, - ...(fees ?? {}), - }); - return { signed, nonce, fees }; -} diff --git a/app/vibenet/demos/validity/lib/bots.test.ts b/app/vibenet/demos/validity/lib/bots.test.ts index 6fa03c9..fcc5dc2 100644 --- a/app/vibenet/demos/validity/lib/bots.test.ts +++ b/app/vibenet/demos/validity/lib/bots.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { BOT_GAS_FLOOR, BOT_GAS_REFILL, USER_GAS_RESERVE, allNeedGas, botNeedsGas, fractionForPriceMove, makerTargetPrice, planSwap, refuelValue } from './bots'; +import { BOT_GAS_FLOOR, allNeedGas, botNeedsGas, fractionForPriceMove, makerTargetPrice, planSwap } from './bots'; describe('fractionForPriceMove', () => { it('sizes a 1% price step at about half a percent of reserves', () => { @@ -33,14 +33,10 @@ describe('planSwap', () => { }); }); -describe('refuelValue', () => { - it('tops a dry maker up to the refill target without taking the trader reserve', () => { +describe('botNeedsGas', () => { + it('is true below the floor', () => { expect(botNeedsGas(0n)).toBe(true); expect(botNeedsGas(BOT_GAS_FLOOR)).toBe(false); - expect(refuelValue(0n, BOT_GAS_REFILL + USER_GAS_RESERVE)).toBe(BOT_GAS_REFILL); - expect(refuelValue(BOT_GAS_FLOOR, 10n ** 18n)).toBe(0n); - expect(refuelValue(0n, USER_GAS_RESERVE)).toBe(0n); - expect(refuelValue(0n, USER_GAS_RESERVE + 1_000n)).toBe(1_000n); }); }); diff --git a/app/vibenet/demos/validity/lib/bots.ts b/app/vibenet/demos/validity/lib/bots.ts index 91d3d3e..2281331 100644 --- a/app/vibenet/demos/validity/lib/bots.ts +++ b/app/vibenet/demos/validity/lib/bots.ts @@ -1,13 +1,6 @@ -import { parseEther, type Account, type Address, type PublicClient, type WalletClient } from 'viem'; +import { parseEther, type Address, type Hex } from 'viem'; -import { amountOut, getReserves, swapExactIn, tokenBalance } from './amm'; -import { - bumpReplacementFees, - isInsufficientFunds, - isNonceTooLow, - isReplacementUnderpriced, - type FeeFields, -} from './fees'; +import { amountOut, encodeSwapLegs } from './amm'; import { quoteWad, swapOuts, @@ -16,7 +9,7 @@ import { vibeIsToken0, vibeReserve, } from './quote'; -import type { Deployment } from './types'; +import type { Deployment, Reserves } from './types'; const ANCHOR = 0.07; const SLOW_PERIOD_MS = 24_000; @@ -26,10 +19,9 @@ const FAST_AMPLITUDE = 0.012; const PRICE_MOVE = 0.01; const HARD_LO = 0.01; const HARD_HI = 1; -const TICK_MS = 240; +/** One maker swap per second is enough to walk the mid. */ +const TICK_MS = 1_000; export const BOT_GAS_FLOOR = parseEther('0.002'); -export const BOT_GAS_REFILL = parseEther('0.03'); -export const USER_GAS_RESERVE = parseEther('0.008'); const GAS_LOW_MS = 4_000; function clamp(n: number, lo: number, hi: number): number { @@ -44,16 +36,6 @@ export function allNeedGas(balances: readonly bigint[]): boolean { return balances.length > 0 && balances.every((balance) => botNeedsGas(balance)); } -/** ETH the trader can send a dry maker without stranding their own swaps. */ -export function refuelValue(botBalance: bigint, userBalance: bigint): bigint { - if (!botNeedsGas(botBalance)) return 0n; - const room = userBalance > USER_GAS_RESERVE ? userBalance - USER_GAS_RESERVE : 0n; - if (room === 0n) return 0n; - const target = botBalance >= BOT_GAS_REFILL ? 0n : BOT_GAS_REFILL - botBalance; - if (target === 0n) return 0n; - return target < room ? target : room; -} - /** * Reserve-in fraction that moves Uni v2 mid by `move` (0.01 = 1%). * Because p ∝ 1/r0², a 1% price step is about 0.5% of the input reserve. @@ -90,28 +72,42 @@ export function planSwap( return { sellVibe, fraction: fractionForPriceMove(move) }; } +export type MakerSwapCalls = { to: Address; data: Hex }[]; + /** - * One ~1% swap per block toward a shared USDV/VIBE target. + * One ~1% swap per second toward a shared USDV/VIBE target. Reserves, gas, and + * inventory come from the demo sync so this loop does not add its own reads. */ export function startBots(args: { - publicClient: PublicClient; - wallets: WalletClient[]; - accounts: Account[]; + addresses: Address[]; deployment: Deployment; + reserves: () => Reserves | null; + ethBalance: (index: number) => bigint | null; + tokenBalance: (index: number, token: Address) => bigint | null; + sendSwap: (index: number, calls: MakerSwapCalls) => Promise; enabled: () => boolean; onPrice?: (price: number) => void; onError?: (message: string) => void; onGasLow?: () => void; }): () => void { - const { publicClient, wallets, accounts, deployment, enabled, onPrice, onError, onGasLow } = args; + const { + addresses, + deployment, + reserves: readReserves, + ethBalance, + tokenBalance, + sendSwap, + enabled, + onPrice, + onError, + onGasLow, + } = args; let stopped = false; let timer: ReturnType | undefined; let turn = 0; let anchor = ANCHOR; let anchored = false; let lastGasLow = 0; - const nonces: Array = []; - const fees: Array = []; const vibeToken0 = vibeIsToken0(deployment); const signalGasLow = () => { @@ -121,55 +117,17 @@ export function startBots(args: { onGasLow?.(); }; - const sendSwap = async ( - index: number, - tokenIn: Address, - used: bigint, - sellVibe: boolean, - out: bigint, - ) => { - const outs = swapOuts({ vibeToken0, sellVibe, amountOut: out }); - const attempt = async (nextFees: FeeFields | null | undefined) => - swapExactIn({ - wallet: wallets[index], - publicClient, - account: accounts[index], - pair: deployment.pair, - tokenIn, - amountIn: used, - amount0Out: outs.amount0Out, - amount1Out: outs.amount1Out, - nonce: nonces[index], - fees: nextFees, - waitForReceipt: false, - }); - - try { - return await attempt(fees[index]); - } catch (err) { - if (isReplacementUnderpriced(err)) { - const base = fees[index] ?? { - maxFeePerGas: 3_000_000_000n, - maxPriorityFeePerGas: 1_500_000_000n, - }; - const bumped = bumpReplacementFees(base); - fees[index] = bumped; - return await attempt(bumped); - } - throw err; - } - }; - const tick = async (index: number) => { if (stopped || !enabled()) return; - const account = accounts[index]; - const eth = await publicClient.getBalance({ address: account.address }); + const eth = ethBalance(index); + if (eth === null) return; if (botNeedsGas(eth)) { signalGasLow(); return; } - const { reserve0, reserve1 } = await getReserves(publicClient, deployment.pair); - if (reserve0 === 0n || reserve1 === 0n) return; + const latest = readReserves(); + if (!latest || latest.reserve0 === 0n || latest.reserve1 === 0n) return; + const { reserve0, reserve1 } = latest; const spot = Number(quoteWad(reserve0, reserve1, vibeToken0)) / 1e18; if (!Number.isFinite(spot) || spot <= 0) return; if (!anchored) { @@ -184,7 +142,8 @@ export function startBots(args: { const tokenIn = tokenInFor(deployment, sellVibe); const amountIn = (poolIn * BigInt(Math.floor(fraction * 10_000))) / 10_000n; if (amountIn === 0n) return; - const bal = await tokenBalance(publicClient, tokenIn, account.address); + const bal = tokenBalance(index, tokenIn); + if (bal === null) return; const used = amountIn <= bal ? amountIn : (bal * 8n) / 10n; if (used === 0n) throw new Error('maker inventory empty'); const reserveIn = poolIn; @@ -192,28 +151,20 @@ export function startBots(args: { ? usdvReserve(reserve0, reserve1, vibeToken0) : vibeReserve(reserve0, reserve1, vibeToken0); const exactOut = amountOut(used, reserveIn, reserveOut); - // 1 wei slack on a 0% fee pair so k stays on the validity hyperbola. const out = exactOut > 1n ? exactOut - 1n : exactOut; if (out === 0n) return; - if (nonces[index] === undefined) { - nonces[index] = await publicClient.getTransactionCount({ - address: account.address, - blockTag: 'pending', - }); - } - try { - const result = await sendSwap(index, tokenIn, used, sellVibe, out); - nonces[index] = result.nextNonce; - fees[index] = null; - } catch (err) { - if (isNonceTooLow(err)) nonces[index] = undefined; - if (isInsufficientFunds(err)) { - fees[index] = null; - signalGasLow(); - return; - } - throw err; - } + const outs = swapOuts({ vibeToken0, sellVibe, amountOut: out }); + await sendSwap( + index, + encodeSwapLegs({ + tokenIn, + pair: deployment.pair, + recipient: addresses[index], + amountIn: used, + amount0Out: outs.amount0Out, + amount1Out: outs.amount1Out, + }), + ); const nextVibe = sellVibe ? vibeReserve(reserve0, reserve1, vibeToken0) + used : vibeReserve(reserve0, reserve1, vibeToken0) - exactOut; @@ -231,7 +182,7 @@ export function startBots(args: { const loop = async () => { if (stopped) return; try { - if (enabled() && accounts.length > 0) await tick(turn % accounts.length); + if (enabled() && addresses.length > 0) await tick(turn % addresses.length); } catch (err: unknown) { const message = err instanceof Error ? err.message : 'maker swap failed'; onError?.(message.split('\n')[0] ?? message); @@ -239,12 +190,10 @@ export function startBots(args: { turn += 1; if (!stopped) timer = setTimeout(loop, TICK_MS); }; - timer = setTimeout(loop, 200); + timer = setTimeout(loop, 400); return () => { stopped = true; if (timer) clearTimeout(timer); }; } - -export type { Address }; diff --git a/app/vibenet/demos/validity/lib/constants.ts b/app/vibenet/demos/validity/lib/constants.ts index 45cfe35..1b4a7f4 100644 --- a/app/vibenet/demos/validity/lib/constants.ts +++ b/app/vibenet/demos/validity/lib/constants.ts @@ -25,8 +25,8 @@ export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Addr export const RPC_PATH = '/api/vibenet/validity/rpc'; export const STATUS_PATH = '/api/vibenet/validity/status'; -export const STORAGE_KEY = 'vibenet.validity.v3'; -export const LEGACY_STORAGE_KEYS = ['vibenet.validity.v2', 'vibenet.validity.v1'] as const; +export const STORAGE_KEY = 'vibenet.validity.v4'; +export const LEGACY_STORAGE_KEYS = ['vibenet.validity.v3', 'vibenet.validity.v2', 'vibenet.validity.v1'] as const; export const WAD = 10n ** 18n; /** ~$0.07 USDV per VIBE so the tape has room to move, not a 1:1 peg. */ @@ -50,7 +50,6 @@ export const MAX_NONCELESS_SECONDS = 20; * are on committed 200ms blocks, not 2s pre-Denim heads or 250ms flashblocks. */ export const BLOCK_SECONDS = 0.2; -export const BLOCK_MS = 200; export const CANDLE_BUCKET_MS = 200; export const CANDLE_WINDOW_MS = 30_000; diff --git a/app/vibenet/demos/validity/lib/faucet.ts b/app/vibenet/demos/validity/lib/faucet.ts index bd2aed5..fc8b3b2 100644 --- a/app/vibenet/demos/validity/lib/faucet.ts +++ b/app/vibenet/demos/validity/lib/faucet.ts @@ -1,12 +1,4 @@ -import type { Address } from 'viem'; - -import { VibenetApiError, vibenetApi } from '../../../library/client'; - -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} +import { VibenetApiError } from '../../../library/client'; export function faucetErrorMessage(err: unknown): string { if (err instanceof VibenetApiError) { @@ -15,24 +7,3 @@ export function faucetErrorMessage(err: unknown): string { } return err instanceof Error ? err.message : 'Faucet request failed.'; } - -/** Drip Vibenet ETH and wait until the address shows a balance. */ -export async function seedEthFromFaucet( - address: Address, - getBalance: () => Promise, -): Promise { - for (let attempt = 0; ; attempt += 1) { - try { - await vibenetApi.faucet.drip({ address }); - break; - } catch (err) { - if (attempt >= 3) throw err; - await sleep(11_000); - } - } - for (let i = 0; i < 30; i += 1) { - if ((await getBalance()) > 0n) return; - await sleep(2_000); - } - throw new Error('Faucet drip submitted, but ETH has not landed yet. Try again in a minute.'); -} diff --git a/app/vibenet/demos/validity/lib/fees.test.ts b/app/vibenet/demos/validity/lib/fees.test.ts index 64b9541..5d96121 100644 --- a/app/vibenet/demos/validity/lib/fees.test.ts +++ b/app/vibenet/demos/validity/lib/fees.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { bumpReplacementFees, isInsufficientFunds, isReplacementUnderpriced } from './fees'; +import { bumpReplacementFees, feesFromHead, isReplacementUnderpriced } from './fees'; describe('bumpReplacementFees', () => { it('raises tip and fee cap by at least 10%', () => { @@ -24,9 +24,15 @@ describe('isReplacementUnderpriced', () => { }); }); -describe('isInsufficientFunds', () => { - it('matches common eth_sendRawTransaction balance errors', () => { - expect(isInsufficientFunds(new Error('insufficient funds for gas * price + value'))).toBe(true); - expect(isInsufficientFunds(new Error('nonce too low'))).toBe(false); +describe('feesFromHead', () => { + it('uses 2× base fee plus the default tip', () => { + expect(feesFromHead({ baseFeePerGas: '0x3b9aca00' })).toEqual({ + maxFeePerGas: 2_000_000_000n + 1_000_000n, + maxPriorityFeePerGas: 1_000_000n, + }); + }); + + it('rejects a missing base fee', () => { + expect(feesFromHead({})).toBeNull(); }); }); diff --git a/app/vibenet/demos/validity/lib/fees.ts b/app/vibenet/demos/validity/lib/fees.ts index 6678cf6..1397072 100644 --- a/app/vibenet/demos/validity/lib/fees.ts +++ b/app/vibenet/demos/validity/lib/fees.ts @@ -26,19 +26,24 @@ export function isReplacementUnderpriced(err: unknown): boolean { return /replacement transaction underpriced|underpriced replacement/i.test(message); } -export function isNonceTooLow(err: unknown): boolean { - const message = err instanceof Error ? err.message : String(err); - return /nonce too low/i.test(message); -} - -export function isInsufficientFunds(err: unknown): boolean { - const message = err instanceof Error ? err.message : String(err); - return /insufficient funds|insufficient balance|exceeds the balance/i.test(message); -} - export function padFees(fees: FeeFields, mul = 3n): FeeFields { return { maxFeePerGas: fees.maxFeePerGas * mul, maxPriorityFeePerGas: fees.maxPriorityFeePerGas * mul, }; } + +/** Tip + 2× base fee from a `newHeads` payload so submit skips `eth_getBlockByNumber`. */ +export function feesFromHead(head: { baseFeePerGas?: string | null }): FeeFields | null { + if (!head.baseFeePerGas) return null; + try { + const base = BigInt(head.baseFeePerGas); + const maxPriorityFeePerGas = 1_000_000n; + return { + maxFeePerGas: (base === 0n ? 1_000_000_000n : base * 2n) + maxPriorityFeePerGas, + maxPriorityFeePerGas, + }; + } catch { + return null; + } +} diff --git a/app/vibenet/demos/validity/lib/makers.test.ts b/app/vibenet/demos/validity/lib/makers.test.ts new file mode 100644 index 0000000..88e761f --- /dev/null +++ b/app/vibenet/demos/validity/lib/makers.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import type { StoredAccount } from '../../account/library/model'; +import { ensureMakers, MAKER_LABELS, rootAccount } from './makers'; + +function account(partial: Partial & Pick): StoredAccount { + return { + saltField: '', + salt: '0x', + address: '0x0000000000000000000000000000000000000001', + initialActors: [], + owners: [], + deployed: false, + configSeq: 0, + sessionKeys: [], + subAccounts: [], + createdAt: 0, + ...partial, + }; +} + +describe('rootAccount', () => { + it('walks up to the parent', () => { + const root = account({ id: 'root', label: 'Root' }); + const child = account({ id: 'child', label: 'Child', parentId: 'root' }); + expect(rootAccount(child, [root, child]).id).toBe('root'); + }); +}); + +describe('ensureMakers', () => { + it('reuses stored ids and creates the rest', () => { + const parent = account({ id: 'p', label: 'Parent' }); + const existing = account({ id: 'm1', label: MAKER_LABELS[0], parentId: 'p' }); + const created: string[] = []; + const [a, b] = ensureMakers(parent, [parent, existing], ['m1', 'missing'], (label) => { + created.push(label); + return { account: account({ id: 'm2', label, parentId: 'p' }) }; + }); + expect(a.id).toBe('m1'); + expect(b.id).toBe('m2'); + expect(created).toEqual([MAKER_LABELS[1]]); + }); +}); diff --git a/app/vibenet/demos/validity/lib/makers.ts b/app/vibenet/demos/validity/lib/makers.ts new file mode 100644 index 0000000..17e8c73 --- /dev/null +++ b/app/vibenet/demos/validity/lib/makers.ts @@ -0,0 +1,48 @@ +import type { StoredAccount } from '../../account/library/model'; + +export const MAKER_LABELS = ['Validity maker A', 'Validity maker B'] as const; + +export function rootAccount(account: StoredAccount, accounts: StoredAccount[]): StoredAccount { + let current = account; + const seen = new Set([current.id]); + while (current.parentId) { + const parent = accounts.find((item) => item.id === current.parentId); + if (!parent || seen.has(parent.id)) break; + seen.add(parent.id); + current = parent; + } + return current; +} + +type CreateSub = ( + label: string, + opts?: { withSpareKey?: boolean; parent?: StoredAccount }, +) => { account: StoredAccount } | null; + +/** Find or create the two delegated maker subaccounts under `parent`. */ +export function ensureMakers( + parent: StoredAccount, + accounts: StoredAccount[], + existingIds: [string, string] | undefined, + create: CreateSub, +): [StoredAccount, StoredAccount] { + const found: StoredAccount[] = []; + for (const id of existingIds ?? []) { + const match = accounts.find((item) => item.id === id); + if (match) found.push(match); + } + for (const label of MAKER_LABELS) { + if (found.length >= 2) break; + const existing = accounts.find( + (item) => item.parentId === parent.id && item.label === label && !found.some((row) => row.id === item.id), + ); + if (existing) found.push(existing); + } + while (found.length < 2) { + const label = MAKER_LABELS[found.length] ?? `Validity maker ${found.length + 1}`; + const created = create(label, { withSpareKey: true, parent }); + if (!created) throw new Error('Could not create a maker subaccount.'); + found.push(created.account); + } + return [found[0], found[1]]; +} diff --git a/app/vibenet/demos/validity/lib/predicates.ts b/app/vibenet/demos/validity/lib/predicates.ts index 3f005a2..14a725e 100644 --- a/app/vibenet/demos/validity/lib/predicates.ts +++ b/app/vibenet/demos/validity/lib/predicates.ts @@ -170,12 +170,3 @@ export function blockExpiryPredicate(maxBlock: bigint): ValidityPredicate { params: { op: '<=', value: toWord(maxBlock) }, }; } - -export function sideFromPrices(spotWad: bigint, targetWad: bigint): Side { - return targetWad <= spotWad ? 'buy' : 'sell'; -} - -/** True when current spot is on the fill side of the target (inclusive). */ -export function spotPastTarget(spotWad: bigint, targetWad: bigint, side: Side): boolean { - return side === 'buy' ? spotWad <= targetWad : spotWad >= targetWad; -} diff --git a/app/vibenet/demos/validity/lib/quote.test.ts b/app/vibenet/demos/validity/lib/quote.test.ts index c57fc48..dec906f 100644 --- a/app/vibenet/demos/validity/lib/quote.test.ts +++ b/app/vibenet/demos/validity/lib/quote.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { WAD } from './constants'; -import { ammPriceFromQuote, ammSide, clampToCondition, quoteFromPreSwapReserves, quoteFromSwapAmounts, quoteWad, swapOuts, vibeIsToken0 } from './quote'; +import { ammPriceFromQuote, ammSide, clampToCondition, quoteFromPreSwapReserves, quoteWad, swapOuts, vibeIsToken0 } from './quote'; const deployment = { tokenA: '0x000000000000000000000000000000000000000a' as const, @@ -36,31 +36,6 @@ describe('quote', () => { }); }); - it('quotes a buy from Swap in/out amounts', () => { - // Pay 816 USDV, receive 10_000 VIBE → $0.0816 - expect( - quoteFromSwapAmounts({ - vibeToken0: true, - amount0In: 0n, - amount1In: 816n, - amount0Out: 10_000n, - amount1Out: 0n, - }), - ).toBe((816n * WAD) / 10_000n); - }); - - it('quotes a sell when VIBE is token1', () => { - expect( - quoteFromSwapAmounts({ - vibeToken0: false, - amount0In: 0n, - amount1In: 10_000n, - amount0Out: 816n, - amount1Out: 0n, - }), - ).toBe((816n * WAD) / 10_000n); - }); - it('reconstructs the pre-swap mid from Sync + Swap amounts', () => { const pre0 = 1_000n; const pre1 = 70n; diff --git a/app/vibenet/demos/validity/lib/quote.ts b/app/vibenet/demos/validity/lib/quote.ts index 7a1fd05..b814186 100644 --- a/app/vibenet/demos/validity/lib/quote.ts +++ b/app/vibenet/demos/validity/lib/quote.ts @@ -33,10 +33,6 @@ export function ammSide(side: Side, vibeToken0: boolean): Side { return side === 'buy' ? 'sell' : 'buy'; } -export function quoteFromAmmPrice(amm: bigint, vibeToken0: boolean): bigint { - return ammPriceFromQuote(amm, vibeToken0); -} - export function vibeReserve(reserve0: bigint, reserve1: bigint, vibeToken0: boolean): bigint { return vibeToken0 ? reserve0 : reserve1; } @@ -65,23 +61,6 @@ export function tokenInFor(deployment: Deployment, sellVibe: boolean): Address { return sellVibe ? deployment.tokenA : deployment.tokenB; } -/** USDV per VIBE from a Uni v2 Swap's in/out amounts. */ -export function quoteFromSwapAmounts(args: { - vibeToken0: boolean; - amount0In: bigint; - amount1In: bigint; - amount0Out: bigint; - amount1Out: bigint; -}): bigint | undefined { - const vibeIn = args.vibeToken0 ? args.amount0In : args.amount1In; - const vibeOut = args.vibeToken0 ? args.amount0Out : args.amount1Out; - const usdvIn = args.vibeToken0 ? args.amount1In : args.amount0In; - const usdvOut = args.vibeToken0 ? args.amount1Out : args.amount0Out; - if (vibeOut > 0n && usdvIn > 0n) return (usdvIn * WAD) / vibeOut; - if (vibeIn > 0n && usdvOut > 0n) return (usdvOut * WAD) / vibeIn; - return undefined; -} - /** Mid before a Swap, reconstructed from post-swap Sync + Swap amounts. */ export function quoteFromPreSwapReserves(args: { vibeToken0: boolean; diff --git a/app/vibenet/demos/validity/lib/rpc.test.ts b/app/vibenet/demos/validity/lib/rpc.test.ts index ddafa4c..184cb4e 100644 --- a/app/vibenet/demos/validity/lib/rpc.test.ts +++ b/app/vibenet/demos/validity/lib/rpc.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import { describeValidityError } from './rpc'; +import { describeValidityError, sendValidityTransaction } from './rpc'; describe('describeValidityError', () => { it('collapses viem method-not-found dumps into one sentence', () => { @@ -31,3 +31,24 @@ describe('describeValidityError', () => { ); }); }); + +describe('sendValidityTransaction', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('posts base_sendRawTransactionValidity through the HTTP proxy', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: async () => ({ result: '0xabc' }), + }); + vi.stubGlobal('fetch', fetchMock); + await expect(sendValidityTransaction('0x01', [])).resolves.toBe('0xabc'); + expect(fetchMock).toHaveBeenCalledWith( + '/api/vibenet/validity/rpc', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('base_sendRawTransactionValidity'), + }), + ); + }); +}); diff --git a/app/vibenet/demos/validity/lib/rpc.ts b/app/vibenet/demos/validity/lib/rpc.ts index 6eaeacc..759f949 100644 --- a/app/vibenet/demos/validity/lib/rpc.ts +++ b/app/vibenet/demos/validity/lib/rpc.ts @@ -1,7 +1,7 @@ import { createPublicClient, createWalletClient, - http, + custom, type Account, type Chain, type Hex, @@ -12,7 +12,37 @@ import { import { RPC_PATH, STATUS_PATH } from './constants'; import type { ChainStatus, ValidityPredicate } from './types'; -export const PROXY_TRANSPORT = http(RPC_PATH); +export type RpcSend = (method: string, params: unknown[]) => Promise; + +const WRITE_METHODS = new Set([ + 'eth_sendRawTransaction', + 'eth_sendRawTransactionSync', + 'base_sendRawTransactionValidity', +]); + +async function proxyRpc(method: string, params: unknown[]): Promise { + const response = await fetch(RPC_PATH, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + }); + const body = (await response.json()) as { result?: unknown; error?: { message?: string } }; + if (body.error?.message) throw new Error(body.error.message); + return body.result; +} + +function eip1193(getSend?: () => RpcSend | null) { + return { + request: async ({ method, params }: { method: string; params?: unknown }) => { + const args = Array.isArray(params) ? params : []; + if (!WRITE_METHODS.has(method)) { + const send = getSend?.(); + if (send) return send(method, args); + } + return proxyRpc(method, args); + }, + }; +} export function chainFromId(id: number): Chain { const name = @@ -25,12 +55,12 @@ export function chainFromId(id: number): Chain { }; } -export function makePublicClient(chain: Chain): PublicClient { - return createPublicClient({ chain, transport: PROXY_TRANSPORT, cacheTime: 0 }); +export function makePublicClient(chain: Chain, getSend?: () => RpcSend | null): PublicClient { + return createPublicClient({ chain, transport: custom(eip1193(getSend)), cacheTime: 0 }); } export function makeWalletClient(chain: Chain, account: Account): WalletClient { - return createWalletClient({ chain, account, transport: PROXY_TRANSPORT }); + return createWalletClient({ chain, account, transport: custom(eip1193()) }); } export async function fetchChainStatus(): Promise { @@ -72,22 +102,10 @@ export function describeValidityError(err: unknown): string { } export async function sendValidityTransaction( - _client: PublicClient, tx: Hex, validity: ValidityPredicate[], ): Promise { - const response = await fetch(RPC_PATH, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'base_sendRawTransactionValidity', - params: [{ tx, validity }], - }), - }); - const body = (await response.json()) as { result?: Hex; error?: { message?: string } }; - if (body.error?.message) throw new Error(body.error.message); - if (!body.result) throw new Error('Validity submit returned no hash.'); - return body.result; + const result = await proxyRpc('base_sendRawTransactionValidity', [{ tx, validity }]); + if (typeof result === 'string' && result.startsWith('0x')) return result as Hex; + throw new Error('Validity submit returned no hash.'); } diff --git a/app/vibenet/demos/validity/lib/store.test.ts b/app/vibenet/demos/validity/lib/store.test.ts new file mode 100644 index 0000000..5440f8a --- /dev/null +++ b/app/vibenet/demos/validity/lib/store.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { parseStored } from './store'; + +describe('parseStored', () => { + it('reads v2 deployment-only state', () => { + const parsed = parseStored( + JSON.stringify({ + v: 2, + chainId: 84538453, + genesisHash: '0xabc', + accountId: 'acct-1', + makerAccountIds: ['m1', 'm2'], + }), + ); + expect(parsed).toEqual({ + v: 2, + chainId: 84538453, + genesisHash: '0xabc', + accountId: 'acct-1', + makerAccountIds: ['m1', 'm2'], + deployment: undefined, + }); + }); + + it('drops v1 Validity-specific keys', () => { + const parsed = parseStored( + JSON.stringify({ + v: 1, + chainId: 1, + genesisHash: '0x1', + userKey: `0x${'11'.repeat(32)}`, + botKeys: [`0x${'22'.repeat(32)}`, `0x${'33'.repeat(32)}`], + }), + ); + expect(parsed).toEqual({ v: 2, chainId: 1, genesisHash: '0x1' }); + expect(parsed && 'userKey' in parsed).toBe(false); + }); +}); diff --git a/app/vibenet/demos/validity/lib/store.ts b/app/vibenet/demos/validity/lib/store.ts index a5d131e..d275823 100644 --- a/app/vibenet/demos/validity/lib/store.ts +++ b/app/vibenet/demos/validity/lib/store.ts @@ -1,22 +1,16 @@ -import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; -import type { Hex } from 'viem'; - import { LEGACY_STORAGE_KEYS, STORAGE_KEY } from './constants'; import type { Deployment } from './types'; export type StoredState = { - v: 1; + v: 2; chainId: number; genesisHash: string; - userKey: Hex; - botKeys: [Hex, Hex]; + /** Shared account that deployed this pool (makers are its subaccounts). */ + accountId?: string; + makerAccountIds?: [string, string]; deployment?: Deployment; }; -function isHexKey(value: unknown): value is Hex { - return typeof value === 'string' && /^0x[0-9a-fA-F]{64}$/.test(value); -} - function isAddress(value: unknown): value is `0x${string}` { return typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value); } @@ -46,6 +40,10 @@ function parseDeployment(value: unknown): Deployment | undefined { }; } +function isId(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + export function loadState(): StoredState | null { if (typeof window === 'undefined') return null; try { @@ -59,7 +57,8 @@ export function loadState(): StoredState | null { if (!legacy) continue; const migrated = parseStored(legacy); if (!migrated) continue; - const next = { ...migrated, deployment: undefined }; + // Drop the pool: v1 keyed off Validity-specific EOAs that no longer exist. + const next = dropDeployment(migrated); saveState(next); return next; } @@ -69,20 +68,33 @@ export function loadState(): StoredState | null { } } -function parseStored(raw: string): StoredState | null { - const parsed = JSON.parse(raw) as Partial; - if (parsed.v !== 1) return null; +function parseMakerIds(value: unknown): [string, string] | undefined { + if (!Array.isArray(value) || !isId(value[0]) || !isId(value[1])) return undefined; + return [value[0], value[1]]; +} + +export function parseStored(raw: string): StoredState | null { + const parsed = JSON.parse(raw) as Partial & { v?: number }; if (typeof parsed.chainId !== 'number' || typeof parsed.genesisHash !== 'string') return null; - if (!isHexKey(parsed.userKey) || !Array.isArray(parsed.botKeys)) return null; - if (!isHexKey(parsed.botKeys[0]) || !isHexKey(parsed.botKeys[1])) return null; - return { - v: 1, - chainId: parsed.chainId, - genesisHash: parsed.genesisHash, - userKey: parsed.userKey, - botKeys: [parsed.botKeys[0], parsed.botKeys[1]], - deployment: parseDeployment(parsed.deployment), - }; + if (parsed.v === 2) { + return { + v: 2, + chainId: parsed.chainId, + genesisHash: parsed.genesisHash, + accountId: isId(parsed.accountId) ? parsed.accountId : undefined, + makerAccountIds: parseMakerIds(parsed.makerAccountIds), + deployment: parseDeployment(parsed.deployment), + }; + } + // v1 Validity-specific keys are not reused — keep chain identity only. + if (parsed.v === 1) { + return { + v: 2, + chainId: parsed.chainId, + genesisHash: parsed.genesisHash, + }; + } + return null; } export function saveState(state: StoredState): void { @@ -91,27 +103,14 @@ export function saveState(state: StoredState): void { export function dropDeployment(state: StoredState): StoredState { return { - v: 1, + v: 2, chainId: state.chainId, genesisHash: state.genesisHash, - userKey: state.userKey, - botKeys: state.botKeys, + accountId: state.accountId, + makerAccountIds: state.makerAccountIds, }; } export function createState(chainId: number, genesisHash: string): StoredState { - return { - v: 1, - chainId, - genesisHash, - userKey: generatePrivateKey(), - botKeys: [generatePrivateKey(), generatePrivateKey()], - }; -} - -export function accountsFrom(state: StoredState) { - return { - user: privateKeyToAccount(state.userKey), - bots: [privateKeyToAccount(state.botKeys[0]), privateKeyToAccount(state.botKeys[1])] as const, - }; + return { v: 2, chainId, genesisHash }; } diff --git a/app/vibenet/demos/validity/lib/stream.test.ts b/app/vibenet/demos/validity/lib/stream.test.ts new file mode 100644 index 0000000..db969f1 --- /dev/null +++ b/app/vibenet/demos/validity/lib/stream.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import { headNumber } from './stream'; + +describe('headNumber', () => { + it('reads a hex block number', () => { + expect(headNumber({ number: '0x6fb4' })).toBe(28596n); + }); + + it('rejects a missing number', () => { + expect(headNumber({ number: 'nope' as `0x${string}` })).toBeNull(); + }); +}); diff --git a/app/vibenet/demos/validity/lib/stream.ts b/app/vibenet/demos/validity/lib/stream.ts new file mode 100644 index 0000000..349a8ff --- /dev/null +++ b/app/vibenet/demos/validity/lib/stream.ts @@ -0,0 +1,126 @@ +type Hex = `0x${string}`; + +type JsonRpcSuccess = { id?: unknown; result?: unknown; error?: { message?: string } }; +type SubscriptionNote = { + method?: string; + params?: { subscription?: string; result?: unknown }; +}; + +export type StreamHead = { + number: Hex; + timestamp?: Hex; + hash?: Hex; + baseFeePerGas?: Hex; +}; + +export type StreamLog = { + address: Hex; + topics: Hex[]; + data: Hex; + transactionHash?: Hex; + blockNumber?: Hex; +}; + +type Pending = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; +}; + +/** Browser JSON-RPC WebSocket with eth_subscribe. */ +export function connectJsonRpcStream(url: string) { + const ws = new WebSocket(url); + const pending = new Map(); + const listeners = new Map void>(); + let nextId = 1; + let opened = false; + let onClose: (() => void) | undefined; + + const ready = new Promise((resolve, reject) => { + const timer = window.setTimeout(() => reject(new Error('WebSocket timed out')), 8_000); + ws.addEventListener('open', () => { + window.clearTimeout(timer); + opened = true; + resolve(); + }); + ws.addEventListener('error', () => { + window.clearTimeout(timer); + if (!opened) reject(new Error('WebSocket failed')); + }); + }); + + ws.addEventListener('message', (event) => { + let body: JsonRpcSuccess & SubscriptionNote; + try { + body = JSON.parse(String(event.data)) as JsonRpcSuccess & SubscriptionNote; + } catch { + return; + } + if (body.method === 'eth_subscription') { + const id = body.params?.subscription; + if (id) listeners.get(id)?.(body.params?.result); + return; + } + if (typeof body.id !== 'number') return; + const waiter = pending.get(body.id); + if (!waiter) return; + pending.delete(body.id); + if (body.error?.message) waiter.reject(new Error(body.error.message)); + else waiter.resolve(body.result); + }); + + ws.addEventListener('close', () => { + for (const waiter of pending.values()) waiter.reject(new Error('WebSocket closed')); + pending.clear(); + onClose?.(); + }); + + const request = async (method: string, params: unknown[]): Promise => { + await ready; + if (ws.readyState !== WebSocket.OPEN) throw new Error('WebSocket closed'); + const id = nextId; + nextId += 1; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + try { + ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })); + } catch (err) { + pending.delete(id); + reject(err instanceof Error ? err : new Error('WebSocket send failed')); + } + }); + }; + + const subscribe = async (params: unknown[], onResult: (result: unknown) => void): Promise<() => void> => { + const subId = await request('eth_subscribe', params); + if (typeof subId !== 'string') throw new Error('eth_subscribe returned no id'); + listeners.set(subId, onResult); + return () => { + listeners.delete(subId); + if (ws.readyState === WebSocket.OPEN) { + void request('eth_unsubscribe', [subId]).catch(() => {}); + } + }; + }; + + return { + ready, + request, + subscribe, + setOnClose: (handler: () => void) => { + onClose = handler; + }, + close: () => { + onClose = undefined; + listeners.clear(); + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) ws.close(); + }, + }; +} + +export function headNumber(head: StreamHead): bigint | null { + try { + return BigInt(head.number); + } catch { + return null; + } +} diff --git a/app/vibenet/demos/validity/lib/types.ts b/app/vibenet/demos/validity/lib/types.ts index 5645186..8f1ca83 100644 --- a/app/vibenet/demos/validity/lib/types.ts +++ b/app/vibenet/demos/validity/lib/types.ts @@ -91,8 +91,6 @@ export type PlacedOrder = { error?: string; rectangle: Rectangle; validity: ValidityPredicate[]; - /** True once spot has crossed the target after this order expired. */ - crossedAfterExpiry?: boolean; filledAt?: number; /** Mid when the condition matched (pre-swap), never worse than the named price. */ fillPriceWad?: bigint; @@ -103,6 +101,8 @@ export type ChainStatus = { genesisHash: string | null; readHost: string; submitHost: string; + /** Browser WebSocket JSON-RPC, when the read host exposes `/ws`. */ + wsUrl: string | null; validitySupported: boolean; blockNumberPredicate: boolean; validityError: string | null;