diff --git a/.env.example b/.env.example index 18a9b3d..5fb4e16 100644 --- a/.env.example +++ b/.env.example @@ -51,3 +51,13 @@ 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 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/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/candles/route.ts b/app/api/vibenet/validity/candles/route.ts new file mode 100644 index 0000000..500edc6 --- /dev/null +++ b/app/api/vibenet/validity/candles/route.ts @@ -0,0 +1,113 @@ +import { NextResponse } from 'next/server'; +import { + decodeFunctionResult, + encodeEventTopics, + encodeFunctionData, + parseAbi, + toHex, + type Address, +} from 'viem'; + +import { pairAbi } from '../../../../vibenet/demos/validity/lib/constants'; +import { quoteWad } from '../../../../vibenet/demos/validity/lib/quote'; +import { + isAddress, + lookbackBlocks, + needsLogBackfill, + parseTapeSamples, + readTape, + samplesFromSyncLogs, + writeTape, + type RpcLog, + type TapeSample, +} from '../../../../vibenet/demos/validity/lib/tape'; +import { forwardJsonRpc } from '../forward'; + +const SYNC_TOPIC = encodeEventTopics({ + abi: parseAbi(['event Sync(uint112 reserve0, uint112 reserve1)']), + eventName: 'Sync', +})[0]; + +type JsonRpcResponse = { result?: unknown; error?: { message?: string } }; + +async function rpc(method: string, params: unknown[]): Promise { + const body = (await forwardJsonRpc({ jsonrpc: '2.0', id: 1, method, params })) as JsonRpcResponse; + if (body.error?.message || body.result === undefined || body.result === null) return null; + return body.result as T; +} + +async function backfillFromLogs(pair: Address, vibeToken0: boolean, now: number): Promise { + const latestHex = await rpc('eth_blockNumber', []); + if (!latestHex) return []; + let latest: bigint; + try { + latest = BigInt(latestHex); + } catch { + return []; + } + const lookback = lookbackBlocks(); + const from = latest > lookback ? latest - lookback : 0n; + const logs = await rpc('eth_getLogs', [ + { + address: pair, + fromBlock: toHex(from), + toBlock: 'latest', + topics: [SYNC_TOPIC], + }, + ]); + if (!logs?.length) return []; + return samplesFromSyncLogs({ logs, pair, vibeToken0, latestBlock: latest, now }); +} + +async function currentMid(pair: Address, vibeToken0: boolean): Promise { + const data = encodeFunctionData({ abi: pairAbi, functionName: 'getReserves' }); + const raw = await rpc<`0x${string}`>('eth_call', [{ to: pair, data }, 'latest']); + if (!raw) return null; + try { + const decoded = decodeFunctionResult({ + abi: pairAbi, + functionName: 'getReserves', + data: raw, + }) as [bigint, bigint, number]; + const price = Number(quoteWad(decoded[0], decoded[1], vibeToken0)) / 1e18; + return Number.isFinite(price) && price > 0 ? price : null; + } catch { + return null; + } +} + +export async function GET(request: Request) { + const url = new URL(request.url); + const pair = url.searchParams.get('pair'); + if (!isAddress(pair)) { + return NextResponse.json({ error: 'pair required' }, { status: 400 }); + } + const vibeToken0 = url.searchParams.get('vibeToken0') !== '0'; + const now = Date.now(); + let samples = readTape(pair); + if (needsLogBackfill(samples, now)) { + const fromLogs = await backfillFromLogs(pair, vibeToken0, now); + if (fromLogs.length > 0) samples = writeTape(pair, fromLogs); + } + const mid = await currentMid(pair, vibeToken0); + if (mid !== null) samples = writeTape(pair, [{ t: now, price: mid }]); + return NextResponse.json( + { samples }, + { headers: { 'Cache-Control': 'no-store' } }, + ); +} + +export async function POST(request: Request) { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'invalid json' }, { status: 400 }); + } + const record = body && typeof body === 'object' ? (body as { pair?: unknown; samples?: unknown }) : {}; + if (!isAddress(typeof record.pair === 'string' ? record.pair : null)) { + return NextResponse.json({ error: 'pair required' }, { status: 400 }); + } + const samples = writeTape(record.pair as Address, parseTapeSamples(record.samples)); + return NextResponse.json({ ok: true, count: samples.length }); +} diff --git a/app/api/vibenet/validity/config.test.ts b/app/api/vibenet/validity/config.test.ts new file mode 100644 index 0000000..fc0cfca --- /dev/null +++ b/app/api/vibenet/validity/config.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { VIBENET_RPC_URL } from '../../../vibenet/library/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', () => { + 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; + }); + + 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 new file mode 100644 index 0000000..0adfe68 --- /dev/null +++ b/app/api/vibenet/validity/config.ts @@ -0,0 +1,69 @@ +// 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'; + } +} + +/** 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', + '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..f547057 --- /dev/null +++ b/app/api/vibenet/validity/status/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from 'next/server'; + +import { getReadRpcUrl, getSubmitRpcUrl, getWsRpcUrl, 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, + wsUrl: getWsRpcUrl(), + 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/components/ExplorerLink.tsx b/app/vibenet/components/ExplorerLink.tsx index e419c3f..c5a9db3 100644 --- a/app/vibenet/components/ExplorerLink.tsx +++ b/app/vibenet/components/ExplorerLink.tsx @@ -9,17 +9,26 @@ import { useAccountNames } from './useAccountNames'; type ExplorerLinkProps = { kind: 'tx' | 'address' | 'block'; - value: string; + value: string | null | undefined; /** Override the displayed text (defaults to a shortened hash/address). */ - label?: string; + label?: string | null; className?: string; }; // Internal link into the Vibenet explorer for a tx / address / block. When the // target is a known local account, its name is shown in place of the hash (with // the truncated address alongside) so saved accounts are recognisable at a glance. +// A missing value renders a muted placeholder — pending txs often omit +// blockHash / from until they are included. export function ExplorerLink({ kind, value, label, className }: ExplorerLinkProps) { const names = useAccountNames(); + if (!value) { + return ( + + {label ?? '—'} + + ); + } const name = kind === 'address' ? names[value.toLowerCase()] : undefined; return ( 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/catalogue.test.ts b/app/vibenet/demos/catalogue.test.ts index e7c6025..34ed354 100644 --- a/app/vibenet/demos/catalogue.test.ts +++ b/app/vibenet/demos/catalogue.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { DEMOS, demoLabel } from './catalogue'; +import { DEMOS, demoLabel, listedDemos } 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', () => { @@ -41,4 +41,9 @@ describe('DEMOS', () => { const hrefs = DEMOS.map((d) => d.href); expect(new Set(hrefs).size).toBe(hrefs.length); }); + + it('keeps Validity off the Vibenet demos grid while the route still resolves', () => { + expect(listedDemos().some((demo) => demo.href === '/vibenet/demos/validity')).toBe(false); + expect(demoLabel('validity')).toBe('Validity'); + }); }); diff --git a/app/vibenet/demos/catalogue.ts b/app/vibenet/demos/catalogue.ts index 65c8c37..83e0052 100644 --- a/app/vibenet/demos/catalogue.ts +++ b/app/vibenet/demos/catalogue.ts @@ -17,8 +17,15 @@ export type DemoEntry = { summary: string; points: string[]; available: boolean; + /** When false, the route stays live but is omitted from the Vibenet demos grid. */ + listed?: boolean; }; +/** Demos shown on the Vibenet index. Unlisted entries stay reachable by URL. */ +export function listedDemos(): DemoEntry[] { + return DEMOS.filter((demo) => demo.listed !== false); +} + export const DEMOS: DemoEntry[] = [ { href: '/vibenet/demos/account', @@ -46,6 +53,20 @@ 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', + 'Stack several 8130 conditions at once, or replace the resting one', + ], + available: true, + listed: false, + }, ]; /** `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..509b9f7 --- /dev/null +++ b/app/vibenet/demos/validity/ValidityDemo.tsx @@ -0,0 +1,1360 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +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'; +import { Card } from '../../../components/ui/Card'; +import { Text } from '../../../components/ui/Text'; +import { CopyableValue } from '../../components/CopyableValue'; +import { AccountDemoShell } from '../_components/AccountDemoShell'; +import { AnimatedAmount } from '../_components/AnimatedAmount'; +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 { VIBENET_EXPLORER_PATH } from '../../library/config'; +import { CallRow } from '../_shared/CallRow'; +import { ChevronIcon } from '../_shared/dropdown'; +import { TransactionModal, type TxStep } from '../_shared/TransactionModal'; +import { OrderList } from './components/OrderList'; +import { OrderTicket } from './components/OrderTicket'; +import { PriceCandles, type FillMark, type PriceLevel, type PriceSample } from './components/PriceCandles'; +import { ValidityJson } from './components/ValidityJson'; +import { + amountInForVibe, + amountOutAtLimit, + deployAmm, + encodeHelperSwap, + fillQuoteFromPairLogs, + fillQuoteFromSwapReceipt, + getReserves, + helperApproveCalls, + inventoryMints, + reservesFromSyncLog, + tokenBalance, +} from './lib/amm'; +import { clampNoncelessExpiry, noncelessFields } from './lib/aa'; +import { startBots, allNeedGas, shouldFlagMakersDry } from './lib/bots'; +import { + CANDLE_SAMPLE_MS, + MAX_EXPIRY_SECONDS, + MAX_NONCELESS_SECONDS, + TRADE_VIBE, +} from './lib/constants'; +import { faucetErrorMessage } from './lib/faucet'; +import { ensureMakers, rootAccount } from './lib/makers'; +import { + ageRestoredOrders, + maxBlockForExpiry, + occupyingOrder, + orderBlockExpired, + orderWallClockExpired, + restingOrderToReplace, +} from './lib/orders'; +import { bumpReplacementFees, feesFromHead, isReplacementUnderpriced, padFees } from './lib/fees'; +import { reviewClauses } from './lib/annotate'; +import { applyOffsetBps, blockExpiryPredicate, formatPrice, priceValidity } from './lib/predicates'; +import { + ammPriceFromQuote, + ammSide, + clampToCondition, + formatTokenAmount, + quoteWad, + swapOuts, + tokenInFor, + USDV_SYMBOL, + vibeIsToken0, + VIBE_SYMBOL, +} from './lib/quote'; +import { + chainFromId, + describeValidityError, + fetchChainStatus, + fetchTape, + makePublicClient, + makeWalletClient, + publishTape, + sendValidityTransaction, + type RpcSend, +} from './lib/rpc'; +import { connectJsonRpcStream, headNumber, type StreamHead, type StreamLog } from './lib/stream'; +import { probeSingleton } from './lib/singleton'; +import { mergeTape } from './lib/tape'; +import { createState, loadState, saveState, type StoredState } from './lib/store'; +import type { ChainStatus, PlacedOrder, Rectangle, Reserves, Side, SubmitMode } from './lib/types'; + +/** 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 OWNER_DEPLOY_GAS = parseEther('0.08'); +const OWNER_DEPLOY_SEND = '0.1'; + +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() { + 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); + const [hydrated, setHydrated] = useState(false); + const [ethBalance, setEthBalance] = useState(null); + const [vibeBalance, setVibeBalance] = useState(null); + const [usdvBalance, setUsdvBalance] = useState(null); + const [reserves, setReserves] = useState(null); + const [progress, setProgress] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [txOpen, setTxOpen] = useState(false); + const [txStep, setTxStep] = useState('review'); + const [txHash, setTxHash] = useState(null); + const [side, setSide] = useState('buy'); + const [offsetBps, setOffsetBps] = useState(100); + const [expirySeconds, setExpirySeconds] = useState(15); + const [submitMode, setSubmitMode] = useState('concurrent'); + 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 [streamLive, setStreamLive] = useState(false); + + const publicRef = useRef(null); + const rpcSendRef = useRef(null); + const headFeesRef = useRef>(null); + const makerNonceRef = useRef<(bigint | null)[]>([]); + const makerDeployedRef = useRef([]); + const engineRef = useRef(engine); + engineRef.current = engine; + const lastMakerPriceAtRef = useRef(0); + const makersRef = useRef([]); + const makerEthRef = useRef<(bigint | null)[]>([]); + const makerTokenRef = useRef>({}); + const refreshBalancesRef = useRef<() => void>(() => {}); + + const ordersRef = useRef([]); + ordersRef.current = orders; + const reservesRef = useRef(null); + reservesRef.current = reserves; + const samplesRef = useRef([]); + samplesRef.current = samples; + const stateRef = useRef(null); + stateRef.current = state; + + const persist = useCallback((next: StoredState) => { + const stored = { ...next, orders: next.orders ?? ordersRef.current }; + saveState(stored); + setState(stored); + }, []); + + useEffect(() => { + if (!hydrated) return; + const current = stateRef.current; + if (!current) return; + const stored = { ...current, orders }; + saveState(stored); + stateRef.current = stored; + }, [hydrated, orders]); + + const pushSample = useCallback((price: number) => { + if (!Number.isFinite(price) || price <= 0) return; + setSamples((prev) => mergeTape(prev, [{ t: Date.now(), price }])); + }, []); + + useEffect(() => { + const stamp = () => { + const current = reservesRef.current; + const deployment = stateRef.current?.deployment; + if (!current || !deployment) return; + const quote = quoteWad(current.reserve0, current.reserve1, vibeIsToken0(deployment)); + pushSample(Number(quote) / 1e18); + }; + let interval: number | undefined; + const delay = CANDLE_SAMPLE_MS - (Date.now() % CANDLE_SAMPLE_MS); + const timeout = window.setTimeout(() => { + stamp(); + interval = window.setInterval(stamp, CANDLE_SAMPLE_MS); + }, delay); + stamp(); + return () => { + window.clearTimeout(timeout); + if (interval !== undefined) window.clearInterval(interval); + }; + }, [pushSample]); + + const pair = state?.deployment?.pair; + const tapeVibeToken0 = Boolean(state?.deployment && vibeIsToken0(state.deployment)); + + useEffect(() => { + if (!hydrated || !pair) return; + let cancelled = false; + void fetchTape(pair, tapeVibeToken0) + .then((remote) => { + if (cancelled || remote.length === 0) return; + setSamples((prev) => mergeTape(remote, prev)); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [hydrated, pair, tapeVibeToken0]); + + useEffect(() => { + if (!hydrated || !pair) return; + const flush = () => { + void publishTape(pair, samplesRef.current).catch(() => {}); + }; + const id = window.setInterval(flush, 2_000); + const onHide = () => { + if (document.visibilityState === 'hidden') flush(); + }; + document.addEventListener('visibilitychange', onHide); + window.addEventListener('pagehide', flush); + return () => { + window.clearInterval(id); + document.removeEventListener('visibilitychange', onHide); + window.removeEventListener('pagehide', flush); + flush(); + }; + }, [hydrated, pair]); + + 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; + + useEffect(() => { + let cancelled = false; + fetchChainStatus() + .then(async (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(); + const sameChain = Boolean( + existing && existing.chainId === next.chainId && existing.genesisHash === next.genesisHash, + ); + const base = sameChain && existing ? existing : createState(next.chainId, next.genesisHash); + const restored = sameChain ? ageRestoredOrders(existing?.orders ?? []) : []; + ordersRef.current = restored; + setOrders(restored); + const client = makePublicClient(chainFromId(next.chainId), () => rpcSendRef.current); + publicRef.current = client; + try { + const live = await probeSingleton(client); + if (cancelled) return; + persist({ ...base, orders: restored, deployment: live ?? undefined }); + } catch { + if (!cancelled) persist({ ...base, orders: restored }); + } + }) + .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]); + + useEffect(() => { + if (!status?.chainId) return; + publicRef.current = makePublicClient(chainFromId(status.chainId), () => rpcSendRef.current); + }, [status?.chainId]); + + 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'); + } + 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], + ); + + const markOrderLanded = useCallback( + (txHash: Hex, filled: boolean, fillPriceWad?: bigint, includedAt?: number) => { + const wanted = txHash.toLowerCase(); + const order = ordersRef.current.find((item) => item.txHash?.toLowerCase() === wanted); + if (!order || (order.status !== 'pending' && order.status !== 'expired')) return; + const filledAt = filled ? (includedAt ?? 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'); + refreshBalancesRef.current(); + } 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 || !acct || !status?.chainId) return; + const client = publicRef.current; + if (!client) return; + let cancelled = false; + let inFlight = false; + 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 known = makerEthRef.current.filter((value): value is bigint => value !== null); + if (known.length === makerList.length && makerList.length > 0 && !allNeedGas(known)) { + setMakersDry(false); + } + }; + + 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)); + } + if (deployment) { + jobs.push(tokenBalance(client, deployment.tokenA, acct.address).catch(() => null)); + jobs.push(tokenBalance(client, deployment.tokenB, acct.address).catch(() => 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); + let offset = 0; + if (includeReserves) { + const latestReserves = rest[offset]; + offset += 1; + if (latestReserves && deployment) { + setReserves(latestReserves as Reserves); + } + } + if (deployment) { + const vibe = rest[offset]; + const usdv = rest[offset + 1]; + offset += 2; + if (typeof vibe === 'bigint') setVibeBalance(vibe); + if (typeof usdv === 'bigint') setUsdvBalance(usdv); + } + applyMakerParts(deployment, rest.slice(offset)); + }; + refreshBalancesRef.current = () => { + void pullBalances(false).catch(() => {}); + }; + + const pollTick = async () => { + if (cancelled || inFlight) return; + inFlight = true; + try { + const pending = pendingWithHash(); + const block = await client.getBlockNumber({ cacheTime: 0 }); + if (cancelled) return; + setBlockNumber((prev) => (prev === block ? prev : block)); + await pullBalances(true); + if (pending.length > 0) await applyReceipts(client, pending, block); + } catch { + // keep last snapshot + } finally { + inFlight = false; + } + }; + + const pendingWithHash = () => + ordersRef.current.filter( + (order) => order.txHash && (order.status === 'pending' || order.status === 'expired'), + ); + + const stopBalances = () => { + if (balanceId === undefined) return; + window.clearInterval(balanceId); + balanceId = undefined; + }; + + const startPoll = () => { + if (pollId !== undefined) return; + stopBalances(); + rpcSendRef.current = null; + setStreamLive(false); + void pollTick(); + pollId = window.setInterval(() => { + void pollTick(); + }, SYNC_MS); + }; + + 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); + }; + + 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); + }; + + if (status.wsUrl) { + void startStream(status.wsUrl).catch(() => { + rpcSendRef.current = null; + stream?.close(); + if (!cancelled) startPoll(); + }); + } else { + startPoll(); + } + + return () => { + cancelled = true; + rpcSendRef.current = null; + if (pollId !== undefined) window.clearInterval(pollId); + if (balanceId !== undefined) window.clearInterval(balanceId); + stream?.close(); + setStreamLive(false); + }; + }, [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; + 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 reviewPredicates = useMemo(() => { + if (!draft) return []; + if (blockNumber === null || !status?.blockNumberPredicate) return draft.predicates; + const seconds = + submitMode === 'concurrent' + ? clampNoncelessExpiry(expirySeconds) + : Math.min(MAX_EXPIRY_SECONDS, expirySeconds); + const maxBlock = maxBlockForExpiry(blockNumber, seconds); + return [...draft.predicates, blockExpiryPredicate(maxBlock)]; + }, [blockNumber, draft, expirySeconds, status?.blockNumberPredicate, submitMode]); + + 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 () => { + setBusy(true); + setError(null); + try { + setProgress('Requesting ETH from the faucet'); + await engine.requestFaucet(); + } catch (err) { + setError(faucetErrorMessage(err)); + } finally { + setBusy(false); + setProgress(null); + } + }, [engine]); + + const deploy = async () => { + if (!acct || !parent || !status?.chainId) return; + const publicClient = publicRef.current; + 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 deployment = await deployAmm({ + wallet, + publicClient, + account: eoa, + onProgress: setProgress, + }); + + setProgress('Minting inventory and approving the helper'); + const starter = await inventoryMints(publicClient, deployment, [ + { to: acct.address }, + { to: makerA.address, mintVibe: true }, + { to: makerB.address, mintVibe: true }, + ]); + const approves = await helperApproveCalls(publicClient, deployment, acct.address); + if (starter.length + approves.length > 0) { + await engine.sendActiveCalls({ + calls: [...starter, ...approves], + metadata: 'Validity inventory', + }); + } + inventoryKeyRef.current = `${deployment.pair}:${acct.id}:${makerA.id},${makerB.id}`; + + setMakersDry(false); + setMakerError(null); + lastMakerPriceAtRef.current = 0; + 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 shared pool ready', + 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 { + setBusy(false); + setProgress(null); + } + }; + + const makerKey = makers.map((maker) => maker.id).join(','); + const inventoryKeyRef = useRef(''); + + useEffect(() => { + if (!hydrated || !engine.hydrated || !status?.chainId || !acct || !parent || !state?.deployment) return; + if (makers.length === 2) return; + const [makerA, makerB] = ensureMakers( + parent, + engine.accounts, + state.makerAccountIds, + engine.doCreateSubAccount, + ); + persist({ + ...state, + accountId: parent.id, + makerAccountIds: [makerA.id, makerB.id], + }); + }, [ + acct, + engine.accounts, + engine.doCreateSubAccount, + engine.hydrated, + hydrated, + makers.length, + parent, + persist, + state, + status?.chainId, + ]); + + useEffect(() => { + if (!hydrated || !state?.deployment || !acct || makers.length !== 2 || busy) return; + if (!ethBalance || ethBalance === 0n) return; + const client = publicRef.current; + if (!client) return; + const key = `${state.deployment.pair}:${acct.id}:${makerKey}`; + if (inventoryKeyRef.current === key) return; + const deployment = state.deployment; + const recipients = [ + { to: acct.address }, + ...makers.map((maker) => ({ to: maker.address, mintVibe: true as const })), + ]; + let cancelled = false; + void (async () => { + try { + const starter = await inventoryMints(client, deployment, recipients); + const approves = await helperApproveCalls(client, deployment, acct.address); + if (cancelled) return; + if (starter.length + approves.length === 0) { + inventoryKeyRef.current = key; + return; + } + setProgress('Minting USDV inventory'); + await engineRef.current.sendActiveCalls({ + calls: [...starter, ...approves], + metadata: 'Validity inventory', + }); + if (!cancelled) inventoryKeyRef.current = key; + } catch (err) { + if (!cancelled) setError(err instanceof Error ? err.message : 'Could not mint inventory'); + } finally { + if (!cancelled) setProgress(null); + } + })(); + return () => { + cancelled = true; + }; + }, [acct, busy, ethBalance, hydrated, makerKey, makers, state?.deployment]); + + useEffect(() => { + if (!hydrated || !status?.chainId || !state?.deployment || makersRef.current.length !== 2) return; + makerNonceRef.current = []; + makerDeployedRef.current = []; + makerEthRef.current = makersRef.current.map(() => null); + setMakersDry(false); + 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; + const rows = calls.map((call) => ({ ...call, value: '0' as const })); + const send = (deployed: boolean) => + engineRef.current.sendAccountCalls({ + account: maker, + calls: rows, + // First swap carries `create` and must land before we pin nonces. + wait: !deployed, + seqOpt: { + nonceSequence, + ...(deployed ? { assumeDeployed: true } : {}), + }, + }); + try { + const deployed = makerDeployedRef.current[index] === true; + try { + await send(deployed); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (!deployed || !/actor is not bound/i.test(message)) throw err; + // Replica still missing the create, or we guessed deployed too early. + makerDeployedRef.current[index] = false; + await send(false); + } + makerDeployedRef.current[index] = true; + makerNonceRef.current[index] = nonceSequence + 1n; + } catch (err) { + makerNonceRef.current[index] = null; + throw err; + } + }, + enabled: () => true, + onPrice: () => { + lastMakerPriceAtRef.current = Date.now(); + setMakerError(null); + setMakersDry(false); + }, + onError: setMakerError, + onGasLow: () => { + for (const maker of makersRef.current) engineRef.current.autoFundNewAccount(maker.address); + if ( + shouldFlagMakersDry( + makerEthRef.current, + makersRef.current.length, + lastMakerPriceAtRef.current, + ) + ) { + setMakersDry(true); + setMakerError('need ETH'); + } + }, + }); + return stop; + }, [hydrated, makerKey, state?.deployment, status?.chainId]); + + const placeOrder = async (): Promise => { + if (!draft || !acct || !state?.deployment || !reserves || !engine.activeSigner) return; + const publicClient = publicRef.current; + if (!publicClient) return; + setBusy(true); + setError(null); + const side: Side = draft.side; + const tokenIn = tokenInFor(state.deployment, side === 'sell'); + try { + const amountIn = amountInForVibe(TRADE_VIBE, side, k, draft.priceWad); + if (amountIn === 0n) throw new Error('Swap size is too small.'); + const inventory = await tokenBalance(publicClient, tokenIn, acct.address); + if (inventory < amountIn) { + throw new Error( + side === 'sell' + ? `Need ${formatTokenAmount(TRADE_VIBE)} ${VIBE_SYMBOL} to sell.` + : `Need ${formatTokenAmount(amountIn)} ${USDV_SYMBOL} to buy ${formatTokenAmount(TRADE_VIBE)} ${VIBE_SYMBOL}.`, + ); + } + 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 seconds = + submitMode === 'concurrent' + ? clampNoncelessExpiry(expirySeconds) + : Math.min(MAX_EXPIRY_SECONDS, expirySeconds); + 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 fromHead = headFeesRef.current; + const estimated = + fromHead ?? + (await publicClient.estimateFeesPerGas().catch(() => null)); + const padded = + estimated?.maxFeePerGas !== undefined && estimated.maxPriorityFeePerGas !== undefined + ? padFees({ + maxFeePerGas: estimated.maxFeePerGas, + maxPriorityFeePerGas: estimated.maxPriorityFeePerGas, + }) + : null; + trackValidityOrder(side, 'submitted'); + let hash: Hex; + 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 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 = 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) { + fees = bumpReplacementFees( + { + maxFeePerGas: occupant.maxFeePerGas, + maxPriorityFeePerGas: occupant.maxPriorityFeePerGas, + }, + padded, + ); + } + const sign = (nextFees: typeof fees) => + 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(signedResult.serialized, validity); + } catch (err) { + if (!isReplacementUnderpriced(err) || !fees) throw err; + fees = bumpReplacementFees(fees, padded); + signedResult = await sign(fees); + hash = await sendValidityTransaction(signedResult.serialized, validity); + } + nonce = confirmedNonce; + } + const order: PlacedOrder = { + id: newId(), + side, + targetPriceWad: draft.priceWad, + size: TRADE_VIBE, + expirySeconds: seconds, + submitMode, + maxBlock: status?.blockNumberPredicate ? maxBlock : undefined, + submittedAt: Date.now(), + txHash: hash, + nonce, + maxFeePerGas: fees?.maxFeePerGas, + maxPriorityFeePerGas: 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'); + 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, + }); + return hash; + } 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 address = acct?.address; + const funded = (ethBalance ?? 0n) > 0n; + const deployed = Boolean(state?.deployment); + const tradeLabel = formatTokenAmount(TRADE_VIBE); + const canAffordTrade = (() => { + if (!draft) return false; + if (side === 'sell') return (vibeBalance ?? 0n) >= TRADE_VIBE; + if (k === 0n) return false; + const need = amountInForVibe(TRADE_VIBE, 'buy', k, draft.priceWad); + return need > 0n && (usdvBalance ?? 0n) >= need; + })(); + + return ( + } + activityCount={engine.activity.length} + activityEmptyMessage="Nothing has happened yet." + > + {!hydrated || !engine.hydrated ? ( +
+ ) : ( +
+ + + {makersDry ? ( + + Simulated flow ran out of ETH. Top up the account so the makers can keep walking the mid. + + ) : makerError ? ( + {makerError} + ) : null} + + {statusError ? ( + {statusError} + ) : null} + + {!deployed ? ( + + Shared pool + + Your Vibenet account signs the swaps. The first visitor publishes a + network-wide VIBE/USDV pair — VIBE is a B20 asset — and everyone + else attaches to the same factory. Makers mint a starter bag and + buy or sell against that pool. + + {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 + +
+
+ + Your {VIBE_SYMBOL} + +
+ + {vibeBalance === null ? ( + '…' + ) : ( + + )} + + {VIBE_SYMBOL} +
+ + Each {side === 'buy' ? 'buy' : 'sell'} is {tradeLabel} {VIBE_SYMBOL} + {side === 'buy' && draft + ? ` · ~${formatTokenAmount(amountInForVibe(TRADE_VIBE, 'buy', k, draft.priceWad))} ${USDV_SYMBOL}` + : null} + +
+
+
+ {draft ? ( + { + setSubmitMode(mode); + if (mode === 'concurrent' && expirySeconds > MAX_NONCELESS_SECONDS) { + setExpirySeconds(15); + } + }} + onSubmit={() => { + setError(null); + setTxHash(null); + setTxStep('review'); + setTxOpen(true); + }} + /> + ) : ( + + Conditional swap + + Waiting for a live mid from the simulated pool. + + + )} + {progress ? {progress} : null} + {error && !txOpen ? {error} : null} +
+
+ +
+
+ +
+ )} +
+ )} + {draft ? ( + { + if (busy) return; + setTxOpen(false); + setTxStep('review'); + }} + step={txStep} + busy={busy} + error={error ?? undefined} + result={txHash ? { txHash } : null} + titles={{ review: 'Review Transaction', submitted: 'Submitted' }} + buildBody={null} + canProceed + proceedLabel="Review" + onProceed={() => setTxStep('review')} + reviewBody={ +
+
+ + {draft.side === 'buy' ? 'Buy' : 'Sell'} {tradeLabel} {VIBE_SYMBOL} if mid{' '} + {draft.side === 'buy' ? '≤' : '≥'} ${formatPrice(draft.priceWad)} + + + {submitMode === 'concurrent' ? '8130 concurrent' : 'Replace resting nonce'} · expires in{' '} + {expirySeconds}s + +
+
    + {reviewClauses(reviewPredicates, vibeToken0).map((clause, index) => ( + + {clause.title} + {clause.detail} + + ))} +
+
+ + Advanced Details + + +
+ +
+
+
+ } + confirmLabel={`${draft.side === 'buy' ? 'Buy' : 'Sell'} ${tradeLabel} if ${draft.side === 'buy' ? '≤' : '≥'} $${formatPrice(draft.priceWad)}`} + onConfirm={() => { + void (async () => { + setTxStep('submitted'); + const hash = await placeOrder(); + if (hash) setTxHash(hash); + })(); + }} + onReviewBack={() => { + if (busy) return; + setTxOpen(false); + }} + onSubmittedBack={() => { + setTxStep('review'); + setError(null); + }} + onRetry={() => { + void (async () => { + setError(null); + setTxHash(null); + setTxStep('submitted'); + const hash = await placeOrder(); + if (hash) setTxHash(hash); + })(); + }} + onDone={() => { + setTxOpen(false); + setTxStep('review'); + setTxHash(null); + }} + explorerTxPath={(hash) => `${VIBENET_EXPLORER_PATH}/tx/${hash}`} + renderSuccess={() => ( +
+ Transaction submitted + + The sequencer will include this swap only while the predicates hold. + +
+ )} + /> + ) : 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..c86c01a --- /dev/null +++ b/app/vibenet/demos/validity/components/OrderList.tsx @@ -0,0 +1,159 @@ +'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 { formatTokenAmount, VIBE_SYMBOL } from '../lib/quote'; +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. Concurrent 8130 orders stack; replace + mode bumps the last nonce. + +
+ ); + } + 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} {order.size > 0n ? `${formatTokenAmount(order.size)} ` : ''} + {VIBE_SYMBOL} ${formatPrice(order.targetPriceWad)} + + + {filled ? : null} + {filled ? 'included!' : STATUS_LABEL[order.status]} + +
    + + {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)}` + : null} + + {filled && order.txHash ? ( + event.stopPropagation()} + > + View transaction + + ) : 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..5293966 --- /dev/null +++ b/app/vibenet/demos/validity/components/OrderTicket.tsx @@ -0,0 +1,207 @@ +'use client'; + +import { Button } from '../../../../components/ui/Button'; +import { Text } from '../../../../components/ui/Text'; +import { MAX_NONCELESS_SECONDS, TRADE_VIBE } from '../lib/constants'; +import { applyOffsetBps, formatPrice } from '../lib/predicates'; +import { formatTokenAmount, VIBE_SYMBOL } from '../lib/quote'; +import type { Side, SubmitMode } from '../lib/types'; + +const TRADE_LABEL = formatTokenAmount(TRADE_VIBE); + +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; + submitMode: SubmitMode; + busy: boolean; + validitySupported: boolean; + onSide: (side: Side) => void; + onOffset: (bps: number) => void; + onExpiry: (seconds: number) => void; + onSubmitMode: (mode: SubmitMode) => void; + onSubmit: () => void; + canAfford: boolean; +}; + +function formatBps(bps: number): string { + const pct = bps / 100; + return Number.isInteger(pct) ? `${pct}%` : `${pct.toFixed(1)}%`; +} + +export function OrderTicket({ + spotWad, + side, + offsetBps, + expirySeconds, + submitMode, + busy, + validitySupported, + onSide, + onOffset, + onExpiry, + onSubmitMode, + onSubmit, + canAfford, +}: 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} + +
+
+ + 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) => { + const blocked = submitMode === 'concurrent' && seconds > MAX_NONCELESS_SECONDS; + return ( + + ); + })} +
+
+ {!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..c21619b --- /dev/null +++ b/app/vibenet/demos/validity/components/PriceCandles.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { CANDLE_BUCKET_MS } from '../lib/constants'; +import { isUpCandle, toCandles, type PriceSample } from './PriceCandles'; + +const BUCKET = CANDLE_BUCKET_MS; + +describe('toCandles', () => { + it('builds a wick when 200ms prints reverse inside a 5s bucket', () => { + const t0 = 1_000_000; + const samples: PriceSample[] = [ + { t: t0, price: 1.0 }, + { t: t0 + 200, price: 1.03 }, + { t: t0 + 400, price: 0.98 }, + { t: t0 + 600, price: 1.01 }, + ]; + const candles = toCandles(samples, { now: t0 + 600, windowMs: BUCKET }); + expect(candles).toHaveLength(1); + const [candle] = candles; + expect(candle.o).toBe(1.0); + expect(candle.c).toBe(1.01); + expect(candle.h).toBe(1.03); + expect(candle.l).toBe(0.98); + }); + + 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 + 200, price: 1.008 }, + { t: t0 + 400, price: 1.008 }, + ]; + const [candle] = toCandles(samples, { now: t0 + 400, windowMs: BUCKET }); + 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 + BUCKET, price: 0.078 }, + { t: t0 + BUCKET + 200, price: 0.0784 }, + ]; + const candles = toCandles(samples, { now: t0 + BUCKET + 200, windowMs: BUCKET * 2 }); + 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); + }); + + it('fills empty 5s buckets so a 15s gap does not leave a hole', () => { + const t0 = 3_000_000; + const samples: PriceSample[] = [ + { t: t0, price: 0.07 }, + { t: t0 + 15_000, price: 0.071 }, + ]; + const candles = toCandles(samples, { now: t0 + 15_000, windowMs: 20_000 }); + expect(candles.map((candle) => candle.t)).toEqual([t0, t0 + 5_000, t0 + 10_000, t0 + 15_000]); + expect(candles[1]).toEqual({ t: t0 + 5_000, o: 0.07, h: 0.07, l: 0.07, c: 0.07 }); + expect(candles[3].o).toBe(0.07); + expect(candles[3].c).toBe(0.071); + }); +}); + +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..2617b33 --- /dev/null +++ b/app/vibenet/demos/validity/components/PriceCandles.tsx @@ -0,0 +1,334 @@ +'use client'; + +import { scaleLinear } from 'd3'; +import { useEffect, useMemo, useState } from 'react'; + +import { CANDLE_BUCKET_MS, CANDLE_SAMPLE_MS, CANDLE_WINDOW_MS } from '../lib/constants'; +import type { Side } from '../lib/types'; + +const BUY_PLOT = '#22ad73'; +const SELL_PLOT = '#ed5966'; +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 }; + +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[], + opts?: { now?: number; windowMs?: number; bucketMs?: number }, +): Candle[] { + if (!samples || samples.length === 0) return []; + const windowMs = opts?.windowMs ?? WINDOW_MS; + const bucketMs = opts?.bucketMs ?? BUCKET_MS; + const lastSample = samples[samples.length - 1].t; + const now = opts?.now ?? lastSample; + const end = Math.floor(now / bucketMs) * bucketMs; + const start = end - windowMs + bucketMs; + const buckets = new Map(); + let seed: number | undefined; + for (const sample of samples) { + if (!Number.isFinite(sample.price) || sample.price <= 0) continue; + if (sample.t < start) { + seed = sample.price; + continue; + } + const bucket = Math.floor(sample.t / bucketMs) * bucketMs; + 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 stitched: Candle[] = []; + let prevClose = seed; + for (let t = start; t <= end; t += bucketMs) { + const raw = buckets.get(t); + if (raw) { + const open = prevClose ?? raw.o; + stitched.push({ + t, + o: open, + h: Math.max(raw.h, open), + l: Math.min(raw.l, open), + c: raw.c, + }); + prevClose = raw.c; + continue; + } + if (prevClose === undefined) continue; + stitched.push({ t, o: prevClose, h: prevClose, l: prevClose, c: prevClose }); + } + 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 [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), CANDLE_SAMPLE_MS); + return () => window.clearInterval(id); + }, []); + const candles = useMemo(() => toCandles(samples ?? [], { now }), [now, 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 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; + const t1 = candles[candles.length - 1].t + BUCKET_MS; + const t0 = t1 - WINDOW_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 / (WINDOW_MS / BUCKET_MS) }; + }, [candles, 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 · 5s 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/ValidityJson.tsx b/app/vibenet/demos/validity/components/ValidityJson.tsx new file mode 100644 index 0000000..a32c280 --- /dev/null +++ b/app/vibenet/demos/validity/components/ValidityJson.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { cn } from '../../../../components/ui/cn'; +import { Text } from '../../../../components/ui/Text'; +import { annotatedValidity } from '../lib/annotate'; +import type { ValidityPredicate } from '../lib/types'; + +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-base-blue dark:text-[#7eb8ff]', + string: 'text-bds-green-70 dark:text-[#7ee0a8]', + number: 'text-bds-orange-70 dark:text-[#f5c542]', + literal: 'text-bds-orange-60 dark:text-[#ed9a6c]', + punct: 'text-bds-gray-50 dark:text-bds-gray-40', +}; + +export function ValidityJson({ + predicates, + frozen, + vibeToken0, + compact, +}: { + predicates: ValidityPredicate[]; + frozen?: boolean; + vibeToken0: boolean; + compact?: boolean; +}) { + 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.' + : 'The sequencer checks every clause before inclusion.'; + 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/aa.test.ts b/app/vibenet/demos/validity/lib/aa.test.ts new file mode 100644 index 0000000..fa22155 --- /dev/null +++ b/app/vibenet/demos/validity/lib/aa.test.ts @@ -0,0 +1,18 @@ +import { nonceKeyMax } from '@aa'; +import { describe, expect, it } from 'vitest'; + +import { clampNoncelessExpiry, noncelessFields } 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); + }); +}); diff --git a/app/vibenet/demos/validity/lib/aa.ts b/app/vibenet/demos/validity/lib/aa.ts new file mode 100644 index 0000000..a006920 --- /dev/null +++ b/app/vibenet/demos/validity/lib/aa.ts @@ -0,0 +1,16 @@ +import { nonceKeyMax } from '@aa'; + +import { MAX_NONCELESS_SECONDS } from './constants'; + +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), + }; +} 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..38ff363 --- /dev/null +++ b/app/vibenet/demos/validity/lib/amm.test.ts @@ -0,0 +1,104 @@ +import { encodeAbiParameters, encodeEventTopics, parseAbi, zeroAddress } from 'viem'; +import { describe, expect, it } from 'vitest'; + +import { amountInForExactOut, amountInForVibe, amountOut, amountOutAtLimit, encodeMint, reservesFromSyncLog } from './amm'; +import { SEED_USDV, SEED_VIBE, TRADE_VIBE, WAD } from './constants'; + +describe('encodeMint', () => { + it('mints VIBE through the open minter and USDV on the token', () => { + const vibe = '0x00000000000000000000000000000000000000aa' as const; + const usdv = '0x00000000000000000000000000000000000000bb' as const; + const minter = '0x00000000000000000000000000000000000000cc' as const; + const to = '0x00000000000000000000000000000000000000dd' as const; + expect(encodeMint(vibe, to, 1n, minter).to).toBe(minter); + expect(encodeMint(usdv, to, 1n).to).toBe(usdv); + }); +}); + +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); + }); +}); + +describe('amountInForVibe', () => { + it('sells a fixed VIBE size and buys enough USDV for that size at the limit', () => { + const k = SEED_VIBE * SEED_USDV; + const spot = (SEED_USDV * WAD) / SEED_VIBE; + expect(amountInForVibe(TRADE_VIBE, 'sell', k, spot)).toBe(TRADE_VIBE); + const usdvIn = amountInForVibe(TRADE_VIBE, 'buy', k, spot); + expect(usdvIn).toBeGreaterThan(0n); + expect(amountOutAtLimit(usdvIn, 'buy', k, spot)).toBeGreaterThanOrEqual(TRADE_VIBE); + }); + + it('ceils exact-out input so rounding cannot underfill', () => { + expect(amountInForExactOut(181n, 1000n, 2000n)).toBe(100n); + expect(amountOut(100n, 1000n, 2000n)).toBe(181n); + expect(amountInForExactOut(0n, 1000n, 2000n)).toBe(0n); + expect(amountInForExactOut(2000n, 1000n, 2000n)).toBe(0n); + }); +}); + +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 new file mode 100644 index 0000000..43c073c --- /dev/null +++ b/app/vibenet/demos/validity/lib/amm.ts @@ -0,0 +1,306 @@ +import { + encodeFunctionData, + parseAbi, + parseEventLogs, + type Account, + type Address, + type Hex, + type PublicClient, + type TransactionReceipt, + type WalletClient, +} from 'viem'; + +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 { TRADER_USDV, TRADER_VIBE, WAD, erc20Abi, helperAbi, minterAbi, pairAbi } from './constants'; +import { sqrt } from './predicates'; +import { quoteFromPreSwapReserves } from './quote'; +import { ensureSingleton } from './singleton'; +import type { Deployment, Reserves, Side } from './types'; + +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); +} + +/** Smallest `amountIn` that yields at least `wantOut` on a 0% curve. */ +export function amountInForExactOut(wantOut: bigint, reserveIn: bigint, reserveOut: bigint): bigint { + if (wantOut === 0n || reserveIn === 0n || reserveOut === 0n || wantOut >= reserveOut) return 0n; + const den = reserveOut - wantOut; + return (wantOut * reserveIn + den - 1n) / den; +} + +/** Input so the swap is `vibeSize` VIBE at the limit curve. Sell spends VIBE; buy spends USDV. */ +export function amountInForVibe( + vibeSize: bigint, + side: Side, + k: bigint, + targetQuoteWad: bigint, +): bigint { + if (side === 'sell') return vibeSize; + const { vibe, usdv } = reservesAtQuote(k, targetQuoteWad); + return amountInForExactOut(vibeSize, usdv, vibe); +} + +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 { + try { + const wanted = pair.toLowerCase(); + const swaps = parseEventLogs({ + abi: pairEvents, + eventName: 'Swap', + logs: logs as never, + }); + const syncs = parseEventLogs({ + abi: pairEvents, + eventName: 'Sync', + logs: logs as never, + }); + 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 function fillQuoteFromSwapReceipt( + receipt: TransactionReceipt, + pair: Address, + vibeToken0: boolean, +): bigint | undefined { + return fillQuoteFromPairLogs(receipt.logs, pair, vibeToken0); +} + +/** First visitor publishes the CREATE2 singleton; later callers attach. */ +export async function deployAmm(args: { + wallet: WalletClient; + publicClient: PublicClient; + account: Account; + onProgress?: (label: string) => void; +}): Promise { + return ensureSingleton(args); +} + +export function encodeMint( + token: Address, + to: Address, + amount: bigint, + minter?: Address, +): { to: Address; data: Hex } { + if (minter) { + return { + to: minter, + data: encodeFunctionData({ + abi: minterAbi, + functionName: 'mint', + args: [token, to, amount], + }), + }; + } + return { + to: token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'mint', + args: [to, amount], + }), + }; +} + +/** USDV for anyone who can buy. VIBE only for makers — traders start at 0. */ +export async function inventoryMints( + publicClient: PublicClient, + deployment: Deployment, + recipients: readonly { to: Address; mintVibe?: boolean }[], +): Promise<{ to: Address; data: Hex }[]> { + const calls: { to: Address; data: Hex }[] = []; + const floorVibe = TRADER_VIBE / 2n; + const floorUsdv = TRADER_USDV / 2n; + for (const { to, mintVibe } of recipients) { + const [vibe, usdv] = await Promise.all([ + tokenBalance(publicClient, deployment.tokenA, to), + tokenBalance(publicClient, deployment.tokenB, to), + ]); + if (mintVibe && vibe < floorVibe) { + calls.push(encodeMint(deployment.tokenA, to, TRADER_VIBE, deployment.minter)); + } + if (usdv < floorUsdv) calls.push(encodeMint(deployment.tokenB, to, TRADER_USDV)); + } + return calls; +} + +export function encodeApprove(token: Address, spender: Address): { to: Address; data: Hex } { + return { + to: token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [spender, 2n ** 256n - 1n], + }), + }; +} + +export function encodeSwapLegs(args: { + tokenIn: Address; + pair: Address; + recipient: Address; + amountIn: bigint; + amount0Out: bigint; + amount1Out: bigint; +}): { 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'], + }), + }, + ]; +} + +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 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 helperApproveCalls( + publicClient: PublicClient, + deployment: Deployment, + owner: Address, +): Promise<{ to: Address; data: Hex }[]> { + const calls: { to: Address; data: Hex }[] = []; + const min = 2n ** 255n; + for (const token of [deployment.token0, deployment.token1] as const) { + const allowance = (await publicClient.readContract({ + address: token, + abi: erc20Abi, + functionName: 'allowance', + args: [owner, deployment.helper], + })) as bigint; + if (allowance < min) calls.push(encodeApprove(token, deployment.helper)); + } + return calls; +} 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..83c2c6f --- /dev/null +++ b/app/vibenet/demos/validity/lib/annotate.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { annotatedValidity, reviewClauses } 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); + }); +}); + +describe('reviewClauses', () => { + it('summarizes each predicate for the review dialog', () => { + const clauses = reviewClauses([blockExpiryPredicate(18_422_105n)]); + expect(clauses).toEqual([ + { + title: 'Block-number expiry', + detail: 'Include only while the head is at most — L2 block 18422105', + }, + ]); + }); +}); diff --git a/app/vibenet/demos/validity/lib/annotate.ts b/app/vibenet/demos/validity/lib/annotate.ts new file mode 100644 index 0000000..e1fe184 --- /dev/null +++ b/app/vibenet/demos/validity/lib/annotate.ts @@ -0,0 +1,144 @@ +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(), + }; +} + +export type PredicateClause = { title: string; detail: string }; + +/** One review row per clause — title plus the include condition. */ +export function reviewClauses( + predicates: ValidityPredicate[], + vibeToken0 = true, +): PredicateClause[] { + return predicates.map((predicate) => { + const notes = notesFor(predicate, vibeToken0); + const title = notes.type ?? predicate.type; + const detail = [notes.op, notes.value].filter(Boolean).join(' — '); + return { title, detail }; + }); +} + +/** 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 }; + }); +} 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/artifacts/ValidityOpenMinter.json b/app/vibenet/demos/validity/lib/artifacts/ValidityOpenMinter.json new file mode 100644 index 0000000..7d99368 --- /dev/null +++ b/app/vibenet/demos/validity/lib/artifacts/ValidityOpenMinter.json @@ -0,0 +1 @@ +{"abi":[{"type":"function","name":"mint","inputs":[{"name":"token","type":"address","internalType":"address"},{"name":"to","type":"address","internalType":"address"},{"name":"amount","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"}],"bytecode":"0x608060405234801561000f575f80fd5b506102128061001d5f395ff3fe608060405234801561000f575f80fd5b5060043610610029575f3560e01c8063c6c3bbe61461002d575b5f80fd5b61004760048036038101906100429190610147565b610049565b005b8273ffffffffffffffffffffffffffffffffffffffff166340c10f1983836040518363ffffffff1660e01b81526004016100849291906101b5565b5f604051808303815f87803b15801561009b575f80fd5b505af11580156100ad573d5f803e3d5ffd5b50505050505050565b5f80fd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f6100e3826100ba565b9050919050565b6100f3816100d9565b81146100fd575f80fd5b50565b5f8135905061010e816100ea565b92915050565b5f819050919050565b61012681610114565b8114610130575f80fd5b50565b5f813590506101418161011d565b92915050565b5f805f6060848603121561015e5761015d6100b6565b5b5f61016b86828701610100565b935050602061017c86828701610100565b925050604061018d86828701610133565b9150509250925092565b6101a0816100d9565b82525050565b6101af81610114565b82525050565b5f6040820190506101c85f830185610197565b6101d560208301846101a6565b939250505056fea2646970667358221220982bd7d143bf768e710bf6cbf384f717a42b6c9872362ff6b0ecfe2de34af85464736f6c63430008180033"} 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..7fb666c --- /dev/null +++ b/app/vibenet/demos/validity/lib/bots.test.ts @@ -0,0 +1,85 @@ +import { parseEther } from 'viem'; +import { describe, expect, it } from 'vitest'; + +import { + BOT_GAS_FLOOR, + MAKER_DRY_GRACE_MS, + allNeedGas, + botNeedsGas, + fractionForPriceMove, + makerTargetPrice, + planSwap, + shouldFlagMakersDry, +} 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('botNeedsGas', () => { + it('is true below the floor', () => { + expect(botNeedsGas(0n)).toBe(true); + expect(botNeedsGas(BOT_GAS_FLOOR)).toBe(false); + }); +}); + +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); + }); +}); + +describe('shouldFlagMakersDry', () => { + const now = 1_000_000; + const afterGrace = now - MAKER_DRY_GRACE_MS - 1; + + it('is false before the first successful maker swap', () => { + expect(shouldFlagMakersDry([0n, 0n], 2, 0, now)).toBe(false); + expect(shouldFlagMakersDry([null, null], 2, 0, now)).toBe(false); + expect(shouldFlagMakersDry([], 2, 0, now)).toBe(false); + }); + + it('is false while a swap landed inside the grace window', () => { + expect(shouldFlagMakersDry([0n, 0n], 2, now - MAKER_DRY_GRACE_MS + 1, now)).toBe(false); + }); + + it('is false when any maker still has gas or a balance is missing', () => { + expect(shouldFlagMakersDry([0n, parseEther('0.1')], 2, afterGrace, now)).toBe(false); + expect(shouldFlagMakersDry([0n, null], 2, afterGrace, now)).toBe(false); + expect(shouldFlagMakersDry([0n], 2, afterGrace, now)).toBe(false); + expect(shouldFlagMakersDry([], 0, afterGrace, now)).toBe(false); + }); + + it('is true only after a swap and every maker is below the floor', () => { + expect(shouldFlagMakersDry([0n, BOT_GAS_FLOOR - 1n], 2, afterGrace, now)).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..a8a76f5 --- /dev/null +++ b/app/vibenet/demos/validity/lib/bots.ts @@ -0,0 +1,218 @@ +import { parseEther, type Address, type Hex } from 'viem'; + +import { amountOut, encodeSwapLegs } from './amm'; +import { + quoteWad, + swapOuts, + tokenInFor, + usdvReserve, + vibeIsToken0, + vibeReserve, +} from './quote'; +import type { Deployment, Reserves } 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; +/** One maker swap per second is enough to walk the mid. */ +const TICK_MS = 1_000; +export const BOT_GAS_FLOOR = parseEther('0.002'); +/** Ignore gas-low while a maker swap just landed — balances can lag the send. */ +export const MAKER_DRY_GRACE_MS = 2_500; +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)); +} + +/** + * Banner only after the simulation has swapped and then every known maker + * balance is below the floor. `lastSwapAt === 0` means no swap yet — empty, + * null, or pre-fund 0n readings must not look like "ran out of ETH". + */ +export function shouldFlagMakersDry( + balances: readonly (bigint | null)[], + makerCount: number, + lastSwapAt: number, + now = Date.now(), +): boolean { + if (lastSwapAt === 0 || now - lastSwapAt < MAKER_DRY_GRACE_MS) return false; + if (makerCount <= 0) return false; + const known = balances.filter((value): value is bigint => value !== null); + return known.length === makerCount && allNeedGas(known); +} + +/** + * 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) }; +} + +export type MakerSwapCalls = { to: Address; data: Hex }[]; + +/** + * 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: { + 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 { + 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 vibeToken0 = vibeIsToken0(deployment); + + const signalGasLow = () => { + const now = Date.now(); + if (now - lastGasLow < GAS_LOW_MS) return; + lastGasLow = now; + onGasLow?.(); + }; + + const tick = async (index: number) => { + if (stopped || !enabled()) return; + const eth = ethBalance(index); + if (eth === null) return; + if (botNeedsGas(eth)) { + signalGasLow(); + 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) { + 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 = 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; + const reserveOut = sellVibe + ? usdvReserve(reserve0, reserve1, vibeToken0) + : vibeReserve(reserve0, reserve1, vibeToken0); + const exactOut = amountOut(used, reserveIn, reserveOut); + const out = exactOut > 1n ? exactOut - 1n : exactOut; + if (out === 0n) return; + 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; + 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() && 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); + } + turn += 1; + if (!stopped) timer = setTimeout(loop, TICK_MS); + }; + timer = setTimeout(loop, 400); + + return () => { + stopped = true; + if (timer) clearTimeout(timer); + }; +} diff --git a/app/vibenet/demos/validity/lib/constants.ts b/app/vibenet/demos/validity/lib/constants.ts new file mode 100644 index 0000000..c91071a --- /dev/null +++ b/app/vibenet/demos/validity/lib/constants.ts @@ -0,0 +1,76 @@ +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'; +import minterArtifact from './artifacts/ValidityOpenMinter.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 minterAbi = minterArtifact.abi as Abi; +export const minterBytecode = with0x(minterArtifact.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 CANDLES_PATH = '/api/vibenet/validity/candles'; + +export const STORAGE_KEY = 'vibenet.validity.v5'; +export const LEGACY_STORAGE_KEYS = [ + 'vibenet.validity.v4', + '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. */ +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; +/** Fixed ticket size. 100 VIBE is ~$7 at the $0.07 mid — readable, not a pool-mover. */ +export const TRADE_VIBE = 100n * 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; +/** + * 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 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; +/** Stamp the mid on each 200ms head so the live 5s candle can wick. */ +export const CANDLE_SAMPLE_MS = 200; +export const CANDLE_BUCKET_MS = 5_000; +export const CANDLE_WINDOW_MS = 180_000; + +/** + * 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..fc8b3b2 --- /dev/null +++ b/app/vibenet/demos/validity/lib/faucet.ts @@ -0,0 +1,9 @@ +import { VibenetApiError } from '../../../library/client'; + +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.'; +} 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..5d96121 --- /dev/null +++ b/app/vibenet/demos/validity/lib/fees.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { bumpReplacementFees, feesFromHead, 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('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 new file mode 100644 index 0000000..1397072 --- /dev/null +++ b/app/vibenet/demos/validity/lib/fees.ts @@ -0,0 +1,49 @@ +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 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/orders.test.ts b/app/vibenet/demos/validity/lib/orders.test.ts new file mode 100644 index 0000000..532fee2 --- /dev/null +++ b/app/vibenet/demos/validity/lib/orders.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { ageRestoredOrders, occupyingOrder, maxBlockForExpiry, orderBlockExpired, orderWallClockExpired, restingOrderToReplace } 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 + 400)).toBe(false); + expect(orderWallClockExpired(order, 1_000 + 5_000 + 401)).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('ageRestoredOrders', () => { + it('expires pending rows that already timed out, and leaves fills', () => { + const pending = { + status: 'pending' as const, + submittedAt: 1_000, + expirySeconds: 5, + id: 'p', + }; + const filled = { ...pending, id: 'f', status: 'filled' as const }; + const [aged, kept] = ageRestoredOrders([pending, filled] as never, 1_000 + 5_000 + 401); + expect(aged.status).toBe('expired'); + expect(kept.status).toBe('filled'); + }); +}); + +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 200ms Denim blocks, not 2s pre-Denim heads', () => { + expect(maxBlockForExpiry(1_000n, 60)).toBe(1_300n); + expect(maxBlockForExpiry(1_000n, 5)).toBe(1_025n); + }); +}); + +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', () => { + 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..956a0ae --- /dev/null +++ b/app/vibenet/demos/validity/lib/orders.ts @@ -0,0 +1,60 @@ +import { BLOCK_SECONDS } from './constants'; +import type { PlacedOrder } from './types'; + +const WALL_CLOCK_GRACE_MS = 400; + +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); +} + +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. */ +/** Wall-clock expire restored pending rows without analytics. */ +export function ageRestoredOrders(orders: PlacedOrder[], now = Date.now()): PlacedOrder[] { + return orders.map((order) => + orderWallClockExpired(order, now) ? { ...order, status: 'expired' } : order, + ); +} + +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..14a725e --- /dev/null +++ b/app/vibenet/demos/validity/lib/predicates.ts @@ -0,0 +1,172 @@ +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) }, + }; +} 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..1376027 --- /dev/null +++ b/app/vibenet/demos/validity/lib/quote.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { WAD } from './constants'; +import { ammPriceFromQuote, ammSide, clampToCondition, formatTokenAmount, quoteFromPreSwapReserves, quoteWad, swapOuts, vibeIsToken0 } from './quote'; + +const deployment = { + tokenA: '0x000000000000000000000000000000000000000a' as const, + token0: '0x000000000000000000000000000000000000000a' as const, + token1: '0x000000000000000000000000000000000000000b' as const, +}; + +describe('quote', () => { + it('groups whole tokens and keeps two dust decimals', () => { + expect(formatTokenAmount(400_000n * WAD)).toBe('400,000'); + expect(formatTokenAmount(100n * WAD)).toBe('100'); + expect(formatTokenAmount(WAD / 2n)).toBe('0.50'); + }); + + 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('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..5d8845e --- /dev/null +++ b/app/vibenet/demos/validity/lib/quote.ts @@ -0,0 +1,95 @@ +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'; + +/** Whole tokens with grouping; two decimals only when there is dust. */ +export function formatTokenAmount(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; +} + +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 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; +} + +/** 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..184cb4e --- /dev/null +++ b/app/vibenet/demos/validity/lib/rpc.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { describeValidityError, sendValidityTransaction } 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', + ); + }); +}); + +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 new file mode 100644 index 0000000..d23ee27 --- /dev/null +++ b/app/vibenet/demos/validity/lib/rpc.ts @@ -0,0 +1,133 @@ +import { + createPublicClient, + createWalletClient, + custom, + type Account, + type Address, + type Chain, + type Hex, + type PublicClient, + type WalletClient, +} from 'viem'; + +import { CANDLES_PATH, RPC_PATH, STATUS_PATH } from './constants'; +import { parseTapeSamples, type TapeSample } from './tape'; +import type { ChainStatus, ValidityPredicate } from './types'; + +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 = + 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: [RPC_PATH] } }, + }; +} + +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: custom(eip1193()) }); +} + +export async function fetchTape(pair: Address, vibeToken0: boolean): Promise { + const response = await fetch( + `${CANDLES_PATH}?pair=${pair}&vibeToken0=${vibeToken0 ? '1' : '0'}`, + { cache: 'no-store' }, + ); + if (!response.ok) return []; + const body = (await response.json().catch(() => null)) as { samples?: unknown } | null; + return parseTapeSamples(body?.samples); +} + +export async function publishTape(pair: Address, samples: readonly TapeSample[]): Promise { + if (samples.length === 0) return; + await fetch(CANDLES_PATH, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pair, samples }), + keepalive: true, + }); +} + +export 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( + tx: Hex, + validity: ValidityPredicate[], +): Promise { + 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/singleton.test.ts b/app/vibenet/demos/validity/lib/singleton.test.ts new file mode 100644 index 0000000..12aae14 --- /dev/null +++ b/app/vibenet/demos/validity/lib/singleton.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { predictSingleton, SINGLETON_SALTS, singletonSalt } from './singleton'; + +describe('predictSingleton', () => { + it('is stable across calls and distinct per contract', () => { + const first = predictSingleton(); + expect(predictSingleton()).toEqual(first); + const addrs = [first.minter, first.tokenB, first.factory, first.helper]; + expect(new Set(addrs.map((addr) => addr.toLowerCase())).size).toBe(4); + for (const addr of addrs) { + expect(addr).toMatch(/^0x[0-9a-fA-F]{40}$/); + } + }); +}); + +describe('singletonSalt', () => { + it('keeps one salt per label', () => { + expect(singletonSalt('vibe')).toBe(SINGLETON_SALTS.vibe); + expect(new Set(Object.values(SINGLETON_SALTS)).size).toBe(5); + }); +}); diff --git a/app/vibenet/demos/validity/lib/singleton.ts b/app/vibenet/demos/validity/lib/singleton.ts new file mode 100644 index 0000000..42c509b --- /dev/null +++ b/app/vibenet/demos/validity/lib/singleton.ts @@ -0,0 +1,491 @@ +import { + concat, + encodeDeployData, + encodeFunctionData, + getContractAddress, + keccak256, + parseEther, + toBytes, + zeroAddress, + type Account, + type Address, + type Hex, + type PublicClient, + type TransactionReceipt, + type WalletClient, +} from 'viem'; + +import { + ACTIVATION_REGISTRY, + activationAbi, + B20_FACTORY, + encodeDeploymentParams, + encodeRoleGrant, + factoryAbi as b20FactoryAbi, + featureId, +} from '../../b20/lib/protocol'; +import { + erc20Abi, + erc20Bytecode, + factoryAbi, + factoryBytecode, + helperAbi, + helperBytecode, + minterAbi, + minterBytecode, + pairAbi, + SEED_USDV, + SEED_VIBE, +} from './constants'; +import { USDV_NAME, USDV_SYMBOL, VIBE_NAME, VIBE_SYMBOL } from './quote'; +import type { Deployment } from './types'; + +/** + * Arachnid deterministic-deployment proxy. Already live on Vibenet; the + * keyless tx is only broadcast when a fresh chain is missing it. + * Address is CREATE(nickSigner, nonce=0). + */ +export const CREATE2_DEPLOYER = '0x4e59b44847b379578588920cA78FbF26c0B4956C' as Address; +export const CREATE2_DEPLOYER_SIGNER = '0x3fab184622dc19b6109349b94811493bf2a45362' as Address; +export const CREATE2_DEPLOYER_FUND = parseEther('0.02'); +export const CREATE2_DEPLOYER_TX = + '0xf8a58085174876e800830186a08080b853604580600e600039806000f350fe7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf31ba02222222222222222222222222222222222222222222222222222222222222222a02222222222222222222222222222222222222222222222222222222222222222' as Hex; + +/** Factory `feeToSetter` is fixed so the CREATE2 address does not depend on who deploys. */ +export const FACTORY_FEE_TO_SETTER = zeroAddress; + +export function singletonSalt(label: string): Hex { + return keccak256(toBytes(`vibenet.validity.${label}.v1`)); +} + +export const SINGLETON_SALTS = { + vibe: singletonSalt('vibe'), + minter: singletonSalt('minter'), + usdv: singletonSalt('usdv'), + factory: singletonSalt('factory'), + helper: singletonSalt('helper'), +} as const; + +export type PredictedSingleton = Pick; + +export function singletonInitCodes(): { + minter: Hex; + tokenB: Hex; + factory: Hex; + helper: Hex; +} { + return { + minter: encodeDeployData({ + abi: minterAbi, + bytecode: minterBytecode, + }), + tokenB: encodeDeployData({ + abi: erc20Abi, + bytecode: erc20Bytecode, + args: [USDV_NAME, USDV_SYMBOL], + }), + factory: encodeDeployData({ + abi: factoryAbi, + bytecode: factoryBytecode, + args: [FACTORY_FEE_TO_SETTER], + }), + helper: encodeDeployData({ + abi: helperAbi, + bytecode: helperBytecode, + }), + }; +} + +function create2Address(salt: Hex, initCode: Hex): Address { + return getContractAddress({ + bytecode: initCode, + from: CREATE2_DEPLOYER, + opcode: 'CREATE2', + salt, + }); +} + +/** CREATE2 addresses for USDV, the Uni factory, helper, and VIBE minter. VIBE is a B20. */ +export function predictSingleton(): PredictedSingleton { + const init = singletonInitCodes(); + return { + minter: create2Address(SINGLETON_SALTS.minter, init.minter), + tokenB: create2Address(SINGLETON_SALTS.usdv, init.tokenB), + factory: create2Address(SINGLETON_SALTS.factory, init.factory), + helper: create2Address(SINGLETON_SALTS.helper, init.helper), + }; +} + +export async function hasCode(client: PublicClient, address: Address): Promise { + const code = await client.getCode({ address }).catch(() => undefined); + return Boolean(code && code !== '0x'); +} + +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: 1_000, + }); + if (receipt.status === 'reverted') { + throw new Error(`Transaction reverted (${hash})`); + } + return receipt; +} + +async function waitForBytecode( + publicClient: PublicClient, + address: Address, + label: string, +): Promise { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + if (await hasCode(publicClient, address)) return; + await sleep(400); + } + throw new Error(`${label} bytecode not visible on the read RPC yet (${address}).`); +} + +/** Vibenet blocks are 6M gas; never ask the node for more than the current head allows. */ +async function capGas(publicClient: PublicClient, requested: bigint): Promise { + const block = await publicClient.getBlock({ blockTag: 'latest' }); + const max = block.gasLimit > 100_000n ? block.gasLimit - 100_000n : block.gasLimit; + return requested < max ? requested : max; +} + +async function send( + wallet: WalletClient, + publicClient: PublicClient, + account: Account, + request: { to?: Address; data: Hex; gas?: bigint; value?: bigint }, +): Promise { + const gas = request.gas !== undefined ? await capGas(publicClient, request.gas) : undefined; + const hash = await wallet.sendTransaction({ + account, + chain: wallet.chain, + ...request, + ...(gas !== undefined ? { gas } : {}), + }); + return wait(publicClient, hash); +} + +async function readPair( + publicClient: PublicClient, + factory: Address, + tokenA: Address, + tokenB: Address, +): Promise
{ + const pair = (await publicClient.readContract({ + address: factory, + abi: factoryAbi, + functionName: 'getPair', + args: [tokenA, tokenB], + })) as Address; + if (!pair || pair === zeroAddress) return null; + return pair; +} + +async function firstPair(client: PublicClient, factory: Address): Promise
{ + const length = (await client.readContract({ + address: factory, + abi: factoryAbi, + functionName: 'allPairsLength', + })) as bigint; + if (length === 0n) return null; + return (await client.readContract({ + address: factory, + abi: factoryAbi, + functionName: 'allPairs', + args: [0n], + })) as Address; +} + +async function pairTokens( + client: PublicClient, + pair: Address, +): Promise<{ token0: Address; token1: Address; reserve0: bigint; reserve1: bigint }> { + const [token0, token1, reserves] = await Promise.all([ + client.readContract({ address: pair, abi: pairAbi, functionName: 'token0' }) as Promise
, + client.readContract({ address: pair, abi: pairAbi, functionName: 'token1' }) as Promise
, + client.readContract({ address: pair, abi: pairAbi, functionName: 'getReserves' }) as Promise< + [bigint, bigint, number] + >, + ]); + return { token0, token1, reserve0: reserves[0], reserve1: reserves[1] }; +} + +/** Live shared pool, or null if this chain still needs the first deploy. */ +export async function probeSingleton(client: PublicClient): Promise { + const predicted = predictSingleton(); + const [usdv, factory, helper, minter] = await Promise.all([ + hasCode(client, predicted.tokenB), + hasCode(client, predicted.factory), + hasCode(client, predicted.helper), + hasCode(client, predicted.minter), + ]); + if (!usdv || !factory || !helper || !minter) return null; + const pair = await firstPair(client, predicted.factory); + if (!pair) return null; + const { token0, token1, reserve0, reserve1 } = await pairTokens(client, pair); + if (reserve0 === 0n || reserve1 === 0n) return null; + const usdvAddr = predicted.tokenB.toLowerCase(); + const tokenA = token0.toLowerCase() === usdvAddr ? token1 : token0; + const isB20 = await client + .readContract({ + address: B20_FACTORY, + abi: b20FactoryAbi, + functionName: 'isB20', + args: [tokenA], + }) + .catch(() => false); + if (!isB20) return null; + return { ...predicted, tokenA, token0, token1, pair }; +} + +export async function ensureCreate2Deployer( + wallet: WalletClient, + publicClient: PublicClient, + account: Account, + onProgress?: (label: string) => void, +): Promise { + if (await hasCode(publicClient, CREATE2_DEPLOYER)) return; + onProgress?.('Publishing the CREATE2 deployer'); + const signerBal = await publicClient.getBalance({ address: CREATE2_DEPLOYER_SIGNER }); + if (signerBal < CREATE2_DEPLOYER_FUND) { + await send(wallet, publicClient, account, { + to: CREATE2_DEPLOYER_SIGNER, + data: '0x', + value: CREATE2_DEPLOYER_FUND - signerBal, + }); + } + const hash = (await publicClient.request({ + method: 'eth_sendRawTransaction', + params: [CREATE2_DEPLOYER_TX], + })) as Hex; + await wait(publicClient, hash); + await waitForBytecode(publicClient, CREATE2_DEPLOYER, 'CREATE2 deployer'); +} + +async function ensureCreate2Contract( + wallet: WalletClient, + publicClient: PublicClient, + account: Account, + salt: Hex, + initCode: Hex, + label: string, + gas: bigint, +): Promise
{ + const address = create2Address(salt, initCode); + if (await hasCode(publicClient, address)) return address; + await send(wallet, publicClient, account, { + to: CREATE2_DEPLOYER, + data: concat([salt, initCode]), + gas, + }); + await waitForBytecode(publicClient, address, label); + return address; +} + +async function seedPair( + wallet: WalletClient, + publicClient: PublicClient, + account: Account, + tokenA: Address, + tokenB: Address, + pair: Address, + minter: Address, +): Promise { + const mintUsdV = (to: Address, amount: bigint) => + send(wallet, publicClient, account, { + to: tokenB, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'mint', + args: [to, amount], + }), + }); + await send(wallet, publicClient, account, { + to: minter, + data: encodeFunctionData({ + abi: minterAbi, + functionName: 'mint', + args: [tokenA, account.address, SEED_VIBE], + }), + }); + await mintUsdV(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, + }); +} + +/** + * Deploy any missing singleton pieces and seed the pair once. + * Later callers no-op once `probeSingleton` would succeed. + */ +export async function ensureSingleton(args: { + wallet: WalletClient; + publicClient: PublicClient; + account: Account; + onProgress?: (label: string) => void; +}): Promise { + const { wallet, publicClient, account, onProgress } = args; + const live = await probeSingleton(publicClient); + if (live) return live; + + const note = (label: string) => onProgress?.(label); + await ensureCreate2Deployer(wallet, publicClient, account, onProgress); + const init = singletonInitCodes(); + const predicted = predictSingleton(); + + note('Deploying shared USDV'); + const tokenB = await ensureCreate2Contract( + wallet, + publicClient, + account, + SINGLETON_SALTS.usdv, + init.tokenB, + 'USDV', + 2_000_000n, + ); + note('Deploying shared Uniswap V2 factory'); + const factory = await ensureCreate2Contract( + wallet, + publicClient, + account, + SINGLETON_SALTS.factory, + init.factory, + 'Factory', + 5_800_000n, + ); + note('Deploying shared swap helper'); + const helper = await ensureCreate2Contract( + wallet, + publicClient, + account, + SINGLETON_SALTS.helper, + init.helper, + 'Swap helper', + 1_000_000n, + ); + note('Deploying VIBE minter'); + const minter = await ensureCreate2Contract( + wallet, + publicClient, + account, + SINGLETON_SALTS.minter, + init.minter, + 'VIBE minter', + 1_000_000n, + ); + if ( + tokenB.toLowerCase() !== predicted.tokenB.toLowerCase() || + factory.toLowerCase() !== predicted.factory.toLowerCase() || + helper.toLowerCase() !== predicted.helper.toLowerCase() || + minter.toLowerCase() !== predicted.minter.toLowerCase() + ) { + throw new Error('CREATE2 address did not match the predicted singleton.'); + } + + let pair = await firstPair(publicClient, factory); + let tokenA: Address | null = null; + if (pair) { + const tokens = await pairTokens(publicClient, pair); + tokenA = tokens.token0.toLowerCase() === tokenB.toLowerCase() ? tokens.token1 : tokens.token0; + } else { + const active = await publicClient.readContract({ + address: ACTIVATION_REGISTRY, + abi: activationAbi, + functionName: 'isActivated', + args: [featureId('asset')], + }); + if (!active) throw new Error('Creating B20 asset tokens is not available on this network right now.'); + note('Creating shared VIBE (B20)'); + const params = encodeDeploymentParams('asset', VIBE_NAME, VIBE_SYMBOL, account.address, 18, ''); + tokenA = (await publicClient.readContract({ + address: B20_FACTORY, + abi: b20FactoryAbi, + functionName: 'getB20Address', + args: [0, account.address, SINGLETON_SALTS.vibe], + })) as Address; + await send(wallet, publicClient, account, { + to: B20_FACTORY, + data: encodeFunctionData({ + abi: b20FactoryAbi, + functionName: 'createB20', + args: [0, SINGLETON_SALTS.vibe, params, []], + }), + gas: 4_000_000n, + }); + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + const ready = await publicClient + .readContract({ + address: B20_FACTORY, + abi: b20FactoryAbi, + functionName: 'isB20Initialized', + args: [tokenA], + }) + .catch(() => false); + if (ready) break; + await sleep(400); + } + await send(wallet, publicClient, account, { + to: tokenA, + data: encodeRoleGrant('MINT_ROLE', minter), + }); + note('Creating the shared pair'); + await send(wallet, publicClient, account, { + to: factory, + data: encodeFunctionData({ + abi: factoryAbi, + functionName: 'createPair', + args: [tokenA, tokenB], + }), + gas: 5_000_000n, + }); + const pairDeadline = Date.now() + 60_000; + while (!pair && Date.now() < pairDeadline) { + pair = await readPair(publicClient, factory, tokenA, tokenB); + if (!pair) await sleep(400); + } + if (!pair) throw new Error('Factory returned no pair.'); + } + if (!tokenA) throw new Error('Could not resolve the shared VIBE token.'); + await waitForBytecode(publicClient, pair, 'Pair'); + + const { token0, token1, reserve0, reserve1 } = await pairTokens(publicClient, pair); + if (reserve0 === 0n || reserve1 === 0n) { + note('Seeding VIBE/USDV (~$0.07)'); + await seedPair(wallet, publicClient, account, tokenA, tokenB, pair, minter); + } + + return { tokenA, tokenB, token0, token1, factory, pair, helper, minter }; +} 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..3cd308e --- /dev/null +++ b/app/vibenet/demos/validity/lib/store.test.ts @@ -0,0 +1,75 @@ +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, + orders: 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); + }); + + it('round-trips submitted orders with bigint fields', () => { + const parsed = parseStored( + JSON.stringify({ + v: 2, + chainId: 84538453, + genesisHash: '0xabc', + orders: [ + { + id: 'ord-1', + side: 'buy', + targetPriceWad: { $bn: '70000000000000000' }, + size: { $bn: '100000000000000000000' }, + expirySeconds: 15, + submittedAt: 1_700_000_000_000, + status: 'pending', + rectangle: { + r0Min: { $bn: '1' }, + r0Max: { $bn: '2' }, + r1Min: { $bn: '3' }, + r1Max: { $bn: '4' }, + side: 'buy', + }, + validity: [], + txHash: `0x${'ab'.repeat(32)}`, + }, + { id: 'bad' }, + ], + }), + ); + expect(parsed?.orders).toHaveLength(1); + expect(parsed?.orders?.[0]?.targetPriceWad).toBe(70000000000000000n); + expect(parsed?.orders?.[0]?.size).toBe(100000000000000000000n); + expect(parsed?.orders?.[0]?.txHash).toMatch(/^0xab/); + }); +}); diff --git a/app/vibenet/demos/validity/lib/store.ts b/app/vibenet/demos/validity/lib/store.ts new file mode 100644 index 0000000..0de8eb9 --- /dev/null +++ b/app/vibenet/demos/validity/lib/store.ts @@ -0,0 +1,224 @@ +import { LEGACY_STORAGE_KEYS, STORAGE_KEY } from './constants'; +import type { Deployment, PlacedOrder, Rectangle, ValidityPredicate } from './types'; + +export const MAX_STORED_ORDERS = 40; + +export type StoredState = { + v: 2; + chainId: number; + genesisHash: string; + /** Shared account that deployed this pool (makers are its subaccounts). */ + accountId?: string; + makerAccountIds?: [string, string]; + deployment?: Deployment; + orders?: PlacedOrder[]; +}; + +function bnReplacer(_key: string, value: unknown): unknown { + return typeof value === 'bigint' ? { $bn: value.toString() } : value; +} + +function bnReviver(_key: string, value: unknown): unknown { + if (value && typeof value === 'object' && '$bn' in value) { + try { + return BigInt((value as { $bn: string }).$bn); + } catch { + return value; + } + } + return 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) || + !isAddress(d.minter) + ) { + return undefined; + } + return { + tokenA: d.tokenA, + tokenB: d.tokenB, + token0: d.token0, + token1: d.token1, + factory: d.factory, + pair: d.pair, + helper: d.helper, + minter: d.minter, + }; +} + +function isId(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function asBigint(value: unknown): bigint | undefined { + if (typeof value === 'bigint') return value; + if (typeof value === 'string' && /^-?\d+$/.test(value)) { + try { + return BigInt(value); + } catch { + return undefined; + } + } + return undefined; +} + +function parseRectangle(value: unknown): Rectangle | undefined { + if (!value || typeof value !== 'object') return undefined; + const row = value as Record; + const r0Min = asBigint(row.r0Min); + const r0Max = asBigint(row.r0Max); + const r1Min = asBigint(row.r1Min); + const r1Max = asBigint(row.r1Max); + if (r0Min === undefined || r0Max === undefined || r1Min === undefined || r1Max === undefined) { + return undefined; + } + if (row.side !== 'buy' && row.side !== 'sell') return undefined; + return { r0Min, r0Max, r1Min, r1Max, side: row.side }; +} + +function parseOrder(value: unknown): PlacedOrder | undefined { + if (!value || typeof value !== 'object') return undefined; + const row = value as Record; + const targetPriceWad = asBigint(row.targetPriceWad); + const size = asBigint(row.size); + const rectangle = parseRectangle(row.rectangle); + if (!isId(row.id) || targetPriceWad === undefined || size === undefined || !rectangle) return undefined; + if (row.side !== 'buy' && row.side !== 'sell') return undefined; + if ( + row.status !== 'pending' && + row.status !== 'filled' && + row.status !== 'expired' && + row.status !== 'replaced' && + row.status !== 'error' + ) { + return undefined; + } + if (typeof row.submittedAt !== 'number' || typeof row.expirySeconds !== 'number') return undefined; + const validity = Array.isArray(row.validity) ? (row.validity as ValidityPredicate[]) : []; + const order: PlacedOrder = { + id: row.id, + side: row.side, + targetPriceWad, + size, + expirySeconds: row.expirySeconds, + submittedAt: row.submittedAt, + status: row.status, + rectangle, + validity, + }; + if (row.submitMode === 'replace' || row.submitMode === 'concurrent') order.submitMode = row.submitMode; + const maxBlock = asBigint(row.maxBlock); + if (maxBlock !== undefined) order.maxBlock = maxBlock; + if (typeof row.txHash === 'string' && /^0x[0-9a-fA-F]+$/.test(row.txHash)) { + order.txHash = row.txHash as PlacedOrder['txHash']; + } + if (typeof row.nonce === 'number') order.nonce = row.nonce; + const maxFeePerGas = asBigint(row.maxFeePerGas); + if (maxFeePerGas !== undefined) order.maxFeePerGas = maxFeePerGas; + const maxPriorityFeePerGas = asBigint(row.maxPriorityFeePerGas); + if (maxPriorityFeePerGas !== undefined) order.maxPriorityFeePerGas = maxPriorityFeePerGas; + if (typeof row.error === 'string') order.error = row.error; + if (typeof row.filledAt === 'number') order.filledAt = row.filledAt; + const fillPriceWad = asBigint(row.fillPriceWad); + if (fillPriceWad !== undefined) order.fillPriceWad = fillPriceWad; + return order; +} + +export function parseOrders(value: unknown): PlacedOrder[] | undefined { + if (!Array.isArray(value)) return undefined; + const orders: PlacedOrder[] = []; + for (const row of value) { + const order = parseOrder(row); + if (order) orders.push(order); + if (orders.length >= MAX_STORED_ORDERS) break; + } + return orders; +} + +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; + // Drop a cached private pool. The live pair is the CREATE2 singleton. + const next = dropDeployment(migrated); + saveState(next); + return next; + } + return null; + } catch { + 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, bnReviver) as Partial & { v?: number }; + if (typeof parsed.chainId !== 'number' || typeof parsed.genesisHash !== 'string') return null; + 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), + orders: parseOrders(parsed.orders), + }; + } + // 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 { + const orders = state.orders?.slice(0, MAX_STORED_ORDERS); + window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...state, orders }, bnReplacer)); +} + +export function dropDeployment(state: StoredState): StoredState { + return { + v: 2, + chainId: state.chainId, + genesisHash: state.genesisHash, + accountId: state.accountId, + makerAccountIds: state.makerAccountIds, + orders: state.orders, + }; +} + +export function createState(chainId: number, genesisHash: string): StoredState { + 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..f1a9924 --- /dev/null +++ b/app/vibenet/demos/validity/lib/stream.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { connectJsonRpcStream, dispatchSubscriptionListener, 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(); + }); +}); + +describe('dispatchSubscriptionListener', () => { + it('invokes a registered callback for a string id', () => { + const received: unknown[] = []; + const listeners = new Map void>([['0xsub', (result) => received.push(result)]]); + dispatchSubscriptionListener(listeners, '0xsub', { number: '0x1' }); + expect(received).toEqual([{ number: '0x1' }]); + }); + + it('invokes a registered callback for a numeric id', () => { + const received: unknown[] = []; + const listeners = new Map void>([['7', (result) => received.push(result)]]); + dispatchSubscriptionListener(listeners, 7, 'ok'); + expect(received).toEqual(['ok']); + }); + + it('leaves the listener registered so later notifications still fire', () => { + const received: unknown[] = []; + const listeners = new Map void>([['0xsub', (result) => received.push(result)]]); + dispatchSubscriptionListener(listeners, '0xsub', 1); + dispatchSubscriptionListener(listeners, '0xsub', 2); + expect(received).toEqual([1, 2]); + expect(listeners.has('0xsub')).toBe(true); + }); + + it('ignores unknown ids and non-function entries without throwing', () => { + const listeners = new Map void>(); + listeners.set('toString', 'not-a-function' as unknown as (result: unknown) => void); + expect(() => dispatchSubscriptionListener(listeners, 'missing', 1)).not.toThrow(); + expect(() => dispatchSubscriptionListener(listeners, 'toString', 1)).not.toThrow(); + expect(() => dispatchSubscriptionListener(listeners, { method: 'toString' }, 1)).not.toThrow(); + expect(() => dispatchSubscriptionListener(listeners, undefined, 1)).not.toThrow(); + }); +}); + +class FakeWebSocket { + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + readyState = FakeWebSocket.CONNECTING; + sent: string[] = []; + private readonly handlers = new Map void>>(); + + constructor(public url: string) { + lastSocket = this; + } + + addEventListener(type: string, handler: (event: unknown) => void) { + const list = this.handlers.get(type) ?? []; + list.push(handler); + this.handlers.set(type, list); + } + + send(data: string) { + this.sent.push(data); + } + + close() { + this.readyState = FakeWebSocket.CLOSED; + this.dispatch('close', {}); + } + + open() { + this.readyState = FakeWebSocket.OPEN; + this.dispatch('open', {}); + } + + receive(body: unknown) { + this.dispatch('message', { data: JSON.stringify(body) }); + } + + private dispatch(type: string, event: unknown) { + for (const handler of this.handlers.get(type) ?? []) handler(event); + } +} + +let lastSocket: FakeWebSocket | undefined; + +function installStreamGlobals() { + lastSocket = undefined; + vi.stubGlobal('WebSocket', FakeWebSocket); + vi.stubGlobal('window', { setTimeout, clearTimeout }); +} + +describe('connectJsonRpcStream subscription dispatch', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('delivers eth_subscription results only for ids this client registered', async () => { + installStreamGlobals(); + const stream = connectJsonRpcStream('wss://example.test'); + lastSocket!.open(); + await stream.ready; + + const received: unknown[] = []; + const subscribePromise = stream.subscribe(['newHeads'], (result) => received.push(result)); + await Promise.resolve(); + const request = JSON.parse(lastSocket!.sent[0]!) as { id: number }; + lastSocket!.receive({ jsonrpc: '2.0', id: request.id, result: '0xsub' }); + const unsubscribe = await subscribePromise; + + lastSocket!.receive({ + method: 'eth_subscription', + params: { subscription: '0xsub', result: { number: '0x1' } }, + }); + lastSocket!.receive({ + method: 'eth_subscription', + params: { subscription: 'toString', result: { number: '0x2' } }, + }); + lastSocket!.receive({ + method: 'eth_subscription', + params: { subscription: { not: 'an-id' }, result: { number: '0x3' } }, + }); + + expect(received).toEqual([{ number: '0x1' }]); + unsubscribe(); + stream.close(); + }); +}); diff --git a/app/vibenet/demos/validity/lib/stream.ts b/app/vibenet/demos/validity/lib/stream.ts new file mode 100644 index 0000000..03e2f98 --- /dev/null +++ b/app/vibenet/demos/validity/lib/stream.ts @@ -0,0 +1,143 @@ +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; +}; + +type SubscriptionListener = (result: unknown) => void; + +function isRpcId(value: unknown): value is string | number { + return typeof value === 'string' || typeof value === 'number'; +} + +/** Invoke a Map-registered callback; never treats `id` as a method name. */ +export function dispatchSubscriptionListener( + listeners: Map, + id: unknown, + result: unknown, +): void { + if (!isRpcId(id)) return; + const listener = listeners.get(String(id)); + if (typeof listener !== 'function') return; + listener(result); +} + +/** 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(); + 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') { + dispatchSubscriptionListener(listeners, body.params?.subscription, 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/tape.test.ts b/app/vibenet/demos/validity/lib/tape.test.ts new file mode 100644 index 0000000..faf43e2 --- /dev/null +++ b/app/vibenet/demos/validity/lib/tape.test.ts @@ -0,0 +1,114 @@ +import { encodeAbiParameters, encodeEventTopics, parseAbi, zeroAddress } from 'viem'; +import { describe, expect, it } from 'vitest'; + +import { CANDLE_SAMPLE_MS, CANDLE_WINDOW_MS, WAD } from './constants'; +import { + mergeTape, + needsLogBackfill, + parseTapeSamples, + resetTapeStore, + samplesFromSyncLogs, + tapeCoverageMs, + writeTape, +} from './tape'; + +describe('mergeTape', () => { + it('slots onto the 200ms clock and drops samples outside the window', () => { + const now = 10_000_000; + const merged = mergeTape( + [{ t: now - CANDLE_WINDOW_MS - 20_000, price: 0.01 }], + [ + { t: now - 400, price: 0.07 }, + { t: now - 200, price: 0.071 }, + { t: now + 5_000, price: 9 }, + ], + now, + ); + expect(merged).toEqual([ + { t: now - 400, price: 0.07 }, + { t: now - 200, price: 0.071 }, + ]); + }); +}); + +describe('parseTapeSamples', () => { + it('keeps finite positive prices', () => { + expect( + parseTapeSamples([ + { t: 1, price: 0.07 }, + { t: 'nope', price: 1 }, + { t: 2, price: 0 }, + { price: 1 }, + ]), + ).toEqual([{ t: 1, price: 0.07 }]); + }); +}); + +describe('tape store', () => { + it('holds samples across write/read for one pair', () => { + resetTapeStore(); + const pair = '0x00000000000000000000000000000000000000aa' as const; + const now = Date.now(); + writeTape(pair, [{ t: now - 1_000, price: 0.07 }]); + expect(writeTape(pair, [{ t: now - 200, price: 0.071 }]).map((row) => row.price)).toEqual([ + 0.07, + 0.071, + ]); + resetTapeStore(); + }); +}); + +describe('needsLogBackfill', () => { + it('asks for logs until the in-memory tape covers most of the window', () => { + const now = 20_000_000; + expect(needsLogBackfill([], now)).toBe(true); + expect( + needsLogBackfill( + [ + { t: now - CANDLE_WINDOW_MS, price: 0.07 }, + { t: now, price: 0.07 }, + ], + now, + ), + ).toBe(false); + expect(tapeCoverageMs([{ t: now - 1_000, price: 0.07 }], now)).toBe(0); + }); +}); + +describe('samplesFromSyncLogs', () => { + it('turns Sync reserves into mids stamped from the latest block', () => { + const abi = parseAbi(['event Sync(uint112 reserve0, uint112 reserve1)']); + const [topic] = encodeEventTopics({ abi, eventName: 'Sync' }); + const pair = '0x00000000000000000000000000000000000000aa' as const; + const logs = [ + { + address: pair, + topics: [topic], + data: encodeAbiParameters( + [{ type: 'uint112' }, { type: 'uint112' }], + [2_000_000n * WAD, 140_000n * WAD], + ), + blockNumber: '0x64', + }, + ]; + const samples = samplesFromSyncLogs({ + logs, + pair, + vibeToken0: true, + latestBlock: 0x6en, + now: 5_000, + }); + expect(samples).toHaveLength(1); + expect(samples[0].price).toBeCloseTo(0.07, 8); + expect(samples[0].t).toBe(5_000 - 10 * 200); + expect( + samplesFromSyncLogs({ + logs, + pair: zeroAddress, + vibeToken0: true, + latestBlock: 0x6en, + now: 5_000, + }), + ).toEqual([]); + }); +}); diff --git a/app/vibenet/demos/validity/lib/tape.ts b/app/vibenet/demos/validity/lib/tape.ts new file mode 100644 index 0000000..d9d3376 --- /dev/null +++ b/app/vibenet/demos/validity/lib/tape.ts @@ -0,0 +1,128 @@ +import type { Address, Hex } from 'viem'; + +import { reservesFromSyncLog } from './amm'; +import { + BLOCK_SECONDS, + CANDLE_BUCKET_MS, + CANDLE_SAMPLE_MS, + CANDLE_WINDOW_MS, +} from './constants'; +import { quoteWad } from './quote'; + +export type TapeSample = { t: number; price: number }; + +export const TAPE_KEEP_MS = CANDLE_WINDOW_MS + CANDLE_BUCKET_MS; +export const TAPE_MAX_SAMPLES = Math.ceil(TAPE_KEEP_MS / CANDLE_SAMPLE_MS) + 8; +const BACKFILL_COVERAGE_MS = (CANDLE_WINDOW_MS * 4) / 5; + +type TapeStore = Map; + +function globalStore(): TapeStore { + const root = globalThis as typeof globalThis & { __validityTape?: TapeStore }; + if (!root.__validityTape) root.__validityTape = new Map(); + return root.__validityTape; +} + +export function resetTapeStore(): void { + globalStore().clear(); +} + +export function isAddress(value: string | null | undefined): value is Address { + return Boolean(value && /^0x[0-9a-fA-F]{40}$/.test(value)); +} + +export function parseTapeSamples(value: unknown): TapeSample[] { + if (!Array.isArray(value)) return []; + const out: TapeSample[] = []; + for (const row of value) { + if (!row || typeof row !== 'object') continue; + const t = Number((row as { t?: unknown }).t); + const price = Number((row as { price?: unknown }).price); + if (!Number.isFinite(t) || !Number.isFinite(price) || price <= 0) continue; + out.push({ t, price }); + if (out.length >= TAPE_MAX_SAMPLES) break; + } + return out; +} + +export function mergeTape( + existing: readonly TapeSample[], + incoming: readonly TapeSample[], + now = Date.now(), +): TapeSample[] { + const slots = new Map(); + const cutoff = now - TAPE_KEEP_MS; + for (const sample of [...existing, ...incoming]) { + if (!Number.isFinite(sample.price) || sample.price <= 0 || !Number.isFinite(sample.t)) continue; + if (sample.t < cutoff || sample.t > now + CANDLE_SAMPLE_MS) continue; + const slot = Math.floor(sample.t / CANDLE_SAMPLE_MS) * CANDLE_SAMPLE_MS; + slots.set(slot, sample.price); + } + return [...slots.entries()] + .sort((left, right) => left[0] - right[0]) + .map(([t, price]) => ({ t, price })); +} + +export function tapeCoverageMs(samples: readonly TapeSample[], now = Date.now()): number { + if (samples.length === 0) return 0; + const first = samples[0].t; + const last = samples[samples.length - 1].t; + return Math.max(0, Math.min(now, last) - first); +} + +export function needsLogBackfill(samples: readonly TapeSample[], now = Date.now()): boolean { + return tapeCoverageMs(samples, now) < BACKFILL_COVERAGE_MS; +} + +export function readTape(pair: Address): TapeSample[] { + return mergeTape(globalStore().get(pair.toLowerCase()) ?? [], [], Date.now()); +} + +export function writeTape(pair: Address, incoming: readonly TapeSample[]): TapeSample[] { + const key = pair.toLowerCase(); + const next = mergeTape(globalStore().get(key) ?? [], incoming, Date.now()); + globalStore().set(key, next); + return next; +} + +export type RpcLog = { + address?: string; + topics?: Hex[]; + data?: Hex; + blockNumber?: string; +}; + +export function samplesFromSyncLogs(args: { + logs: readonly RpcLog[]; + pair: Address; + vibeToken0: boolean; + latestBlock: bigint; + now: number; +}): TapeSample[] { + const blockMs = BLOCK_SECONDS * 1000; + const incoming: TapeSample[] = []; + for (const log of args.logs) { + if (!log.address || !log.topics?.length || !log.data || !log.blockNumber) continue; + if (log.address.toLowerCase() !== args.pair.toLowerCase()) continue; + const reserves = reservesFromSyncLog({ + address: log.address as Address, + topics: log.topics, + data: log.data, + }); + if (!reserves) continue; + let block: bigint; + try { + block = BigInt(log.blockNumber); + } catch { + continue; + } + const t = args.now - Number(args.latestBlock - block) * blockMs; + const price = Number(quoteWad(reserves.reserve0, reserves.reserve1, args.vibeToken0)) / 1e18; + incoming.push({ t, price }); + } + return mergeTape([], incoming, args.now); +} + +export function lookbackBlocks(): bigint { + return BigInt(Math.ceil(TAPE_KEEP_MS / (BLOCK_SECONDS * 1000)) + 8); +} diff --git a/app/vibenet/demos/validity/lib/types.ts b/app/vibenet/demos/validity/lib/types.ts new file mode 100644 index 0000000..4d4e7c0 --- /dev/null +++ b/app/vibenet/demos/validity/lib/types.ts @@ -0,0 +1,111 @@ +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 SubmitMode = 'replace' | 'concurrent'; + +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; + /** CREATE2 relay that holds VIBE's B20 MINT_ROLE. */ + minter: Address; +}; + +export type OrderStatus = 'pending' | 'filled' | 'expired' | 'replaced' | 'error'; + +export type PlacedOrder = { + id: string; + side: Side; + targetPriceWad: bigint; + size: bigint; + expirySeconds: number; + submitMode?: SubmitMode; + maxBlock?: bigint; + submittedAt: number; + txHash?: Hex; + nonce?: number; + maxFeePerGas?: bigint; + maxPriorityFeePerGas?: bigint; + status: OrderStatus; + error?: string; + rectangle: Rectangle; + validity: ValidityPredicate[]; + 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; + /** Browser WebSocket JSON-RPC, when the read host exposes `/ws`. */ + wsUrl: string | null; + 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 ; +} diff --git a/app/vibenet/explorer/tx/[hash]/page.tsx b/app/vibenet/explorer/tx/[hash]/page.tsx index 626773d..de35a67 100644 --- a/app/vibenet/explorer/tx/[hash]/page.tsx +++ b/app/vibenet/explorer/tx/[hash]/page.tsx @@ -4,6 +4,7 @@ import { use, useEffect, useState } from 'react'; import type { ReactNode } from 'react'; import { notFound } from 'next/navigation'; +import { Banner } from '../../../../components/ui/Banner'; import { Card } from '../../../../components/ui/Card'; import { Text } from '../../../../components/ui/Text'; import { DetailList, DetailRow } from '../../../components/DetailList'; @@ -362,11 +363,13 @@ function TxBody({ tx }: TxBodyProps) { const blockNum = hexToInt(tx.blockNumber); const ts = timeFromHex(tx.timestamp, tx.blockTimestampMs); const typeInfo = txTypeLabel(tx.type, tx.typeHex ?? null); - const status = STATUS_STYLE[tx.status]; + const status = STATUS_STYLE[tx.status] ?? STATUS_STYLE.pending; + const included = Boolean(tx.blockHash); + const logs = tx.logs ?? []; const memo = decodeMetadata(tx.metadata); const hasMetadata = Boolean(tx.metadata && tx.metadata !== '0x'); const selector = tx.input && tx.input.length >= 10 ? tx.input.slice(0, 10) : null; - const b20Memo = tx.isAa ? null : decodeB20MemoCalldata(tx.input); + const b20Memo = tx.isAa ? null : decodeB20MemoCalldata(tx.input ?? ''); const gasTokenFee = tx.isAa ? findGasTokenFee(tx.payer, tx.aa) : null; const gasTokenAddress = gasTokenFee?.token ?? null; const [gasTokenMeta, setGasTokenMeta] = useState(null); @@ -386,7 +389,7 @@ function TxBody({ tx }: TxBodyProps) { const inputBytes = tx.input && tx.input !== '0x' ? (tx.input.length - 2) / 2 : 0; const callCount = (tx.aa?.calls ?? []).reduce((sum, phase) => sum + phase.length, 0); const phaseCount = tx.aa?.calls.length ?? 0; - const b20Events = tx.logs.map(decodeB20Event).filter((event) => event !== null); + const b20Events = logs.map(decodeB20Event).filter((event) => event !== null); const announcement = b20Events.find((event) => event.eventName === 'Announcement'); const multiplierUpdate = b20Events.find((event) => event.eventName === 'UIMultiplierUpdated'); const announcementClosed = b20Events.some((event) => event.eventName === 'EndAnnouncement'); @@ -435,18 +438,31 @@ function TxBody({ tx }: TxBodyProps) { nonceBody = Number.parseInt(tx.nonce, 16).toString(); } - const selfPay = Boolean(tx.payer && tx.payer.toLowerCase() === tx.from.toLowerCase()); + const selfPay = Boolean( + tx.payer && tx.from && tx.payer.toLowerCase() === tx.from.toLowerCase(), + ); return ( <> + {!included ? ( + + This transaction has been submitted but is not yet included in a block. + Conditional and validity transactions stay in the mempool until their + predicates hold or they expire. + + ) : null} - + {tx.blockHash ? ( + + ) : ( + Not yet included + )} {ts ? ( @@ -463,7 +479,11 @@ function TxBody({ tx }: TxBodyProps) { ) : null} - + {tx.from ? ( + + ) : ( + + )} {toBody} {tx.payer ? ( @@ -657,16 +677,16 @@ function TxBody({ tx }: TxBodyProps) { ) : null}
- Logs ({tx.logs.length}) - {tx.logs.length === 0 ? ( + Logs ({logs.length}) + {logs.length === 0 ? ( - No logs emitted. + {included ? 'No logs emitted.' : 'Logs will appear once the transaction is included.'} ) : (
- {tx.logs.map((log) => ( + {logs.map((log) => ( ))}
@@ -715,7 +735,7 @@ export default function ExplorerTxPage({ params }: PageProps) { Transaction {tx ? ( - {tx.hash} + {tx.hash ?? hash} ) : null}
diff --git a/app/vibenet/library/api-types.ts b/app/vibenet/library/api-types.ts index 53b85a0..31de55b 100644 --- a/app/vibenet/library/api-types.ts +++ b/app/vibenet/library/api-types.ts @@ -182,12 +182,15 @@ export type ExplorerTxLog = { decoded: DecodedAccountConfigEvent | null; }; export type ExplorerTxResponse = { - hash: Hex; - blockHash: Hex; + /** May be omitted; the explorer page falls back to the route hash. */ + hash?: Hex | null; + /** Null while the tx is still in the mempool / not yet included. */ + blockHash: Hex | null; blockNumber: Hex | null; timestamp: Hex | null; blockTimestampMs?: Hex; - from: Address; + /** Recovered signer; may be absent on some pending payloads. */ + from: Address | null; to: Address | null; value: Hex | null; gas: Hex | null; diff --git a/app/vibenet/library/format.test.ts b/app/vibenet/library/format.test.ts new file mode 100644 index 0000000..8af04b6 --- /dev/null +++ b/app/vibenet/library/format.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { shortAddress } from './format'; + +describe('shortAddress', () => { + it('abbreviates a full address', () => { + expect(shortAddress('0x1234567890abcdef1234567890abcdef12345678')).toBe('0x1234…5678'); + }); + + it('leaves values that already fit untouched', () => { + expect(shortAddress('0x1234')).toBe('0x1234'); + }); + + it('never throws on missing values', () => { + expect(shortAddress(null)).toBe('—'); + expect(shortAddress(undefined)).toBe('—'); + expect(shortAddress('')).toBe('—'); + }); + + it('honours custom lead/tail', () => { + expect(shortAddress('0x1234567890abcdef', 4, 2)).toBe('0x12…ef'); + }); +}); diff --git a/app/vibenet/library/format.ts b/app/vibenet/library/format.ts index 4f2550d..2436793 100644 --- a/app/vibenet/library/format.ts +++ b/app/vibenet/library/format.ts @@ -8,8 +8,13 @@ export function isAddress(value: unknown): value is string { return typeof value === 'string' && ADDRESS_RE.test(value); } -/** Abbreviate a hash/address as `0x1234…abcd`. */ -export function shortAddress(value: string, lead = 6, tail = 4): string { +/** Abbreviate a hash/address as `0x1234…abcd`. Missing values render as an em dash. */ +export function shortAddress( + value: string | null | undefined, + lead = 6, + tail = 4, +): string { + if (!value) return '—'; if (value.length <= lead + tail + 1) return value; return `${value.slice(0, lead)}…${value.slice(-tail)}`; } diff --git a/app/vibenet/page.tsx b/app/vibenet/page.tsx index f394781..0bb2982 100644 --- a/app/vibenet/page.tsx +++ b/app/vibenet/page.tsx @@ -7,7 +7,7 @@ import { Card, LinkCard } from '../components/ui/Card'; import { Text } from '../components/ui/Text'; import { CopyableValue } from './components/CopyableValue'; -import { DEMOS, type DemoEntry } from './demos/catalogue'; +import { listedDemos, type DemoEntry } from './demos/catalogue'; import type { ConfigResponse } from './library/api-types'; import { vibenetApi } from './library/client'; import { VIBENET_EXPLORER_PATH, VIBENET_RPC_URL } from './library/config'; @@ -72,7 +72,7 @@ export default function VibenetHomePage() {
Demos
- {DEMOS.map((demo) => + {listedDemos().map((demo) => demo.available ? (