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/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/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..121d136 100644 --- a/app/vibenet/demos/catalogue.test.ts +++ b/app/vibenet/demos/catalogue.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it } from 'vitest'; import { DEMOS, demoLabel } from './catalogue'; describe('demoLabel', () => { - it('uses the catalogue entry so the crumb matches the demo name', () => { - expect(demoLabel('account')).toBe('Account'); + it('prefers shortTitle for the validity demo', () => { + expect(demoLabel('validity')).toBe('Validity'); }); it('prefers shortTitle over title when both are set', () => { diff --git a/app/vibenet/demos/catalogue.ts b/app/vibenet/demos/catalogue.ts index 65c8c37..a94c1f9 100644 --- a/app/vibenet/demos/catalogue.ts +++ b/app/vibenet/demos/catalogue.ts @@ -46,6 +46,19 @@ export const DEMOS: DemoEntry[] = [ ], available: true, }, + { + href: '/vibenet/demos/validity', + title: 'Validity', + shortTitle: 'Validity', + summary: + 'Attach conditions to a transaction so the sequencer includes it only while they hold. A simulated pool shows a swap waiting on price, then landing or expiring.', + points: [ + 'Add storage and block-number conditions to an ordinary swap', + 'A simulated AMM makes those conditions visible on a moving mid', + 'Stack several 8130 conditions at once, or replace the resting one', + ], + available: true, + }, ]; /** `smart-wallet` -> `Smart Wallet`. Fallback for a route with no catalogue entry. */ diff --git a/app/vibenet/demos/validity/ValidityDemo.tsx b/app/vibenet/demos/validity/ValidityDemo.tsx new file mode 100644 index 0000000..753afab --- /dev/null +++ b/app/vibenet/demos/validity/ValidityDemo.tsx @@ -0,0 +1,1105 @@ +'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 { cn } from '../../../components/ui/cn'; +import { Text } from '../../../components/ui/Text'; +import { CopyableValue } from '../../components/CopyableValue'; +import { AccountDemoShell } from '../_components/AccountDemoShell'; +import { DemoHeader } from '../_components/DemoHeader'; +import { newCallRow } from '../account/library/calls'; +import type { StoredAccount } from '../account/library/model'; +import { ActivityLog } from '../account/components/ActivityLog'; +import { AccountEngineProvider, useAccountEngine } from '../account/useAccountEngine'; +import { OrderList } from './components/OrderList'; +import { OrderTicket } from './components/OrderTicket'; +import { PriceCandles, type FillMark, type PriceLevel, type PriceSample } from './components/PriceCandles'; +import { ReserveChart } from './components/ReserveChart'; +import { ValidityJson } from './components/ValidityJson'; +import { + amountOutAtLimit, + deployAmm, + encodeApprove, + encodeHelperSwap, + fillQuoteFromPairLogs, + fillQuoteFromSwapReceipt, + getReserves, + reservesFromSyncLog, + tokenBalance, +} from './lib/amm'; +import { clampNoncelessExpiry, noncelessFields } from './lib/aa'; +import { startBots, allNeedGas } from './lib/bots'; +import { MAX_EXPIRY_SECONDS, MAX_NONCELESS_SECONDS } from './lib/constants'; +import { faucetErrorMessage } from './lib/faucet'; +import { ensureMakers, rootAccount } from './lib/makers'; +import { + maxBlockForExpiry, + occupyingOrder, + orderBlockExpired, + orderWallClockExpired, + restingOrderToReplace, + tapeCrossedAt, +} from './lib/orders'; +import { bumpReplacementFees, feesFromHead, isReplacementUnderpriced, padFees } from './lib/fees'; +import { applyOffsetBps, blockExpiryPredicate, formatPrice, priceValidity } from './lib/predicates'; +import { + ammPriceFromQuote, + ammSide, + clampToCondition, + quoteWad, + swapOuts, + tokenInFor, + vibeIsToken0, +} from './lib/quote'; +import { + chainFromId, + describeValidityError, + fetchChainStatus, + makePublicClient, + makeWalletClient, + sendValidityTransaction, + type RpcSend, +} from './lib/rpc'; +import { connectJsonRpcStream, headNumber, type StreamHead, type StreamLog } from './lib/stream'; +import { createState, dropDeployment, loadState, saveState, type StoredState } from './lib/store'; +import type { ChainStatus, PlacedOrder, Rectangle, Reserves, Side, SubmitMode } from './lib/types'; + +/** HTTP fallback when the read host has no `/ws`. Submit is always HTTP. + * The socket carries heads, pair logs, and remaining reads (balances, receipts). */ +const SYNC_MS = 1_000; +const BALANCE_MS = 5_000; +const DEFAULT_SIZE_FRACTION = 50n; // 1/50 of inventory +const OWNER_DEPLOY_GAS = parseEther('0.05'); +const OWNER_DEPLOY_SEND = '0.06'; + +function wadToNumber(wad: bigint): number { + return Number(wad) / 1e18; +} + +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 [reserves, setReserves] = useState(null); + const [progress, setProgress] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [botsOn, setBotsOn] = useState(true); + const [hoverPrice, setHoverPrice] = useState(null); + const [side, setSide] = useState('buy'); + const [offsetBps, setOffsetBps] = useState(100); + const [expirySeconds, setExpirySeconds] = useState(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 engineRef = useRef(engine); + engineRef.current = engine; + const botsEnabledRef = useRef(true); + botsEnabledRef.current = botsOn; + const lastMakerPriceAtRef = useRef(0); + const makersRef = useRef([]); + const makerEthRef = useRef<(bigint | null)[]>([]); + const makerTokenRef = useRef>({}); + + 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) => { + saveState(next); + setState(next); + }, []); + + const pushSample = useCallback((price: number) => { + if (!Number.isFinite(price) || price <= 0) return; + setSamples((prev) => { + const next = [...prev, { t: Date.now(), price }]; + return next.length > 500 ? next.slice(-500) : next; + }); + }, []); + + const parent = useMemo( + () => (acct ? rootAccount(acct, engine.accounts) : null), + [acct, engine.accounts], + ); + + const makers = useMemo(() => { + if (!parent) return [] as StoredAccount[]; + const ids = state?.makerAccountIds; + const resolved = (ids ?? []) + .map((id) => engine.accounts.find((item) => item.id === id)) + .filter((item): item is StoredAccount => Boolean(item)); + if (resolved.length === 2) return resolved; + return engine.accounts.filter((item) => item.parentId === parent.id && item.label.startsWith('Validity maker')); + }, [engine.accounts, parent, state?.makerAccountIds]); + makersRef.current = makers; + + const poolForThisAccount = Boolean( + state?.deployment && + (!state.accountId || + parent?.id === state.accountId || + (acct && state.makerAccountIds?.includes(acct.id))), + ); + + useEffect(() => { + let cancelled = false; + fetchChainStatus() + .then((next) => { + if (cancelled) return; + setStatus(next); + if (!next.chainId || !next.genesisHash) { + setStatusError('RPC did not return a chain id / genesis hash.'); + return; + } + const existing = loadState(); + if (existing && existing.chainId === next.chainId && existing.genesisHash === next.genesisHash) { + setState(existing); + } else { + const created = createState(next.chainId, next.genesisHash); + persist(created); + } + publicRef.current = makePublicClient(chainFromId(next.chainId), () => rpcSendRef.current); + }) + .catch((err: unknown) => { + if (!cancelled) setStatusError(err instanceof Error ? err.message : 'Could not reach the validity RPC proxy.'); + }) + .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) => { + const wanted = txHash.toLowerCase(); + const order = ordersRef.current.find((item) => item.txHash?.toLowerCase() === wanted); + if (!order || (order.status !== 'pending' && order.status !== 'expired')) return; + const target = wadToNumber(order.targetPriceWad); + const crossed = tapeCrossedAt(samplesRef.current, order.submittedAt, target, order.side); + const filledAt = filled ? (crossed ?? Date.now()) : undefined; + const clamped = filled + ? clampToCondition(order.side, fillPriceWad ?? order.targetPriceWad, order.targetPriceWad) + : undefined; + const wasPending = order.status === 'pending'; + patchOrders((item) => + item.id === order.id + ? { + ...item, + status: filled ? 'filled' : 'error', + filledAt: filled ? (item.filledAt ?? filledAt) : item.filledAt, + fillPriceWad: filled ? (item.fillPriceWad ?? clamped) : item.fillPriceWad, + } + : item, + ); + if (filled) trackValidityOrder(order.side, 'filled'); + else if (wasPending) trackValidityOrder(order.side, 'error'); + }, + [patchOrders], + ); + + const applyReceipts = useCallback( + async (client: PublicClient, pending: PlacedOrder[], block: bigint | null) => { + const withTimeout = (promise: Promise, ms: number): Promise => + new Promise((resolve, reject) => { + const timer = window.setTimeout(() => reject(new Error('rpc timeout')), ms); + promise.then( + (value) => { + window.clearTimeout(timer); + resolve(value); + }, + (err: unknown) => { + window.clearTimeout(timer); + reject(err); + }, + ); + }); + + const receipts = await Promise.all( + pending.map((order) => + order.txHash + ? withTimeout(client.getTransactionReceipt({ hash: order.txHash }), 2_500).catch(() => null) + : Promise.resolve(null), + ), + ); + const deployment = stateRef.current?.deployment; + const vibeToken0Now = Boolean(deployment && vibeIsToken0(deployment)); + + for (let i = 0; i < pending.length; i += 1) { + const order = pending[i]; + const receipt = receipts[i]; + if (!receipt || !order.txHash) continue; + const filled = receipt.status === 'success'; + const observed = filled && deployment + ? fillQuoteFromSwapReceipt(receipt, deployment.pair, vibeToken0Now) + : undefined; + markOrderLanded(order.txHash, filled, observed); + } + expireOrders(block); + }, + [expireOrders, markOrderLanded], + ); + + useEffect(() => { + if (!hydrated || !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 pullBalances = async (includeReserves: boolean) => { + const deployment = stateRef.current?.deployment; + const makerList = makersRef.current; + const jobs: Promise[] = [client.getBalance({ address: acct.address })]; + if (includeReserves) { + jobs.push(deployment ? getReserves(client, deployment.pair).catch(() => null) : Promise.resolve(null)); + } + for (const maker of makerList) { + jobs.push(client.getBalance({ address: maker.address }).catch(() => null)); + if (deployment) { + jobs.push(tokenBalance(client, deployment.tokenA, maker.address).catch(() => null)); + jobs.push(tokenBalance(client, deployment.tokenB, maker.address).catch(() => null)); + } + } + const [eth, ...rest] = await Promise.all(jobs); + if (cancelled) return; + if (typeof eth === 'bigint') setEthBalance(eth); + if (includeReserves) { + const latestReserves = rest[0]; + const makerParts = rest.slice(1); + if (latestReserves && deployment) { + const latest = latestReserves as Reserves; + setReserves(latest); + const quote = quoteWad(latest.reserve0, latest.reserve1, vibeIsToken0(deployment)); + pushSample(Number(quote) / 1e18); + } + applyMakerParts(deployment, makerParts); + return; + } + applyMakerParts(deployment, rest); + }; + + const pollTick = async () => { + if (cancelled || inFlight) return; + inFlight = true; + try { + const pending = pendingWithHash(); + const block = await client.getBlockNumber({ cacheTime: 0 }); + if (cancelled) 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 jsonOrder = + orders.find((order) => order.id === hoveredOrderId && order.validity.length > 0) ?? null; + + const hoodPredicates = useMemo(() => { + if (jsonOrder) return jsonOrder.validity; + if (!draft) return []; + if (blockNumber === null || !status?.blockNumberPredicate) return draft.predicates; + const seconds = Math.min(MAX_EXPIRY_SECONDS, expirySeconds); + const maxBlock = maxBlockForExpiry(blockNumber, seconds); + return [...draft.predicates, blockExpiryPredicate(maxBlock)]; + }, [blockNumber, draft, expirySeconds, jsonOrder, status?.blockNumberPredicate]); + + const chartLevels = useMemo((): PriceLevel[] => { + const levels: PriceLevel[] = []; + if (draft) { + levels.push({ + id: 'draft', + price: wadToNumber(draft.priceWad), + side: draft.side, + kind: 'draft', + }); + } + for (const order of orders) { + if (order.status !== 'pending' && order.id !== hoveredOrderId) continue; + levels.push({ + id: order.id, + price: wadToNumber(order.targetPriceWad), + side: order.side, + kind: 'resting', + highlighted: order.id === hoveredOrderId, + }); + } + return levels; + }, [draft, hoveredOrderId, orders]); + + const fillMarks = useMemo((): FillMark[] => { + const marks: FillMark[] = []; + for (const order of orders) { + if (order.status !== 'filled' || order.filledAt === undefined) continue; + const price = wadToNumber(order.fillPriceWad ?? order.targetPriceWad); + if (!Number.isFinite(price) || price <= 0) continue; + marks.push({ + id: order.id, + t: order.filledAt, + price, + target: wadToNumber(order.targetPriceWad), + side: order.side, + highlighted: order.id === hoveredOrderId, + }); + } + return marks; + }, [hoveredOrderId, orders]); + + const fund = useCallback(async () => { + 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 traders = [acct.address, makerA.address, makerB.address]; + const deployment = await deployAmm({ + wallet, + publicClient, + account: eoa, + traders, + onProgress: setProgress, + }); + + setProgress('Approving the swap helper'); + await engine.sendActiveCalls({ + calls: [ + encodeApprove(deployment.token0, deployment.helper), + encodeApprove(deployment.token1, deployment.helper), + ], + metadata: 'Validity helper approve', + }); + + persist({ + ...(state ?? createState(status.chainId, status.genesisHash ?? '')), + v: 2, + chainId: status.chainId, + genesisHash: status.genesisHash ?? '', + accountId: parent.id, + makerAccountIds: [makerA.id, makerB.id], + deployment, + }); + engine.pushActivity({ + kind: 'transact', + title: 'Validity pool deployed', + detail: `Pair ${deployment.pair}`, + account: acct.address, + network: engine.chain.name, + mode: engine.chain.mode, + }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Deploy failed'); + } finally { + setBusy(false); + setProgress(null); + } + }; + + const makerKey = makers.map((maker) => maker.id).join(','); + + useEffect(() => { + if (!hydrated || !status?.chainId || !state?.deployment || makersRef.current.length !== 2) return; + makerNonceRef.current = []; + const deployment = state.deployment; + const stop = startBots({ + addresses: makersRef.current.map((maker) => maker.address), + deployment, + reserves: () => reservesRef.current, + ethBalance: (index) => makerEthRef.current[index] ?? null, + tokenBalance: (index, token) => { + const maker = makersRef.current[index]; + if (!maker) return null; + return makerTokenRef.current[`${maker.address}:${token}`] ?? null; + }, + sendSwap: async (index, calls) => { + const maker = makersRef.current[index]; + if (!maker) throw new Error('maker missing'); + const client = publicRef.current; + let nonce = makerNonceRef.current[index] ?? null; + if (nonce === null && client) { + nonce = BigInt(await client.getTransactionCount({ address: maker.address })); + } + const nonceSequence = nonce ?? 0n; + try { + await engineRef.current.sendAccountCalls({ + account: maker, + calls: calls.map((call) => ({ ...call, value: '0' })), + wait: false, + seqOpt: { assumeDeployed: true, nonceSequence }, + }); + makerNonceRef.current[index] = nonceSequence + 1n; + } catch (err) { + makerNonceRef.current[index] = null; + throw err; + } + }, + enabled: () => botsEnabledRef.current, + onPrice: (price) => { + lastMakerPriceAtRef.current = Date.now(); + pushSample(price); + setMakerError(null); + setMakersDry(false); + }, + onError: setMakerError, + onGasLow: () => { + for (const maker of makersRef.current) engineRef.current.autoFundNewAccount(maker.address); + if (Date.now() - lastMakerPriceAtRef.current < 2_500) return; + const balances = makerEthRef.current.filter((value): value is bigint => value !== null); + if (balances.length === makersRef.current.length && allNeedGas(balances)) { + setMakersDry(true); + setMakerError('need ETH'); + } + }, + }); + return stop; + }, [hydrated, makerKey, pushSample, state?.deployment, status?.chainId]); + + const placeOrder = async () => { + if (!draft || !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 inventory = await tokenBalance(publicClient, tokenIn, acct.address); + const amountIn = inventory / DEFAULT_SIZE_FRACTION; + if (amountIn === 0n) throw new Error('Not enough token inventory to swap.'); + const outExact = amountOutAtLimit(amountIn, side, k, draft.priceWad); + 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: amountIn, + 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, + }); + } catch (err) { + const message = describeValidityError(err); + setError(message); + trackValidityOrder(side, 'error'); + setOrders((prev) => [ + { + id: newId(), + side, + targetPriceWad: draft.priceWad, + size: 0n, + expirySeconds, + submittedAt: Date.now(), + status: 'error', + error: message, + rectangle: draft.rectangle, + validity: draft.predicates, + }, + ...prev, + ]); + } finally { + setBusy(false); + } + }; + + const resetDemo = () => { + if (!state) return; + setOrders([]); + setSamples([]); + setReserves(null); + setMakerError(null); + setMakersDry(false); + setHoveredOrderId(null); + setError(null); + setProgress(null); + setBotsOn(true); + lastMakerPriceAtRef.current = 0; + persist(dropDeployment(state)); + }; + + const address = acct?.address; + const funded = (ethBalance ?? 0n) > 0n; + const deployed = Boolean(poolForThisAccount); + + return ( + } + activityCount={engine.activity.length} + activityEmptyMessage="Nothing has happened yet." + > + {!hydrated || !engine.hydrated ? ( +
+ ) : ( +
+ + +
+
+ {status?.readHost ?? 'no rpc'} + {streamLive ? '200ms heads' : '200ms blocks'} + validity {status?.validitySupported ? 'on' : 'unavailable'} + {status?.blockNumberPredicate ? block bounds on : client-side expiry only} + simulation {botsOn ? (makersDry ? 'out of ETH' : 'live') : 'paused'} + {makerError && !makersDry ? simulation {makerError} : null} + {deployed || makersDry ? ( + + ) : null} +
+ {makersDry ? ( + + Simulated flow ran out of ETH. Reset drops this pool so you can top up and deploy again. + + ) : null} +
+ + {statusError ? ( + {statusError} + ) : null} + + {!deployed ? ( + + Simulated pool + + Your Vibenet account signs the swaps — several 8130 conditions can rest + at once, or one sequenced replacement. Deploy creates two maker + subaccounts that move the simulated mid. + + {address ? ( +
+ + Address + + +
+ ) : null} +
+ + ETH + + {ethBalance === null ? '…' : formatEther(ethBalance)} +
+ {error ? {error} : null} + {progress ? {progress} : null} +
+ + +
+
+ ) : ( +
+
+ +
+ + Spot {spot === 0n ? '—' : `$${formatPrice(spot)}`} USDV · simulated flow moves the mid + + +
+
+
+
+ {draft ? ( + { + setSubmitMode(mode); + if (mode === 'concurrent' && expirySeconds > MAX_NONCELESS_SECONDS) { + setExpirySeconds(15); + } + }} + onSubmit={() => void placeOrder()} + /> + ) : ( + + Conditional swap + + Waiting for a live mid from the simulated pool. + + + )} + {error ? {error} : null} +
+
+ +
+
+ +
+
+ + Under the hood + + + The hatched box on the curve is four storage reads on the pair's + reserves word, plus an optional block expiry. The payload on the left + is what gets submitted; the right column is what each field means. + +
+ + {draft || jsonOrder ? ( + + ) : 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..1d18a10 --- /dev/null +++ b/app/vibenet/demos/validity/components/OrderList.tsx @@ -0,0 +1,157 @@ +'use client'; + +import Link from 'next/link'; +import type { CSSProperties } from 'react'; + +import { cn } from '../../../../components/ui/cn'; +import { CheckIcon } from '../../../../components/ui/icons'; +import { Text } from '../../../../components/ui/Text'; +import { VIBENET_EXPLORER_PATH } from '../../../library/config'; +import { formatPrice } from '../lib/predicates'; +import type { PlacedOrder } from '../lib/types'; + +const STATUS_LABEL: Record = { + pending: 'pending', + filled: 'included', + expired: 'expired · not included', + replaced: 'replaced', + error: 'rejected', +}; + +const CELEBRATE_MS = 2_400; + +const CONFETTI_PIECES = [ + { x: -42, y: 36, r: -48, c: 'bg-bds-green-50', d: 0 }, + { x: -18, y: 52, r: 32, c: 'bg-bds-orange-50', d: 40 }, + { x: 8, y: 28, r: -18, c: 'bg-base-blue', d: 20 }, + { x: 28, y: 48, r: 54, c: 'bg-bds-green-40', d: 70 }, + { x: 52, y: 22, r: -36, c: 'bg-bds-orange-40', d: 30 }, + { x: 74, y: 44, r: 22, c: 'bg-bds-green-50', d: 90 }, +] as const; + +function formatClock(ts: number): string { + return new Date(ts).toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + }); +} + +function FillConfetti() { + return ( + + ); +} + +type Props = { + orders: PlacedOrder[]; + highlightedOrderId: string | null; + onHighlight: (id: string | null) => void; +}; + +export function OrderList({ orders, highlightedOrderId, onHighlight }: Props) { + if (orders.length === 0) { + return ( +
+ Submitted + + Conditional swaps land here. 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} VIBE ${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..d7b4c33 --- /dev/null +++ b/app/vibenet/demos/validity/components/OrderTicket.tsx @@ -0,0 +1,196 @@ +'use client'; + +import { Button } from '../../../../components/ui/Button'; +import { Text } from '../../../../components/ui/Text'; +import { MAX_NONCELESS_SECONDS } from '../lib/constants'; +import { applyOffsetBps, formatPrice } from '../lib/predicates'; +import type { Side, SubmitMode } from '../lib/types'; + +const EXPIRIES = [5, 15, 60] as const; +const OFFSETS = [0, 50, 100, 200, 500] as const; + +type Props = { + spotWad: bigint; + side: Side; + offsetBps: number; + expirySeconds: number; + submitMode: SubmitMode; + busy: boolean; + validitySupported: boolean; + onSide: (side: Side) => void; + onOffset: (bps: number) => void; + onExpiry: (seconds: number) => void; + onSubmitMode: (mode: SubmitMode) => void; + onSubmit: () => void; +}; + +function formatBps(bps: number): string { + const pct = bps / 100; + return Number.isInteger(pct) ? `${pct}%` : `${pct.toFixed(1)}%`; +} + +export function OrderTicket({ + spotWad, + side, + offsetBps, + expirySeconds, + submitMode, + busy, + validitySupported, + onSide, + onOffset, + onExpiry, + onSubmitMode, + onSubmit, +}: Props) { + const target = applyOffsetBps(spotWad, side, offsetBps); + const signed = offsetBps === 0 ? '±0%' : side === 'buy' ? `−${formatBps(offsetBps)}` : `+${formatBps(offsetBps)}`; + + return ( +
+
+ Conditional swap + + mid ${formatPrice(spotWad)} + +
+
+ + +
+
+ + {offsetBps === 0 ? 'At mid' : side === 'buy' ? 'Below mid' : 'Above mid'} + +
+ {OFFSETS.map((bps) => ( + + ))} +
+
+
+ + Include when price is {side === 'buy' ? '≤' : '≥'} + + + ${formatPrice(target)} + + + mid {signed} + +
+
+ + 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..e7a0385 --- /dev/null +++ b/app/vibenet/demos/validity/components/PriceCandles.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { isUpCandle, toCandles, type PriceSample } from './PriceCandles'; + +describe('toCandles', () => { + it('builds a wick when price reverses inside a 200ms bucket', () => { + const t0 = 1_000_000; + const samples: PriceSample[] = [ + { t: t0, price: 1.0 }, + { t: t0 + 40, price: 1.03 }, + { t: t0 + 80, price: 0.98 }, + { t: t0 + 120, price: 1.01 }, + ]; + const [candle] = toCandles(samples); + expect(candle.o).toBe(1.0); + expect(candle.c).toBe(1.01); + expect(candle.h).toBe(1.03); + expect(candle.l).toBe(0.98); + expect(candle.h).toBeGreaterThan(Math.max(candle.o, candle.c)); + expect(candle.l).toBeLessThan(Math.min(candle.o, candle.c)); + }); + + it('stays a doji when every sample is the same price', () => { + const t0 = 1_000_000; + const samples: PriceSample[] = [ + { t: t0, price: 1.008 }, + { t: t0 + 40, price: 1.008 }, + { t: t0 + 80, price: 1.008 }, + ]; + const [candle] = toCandles(samples); + expect(candle.o).toBe(candle.h); + expect(candle.h).toBe(candle.l); + expect(candle.l).toBe(candle.c); + }); + + it('opens each bucket at the previous close so a dump is red', () => { + const t0 = 2_000_000; + const samples: PriceSample[] = [ + { t: t0, price: 0.08 }, + { t: t0 + 200, price: 0.078 }, + { t: t0 + 240, price: 0.0784 }, + ]; + const candles = toCandles(samples); + expect(candles).toHaveLength(2); + expect(candles[1].o).toBe(0.08); + expect(candles[1].c).toBe(0.0784); + expect(isUpCandle(candles[1], candles[0])).toBe(false); + }); +}); + +describe('isUpCandle', () => { + it('colors a flat candle from the prior close, not as a default green', () => { + const prev = { t: 0, o: 0.08, h: 0.08, l: 0.08, c: 0.079 }; + const flat = { t: 2_000, o: 0.079, h: 0.079, l: 0.079, c: 0.079 }; + expect(isUpCandle(flat, prev)).toBe(true); + const lower = { t: 4_000, o: 0.078, h: 0.078, l: 0.078, c: 0.078 }; + expect(isUpCandle(lower, flat)).toBe(false); + }); +}); diff --git a/app/vibenet/demos/validity/components/PriceCandles.tsx b/app/vibenet/demos/validity/components/PriceCandles.tsx new file mode 100644 index 0000000..2c2fb98 --- /dev/null +++ b/app/vibenet/demos/validity/components/PriceCandles.tsx @@ -0,0 +1,316 @@ +'use client'; + +import { scaleLinear } from 'd3'; +import { useMemo } from 'react'; + +import { CANDLE_BUCKET_MS, CANDLE_WINDOW_MS } from '../lib/constants'; +import type { Side } from '../lib/types'; + +const BUY_PLOT = '#22ad73'; +const SELL_PLOT = '#ed5966'; +const TICKER = '#c8ff4a'; +const BUCKET_MS = 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[]): Candle[] { + if (!samples || samples.length === 0) return []; + const lastT = samples[samples.length - 1].t; + const start = lastT - WINDOW_MS; + const buckets = new Map(); + for (const sample of samples) { + if (sample.t < start || !Number.isFinite(sample.price) || sample.price <= 0) continue; + const bucket = Math.floor(sample.t / BUCKET_MS) * BUCKET_MS; + const existing = buckets.get(bucket); + if (!existing) { + buckets.set(bucket, { t: bucket, o: sample.price, h: sample.price, l: sample.price, c: sample.price }); + continue; + } + existing.h = Math.max(existing.h, sample.price); + existing.l = Math.min(existing.l, sample.price); + existing.c = sample.price; + } + const raw = [...buckets.values()].sort((a, b) => a.t - b.t); + const stitched: Candle[] = []; + for (const candle of raw) { + const open = stitched.length === 0 ? candle.o : stitched[stitched.length - 1].c; + stitched.push({ + t: candle.t, + o: open, + h: Math.max(candle.h, open), + l: Math.min(candle.l, open), + c: candle.c, + }); + } + return stitched; +} + +export function isUpCandle(candle: Candle, prev?: Candle): boolean { + if (candle.c > candle.o) return true; + if (candle.c < candle.o) return false; + if (!prev) return true; + return candle.c >= prev.c; +} + +function formatAxisPrice(price: number): string { + if (price >= 1) return `$${price.toFixed(2)}`; + if (price >= 0.1) return `$${price.toFixed(3)}`; + return `$${price.toFixed(4)}`; +} + +function formatAxisTime(ts: number): string { + return new Date(ts).toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + }); +} + +function VibeMark() { + return ( + + ); +} + +export type FillMark = { + id: string; + t: number; + price: number; + target: number; + side: Side; + highlighted?: boolean; +}; + +type Props = { + samples: PriceSample[]; + levels?: PriceLevel[]; + fills?: FillMark[]; +}; + +export function PriceCandles({ samples, levels = [], fills = [] }: Props) { + const candles = useMemo(() => toCandles(samples ?? []), [samples]); + const innerW = WIDTH - PAD.left - PAD.right; + const innerH = HEIGHT - PAD.top - PAD.bottom; + const visibleLevels = levels.filter((level) => Number.isFinite(level.price) && level.price > 0); + const visibleFills = fills.filter((fill) => Number.isFinite(fill.price) && fill.price > 0 && fill.t > 0); + const focusFill = visibleFills.find((fill) => fill.highlighted) ?? null; + + const layout = useMemo(() => { + if (candles.length === 0) return null; + let lo = candles[0].l; + let hi = candles[0].h; + for (const candle of candles) { + lo = Math.min(lo, candle.l); + hi = Math.max(hi, candle.h); + } + for (const level of visibleLevels) { + lo = Math.min(lo, level.price); + hi = Math.max(hi, level.price); + } + for (const fill of visibleFills) { + lo = Math.min(lo, fill.price, fill.target); + hi = Math.max(hi, fill.price, fill.target); + } + const last = candles[candles.length - 1].c; + const minSpan = Math.max(last * 0.06, 0.002); + if (hi - lo < minSpan) { + const mid = (hi + lo) / 2; + lo = mid - minSpan / 2; + hi = mid + minSpan / 2; + } + const pad = (hi - lo) * 0.08; + const yMin = Math.max(lo - pad, 0); + const yMax = hi + pad; + let t0 = candles[0].t; + let t1 = Math.max(candles[candles.length - 1].t + BUCKET_MS, t0 + BUCKET_MS); + if (focusFill) { + t0 = Math.min(t0, focusFill.t - BUCKET_MS); + t1 = Math.max(t1, focusFill.t + BUCKET_MS); + } + const x = scaleLinear().domain([t0, t1]).range([0, innerW]); + const y = scaleLinear().domain([yMin, yMax]).range([innerH, 0]); + const yTicks = y.ticks(6); + const xTicks = x.ticks(5); + return { x, y, yMin, yMax, yTicks, xTicks, last, slot: innerW / Math.max(candles.length, 1) }; + }, [candles, focusFill, innerH, innerW, visibleFills, visibleLevels]); + + const firstOpen = candles[0]?.o; + const lastClose = layout?.last; + const change = + firstOpen && lastClose ? ((lastClose - firstOpen) / firstOpen) * 100 : 0; + const up = change >= 0; + + return ( +
+
+
+ +
+
VIBE / USDV
+
simulated pool · 200ms candles
+
+
+
+
+ {layout ? formatAxisPrice(layout.last) : '—'} +
+
+ {layout ? `${up ? '+' : ''}${change.toFixed(2)}%` : ''} +
+
+
+ {layout ? ( + + + {layout.yTicks.map((tick) => ( + + + + {formatAxisPrice(tick)} + + + ))} + {layout.xTicks.map((tick) => ( + + {formatAxisTime(tick)} + + ))} + + USDV + + {candles.map((candle, index) => { + const color = isUpCandle(candle, candles[index - 1]) ? BUY_PLOT : SELL_PLOT; + const cx = layout.x(candle.t + BUCKET_MS / 2); + const highY = layout.y(candle.h); + const lowY = layout.y(candle.l); + const bodyTop = layout.y(Math.max(candle.o, candle.c)); + const bodyBot = layout.y(Math.min(candle.o, candle.c)); + const rawBody = Math.max(bodyBot - bodyTop, 0); + const doji = rawBody < 0.8; + const bodyH = doji ? 1.6 : Math.max(rawBody, 2); + const bodyW = Math.min(Math.max(layout.slot * 0.55, 4), 14); + return ( + + + + + ); + })} + {visibleLevels.map((level) => { + const y = layout.y(level.price); + const color = level.side === 'buy' ? BUY_PLOT : SELL_PLOT; + const draft = level.kind === 'draft'; + return ( + + + + {draft ? 'draft' : level.side} {formatAxisPrice(level.price)} + + + ); + })} + {visibleFills.map((fill) => { + const cx = layout.x(fill.t); + const cy = layout.y(fill.price); + if (cx < -8 || cx > innerW + 8) return null; + const color = fill.side === 'buy' ? BUY_PLOT : SELL_PLOT; + const r = fill.highlighted ? 7 : 4.5; + return ( + + {fill.highlighted ? ( + + ) : null} + + + {fill.highlighted ? ( + innerW * 0.62 ? cx - 10 : cx + 10} + y={cy - 10} + textAnchor={cx > innerW * 0.62 ? 'end' : 'start'} + fill={color} + fontSize={10} + fontFamily="ui-monospace, monospace" + > + included {formatAxisPrice(fill.price)} + + ) : null} + + ); + })} + + + ) : ( +

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

+ )} +
+ ); +} diff --git a/app/vibenet/demos/validity/components/ReserveChart.tsx b/app/vibenet/demos/validity/components/ReserveChart.tsx new file mode 100644 index 0000000..50669e9 --- /dev/null +++ b/app/vibenet/demos/validity/components/ReserveChart.tsx @@ -0,0 +1,265 @@ +'use client'; + +import { scaleLinear } from 'd3'; +import { useMemo, useRef } from 'react'; + +import { WAD } from '../lib/constants'; +import { formatPrice, priceWad } from '../lib/predicates'; +import { ammPriceFromQuote, USDV_SYMBOL, VIBE_SYMBOL } from '../lib/quote'; +import type { PlacedOrder, Rectangle, Reserves, Side } from '../lib/types'; + +// Plot is always dark; pin light-theme greens/reds so they stay vivid on #0c1117. +const BUY_PLOT = '#22ad73'; +const SELL_PLOT = '#ed5966'; + +function sidePlotColor(side: Side): string { + return side === 'buy' ? BUY_PLOT : SELL_PLOT; +} + +type Props = { + reserves: Reserves | null; + hoverPriceWad: bigint | null; + draft: { priceWad: bigint; side: Side; rectangle: Rectangle } | null; + orders: PlacedOrder[]; + highlightedOrderId: string | null; + vibeToken0: boolean; + onHover: (priceWad: bigint | null) => void; +}; + +function hyperbolaPoints(r0: number, r1: number, xMin: number, xMax: number, count = 80): Array<[number, number]> { + const k = r0 * r1; + const points: Array<[number, number]> = []; + for (let i = 0; i <= count; i += 1) { + const t = i / count; + const x = xMin * Math.pow(xMax / xMin, t); + const y = k / x; + if (Number.isFinite(y) && y > 0) points.push([x, y]); + } + return points; +} + +function toTokens(amount: bigint): number { + return Number(amount) / 1e18; +} + +function recencyOpacity(orders: PlacedOrder[], order: PlacedOrder): number { + const ranked = [...orders].sort((a, b) => a.submittedAt - b.submittedAt); + const index = ranked.findIndex((item) => item.id === order.id); + const newest = ranked.length <= 1 ? 1 : Math.max(index, 0) / Math.max(ranked.length - 1, 1); + const statusMul = order.status === 'pending' ? 1 : order.status === 'filled' ? 0.78 : 0.48; + return (0.2 + 0.8 * newest) * statusMul; +} + +export function ReserveChart({ + reserves, + hoverPriceWad, + draft, + orders, + highlightedOrderId, + vibeToken0, + onHover, +}: Props) { + const svgRef = useRef(null); + const width = 960; + const height = 560; + const pad = { top: 28, right: 24, bottom: 56, left: 64 }; + const innerW = width - pad.left - pad.right; + const innerH = height - pad.top - pad.bottom; + + const layout = useMemo(() => { + if (!reserves || reserves.reserve0 === 0n || reserves.reserve1 === 0n) return null; + const r0 = toTokens(reserves.reserve0); + const r1 = toTokens(reserves.reserve1); + const xMin = r0 * 0.45; + const xMax = r0 * 1.7; + const yMin = r1 * 0.45; + const yMax = r1 * 1.7; + const x = scaleLinear().domain([xMin, xMax]).range([0, innerW]); + const y = scaleLinear().domain([yMin, yMax]).range([innerH, 0]); + const curve = hyperbolaPoints(r0, r1, xMin, xMax) + .map(([px, py]) => `${x(px).toFixed(1)},${y(py).toFixed(1)}`) + .join(' '); + const spot = priceWad(reserves.reserve0, reserves.reserve1); + const quote = vibeToken0 || spot === 0n ? spot : (WAD * WAD) / spot; + return { r0, r1, x, y, curve, spot, quote }; + }, [innerH, innerW, reserves, vibeToken0]); + + const priceAt = (clientX: number, clientY: number): bigint | null => { + if (!layout || !svgRef.current) return null; + const rect = svgRef.current.getBoundingClientRect(); + const sx = ((clientX - rect.left) / rect.width) * width; + const sy = ((clientY - rect.top) / rect.height) * height; + const dx = sx - pad.left; + const dy = sy - pad.top; + if (dx < 0 || dy < 0 || dx > innerW || dy > innerH) return null; + const rx = layout.x.invert(dx); + const ry = layout.y.invert(dy); + if (rx <= 0 || ry <= 0) return null; + const amm = BigInt(Math.round((ry / rx) * 1e18)); + if (!vibeToken0) { + if (amm === 0n) return null; + return (WAD * WAD) / amm; + } + return amm; + }; + + const rectanglePath = (rect: Rectangle) => { + if (!layout) return ''; + const [x0, x1] = layout.x.domain() as [number, number]; + const [y0, y1] = layout.y.domain() as [number, number]; + const left = Math.max(toTokens(rect.r0Min), x0); + const right = Math.min(toTokens(rect.r0Max), x1); + const bottom = Math.max(toTokens(rect.r1Min), y0); + const top = Math.min(toTokens(rect.r1Max), y1); + if (left >= right || bottom >= top) return ''; + return `M ${layout.x(left)} ${layout.y(bottom)} H ${layout.x(right)} V ${layout.y(top)} H ${layout.x(left)} Z`; + }; + + const painted = [...orders].sort((a, b) => { + if (a.id === highlightedOrderId) return 1; + if (b.id === highlightedOrderId) return -1; + return a.submittedAt - b.submittedAt; + }); + + const hoverAmmWad = hoverPriceWad + ? ammPriceFromQuote(hoverPriceWad, vibeToken0) + : null; + const hoverIsBuy = hoverPriceWad !== null && layout !== null && hoverPriceWad <= layout.quote; + + return ( +
+ onHover(null)} + onMouseMove={(event) => onHover(priceAt(event.clientX, event.clientY))} + > + + + + + + + + + + + + + + + + + {layout ? ( + + {Array.from({ length: 6 }, (_, i) => { + const gx = (innerW / 5) * i; + const gy = (innerH / 5) * i; + return ( + + + + + ); + })} + {painted.map((order) => { + const highlighted = order.id === highlightedOrderId; + const opacity = highlighted ? 1 : recencyOpacity(orders, order); + return ( + + ); + })} + {draft ? ( + + ) : null} + + + + {hoverAmmWad ? ( + + ) : null} + + {vibeToken0 ? USDV_SYMBOL : VIBE_SYMBOL} + + + {vibeToken0 ? VIBE_SYMBOL : USDV_SYMBOL} + + + ) : ( + + Deploy the pool to see the curve + + )} + +
+ + x · y = k + + + {layout ? `$${formatPrice(layout.quote)}` : '—'} + + + USDV per VIBE · condition box + +
+ {hoverPriceWad ? ( +
+ {hoverIsBuy ? 'buy VIBE <= ' : 'sell VIBE >= '} + ${formatPrice(hoverPriceWad)} +
+ ) : null} +
+ ); +} diff --git a/app/vibenet/demos/validity/components/ValidityJson.tsx b/app/vibenet/demos/validity/components/ValidityJson.tsx new file mode 100644 index 0000000..f50526e --- /dev/null +++ b/app/vibenet/demos/validity/components/ValidityJson.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { Text } from '../../../../components/ui/Text'; +import { annotatedValidity } from '../lib/annotate'; +import type { ValidityPredicate } from '../lib/types'; + +type TokenKind = 'key' | 'string' | 'number' | 'literal' | 'punct'; + +function tokenizeJson(source: string): Array<{ kind: TokenKind; text: string }> { + const tokens: Array<{ kind: TokenKind; text: string }> = []; + const pattern = + /("(?:\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(?:\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?|[{}[\]:,])/g; + let last = 0; + for (const hit of source.matchAll(pattern)) { + const text = hit[0]; + const index = hit.index ?? 0; + if (index > last) { + tokens.push({ kind: 'punct', text: source.slice(last, index) }); + } + let kind: TokenKind = 'punct'; + if (text.startsWith('"')) { + kind = text.endsWith(':') ? 'key' : 'string'; + } else if (text === 'true' || text === 'false' || text === 'null') { + kind = 'literal'; + } else if (/^-?\d/.test(text)) { + kind = 'number'; + } + tokens.push({ kind, text }); + last = index + text.length; + } + if (last < source.length) tokens.push({ kind: 'punct', text: source.slice(last) }); + return tokens; +} + +const KIND_CLASS: Record = { + key: 'text-[#7eb8ff]', + string: 'text-[#7ee0a8]', + number: 'text-[#f5c542]', + literal: 'text-[#ed9a6c]', + punct: 'text-[#8b98a5]', +}; + +export function ValidityJson({ + predicates, + frozen, + vibeToken0, +}: { + predicates: ValidityPredicate[]; + frozen?: boolean; + vibeToken0: 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.' + : 'Hover a field. The right column is what the sequencer is actually checking.'; + 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..1539cb5 --- /dev/null +++ b/app/vibenet/demos/validity/lib/amm.test.ts @@ -0,0 +1,75 @@ +import { encodeAbiParameters, encodeEventTopics, parseAbi, zeroAddress } from 'viem'; +import { describe, expect, it } from 'vitest'; + +import { amountOut, amountOutAtLimit, reservesFromSyncLog } from './amm'; +import { SEED_USDV, SEED_VIBE, WAD } from './constants'; + +describe('amountOut', () => { + it('uses a 0% fee so k is conserved', () => { + expect(amountOut(100n, 1000n, 2000n)).toBe(181n); + expect(amountOut(10n ** 18n, 100n * 10n ** 18n, 100n * 10n ** 18n)).toBe( + (10n ** 18n * 100n * 10n ** 18n) / (101n * 10n ** 18n), + ); + }); + + it('returns 0 when any leg is empty', () => { + expect(amountOut(0n, 1000n, 2000n)).toBe(0n); + expect(amountOut(100n, 0n, 2000n)).toBe(0n); + }); +}); + +describe('amountOutAtLimit', () => { + it('sizes a resting buy on the limit curve, not submit-time spot', () => { + const k = SEED_VIBE * SEED_USDV; + const spot = (SEED_USDV * WAD) / SEED_VIBE; + const limit = (spot * 98n) / 100n; + const amountIn = 800n * WAD; + const atSpot = amountOut(amountIn, SEED_USDV, SEED_VIBE); + const atLimit = amountOutAtLimit(amountIn, 'buy', k, limit); + expect(atLimit).toBeGreaterThan(atSpot); + const fill = (amountIn * WAD) / atLimit; + expect(fill).toBeLessThan((amountIn * WAD) / atSpot); + expect(((fill - limit) * 10_000n) / limit).toBeLessThan(100n); + }); +}); + +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..d10a108 --- /dev/null +++ b/app/vibenet/demos/validity/lib/amm.ts @@ -0,0 +1,463 @@ +import { + encodeFunctionData, + parseAbi, + parseEventLogs, + zeroAddress, + type Account, + type Address, + type Hex, + type PublicClient, + type TransactionReceipt, + type WalletClient, +} from 'viem'; + +const factoryEvents = parseAbi([ + 'event PairCreated(address indexed token0, address indexed token1, address pair, uint256 allPairsLength)', +]); + +const pairEvents = parseAbi([ + 'event Swap(address indexed sender, uint256 amount0In, uint256 amount1In, uint256 amount0Out, uint256 amount1Out, address indexed to)', + 'event Sync(uint112 reserve0, uint112 reserve1)', +]); + +import { + SEED_USDV, + SEED_VIBE, + TRADER_USDV, + TRADER_VIBE, + WAD, + erc20Abi, + erc20Bytecode, + factoryAbi, + factoryBytecode, + helperAbi, + helperBytecode, + pairAbi, +} from './constants'; +import { sqrt } from './predicates'; +import { quoteFromPreSwapReserves, USDV_NAME, USDV_SYMBOL, VIBE_NAME, VIBE_SYMBOL } from './quote'; +import type { Deployment, Reserves, Side } from './types'; + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function wait( + publicClient: PublicClient, + hash: Hex, +): Promise { + const receipt = await publicClient.waitForTransactionReceipt({ + hash, + timeout: 120_000, + pollingInterval: 1_000, + }); + if (receipt.status === 'reverted') { + throw new Error(`Transaction reverted (${hash})`); + } + return receipt; +} + +/** Zeronet query RPC is load-balanced; a receipt can land before bytecode is visible. */ +async function waitForBytecode( + publicClient: PublicClient, + address: Address, + label: string, +): Promise { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + const code = await publicClient.getCode({ address }).catch(() => undefined); + if (code && code !== '0x') return; + await sleep(400); + } + throw new Error(`${label} bytecode not visible on the read RPC yet (${address}).`); +} + +function pairFromCreateReceipt(receipt: TransactionReceipt): Address | null { + const logs = parseEventLogs({ + abi: factoryEvents, + eventName: 'PairCreated', + logs: receipt.logs, + }); + return logs[0]?.args.pair ?? null; +} + +async function readPair( + publicClient: PublicClient, + factory: Address, + tokenA: Address, + tokenB: Address, +): Promise
{ + const deadline = Date.now() + 60_000; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const pair = (await publicClient.readContract({ + address: factory, + abi: factoryAbi, + functionName: 'getPair', + args: [tokenA, tokenB], + })) as Address; + if (pair && pair !== zeroAddress) return pair; + } catch (err) { + lastError = err; + } + await sleep(400); + } + if (lastError instanceof Error) throw lastError; + throw new Error('Factory returned no pair.'); +} + +async function send( + wallet: WalletClient, + publicClient: PublicClient, + account: Account, + request: { + to?: Address; + data: Hex; + gas?: bigint; + }, +): Promise { + const hash = await wallet.sendTransaction({ + account, + chain: wallet.chain, + ...request, + }); + return wait(publicClient, hash); +} + +export async function getReserves(publicClient: PublicClient, pair: Address): Promise { + const result = (await publicClient.readContract({ + address: pair, + abi: pairAbi, + functionName: 'getReserves', + })) as [bigint, bigint, number]; + return { + reserve0: result[0], + reserve1: result[1], + blockTimestampLast: Number(result[2]), + }; +} + +export function amountOut(amountIn: bigint, reserveIn: bigint, reserveOut: bigint): bigint { + if (amountIn === 0n || reserveIn === 0n || reserveOut === 0n) return 0n; + // 0% swap fee so k stays put; the validity rectangle is a patch on one hyperbola. + const numerator = amountIn * reserveOut; + const denominator = reserveIn + amountIn; + return numerator / denominator; +} + +/** Reserves on the current hyperbola at a USDV-per-VIBE quote. */ +export function reservesAtQuote(k: bigint, quoteWad: bigint): { vibe: bigint; usdv: bigint } { + if (k === 0n || quoteWad <= 0n) { + throw new Error('Need a live pool and a positive target price.'); + } + const vibe = sqrt((k * WAD) / quoteWad); + if (vibe === 0n) throw new Error('Degenerate reserve bound.'); + const usdv = (vibe * quoteWad) / WAD || 1n; + return { vibe, usdv }; +} + +/** + * Output sized at the limit, not at submit-time spot. Resting buys locked against + * the then-current (worse) curve would fill above the line once the box hit. + */ +export function amountOutAtLimit( + amountIn: bigint, + side: Side, + k: bigint, + targetQuoteWad: bigint, +): bigint { + const { vibe, usdv } = reservesAtQuote(k, targetQuoteWad); + return side === 'buy' ? amountOut(amountIn, usdv, vibe) : amountOut(amountIn, vibe, usdv); +} + +export function 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); +} + +export async function deployAmm(args: { + wallet: WalletClient; + publicClient: PublicClient; + account: Account; + traders: Address[]; + onProgress?: (label: string) => void; +}): Promise { + const { wallet, publicClient, account, traders, onProgress } = args; + const note = (label: string) => onProgress?.(label); + + note('Deploying VIBE'); + const tokenAHash = await wallet.deployContract({ + abi: erc20Abi, + bytecode: erc20Bytecode, + args: [VIBE_NAME, VIBE_SYMBOL], + account, + chain: wallet.chain, + }); + const tokenAReceipt = await wait(publicClient, tokenAHash); + const tokenA = tokenAReceipt.contractAddress; + if (!tokenA) throw new Error('VIBE deploy returned no address.'); + await waitForBytecode(publicClient, tokenA, 'VIBE'); + + note('Deploying USDV'); + const tokenBHash = await wallet.deployContract({ + abi: erc20Abi, + bytecode: erc20Bytecode, + args: [USDV_NAME, USDV_SYMBOL], + account, + chain: wallet.chain, + }); + const tokenBReceipt = await wait(publicClient, tokenBHash); + const tokenB = tokenBReceipt.contractAddress; + if (!tokenB) throw new Error('USDV deploy returned no address.'); + await waitForBytecode(publicClient, tokenB, 'USDV'); + + note('Deploying Uniswap V2 factory'); + const factoryHash = await wallet.deployContract({ + abi: factoryAbi, + bytecode: factoryBytecode, + args: [account.address], + account, + chain: wallet.chain, + }); + const factoryReceipt = await wait(publicClient, factoryHash); + const factory = factoryReceipt.contractAddress; + if (!factory) throw new Error('Factory deploy returned no address.'); + await waitForBytecode(publicClient, factory, 'Factory'); + + note('Creating the pair'); + const createReceipt = await send(wallet, publicClient, account, { + to: factory, + data: encodeFunctionData({ + abi: factoryAbi, + functionName: 'createPair', + args: [tokenA, tokenB], + }), + gas: 5_000_000n, + }); + const pair = + pairFromCreateReceipt(createReceipt) ?? (await readPair(publicClient, factory, tokenA, tokenB)); + await waitForBytecode(publicClient, pair, 'Pair'); + + const token0 = (await publicClient.readContract({ + address: pair, + abi: pairAbi, + functionName: 'token0', + })) as Address; + const token1 = (await publicClient.readContract({ + address: pair, + abi: pairAbi, + functionName: 'token1', + })) as Address; + + note('Seeding VIBE/USDV (~$0.07)'); + const mintTo = (token: Address, to: Address, amount: bigint) => + send(wallet, publicClient, account, { + to: token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'mint', + args: [to, amount], + }), + }); + + await mintTo(tokenA, account.address, SEED_VIBE); + await mintTo(tokenB, account.address, SEED_USDV); + await send(wallet, publicClient, account, { + to: tokenA, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [pair, SEED_VIBE], + }), + }); + await send(wallet, publicClient, account, { + to: tokenB, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [pair, SEED_USDV], + }), + }); + await send(wallet, publicClient, account, { + to: pair, + data: encodeFunctionData({ + abi: pairAbi, + functionName: 'mint', + args: [account.address], + }), + gas: 500_000n, + }); + + note('Deploying swap helper'); + const helperHash = await wallet.deployContract({ + abi: helperAbi, + bytecode: helperBytecode, + account, + chain: wallet.chain, + }); + const helperReceipt = await wait(publicClient, helperHash); + const helper = helperReceipt.contractAddress; + if (!helper) throw new Error('Swap helper deploy returned no address.'); + await waitForBytecode(publicClient, helper, 'Swap helper'); + + note('Minting trader inventory'); + for (const recipient of traders) { + await mintTo(tokenA, recipient, TRADER_VIBE); + await mintTo(tokenB, recipient, TRADER_USDV); + } + + note('Approving the helper'); + const max = 2n ** 256n - 1n; + for (const token of [token0, token1] as const) { + await send(wallet, publicClient, account, { + to: token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [helper, max], + }), + }); + } + + return { tokenA, tokenB, token0, token1, factory, pair, helper }; +} + +export function 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; +} diff --git a/app/vibenet/demos/validity/lib/annotate.test.ts b/app/vibenet/demos/validity/lib/annotate.test.ts new file mode 100644 index 0000000..5e0f604 --- /dev/null +++ b/app/vibenet/demos/validity/lib/annotate.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { annotatedValidity } from './annotate'; +import { WAD } from './constants'; +import { blockExpiryPredicate, priceValidity } from './predicates'; + +const PAIR = '0x1111111111111111111111111111111111111111'; + +describe('annotatedValidity', () => { + it('explains each storage field and decodes reserve bounds', () => { + const k = 2_000_000n * WAD * (140_000n * WAD); + const { predicates } = priceValidity(PAIR, k, (7n * WAD) / 100n, 'buy'); + const rows = annotatedValidity(predicates, true); + const notes = rows.map((row) => row.note).filter(Boolean); + + expect(notes[0]).toMatch(/every clause/i); + expect(notes).toContain('The simulated VIBE/USDV pair'); + expect(notes.some((note) => note?.includes('packed reserves'))).toBe(true); + expect(notes.some((note) => note?.includes('low 112 bits') && note.includes('VIBE'))).toBe(true); + expect(notes.some((note) => note?.includes('high 112 bits') && note.includes('USDV'))).toBe(true); + expect(notes.some((note) => note?.includes('Floor') && note.includes('VIBE'))).toBe(true); + expect(notes.some((note) => note?.includes('Ceiling') && note.includes('USDV'))).toBe(true); + expect(notes.filter((note) => /VIBE$/.test(note ?? '')).length).toBeGreaterThanOrEqual(2); + }); + + it('labels token0 as USDV when VIBE is token1', () => { + const k = 1_000n * WAD * (1_000n * WAD); + const { predicates } = priceValidity(PAIR, k, WAD, 'buy'); + const notes = annotatedValidity(predicates, false) + .map((row) => row.note) + .filter(Boolean); + expect(notes.some((note) => note?.includes('low 112 bits') && note.includes('USDV'))).toBe(true); + expect(notes.some((note) => note?.includes('high 112 bits') && note.includes('VIBE'))).toBe(true); + }); + + it('decodes a block-number expiry as an L2 head bound', () => { + const rows = annotatedValidity([blockExpiryPredicate(18_422_105n)]); + const notes = rows.map((row) => row.note).filter(Boolean); + expect(notes).toContain('Block-number expiry'); + expect(notes).toContain('L2 block 18422105'); + expect(notes.some((note) => note?.includes('at most'))).toBe(true); + }); +}); diff --git a/app/vibenet/demos/validity/lib/annotate.ts b/app/vibenet/demos/validity/lib/annotate.ts new file mode 100644 index 0000000..a0f8331 --- /dev/null +++ b/app/vibenet/demos/validity/lib/annotate.ts @@ -0,0 +1,129 @@ +import { PAIR_RESERVES_SLOT, RESERVE0_MASK, RESERVE1_MASK, RESERVE_BITS, WAD } from './constants'; +import { prettyValidity } from './predicates'; +import { USDV_SYMBOL, VIBE_SYMBOL } from './quote'; +import type { StoragePredicate, ValidityOperator, ValidityPredicate } from './types'; + +export type AnnotatedJsonLine = { + text: string; + note?: string; +}; + +function tokenForReserve(reserve: 0 | 1, vibeToken0: boolean): string { + if (reserve === 0) return vibeToken0 ? VIBE_SYMBOL : USDV_SYMBOL; + return vibeToken0 ? USDV_SYMBOL : VIBE_SYMBOL; +} + +function reserveFromMask(mask: bigint): 0 | 1 | null { + if (mask === RESERVE0_MASK) return 0; + if (mask === RESERVE1_MASK) return 1; + return null; +} + +function decodeReserve(value: bigint, mask: bigint): bigint { + return mask === RESERVE1_MASK ? value >> RESERVE_BITS : value; +} + +function formatAmount(wad: bigint): string { + const negative = wad < 0n; + const abs = negative ? -wad : wad; + const whole = abs / WAD; + const frac = ((abs % WAD) * 100n) / WAD; + const grouped = whole.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); + const body = frac === 0n ? grouped : `${grouped}.${frac.toString().padStart(2, '0')}`; + return negative ? `-${body}` : body; +} + +function comparePhrase(op: ValidityOperator): string { + switch (op) { + case '>=': + return 'at least'; + case '<=': + return 'at most'; + case '>': + return 'above'; + case '<': + return 'below'; + case '=': + return 'exactly'; + case '!=': + return 'anything but'; + default: + return op; + } +} + +function boundWord(op: ValidityOperator): string { + if (op === '>=' || op === '>') return 'Floor'; + if (op === '<=' || op === '<') return 'Ceiling'; + return 'Check'; +} + +function storageNotes(predicate: StoragePredicate, vibeToken0: boolean): Record { + const mask = BigInt(predicate.params.mask); + const slot = BigInt(predicate.params.slot); + const value = BigInt(predicate.params.value); + const reserve = reserveFromMask(mask); + const symbol = reserve === null ? 'reserve' : tokenForReserve(reserve, vibeToken0); + const half = reserve === 0 ? 'low 112 bits' : reserve === 1 ? 'high 112 bits' : 'selected bits'; + const amount = reserve === null ? value.toString() : formatAmount(decodeReserve(value, mask)); + return { + type: `${boundWord(predicate.params.op)} on the ${symbol} reserve`, + address: 'The simulated VIBE/USDV pair', + slot: + slot === PAIR_RESERVES_SLOT + ? 'Uni v2 packed reserves (reserve0 | reserve1 << 112)' + : `Storage slot ${slot.toString()}`, + mask: `Keep the ${half} — ${symbol}`, + op: `Include only if that reserve is ${comparePhrase(predicate.params.op)}`, + value: `${amount} ${symbol}`, + }; +} + +function notesFor(predicate: ValidityPredicate, vibeToken0: boolean): Record { + if (predicate.type === 'storage') return storageNotes(predicate, vibeToken0); + if (predicate.type === 'block_number') { + const block = BigInt(predicate.params.value); + return { + type: 'Block-number expiry', + op: `Include only while the head is ${comparePhrase(predicate.params.op)}`, + value: `L2 block ${block.toString()}`, + }; + } + if (predicate.type === 'balance') { + return { + type: 'Balance check', + address: 'Account whose ETH balance is read', + op: `Include only if the balance is ${comparePhrase(predicate.params.op)}`, + value: `${formatAmount(BigInt(predicate.params.value))} ETH`, + }; + } + return { + type: 'Flashblock-index bound', + op: `Include only if the flashblock index is ${comparePhrase(predicate.params.op)}`, + value: BigInt(predicate.params.value).toString(), + }; +} + +/** Pretty JSON plus a plain-English note for each field the sequencer actually reads. */ +export function annotatedValidity( + predicates: ValidityPredicate[], + vibeToken0 = true, +): AnnotatedJsonLine[] { + const lines = prettyValidity(predicates).split('\n'); + let index = -1; + let fields: Record = {}; + return lines.map((text, lineIndex) => { + if (lineIndex === 0 && text.trim() === '[') { + return { text, note: 'Every clause must hold for the swap to land' }; + } + const key = text.match(/^\s*"([^"]+)":/)?.[1]; + if (!key) return { text }; + if (key === 'type') { + index += 1; + const predicate = predicates[index]; + fields = predicate ? notesFor(predicate, vibeToken0) : {}; + } + const note = fields[key]; + return note ? { text, note } : { text }; + }); +} diff --git a/app/vibenet/demos/validity/lib/artifacts/MintableERC20.json b/app/vibenet/demos/validity/lib/artifacts/MintableERC20.json new file mode 100644 index 0000000..b601f79 --- /dev/null +++ b/app/vibenet/demos/validity/lib/artifacts/MintableERC20.json @@ -0,0 +1 @@ +{"abi":[{"type":"constructor","inputs":[{"name":"name_","type":"string","internalType":"string"},{"name":"symbol_","type":"string","internalType":"string"}],"stateMutability":"nonpayable"},{"type":"function","name":"allowance","inputs":[{"name":"","type":"address","internalType":"address"},{"name":"","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"approve","inputs":[{"name":"spender","type":"address","internalType":"address"},{"name":"amount","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"nonpayable"},{"type":"function","name":"balanceOf","inputs":[{"name":"","type":"address","internalType":"address"}],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"decimals","inputs":[],"outputs":[{"name":"","type":"uint8","internalType":"uint8"}],"stateMutability":"view"},{"type":"function","name":"mint","inputs":[{"name":"to","type":"address","internalType":"address"},{"name":"amount","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"name","inputs":[],"outputs":[{"name":"","type":"string","internalType":"string"}],"stateMutability":"view"},{"type":"function","name":"symbol","inputs":[],"outputs":[{"name":"","type":"string","internalType":"string"}],"stateMutability":"view"},{"type":"function","name":"totalSupply","inputs":[],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"transfer","inputs":[{"name":"to","type":"address","internalType":"address"},{"name":"amount","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"nonpayable"},{"type":"function","name":"transferFrom","inputs":[{"name":"from","type":"address","internalType":"address"},{"name":"to","type":"address","internalType":"address"},{"name":"amount","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"nonpayable"},{"type":"event","name":"Approval","inputs":[{"name":"owner","type":"address","indexed":true,"internalType":"address"},{"name":"spender","type":"address","indexed":true,"internalType":"address"},{"name":"value","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"Transfer","inputs":[{"name":"from","type":"address","indexed":true,"internalType":"address"},{"name":"to","type":"address","indexed":true,"internalType":"address"},{"name":"value","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false}],"bytecode":"0x608060405234801562000010575f80fd5b50604051620009c6380380620009c6833981016040819052620000339162000116565b5f62000040838262000208565b5060016200004f828262000208565b505050620002d0565b634e487b7160e01b5f52604160045260245ffd5b5f82601f8301126200007c575f80fd5b81516001600160401b038082111562000099576200009962000058565b604051601f8301601f19908116603f01168101908282118183101715620000c457620000c462000058565b81604052838152602092508683858801011115620000e0575f80fd5b5f91505b83821015620001035785820183015181830184015290820190620000e4565b5f93810190920192909252949350505050565b5f806040838503121562000128575f80fd5b82516001600160401b03808211156200013f575f80fd5b6200014d868387016200006c565b9350602085015191508082111562000163575f80fd5b5062000172858286016200006c565b9150509250929050565b600181811c908216806200019157607f821691505b602082108103620001b057634e487b7160e01b5f52602260045260245ffd5b50919050565b601f82111562000203575f81815260208120601f850160051c81016020861015620001de5750805b601f850160051c820191505b81811015620001ff57828155600101620001ea565b5050505b505050565b81516001600160401b0381111562000224576200022462000058565b6200023c816200023584546200017c565b84620001b6565b602080601f83116001811462000272575f84156200025a5750858301515b5f19600386901b1c1916600185901b178555620001ff565b5f85815260208120601f198616915b82811015620002a25788860151825594840194600190910190840162000281565b5085821015620002c057878501515f19600388901b60f8161c191681555b5050505050600190811b01905550565b6106e880620002de5f395ff3fe608060405234801561000f575f80fd5b506004361061009b575f3560e01c806340c10f191161006357806340c10f191461012457806370a082311461013957806395d89b4114610158578063a9059cbb14610160578063dd62ed3e14610173575f80fd5b806306fdde031461009f578063095ea7b3146100bd57806318160ddd146100e057806323b872dd146100f7578063313ce5671461010a575b5f80fd5b6100a761019d565b6040516100b49190610528565b60405180910390f35b6100d06100cb36600461058e565b610228565b60405190151581526020016100b4565b6100e960025481565b6040519081526020016100b4565b6100d06101053660046105b6565b610294565b610112601281565b60405160ff90911681526020016100b4565b61013761013236600461058e565b610344565b005b6100e96101473660046105ef565b60036020525f908152604090205481565b6100a76103ca565b6100d061016e36600461058e565b6103d7565b6100e961018136600461060f565b600460209081525f928352604080842090915290825290205481565b5f80546101a990610640565b80601f01602080910402602001604051908101604052809291908181526020018280546101d590610640565b80156102205780601f106101f757610100808354040283529160200191610220565b820191905f5260205f20905b81548152906001019060200180831161020357829003601f168201915b505050505081565b335f8181526004602090815260408083206001600160a01b038716808552925280832085905551919290917f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925906102829086815260200190565b60405180910390a35060015b92915050565b6001600160a01b0383165f9081526004602090815260408083203384529091528120545f19811461032e57828110156103005760405162461bcd60e51b8152602060048201526009602482015268414c4c4f57414e434560b81b60448201526064015b60405180910390fd5b61030a838261068c565b6001600160a01b0386165f9081526004602090815260408083203384529091529020555b6103398585856103ec565b506001949350505050565b8060025f828254610355919061069f565b90915550506001600160a01b0382165f908152600360205260408120805483929061038190849061069f565b90915550506040518181526001600160a01b038316905f907fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9060200160405180910390a35050565b600180546101a990610640565b5f6103e33384846103ec565b50600192915050565b6001600160a01b03821661042b5760405162461bcd60e51b81526004016102f7906020808252600490820152635a45524f60e01b604082015260600190565b6001600160a01b0383165f9081526003602052604090205481111561047c5760405162461bcd60e51b815260206004820152600760248201526642414c414e434560c81b60448201526064016102f7565b6001600160a01b0383165f90815260036020526040812080548392906104a390849061068c565b90915550506001600160a01b0382165f90815260036020526040812080548392906104cf90849061069f565b92505081905550816001600160a01b0316836001600160a01b03167fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef8360405161051b91815260200190565b60405180910390a3505050565b5f6020808352835180828501525f5b8181101561055357858101830151858201604001528201610537565b505f604082860101526040601f19601f8301168501019250505092915050565b80356001600160a01b0381168114610589575f80fd5b919050565b5f806040838503121561059f575f80fd5b6105a883610573565b946020939093013593505050565b5f805f606084860312156105c8575f80fd5b6105d184610573565b92506105df60208501610573565b9150604084013590509250925092565b5f602082840312156105ff575f80fd5b61060882610573565b9392505050565b5f8060408385031215610620575f80fd5b61062983610573565b915061063760208401610573565b90509250929050565b600181811c9082168061065457607f821691505b60208210810361067257634e487b7160e01b5f52602260045260245ffd5b50919050565b634e487b7160e01b5f52601160045260245ffd5b8181038181111561028e5761028e610678565b8082018082111561028e5761028e61067856fea2646970667358221220b964cf8c676498398f7437a6da85d426afbb81592388a93aa134bd95594bbf5364736f6c63430008140033"} \ No newline at end of file diff --git a/app/vibenet/demos/validity/lib/artifacts/SwapHelper.json b/app/vibenet/demos/validity/lib/artifacts/SwapHelper.json new file mode 100644 index 0000000..ce1fcf6 --- /dev/null +++ b/app/vibenet/demos/validity/lib/artifacts/SwapHelper.json @@ -0,0 +1 @@ +{"abi":[{"type":"function","name":"swap","inputs":[{"name":"tokenIn","type":"address","internalType":"address"},{"name":"pair","type":"address","internalType":"address"},{"name":"amountIn","type":"uint256","internalType":"uint256"},{"name":"amount0Out","type":"uint256","internalType":"uint256"},{"name":"amount1Out","type":"uint256","internalType":"uint256"}],"outputs":[],"stateMutability":"nonpayable"}],"bytecode":"0x608060405234801561000f575f80fd5b506102298061001d5f395ff3fe608060405234801561000f575f80fd5b5060043610610029575f3560e01c80637a950f991461002d575b5f80fd5b61004061003b366004610184565b610042565b005b6040516323b872dd60e01b81523360048201526001600160a01b038581166024830152604482018590528616906323b872dd906064016020604051808303815f875af1158015610094573d5f803e3d5ffd5b505050506040513d601f19601f820116820180604052508101906100b891906101cd565b6100f35760405162461bcd60e51b81526020600482015260086024820152672a2920a729a322a960c11b604482015260640160405180910390fd5b60405163022c0d9f60e01b81526004810183905260248101829052336044820152608060648201525f60848201526001600160a01b0385169063022c0d9f9060a4015f604051808303815f87803b15801561014c575f80fd5b505af115801561015e573d5f803e3d5ffd5b505050505050505050565b80356001600160a01b038116811461017f575f80fd5b919050565b5f805f805f60a08688031215610198575f80fd5b6101a186610169565b94506101af60208701610169565b94979496505050506040830135926060810135926080909101359150565b5f602082840312156101dd575f80fd5b815180151581146101ec575f80fd5b939250505056fea2646970667358221220543bb5afcba8324e91fa3066ed0aabc3ba64dc9364a70aec6466d23a776947e264736f6c63430008140033"} \ No newline at end of file diff --git a/app/vibenet/demos/validity/lib/artifacts/UniswapV2Factory.json b/app/vibenet/demos/validity/lib/artifacts/UniswapV2Factory.json new file mode 100644 index 0000000..dc38db7 --- /dev/null +++ b/app/vibenet/demos/validity/lib/artifacts/UniswapV2Factory.json @@ -0,0 +1 @@ +{"abi":[{"inputs":[{"internalType":"address","name":"_feeToSetter","type":"address"}],"payable":false,"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"token0","type":"address"},{"indexed":true,"internalType":"address","name":"token1","type":"address"},{"indexed":false,"internalType":"address","name":"pair","type":"address"},{"indexed":false,"internalType":"uint256","name":"","type":"uint256"}],"name":"PairCreated","type":"event"},{"constant":true,"inputs":[{"internalType":"uint256","name":"","type":"uint256"}],"name":"allPairs","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"allPairsLength","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"tokenA","type":"address"},{"internalType":"address","name":"tokenB","type":"address"}],"name":"createPair","outputs":[{"internalType":"address","name":"pair","type":"address"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"feeTo","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"feeToSetter","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"address","name":"","type":"address"},{"internalType":"address","name":"","type":"address"}],"name":"getPair","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"_feeTo","type":"address"}],"name":"setFeeTo","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"_feeToSetter","type":"address"}],"name":"setFeeToSetter","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"}],"bytecode":"0x608060405234801561001057600080fd5b506040516136863803806136868339818101604052602081101561003357600080fd5b5051600180546001600160a01b0319166001600160a01b03909216919091179055613623806100636000396000f3fe608060405234801561001057600080fd5b50600436106100885760003560e01c8063a2e74af61161005b578063a2e74af6146100fd578063c9c6539614610132578063e6a439051461016d578063f46901ed146101a857610088565b8063017e7e581461008d578063094b7415146100be5780631e3dd18b146100c6578063574f2ba3146100e3575b600080fd5b6100956101db565b6040805173ffffffffffffffffffffffffffffffffffffffff9092168252519081900360200190f35b6100956101f7565b610095600480360360208110156100dc57600080fd5b5035610213565b6100eb610247565b60408051918252519081900360200190f35b6101306004803603602081101561011357600080fd5b503573ffffffffffffffffffffffffffffffffffffffff1661024d565b005b6100956004803603604081101561014857600080fd5b5073ffffffffffffffffffffffffffffffffffffffff8135811691602001351661031a565b6100956004803603604081101561018357600080fd5b5073ffffffffffffffffffffffffffffffffffffffff8135811691602001351661076d565b610130600480360360208110156101be57600080fd5b503573ffffffffffffffffffffffffffffffffffffffff166107a0565b60005473ffffffffffffffffffffffffffffffffffffffff1681565b60015473ffffffffffffffffffffffffffffffffffffffff1681565b6003818154811061022057fe5b60009182526020909120015473ffffffffffffffffffffffffffffffffffffffff16905081565b60035490565b60015473ffffffffffffffffffffffffffffffffffffffff1633146102d357604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f556e697377617056323a20464f5242494444454e000000000000000000000000604482015290519081900360640190fd5b600180547fffffffffffffffffffffffff00000000000000000000000000000000000000001673ffffffffffffffffffffffffffffffffffffffff92909216919091179055565b60008173ffffffffffffffffffffffffffffffffffffffff168373ffffffffffffffffffffffffffffffffffffffff1614156103b757604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601e60248201527f556e697377617056323a204944454e544943414c5f4144445245535345530000604482015290519081900360640190fd5b6000808373ffffffffffffffffffffffffffffffffffffffff168573ffffffffffffffffffffffffffffffffffffffff16106103f45783856103f7565b84845b909250905073ffffffffffffffffffffffffffffffffffffffff821661047e57604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601760248201527f556e697377617056323a205a45524f5f41444452455353000000000000000000604482015290519081900360640190fd5b73ffffffffffffffffffffffffffffffffffffffff82811660009081526002602090815260408083208585168452909152902054161561051f57604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601660248201527f556e697377617056323a20504149525f45584953545300000000000000000000604482015290519081900360640190fd5b6060604051806020016105319061086d565b6020820181038252601f19601f82011660405250905060008383604051602001808373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b81526014018273ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1660601b815260140192505050604051602081830303815290604052805190602001209050808251602084016000f5604080517f485cc95500000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff8781166004830152868116602483015291519297509087169163485cc9559160448082019260009290919082900301818387803b15801561065e57600080fd5b505af1158015610672573d6000803e3d6000fd5b5050505073ffffffffffffffffffffffffffffffffffffffff84811660008181526002602081815260408084208987168086529083528185208054978d167fffffffffffffffffffffffff000000000000000000000000000000000000000098891681179091559383528185208686528352818520805488168517905560038054600181018255958190527fc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b90950180549097168417909655925483519283529082015281517f0d3648bd0f6ba80134a33ba9275ac585d9d315f0ad8355cddefde31afa28d0e9929181900390910190a35050505092915050565b600260209081526000928352604080842090915290825290205473ffffffffffffffffffffffffffffffffffffffff1681565b60015473ffffffffffffffffffffffffffffffffffffffff16331461082657604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f556e697377617056323a20464f5242494444454e000000000000000000000000604482015290519081900360640190fd5b600080547fffffffffffffffffffffffff00000000000000000000000000000000000000001673ffffffffffffffffffffffffffffffffffffffff92909216919091179055565b612d748061087b8339019056fe60806040526001600c5534801561001557600080fd5b506040514690806052612d228239604080519182900360520182208282018252600a8352692ab734b9bbb0b8102b1960b11b6020938401528151808301835260018152603160f81b908401528151808401919091527fbfcc8ef98ffbf7b6c3fec7bf5185b566b9863e35a9d83acd49ad6824b5969738818301527fc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc6606082015260808101949094523060a0808601919091528151808603909101815260c09094019052825192019190912060035550600580546001600160a01b03191633179055612c1d806101056000396000f3fe608060405234801561001057600080fd5b50600436106101b95760003560e01c80636a627842116100f9578063ba9a7a5611610097578063d21220a711610071578063d21220a7146105da578063d505accf146105e2578063dd62ed3e14610640578063fff6cae91461067b576101b9565b8063ba9a7a5614610597578063bc25cf771461059f578063c45a0155146105d2576101b9565b80637ecebe00116100d35780637ecebe00146104d757806389afcb441461050a57806395d89b4114610556578063a9059cbb1461055e576101b9565b80636a6278421461046957806370a082311461049c5780637464fc3d146104cf576101b9565b806323b872dd116101665780633644e515116101405780633644e51514610416578063485cc9551461041e5780635909c0d5146104595780635a3d549314610461576101b9565b806323b872dd146103ad57806330adf81f146103f0578063313ce567146103f8576101b9565b8063095ea7b311610197578063095ea7b3146103155780630dfe16811461036257806318160ddd14610393576101b9565b8063022c0d9f146101be57806306fdde03146102595780630902f1ac146102d6575b600080fd5b610257600480360360808110156101d457600080fd5b81359160208101359173ffffffffffffffffffffffffffffffffffffffff604083013516919081019060808101606082013564010000000081111561021857600080fd5b82018360208201111561022a57600080fd5b8035906020019184600183028401116401000000008311171561024c57600080fd5b509092509050610683565b005b610261610d57565b6040805160208082528351818301528351919283929083019185019080838360005b8381101561029b578181015183820152602001610283565b50505050905090810190601f1680156102c85780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6102de610d90565b604080516dffffffffffffffffffffffffffff948516815292909316602083015263ffffffff168183015290519081900360600190f35b61034e6004803603604081101561032b57600080fd5b5073ffffffffffffffffffffffffffffffffffffffff8135169060200135610de5565b604080519115158252519081900360200190f35b61036a610dfc565b6040805173ffffffffffffffffffffffffffffffffffffffff9092168252519081900360200190f35b61039b610e18565b60408051918252519081900360200190f35b61034e600480360360608110156103c357600080fd5b5073ffffffffffffffffffffffffffffffffffffffff813581169160208101359091169060400135610e1e565b61039b610efd565b610400610f21565b6040805160ff9092168252519081900360200190f35b61039b610f26565b6102576004803603604081101561043457600080fd5b5073ffffffffffffffffffffffffffffffffffffffff81358116916020013516610f2c565b61039b611005565b61039b61100b565b61039b6004803603602081101561047f57600080fd5b503573ffffffffffffffffffffffffffffffffffffffff16611011565b61039b600480360360208110156104b257600080fd5b503573ffffffffffffffffffffffffffffffffffffffff166113cb565b61039b6113dd565b61039b600480360360208110156104ed57600080fd5b503573ffffffffffffffffffffffffffffffffffffffff166113e3565b61053d6004803603602081101561052057600080fd5b503573ffffffffffffffffffffffffffffffffffffffff166113f5565b6040805192835260208301919091528051918290030190f35b610261611892565b61034e6004803603604081101561057457600080fd5b5073ffffffffffffffffffffffffffffffffffffffff81351690602001356118cb565b61039b6118d8565b610257600480360360208110156105b557600080fd5b503573ffffffffffffffffffffffffffffffffffffffff166118de565b61036a611ad4565b61036a611af0565b610257600480360360e08110156105f857600080fd5b5073ffffffffffffffffffffffffffffffffffffffff813581169160208101359091169060408101359060608101359060ff6080820135169060a08101359060c00135611b0c565b61039b6004803603604081101561065657600080fd5b5073ffffffffffffffffffffffffffffffffffffffff81358116916020013516611dd8565b610257611df5565b600c546001146106f457604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f556e697377617056323a204c4f434b4544000000000000000000000000000000604482015290519081900360640190fd5b6000600c55841515806107075750600084115b61075c576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526025815260200180612b2f6025913960400191505060405180910390fd5b600080610767610d90565b5091509150816dffffffffffffffffffffffffffff168710801561079a5750806dffffffffffffffffffffffffffff1686105b6107ef576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526021815260200180612b786021913960400191505060405180910390fd5b600654600754600091829173ffffffffffffffffffffffffffffffffffffffff91821691908116908916821480159061085457508073ffffffffffffffffffffffffffffffffffffffff168973ffffffffffffffffffffffffffffffffffffffff1614155b6108bf57604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601560248201527f556e697377617056323a20494e56414c49445f544f0000000000000000000000604482015290519081900360640190fd5b8a156108d0576108d0828a8d611fdb565b89156108e1576108e1818a8c611fdb565b86156109c3578873ffffffffffffffffffffffffffffffffffffffff166310d1e85c338d8d8c8c6040518663ffffffff1660e01b8152600401808673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001858152602001848152602001806020018281038252848482818152602001925080828437600081840152601f19601f8201169050808301925050509650505050505050600060405180830381600087803b1580156109aa57600080fd5b505af11580156109be573d6000803e3d6000fd5b505050505b604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905173ffffffffffffffffffffffffffffffffffffffff8416916370a08231916024808301926020929190829003018186803b158015610a2f57600080fd5b505afa158015610a43573d6000803e3d6000fd5b505050506040513d6020811015610a5957600080fd5b5051604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905191955073ffffffffffffffffffffffffffffffffffffffff8316916370a0823191602480820192602092909190829003018186803b158015610acb57600080fd5b505afa158015610adf573d6000803e3d6000fd5b505050506040513d6020811015610af557600080fd5b5051925060009150506dffffffffffffffffffffffffffff85168a90038311610b1f576000610b35565b89856dffffffffffffffffffffffffffff160383035b9050600089856dffffffffffffffffffffffffffff16038311610b59576000610b6f565b89856dffffffffffffffffffffffffffff160383035b90506000821180610b805750600081115b610bd5576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526024815260200180612b546024913960400191505060405180910390fd5b6000610c09610beb84600063ffffffff6121e816565b610bfd876103e863ffffffff6121e816565b9063ffffffff61226e16565b90506000610c21610beb84600063ffffffff6121e816565b9050610c59620f4240610c4d6dffffffffffffffffffffffffffff8b8116908b1663ffffffff6121e816565b9063ffffffff6121e816565b610c69838363ffffffff6121e816565b1015610cd657604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152600c60248201527f556e697377617056323a204b0000000000000000000000000000000000000000604482015290519081900360640190fd5b5050610ce4848488886122e0565b60408051838152602081018390528082018d9052606081018c9052905173ffffffffffffffffffffffffffffffffffffffff8b169133917fd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d8229181900360800190a350506001600c55505050505050505050565b6040518060400160405280600a81526020017f556e69737761702056320000000000000000000000000000000000000000000081525081565b6008546dffffffffffffffffffffffffffff808216926e0100000000000000000000000000008304909116917c0100000000000000000000000000000000000000000000000000000000900463ffffffff1690565b6000610df233848461259c565b5060015b92915050565b60065473ffffffffffffffffffffffffffffffffffffffff1681565b60005481565b73ffffffffffffffffffffffffffffffffffffffff831660009081526002602090815260408083203384529091528120547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff14610ee85773ffffffffffffffffffffffffffffffffffffffff84166000908152600260209081526040808320338452909152902054610eb6908363ffffffff61226e16565b73ffffffffffffffffffffffffffffffffffffffff851660009081526002602090815260408083203384529091529020555b610ef384848461260b565b5060019392505050565b7f6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c981565b601281565b60035481565b60055473ffffffffffffffffffffffffffffffffffffffff163314610fb257604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f556e697377617056323a20464f5242494444454e000000000000000000000000604482015290519081900360640190fd5b6006805473ffffffffffffffffffffffffffffffffffffffff9384167fffffffffffffffffffffffff00000000000000000000000000000000000000009182161790915560078054929093169116179055565b60095481565b600a5481565b6000600c5460011461108457604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f556e697377617056323a204c4f434b4544000000000000000000000000000000604482015290519081900360640190fd5b6000600c81905580611094610d90565b50600654604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905193955091935060009273ffffffffffffffffffffffffffffffffffffffff909116916370a08231916024808301926020929190829003018186803b15801561110e57600080fd5b505afa158015611122573d6000803e3d6000fd5b505050506040513d602081101561113857600080fd5b5051600754604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905192935060009273ffffffffffffffffffffffffffffffffffffffff909216916370a0823191602480820192602092909190829003018186803b1580156111b157600080fd5b505afa1580156111c5573d6000803e3d6000fd5b505050506040513d60208110156111db57600080fd5b505190506000611201836dffffffffffffffffffffffffffff871663ffffffff61226e16565b90506000611225836dffffffffffffffffffffffffffff871663ffffffff61226e16565b9050600061123387876126ec565b600054909150806112705761125c6103e8610bfd611257878763ffffffff6121e816565b612878565b985061126b60006103e86128ca565b6112cd565b6112ca6dffffffffffffffffffffffffffff8916611294868463ffffffff6121e816565b8161129b57fe5b046dffffffffffffffffffffffffffff89166112bd868563ffffffff6121e816565b816112c457fe5b0461297a565b98505b60008911611326576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526028815260200180612bc16028913960400191505060405180910390fd5b6113308a8a6128ca565b61133c86868a8a6122e0565b811561137e5760085461137a906dffffffffffffffffffffffffffff808216916e01000000000000000000000000000090041663ffffffff6121e816565b600b555b6040805185815260208101859052815133927f4c209b5fc8ad50758f13e2e1088ba56a560dff690a1c6fef26394f4c03821c4f928290030190a250506001600c5550949695505050505050565b60016020526000908152604090205481565b600b5481565b60046020526000908152604090205481565b600080600c5460011461146957604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f556e697377617056323a204c4f434b4544000000000000000000000000000000604482015290519081900360640190fd5b6000600c81905580611479610d90565b50600654600754604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905194965092945073ffffffffffffffffffffffffffffffffffffffff9182169391169160009184916370a08231916024808301926020929190829003018186803b1580156114fb57600080fd5b505afa15801561150f573d6000803e3d6000fd5b505050506040513d602081101561152557600080fd5b5051604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905191925060009173ffffffffffffffffffffffffffffffffffffffff8516916370a08231916024808301926020929190829003018186803b15801561159957600080fd5b505afa1580156115ad573d6000803e3d6000fd5b505050506040513d60208110156115c357600080fd5b5051306000908152600160205260408120549192506115e288886126ec565b600054909150806115f9848763ffffffff6121e816565b8161160057fe5b049a5080611614848663ffffffff6121e816565b8161161b57fe5b04995060008b11801561162e575060008a115b611683576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526028815260200180612b996028913960400191505060405180910390fd5b61168d3084612992565b611698878d8d611fdb565b6116a3868d8c611fdb565b604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905173ffffffffffffffffffffffffffffffffffffffff8916916370a08231916024808301926020929190829003018186803b15801561170f57600080fd5b505afa158015611723573d6000803e3d6000fd5b505050506040513d602081101561173957600080fd5b5051604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905191965073ffffffffffffffffffffffffffffffffffffffff8816916370a0823191602480820192602092909190829003018186803b1580156117ab57600080fd5b505afa1580156117bf573d6000803e3d6000fd5b505050506040513d60208110156117d557600080fd5b505193506117e585858b8b6122e0565b811561182757600854611823906dffffffffffffffffffffffffffff808216916e01000000000000000000000000000090041663ffffffff6121e816565b600b555b604080518c8152602081018c9052815173ffffffffffffffffffffffffffffffffffffffff8f169233927fdccd412f0b1252819cb1fd330b93224ca42612892bb3f4f789976e6d81936496929081900390910190a35050505050505050506001600c81905550915091565b6040518060400160405280600681526020017f554e492d5632000000000000000000000000000000000000000000000000000081525081565b6000610df233848461260b565b6103e881565b600c5460011461194f57604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f556e697377617056323a204c4f434b4544000000000000000000000000000000604482015290519081900360640190fd5b6000600c55600654600754600854604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905173ffffffffffffffffffffffffffffffffffffffff9485169490931692611a2b9285928792611a26926dffffffffffffffffffffffffffff169185916370a0823191602480820192602092909190829003018186803b1580156119ee57600080fd5b505afa158015611a02573d6000803e3d6000fd5b505050506040513d6020811015611a1857600080fd5b50519063ffffffff61226e16565b611fdb565b600854604080517f70a082310000000000000000000000000000000000000000000000000000000081523060048201529051611aca9284928792611a26926e01000000000000000000000000000090046dffffffffffffffffffffffffffff169173ffffffffffffffffffffffffffffffffffffffff8616916370a0823191602480820192602092909190829003018186803b1580156119ee57600080fd5b50506001600c5550565b60055473ffffffffffffffffffffffffffffffffffffffff1681565b60075473ffffffffffffffffffffffffffffffffffffffff1681565b42841015611b7b57604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601260248201527f556e697377617056323a20455850495245440000000000000000000000000000604482015290519081900360640190fd5b60035473ffffffffffffffffffffffffffffffffffffffff80891660008181526004602090815260408083208054600180820190925582517f6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c98186015280840196909652958d166060860152608085018c905260a085019590955260c08085018b90528151808603909101815260e0850182528051908301207f19010000000000000000000000000000000000000000000000000000000000006101008601526101028501969096526101228085019690965280518085039096018652610142840180825286519683019690962095839052610162840180825286905260ff89166101828501526101a284018890526101c28401879052519193926101e2808201937fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe081019281900390910190855afa158015611cdc573d6000803e3d6000fd5b50506040517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0015191505073ffffffffffffffffffffffffffffffffffffffff811615801590611d5757508873ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16145b611dc257604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601c60248201527f556e697377617056323a20494e56414c49445f5349474e415455524500000000604482015290519081900360640190fd5b611dcd89898961259c565b505050505050505050565b600260209081526000928352604080842090915290825290205481565b600c54600114611e6657604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f556e697377617056323a204c4f434b4544000000000000000000000000000000604482015290519081900360640190fd5b6000600c55600654604080517f70a082310000000000000000000000000000000000000000000000000000000081523060048201529051611fd49273ffffffffffffffffffffffffffffffffffffffff16916370a08231916024808301926020929190829003018186803b158015611edd57600080fd5b505afa158015611ef1573d6000803e3d6000fd5b505050506040513d6020811015611f0757600080fd5b5051600754604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905173ffffffffffffffffffffffffffffffffffffffff909216916370a0823191602480820192602092909190829003018186803b158015611f7a57600080fd5b505afa158015611f8e573d6000803e3d6000fd5b505050506040513d6020811015611fa457600080fd5b50516008546dffffffffffffffffffffffffffff808216916e0100000000000000000000000000009004166122e0565b6001600c55565b604080518082018252601981527f7472616e7366657228616464726573732c75696e743235362900000000000000602091820152815173ffffffffffffffffffffffffffffffffffffffff85811660248301526044808301869052845180840390910181526064909201845291810180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff167fa9059cbb000000000000000000000000000000000000000000000000000000001781529251815160009460609489169392918291908083835b602083106120e157805182527fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe090920191602091820191016120a4565b6001836020036101000a0380198251168184511680821785525050505050509050019150506000604051808303816000865af19150503d8060008114612143576040519150601f19603f3d011682016040523d82523d6000602084013e612148565b606091505b5091509150818015612176575080511580612176575080806020019051602081101561217357600080fd5b50515b6121e157604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601a60248201527f556e697377617056323a205452414e534645525f4641494c4544000000000000604482015290519081900360640190fd5b5050505050565b60008115806122035750508082028282828161220057fe5b04145b610df657604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f64732d6d6174682d6d756c2d6f766572666c6f77000000000000000000000000604482015290519081900360640190fd5b80820382811115610df657604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601560248201527f64732d6d6174682d7375622d756e646572666c6f770000000000000000000000604482015290519081900360640190fd5b6dffffffffffffffffffffffffffff841180159061230c57506dffffffffffffffffffffffffffff8311155b61237757604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601360248201527f556e697377617056323a204f564552464c4f5700000000000000000000000000604482015290519081900360640190fd5b60085463ffffffff428116917c0100000000000000000000000000000000000000000000000000000000900481168203908116158015906123c757506dffffffffffffffffffffffffffff841615155b80156123e257506dffffffffffffffffffffffffffff831615155b15612492578063ffffffff16612425856123fb86612a57565b7bffffffffffffffffffffffffffffffffffffffffffffffffffffffff169063ffffffff612a7b16565b600980547bffffffffffffffffffffffffffffffffffffffffffffffffffffffff929092169290920201905563ffffffff8116612465846123fb87612a57565b600a80547bffffffffffffffffffffffffffffffffffffffffffffffffffffffff92909216929092020190555b600880547fffffffffffffffffffffffffffffffffffff0000000000000000000000000000166dffffffffffffffffffffffffffff888116919091177fffffffff0000000000000000000000000000ffffffffffffffffffffffffffff166e0100000000000000000000000000008883168102919091177bffffffffffffffffffffffffffffffffffffffffffffffffffffffff167c010000000000000000000000000000000000000000000000000000000063ffffffff871602179283905560408051848416815291909304909116602082015281517f1c411e9a96e071241c2f21f7726b17ae89e3cab4c78be50e062b03a9fffbbad1929181900390910190a1505050505050565b73ffffffffffffffffffffffffffffffffffffffff808416600081815260026020908152604080832094871680845294825291829020859055815185815291517f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9259281900390910190a3505050565b73ffffffffffffffffffffffffffffffffffffffff8316600090815260016020526040902054612641908263ffffffff61226e16565b73ffffffffffffffffffffffffffffffffffffffff8085166000908152600160205260408082209390935590841681522054612683908263ffffffff612abc16565b73ffffffffffffffffffffffffffffffffffffffff80841660008181526001602090815260409182902094909455805185815290519193928716927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef92918290030190a3505050565b600080600560009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1663017e7e586040518163ffffffff1660e01b815260040160206040518083038186803b15801561275757600080fd5b505afa15801561276b573d6000803e3d6000fd5b505050506040513d602081101561278157600080fd5b5051600b5473ffffffffffffffffffffffffffffffffffffffff821615801594509192509061286457801561285f5760006127d86112576dffffffffffffffffffffffffffff88811690881663ffffffff6121e816565b905060006127e583612878565b90508082111561285c576000612813612804848463ffffffff61226e16565b6000549063ffffffff6121e816565b905060006128388361282c86600563ffffffff6121e816565b9063ffffffff612abc16565b9050600081838161284557fe5b04905080156128585761285887826128ca565b5050505b50505b612870565b8015612870576000600b555b505092915050565b600060038211156128bb575080600160028204015b818110156128b5578091506002818285816128a457fe5b0401816128ad57fe5b04905061288d565b506128c5565b81156128c5575060015b919050565b6000546128dd908263ffffffff612abc16565b600090815573ffffffffffffffffffffffffffffffffffffffff8316815260016020526040902054612915908263ffffffff612abc16565b73ffffffffffffffffffffffffffffffffffffffff831660008181526001602090815260408083209490945583518581529351929391927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9281900390910190a35050565b6000818310612989578161298b565b825b9392505050565b73ffffffffffffffffffffffffffffffffffffffff82166000908152600160205260409020546129c8908263ffffffff61226e16565b73ffffffffffffffffffffffffffffffffffffffff831660009081526001602052604081209190915554612a02908263ffffffff61226e16565b600090815560408051838152905173ffffffffffffffffffffffffffffffffffffffff8516917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef919081900360200190a35050565b6dffffffffffffffffffffffffffff166e0100000000000000000000000000000290565b60006dffffffffffffffffffffffffffff82167bffffffffffffffffffffffffffffffffffffffffffffffffffffffff841681612ab457fe5b049392505050565b80820182811015610df657604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f64732d6d6174682d6164642d6f766572666c6f77000000000000000000000000604482015290519081900360640190fdfe556e697377617056323a20494e53554646494349454e545f4f55545055545f414d4f554e54556e697377617056323a20494e53554646494349454e545f494e5055545f414d4f554e54556e697377617056323a20494e53554646494349454e545f4c4951554944495459556e697377617056323a20494e53554646494349454e545f4c49515549444954595f4255524e4544556e697377617056323a20494e53554646494349454e545f4c49515549444954595f4d494e544544a265627a7a723158207dca18479e58487606bf70c79e44d8dee62353c9ee6d01f9a9d70885b8765f2264736f6c63430005100032454950373132446f6d61696e28737472696e67206e616d652c737472696e672076657273696f6e2c75696e7432353620636861696e49642c6164647265737320766572696679696e67436f6e747261637429a265627a7a723158202760f92d7fa1db6f5aa16307bad65df4ebcc8550c4b1f03755ab8dfd830c178f64736f6c63430005100032"} \ No newline at end of file diff --git a/app/vibenet/demos/validity/lib/artifacts/UniswapV2Pair.json b/app/vibenet/demos/validity/lib/artifacts/UniswapV2Pair.json new file mode 100644 index 0000000..c2ccc0f --- /dev/null +++ b/app/vibenet/demos/validity/lib/artifacts/UniswapV2Pair.json @@ -0,0 +1 @@ +{"abi":[{"inputs":[],"payable":false,"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"owner","type":"address"},{"indexed":true,"internalType":"address","name":"spender","type":"address"},{"indexed":false,"internalType":"uint256","name":"value","type":"uint256"}],"name":"Approval","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"sender","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount0","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"amount1","type":"uint256"},{"indexed":true,"internalType":"address","name":"to","type":"address"}],"name":"Burn","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"sender","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount0","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"amount1","type":"uint256"}],"name":"Mint","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"sender","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount0In","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"amount1In","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"amount0Out","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"amount1Out","type":"uint256"},{"indexed":true,"internalType":"address","name":"to","type":"address"}],"name":"Swap","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"uint112","name":"reserve0","type":"uint112"},{"indexed":false,"internalType":"uint112","name":"reserve1","type":"uint112"}],"name":"Sync","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":false,"internalType":"uint256","name":"value","type":"uint256"}],"name":"Transfer","type":"event"},{"constant":true,"inputs":[],"name":"DOMAIN_SEPARATOR","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"MINIMUM_LIQUIDITY","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"PERMIT_TYPEHASH","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"address","name":"","type":"address"},{"internalType":"address","name":"","type":"address"}],"name":"allowance","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"value","type":"uint256"}],"name":"approve","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"balanceOf","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"to","type":"address"}],"name":"burn","outputs":[{"internalType":"uint256","name":"amount0","type":"uint256"},{"internalType":"uint256","name":"amount1","type":"uint256"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"decimals","outputs":[{"internalType":"uint8","name":"","type":"uint8"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"factory","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"getReserves","outputs":[{"internalType":"uint112","name":"_reserve0","type":"uint112"},{"internalType":"uint112","name":"_reserve1","type":"uint112"},{"internalType":"uint32","name":"_blockTimestampLast","type":"uint32"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"_token0","type":"address"},{"internalType":"address","name":"_token1","type":"address"}],"name":"initialize","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"kLast","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"to","type":"address"}],"name":"mint","outputs":[{"internalType":"uint256","name":"liquidity","type":"uint256"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"name","outputs":[{"internalType":"string","name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"nonces","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"owner","type":"address"},{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"value","type":"uint256"},{"internalType":"uint256","name":"deadline","type":"uint256"},{"internalType":"uint8","name":"v","type":"uint8"},{"internalType":"bytes32","name":"r","type":"bytes32"},{"internalType":"bytes32","name":"s","type":"bytes32"}],"name":"permit","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"price0CumulativeLast","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"price1CumulativeLast","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"to","type":"address"}],"name":"skim","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"uint256","name":"amount0Out","type":"uint256"},{"internalType":"uint256","name":"amount1Out","type":"uint256"},{"internalType":"address","name":"to","type":"address"},{"internalType":"bytes","name":"data","type":"bytes"}],"name":"swap","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"symbol","outputs":[{"internalType":"string","name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[],"name":"sync","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"token0","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"token1","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"totalSupply","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"value","type":"uint256"}],"name":"transfer","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"value","type":"uint256"}],"name":"transferFrom","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"}],"bytecode":"0x60806040526001600c5534801561001557600080fd5b506040514690806052612d228239604080519182900360520182208282018252600a8352692ab734b9bbb0b8102b1960b11b6020938401528151808301835260018152603160f81b908401528151808401919091527fbfcc8ef98ffbf7b6c3fec7bf5185b566b9863e35a9d83acd49ad6824b5969738818301527fc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc6606082015260808101949094523060a0808601919091528151808603909101815260c09094019052825192019190912060035550600580546001600160a01b03191633179055612c1d806101056000396000f3fe608060405234801561001057600080fd5b50600436106101b95760003560e01c80636a627842116100f9578063ba9a7a5611610097578063d21220a711610071578063d21220a7146105da578063d505accf146105e2578063dd62ed3e14610640578063fff6cae91461067b576101b9565b8063ba9a7a5614610597578063bc25cf771461059f578063c45a0155146105d2576101b9565b80637ecebe00116100d35780637ecebe00146104d757806389afcb441461050a57806395d89b4114610556578063a9059cbb1461055e576101b9565b80636a6278421461046957806370a082311461049c5780637464fc3d146104cf576101b9565b806323b872dd116101665780633644e515116101405780633644e51514610416578063485cc9551461041e5780635909c0d5146104595780635a3d549314610461576101b9565b806323b872dd146103ad57806330adf81f146103f0578063313ce567146103f8576101b9565b8063095ea7b311610197578063095ea7b3146103155780630dfe16811461036257806318160ddd14610393576101b9565b8063022c0d9f146101be57806306fdde03146102595780630902f1ac146102d6575b600080fd5b610257600480360360808110156101d457600080fd5b81359160208101359173ffffffffffffffffffffffffffffffffffffffff604083013516919081019060808101606082013564010000000081111561021857600080fd5b82018360208201111561022a57600080fd5b8035906020019184600183028401116401000000008311171561024c57600080fd5b509092509050610683565b005b610261610d57565b6040805160208082528351818301528351919283929083019185019080838360005b8381101561029b578181015183820152602001610283565b50505050905090810190601f1680156102c85780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6102de610d90565b604080516dffffffffffffffffffffffffffff948516815292909316602083015263ffffffff168183015290519081900360600190f35b61034e6004803603604081101561032b57600080fd5b5073ffffffffffffffffffffffffffffffffffffffff8135169060200135610de5565b604080519115158252519081900360200190f35b61036a610dfc565b6040805173ffffffffffffffffffffffffffffffffffffffff9092168252519081900360200190f35b61039b610e18565b60408051918252519081900360200190f35b61034e600480360360608110156103c357600080fd5b5073ffffffffffffffffffffffffffffffffffffffff813581169160208101359091169060400135610e1e565b61039b610efd565b610400610f21565b6040805160ff9092168252519081900360200190f35b61039b610f26565b6102576004803603604081101561043457600080fd5b5073ffffffffffffffffffffffffffffffffffffffff81358116916020013516610f2c565b61039b611005565b61039b61100b565b61039b6004803603602081101561047f57600080fd5b503573ffffffffffffffffffffffffffffffffffffffff16611011565b61039b600480360360208110156104b257600080fd5b503573ffffffffffffffffffffffffffffffffffffffff166113cb565b61039b6113dd565b61039b600480360360208110156104ed57600080fd5b503573ffffffffffffffffffffffffffffffffffffffff166113e3565b61053d6004803603602081101561052057600080fd5b503573ffffffffffffffffffffffffffffffffffffffff166113f5565b6040805192835260208301919091528051918290030190f35b610261611892565b61034e6004803603604081101561057457600080fd5b5073ffffffffffffffffffffffffffffffffffffffff81351690602001356118cb565b61039b6118d8565b610257600480360360208110156105b557600080fd5b503573ffffffffffffffffffffffffffffffffffffffff166118de565b61036a611ad4565b61036a611af0565b610257600480360360e08110156105f857600080fd5b5073ffffffffffffffffffffffffffffffffffffffff813581169160208101359091169060408101359060608101359060ff6080820135169060a08101359060c00135611b0c565b61039b6004803603604081101561065657600080fd5b5073ffffffffffffffffffffffffffffffffffffffff81358116916020013516611dd8565b610257611df5565b600c546001146106f457604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f556e697377617056323a204c4f434b4544000000000000000000000000000000604482015290519081900360640190fd5b6000600c55841515806107075750600084115b61075c576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526025815260200180612b2f6025913960400191505060405180910390fd5b600080610767610d90565b5091509150816dffffffffffffffffffffffffffff168710801561079a5750806dffffffffffffffffffffffffffff1686105b6107ef576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526021815260200180612b786021913960400191505060405180910390fd5b600654600754600091829173ffffffffffffffffffffffffffffffffffffffff91821691908116908916821480159061085457508073ffffffffffffffffffffffffffffffffffffffff168973ffffffffffffffffffffffffffffffffffffffff1614155b6108bf57604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601560248201527f556e697377617056323a20494e56414c49445f544f0000000000000000000000604482015290519081900360640190fd5b8a156108d0576108d0828a8d611fdb565b89156108e1576108e1818a8c611fdb565b86156109c3578873ffffffffffffffffffffffffffffffffffffffff166310d1e85c338d8d8c8c6040518663ffffffff1660e01b8152600401808673ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001858152602001848152602001806020018281038252848482818152602001925080828437600081840152601f19601f8201169050808301925050509650505050505050600060405180830381600087803b1580156109aa57600080fd5b505af11580156109be573d6000803e3d6000fd5b505050505b604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905173ffffffffffffffffffffffffffffffffffffffff8416916370a08231916024808301926020929190829003018186803b158015610a2f57600080fd5b505afa158015610a43573d6000803e3d6000fd5b505050506040513d6020811015610a5957600080fd5b5051604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905191955073ffffffffffffffffffffffffffffffffffffffff8316916370a0823191602480820192602092909190829003018186803b158015610acb57600080fd5b505afa158015610adf573d6000803e3d6000fd5b505050506040513d6020811015610af557600080fd5b5051925060009150506dffffffffffffffffffffffffffff85168a90038311610b1f576000610b35565b89856dffffffffffffffffffffffffffff160383035b9050600089856dffffffffffffffffffffffffffff16038311610b59576000610b6f565b89856dffffffffffffffffffffffffffff160383035b90506000821180610b805750600081115b610bd5576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526024815260200180612b546024913960400191505060405180910390fd5b6000610c09610beb84600063ffffffff6121e816565b610bfd876103e863ffffffff6121e816565b9063ffffffff61226e16565b90506000610c21610beb84600063ffffffff6121e816565b9050610c59620f4240610c4d6dffffffffffffffffffffffffffff8b8116908b1663ffffffff6121e816565b9063ffffffff6121e816565b610c69838363ffffffff6121e816565b1015610cd657604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152600c60248201527f556e697377617056323a204b0000000000000000000000000000000000000000604482015290519081900360640190fd5b5050610ce4848488886122e0565b60408051838152602081018390528082018d9052606081018c9052905173ffffffffffffffffffffffffffffffffffffffff8b169133917fd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d8229181900360800190a350506001600c55505050505050505050565b6040518060400160405280600a81526020017f556e69737761702056320000000000000000000000000000000000000000000081525081565b6008546dffffffffffffffffffffffffffff808216926e0100000000000000000000000000008304909116917c0100000000000000000000000000000000000000000000000000000000900463ffffffff1690565b6000610df233848461259c565b5060015b92915050565b60065473ffffffffffffffffffffffffffffffffffffffff1681565b60005481565b73ffffffffffffffffffffffffffffffffffffffff831660009081526002602090815260408083203384529091528120547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff14610ee85773ffffffffffffffffffffffffffffffffffffffff84166000908152600260209081526040808320338452909152902054610eb6908363ffffffff61226e16565b73ffffffffffffffffffffffffffffffffffffffff851660009081526002602090815260408083203384529091529020555b610ef384848461260b565b5060019392505050565b7f6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c981565b601281565b60035481565b60055473ffffffffffffffffffffffffffffffffffffffff163314610fb257604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f556e697377617056323a20464f5242494444454e000000000000000000000000604482015290519081900360640190fd5b6006805473ffffffffffffffffffffffffffffffffffffffff9384167fffffffffffffffffffffffff00000000000000000000000000000000000000009182161790915560078054929093169116179055565b60095481565b600a5481565b6000600c5460011461108457604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f556e697377617056323a204c4f434b4544000000000000000000000000000000604482015290519081900360640190fd5b6000600c81905580611094610d90565b50600654604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905193955091935060009273ffffffffffffffffffffffffffffffffffffffff909116916370a08231916024808301926020929190829003018186803b15801561110e57600080fd5b505afa158015611122573d6000803e3d6000fd5b505050506040513d602081101561113857600080fd5b5051600754604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905192935060009273ffffffffffffffffffffffffffffffffffffffff909216916370a0823191602480820192602092909190829003018186803b1580156111b157600080fd5b505afa1580156111c5573d6000803e3d6000fd5b505050506040513d60208110156111db57600080fd5b505190506000611201836dffffffffffffffffffffffffffff871663ffffffff61226e16565b90506000611225836dffffffffffffffffffffffffffff871663ffffffff61226e16565b9050600061123387876126ec565b600054909150806112705761125c6103e8610bfd611257878763ffffffff6121e816565b612878565b985061126b60006103e86128ca565b6112cd565b6112ca6dffffffffffffffffffffffffffff8916611294868463ffffffff6121e816565b8161129b57fe5b046dffffffffffffffffffffffffffff89166112bd868563ffffffff6121e816565b816112c457fe5b0461297a565b98505b60008911611326576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526028815260200180612bc16028913960400191505060405180910390fd5b6113308a8a6128ca565b61133c86868a8a6122e0565b811561137e5760085461137a906dffffffffffffffffffffffffffff808216916e01000000000000000000000000000090041663ffffffff6121e816565b600b555b6040805185815260208101859052815133927f4c209b5fc8ad50758f13e2e1088ba56a560dff690a1c6fef26394f4c03821c4f928290030190a250506001600c5550949695505050505050565b60016020526000908152604090205481565b600b5481565b60046020526000908152604090205481565b600080600c5460011461146957604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f556e697377617056323a204c4f434b4544000000000000000000000000000000604482015290519081900360640190fd5b6000600c81905580611479610d90565b50600654600754604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905194965092945073ffffffffffffffffffffffffffffffffffffffff9182169391169160009184916370a08231916024808301926020929190829003018186803b1580156114fb57600080fd5b505afa15801561150f573d6000803e3d6000fd5b505050506040513d602081101561152557600080fd5b5051604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905191925060009173ffffffffffffffffffffffffffffffffffffffff8516916370a08231916024808301926020929190829003018186803b15801561159957600080fd5b505afa1580156115ad573d6000803e3d6000fd5b505050506040513d60208110156115c357600080fd5b5051306000908152600160205260408120549192506115e288886126ec565b600054909150806115f9848763ffffffff6121e816565b8161160057fe5b049a5080611614848663ffffffff6121e816565b8161161b57fe5b04995060008b11801561162e575060008a115b611683576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401808060200182810382526028815260200180612b996028913960400191505060405180910390fd5b61168d3084612992565b611698878d8d611fdb565b6116a3868d8c611fdb565b604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905173ffffffffffffffffffffffffffffffffffffffff8916916370a08231916024808301926020929190829003018186803b15801561170f57600080fd5b505afa158015611723573d6000803e3d6000fd5b505050506040513d602081101561173957600080fd5b5051604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905191965073ffffffffffffffffffffffffffffffffffffffff8816916370a0823191602480820192602092909190829003018186803b1580156117ab57600080fd5b505afa1580156117bf573d6000803e3d6000fd5b505050506040513d60208110156117d557600080fd5b505193506117e585858b8b6122e0565b811561182757600854611823906dffffffffffffffffffffffffffff808216916e01000000000000000000000000000090041663ffffffff6121e816565b600b555b604080518c8152602081018c9052815173ffffffffffffffffffffffffffffffffffffffff8f169233927fdccd412f0b1252819cb1fd330b93224ca42612892bb3f4f789976e6d81936496929081900390910190a35050505050505050506001600c81905550915091565b6040518060400160405280600681526020017f554e492d5632000000000000000000000000000000000000000000000000000081525081565b6000610df233848461260b565b6103e881565b600c5460011461194f57604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f556e697377617056323a204c4f434b4544000000000000000000000000000000604482015290519081900360640190fd5b6000600c55600654600754600854604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905173ffffffffffffffffffffffffffffffffffffffff9485169490931692611a2b9285928792611a26926dffffffffffffffffffffffffffff169185916370a0823191602480820192602092909190829003018186803b1580156119ee57600080fd5b505afa158015611a02573d6000803e3d6000fd5b505050506040513d6020811015611a1857600080fd5b50519063ffffffff61226e16565b611fdb565b600854604080517f70a082310000000000000000000000000000000000000000000000000000000081523060048201529051611aca9284928792611a26926e01000000000000000000000000000090046dffffffffffffffffffffffffffff169173ffffffffffffffffffffffffffffffffffffffff8616916370a0823191602480820192602092909190829003018186803b1580156119ee57600080fd5b50506001600c5550565b60055473ffffffffffffffffffffffffffffffffffffffff1681565b60075473ffffffffffffffffffffffffffffffffffffffff1681565b42841015611b7b57604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601260248201527f556e697377617056323a20455850495245440000000000000000000000000000604482015290519081900360640190fd5b60035473ffffffffffffffffffffffffffffffffffffffff80891660008181526004602090815260408083208054600180820190925582517f6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c98186015280840196909652958d166060860152608085018c905260a085019590955260c08085018b90528151808603909101815260e0850182528051908301207f19010000000000000000000000000000000000000000000000000000000000006101008601526101028501969096526101228085019690965280518085039096018652610142840180825286519683019690962095839052610162840180825286905260ff89166101828501526101a284018890526101c28401879052519193926101e2808201937fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe081019281900390910190855afa158015611cdc573d6000803e3d6000fd5b50506040517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0015191505073ffffffffffffffffffffffffffffffffffffffff811615801590611d5757508873ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16145b611dc257604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601c60248201527f556e697377617056323a20494e56414c49445f5349474e415455524500000000604482015290519081900360640190fd5b611dcd89898961259c565b505050505050505050565b600260209081526000928352604080842090915290825290205481565b600c54600114611e6657604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601160248201527f556e697377617056323a204c4f434b4544000000000000000000000000000000604482015290519081900360640190fd5b6000600c55600654604080517f70a082310000000000000000000000000000000000000000000000000000000081523060048201529051611fd49273ffffffffffffffffffffffffffffffffffffffff16916370a08231916024808301926020929190829003018186803b158015611edd57600080fd5b505afa158015611ef1573d6000803e3d6000fd5b505050506040513d6020811015611f0757600080fd5b5051600754604080517f70a08231000000000000000000000000000000000000000000000000000000008152306004820152905173ffffffffffffffffffffffffffffffffffffffff909216916370a0823191602480820192602092909190829003018186803b158015611f7a57600080fd5b505afa158015611f8e573d6000803e3d6000fd5b505050506040513d6020811015611fa457600080fd5b50516008546dffffffffffffffffffffffffffff808216916e0100000000000000000000000000009004166122e0565b6001600c55565b604080518082018252601981527f7472616e7366657228616464726573732c75696e743235362900000000000000602091820152815173ffffffffffffffffffffffffffffffffffffffff85811660248301526044808301869052845180840390910181526064909201845291810180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff167fa9059cbb000000000000000000000000000000000000000000000000000000001781529251815160009460609489169392918291908083835b602083106120e157805182527fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe090920191602091820191016120a4565b6001836020036101000a0380198251168184511680821785525050505050509050019150506000604051808303816000865af19150503d8060008114612143576040519150601f19603f3d011682016040523d82523d6000602084013e612148565b606091505b5091509150818015612176575080511580612176575080806020019051602081101561217357600080fd5b50515b6121e157604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601a60248201527f556e697377617056323a205452414e534645525f4641494c4544000000000000604482015290519081900360640190fd5b5050505050565b60008115806122035750508082028282828161220057fe5b04145b610df657604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f64732d6d6174682d6d756c2d6f766572666c6f77000000000000000000000000604482015290519081900360640190fd5b80820382811115610df657604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601560248201527f64732d6d6174682d7375622d756e646572666c6f770000000000000000000000604482015290519081900360640190fd5b6dffffffffffffffffffffffffffff841180159061230c57506dffffffffffffffffffffffffffff8311155b61237757604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601360248201527f556e697377617056323a204f564552464c4f5700000000000000000000000000604482015290519081900360640190fd5b60085463ffffffff428116917c0100000000000000000000000000000000000000000000000000000000900481168203908116158015906123c757506dffffffffffffffffffffffffffff841615155b80156123e257506dffffffffffffffffffffffffffff831615155b15612492578063ffffffff16612425856123fb86612a57565b7bffffffffffffffffffffffffffffffffffffffffffffffffffffffff169063ffffffff612a7b16565b600980547bffffffffffffffffffffffffffffffffffffffffffffffffffffffff929092169290920201905563ffffffff8116612465846123fb87612a57565b600a80547bffffffffffffffffffffffffffffffffffffffffffffffffffffffff92909216929092020190555b600880547fffffffffffffffffffffffffffffffffffff0000000000000000000000000000166dffffffffffffffffffffffffffff888116919091177fffffffff0000000000000000000000000000ffffffffffffffffffffffffffff166e0100000000000000000000000000008883168102919091177bffffffffffffffffffffffffffffffffffffffffffffffffffffffff167c010000000000000000000000000000000000000000000000000000000063ffffffff871602179283905560408051848416815291909304909116602082015281517f1c411e9a96e071241c2f21f7726b17ae89e3cab4c78be50e062b03a9fffbbad1929181900390910190a1505050505050565b73ffffffffffffffffffffffffffffffffffffffff808416600081815260026020908152604080832094871680845294825291829020859055815185815291517f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b9259281900390910190a3505050565b73ffffffffffffffffffffffffffffffffffffffff8316600090815260016020526040902054612641908263ffffffff61226e16565b73ffffffffffffffffffffffffffffffffffffffff8085166000908152600160205260408082209390935590841681522054612683908263ffffffff612abc16565b73ffffffffffffffffffffffffffffffffffffffff80841660008181526001602090815260409182902094909455805185815290519193928716927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef92918290030190a3505050565b600080600560009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1663017e7e586040518163ffffffff1660e01b815260040160206040518083038186803b15801561275757600080fd5b505afa15801561276b573d6000803e3d6000fd5b505050506040513d602081101561278157600080fd5b5051600b5473ffffffffffffffffffffffffffffffffffffffff821615801594509192509061286457801561285f5760006127d86112576dffffffffffffffffffffffffffff88811690881663ffffffff6121e816565b905060006127e583612878565b90508082111561285c576000612813612804848463ffffffff61226e16565b6000549063ffffffff6121e816565b905060006128388361282c86600563ffffffff6121e816565b9063ffffffff612abc16565b9050600081838161284557fe5b04905080156128585761285887826128ca565b5050505b50505b612870565b8015612870576000600b555b505092915050565b600060038211156128bb575080600160028204015b818110156128b5578091506002818285816128a457fe5b0401816128ad57fe5b04905061288d565b506128c5565b81156128c5575060015b919050565b6000546128dd908263ffffffff612abc16565b600090815573ffffffffffffffffffffffffffffffffffffffff8316815260016020526040902054612915908263ffffffff612abc16565b73ffffffffffffffffffffffffffffffffffffffff831660008181526001602090815260408083209490945583518581529351929391927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9281900390910190a35050565b6000818310612989578161298b565b825b9392505050565b73ffffffffffffffffffffffffffffffffffffffff82166000908152600160205260409020546129c8908263ffffffff61226e16565b73ffffffffffffffffffffffffffffffffffffffff831660009081526001602052604081209190915554612a02908263ffffffff61226e16565b600090815560408051838152905173ffffffffffffffffffffffffffffffffffffffff8516917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef919081900360200190a35050565b6dffffffffffffffffffffffffffff166e0100000000000000000000000000000290565b60006dffffffffffffffffffffffffffff82167bffffffffffffffffffffffffffffffffffffffffffffffffffffffff841681612ab457fe5b049392505050565b80820182811015610df657604080517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601460248201527f64732d6d6174682d6164642d6f766572666c6f77000000000000000000000000604482015290519081900360640190fdfe556e697377617056323a20494e53554646494349454e545f4f55545055545f414d4f554e54556e697377617056323a20494e53554646494349454e545f494e5055545f414d4f554e54556e697377617056323a20494e53554646494349454e545f4c4951554944495459556e697377617056323a20494e53554646494349454e545f4c49515549444954595f4255524e4544556e697377617056323a20494e53554646494349454e545f4c49515549444954595f4d494e544544a265627a7a723158207dca18479e58487606bf70c79e44d8dee62353c9ee6d01f9a9d70885b8765f2264736f6c63430005100032454950373132446f6d61696e28737472696e67206e616d652c737472696e672076657273696f6e2c75696e7432353620636861696e49642c6164647265737320766572696679696e67436f6e747261637429"} \ No newline at end of file diff --git a/app/vibenet/demos/validity/lib/bots.test.ts b/app/vibenet/demos/validity/lib/bots.test.ts new file mode 100644 index 0000000..fcc5dc2 --- /dev/null +++ b/app/vibenet/demos/validity/lib/bots.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { BOT_GAS_FLOOR, allNeedGas, botNeedsGas, fractionForPriceMove, makerTargetPrice, planSwap } from './bots'; + +describe('fractionForPriceMove', () => { + it('sizes a 1% price step at about half a percent of reserves', () => { + 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); + }); +}); diff --git a/app/vibenet/demos/validity/lib/bots.ts b/app/vibenet/demos/validity/lib/bots.ts new file mode 100644 index 0000000..2281331 --- /dev/null +++ b/app/vibenet/demos/validity/lib/bots.ts @@ -0,0 +1,199 @@ +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'); +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)); +} + +/** + * 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..1b4a7f4 --- /dev/null +++ b/app/vibenet/demos/validity/lib/constants.ts @@ -0,0 +1,62 @@ +import type { Abi, Address, Hex } from 'viem'; + +import erc20Artifact from './artifacts/MintableERC20.json'; +import helperArtifact from './artifacts/SwapHelper.json'; +import factoryArtifact from './artifacts/UniswapV2Factory.json'; +import pairArtifact from './artifacts/UniswapV2Pair.json'; + +function with0x(value: string): Hex { + return (value.startsWith('0x') ? value : `0x${value}`) as Hex; +} + +export const erc20Abi = erc20Artifact.abi as Abi; +export const erc20Bytecode = with0x(erc20Artifact.bytecode); + +export const factoryAbi = factoryArtifact.abi as Abi; +export const factoryBytecode = with0x(factoryArtifact.bytecode); + +export const pairAbi = pairArtifact.abi as Abi; + +export const helperAbi = helperArtifact.abi as Abi; +export const helperBytecode = with0x(helperArtifact.bytecode); + +export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Address; + +export const RPC_PATH = '/api/vibenet/validity/rpc'; +export const STATUS_PATH = '/api/vibenet/validity/status'; + +export const STORAGE_KEY = 'vibenet.validity.v4'; +export const LEGACY_STORAGE_KEYS = ['vibenet.validity.v3', 'vibenet.validity.v2', 'vibenet.validity.v1'] as const; + +export const WAD = 10n ** 18n; +/** ~$0.07 USDV per VIBE so the tape has room to move, not a 1:1 peg. */ +export const SEED_VIBE = 2_000_000n * WAD; +export const SEED_USDV = 140_000n * WAD; +export const TRADER_VIBE = 400_000n * WAD; +export const TRADER_USDV = 40_000n * WAD; +export const PAIR_RESERVES_SLOT = 8n; +export const RESERVE_BITS = 112n; +export const RESERVE0_MASK = (1n << RESERVE_BITS) - 1n; +export const RESERVE1_MASK = RESERVE0_MASK << RESERVE_BITS; + +export const MAX_EXPIRY_SECONDS = 60; +/** + * 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; +export const CANDLE_BUCKET_MS = 200; +export const CANDLE_WINDOW_MS = 30_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..be4d5dd --- /dev/null +++ b/app/vibenet/demos/validity/lib/orders.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { occupyingOrder, maxBlockForExpiry, orderBlockExpired, orderWallClockExpired, restingOrderToReplace, tapeCrossedAt } from './orders'; + +describe('orderWallClockExpired', () => { + it('expires a resting order after the window plus grace', () => { + const order = { status: 'pending' as const, submittedAt: 1_000, expirySeconds: 5 }; + expect(orderWallClockExpired(order, 1_000 + 5_000 + 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('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); + }); +}); + +describe('tapeCrossedAt', () => { + it('uses the first print on the fill side, not a later wick', () => { + const samples = [ + { t: 1_000, price: 0.083 }, + { t: 2_000, price: 0.0816 }, + { t: 3_000, price: 0.0828 }, + ]; + expect(tapeCrossedAt(samples, 1_500, 0.0816, 'buy')).toBe(2_000); + }); + + it('ignores prints before submit', () => { + const samples = [ + { t: 1_000, price: 0.08 }, + { t: 3_000, price: 0.082 }, + ]; + expect(tapeCrossedAt(samples, 2_000, 0.081, 'sell')).toBe(3_000); + }); +}); + +const fees = { nonce: 3, maxFeePerGas: 1n, maxPriorityFeePerGas: 1n, 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..3133c80 --- /dev/null +++ b/app/vibenet/demos/validity/lib/orders.ts @@ -0,0 +1,69 @@ +import { BLOCK_SECONDS } from './constants'; +import type { PlacedOrder, Side } from './types'; + +const WALL_CLOCK_GRACE_MS = 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); +} + +/** First tape print on the fill side of the limit after submit — not when the receipt lagged in. */ +export function tapeCrossedAt( + samples: { t: number; price: number }[], + submittedAt: number, + target: number, + side: Side, +): number | undefined { + if (!Number.isFinite(target) || target <= 0) return undefined; + for (const sample of samples) { + if (sample.t < submittedAt) continue; + const hit = side === 'buy' ? sample.price <= target : sample.price >= target; + if (hit) return sample.t; + } + return undefined; +} + +export function occupyingOrder( + orders: Pick[], + nonce: number, +): (typeof orders)[number] | undefined { + return orders.find( + (order) => + order.nonce === nonce && + (order.status === 'pending' || order.status === 'expired') && + order.maxFeePerGas !== undefined && + order.maxPriorityFeePerGas !== undefined, + ); +} + +/** UI replacement only. Expired stays expired even if we bump fees over its pooled nonce. */ +export function restingOrderToReplace( + orders: Pick[], + nonce: number, +): (typeof orders)[number] | undefined { + return orders.find( + (order) => + order.nonce === nonce && + order.status === 'pending' && + order.maxFeePerGas !== undefined && + order.maxPriorityFeePerGas !== undefined, + ); +} diff --git a/app/vibenet/demos/validity/lib/predicates.test.ts b/app/vibenet/demos/validity/lib/predicates.test.ts new file mode 100644 index 0000000..7bb42be --- /dev/null +++ b/app/vibenet/demos/validity/lib/predicates.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; + +import { PAIR_RESERVES_SLOT, RESERVE0_MASK, RESERVE1_MASK, RESERVE_BITS, WAD } from './constants'; +import { + applyOffsetBps, + formatCompactHex, + formatPrice, + prettyValidity, + priceValidity, + priceWad, + rectangleForTarget, + sqrt, + toWord, +} from './predicates'; + +const PAIR = '0x1111111111111111111111111111111111111111'; + +describe('predicates', () => { + it('integer-square-roots perfect and imperfect squares', () => { + expect(sqrt(0n)).toBe(0n); + expect(sqrt(1n)).toBe(1n); + expect(sqrt(9n)).toBe(3n); + expect(sqrt(10n)).toBe(3n); + expect(sqrt(100n * WAD * WAD)).toBe(10n * WAD); + }); + + it('formats wad prices', () => { + expect(formatPrice(WAD)).toBe('1.0000'); + expect(formatPrice(99n * 10n ** 16n)).toBe('0.9900'); + }); + + it('formats compact hex without leading zeros', () => { + expect(formatCompactHex(0n)).toBe('0x0'); + expect(formatCompactHex(PAIR_RESERVES_SLOT)).toBe('0x8'); + expect(formatCompactHex(255n)).toBe('0xff'); + }); + + it('offsets spot in basis points for buy and sell', () => { + expect(applyOffsetBps(WAD, 'buy', 100)).toBe((99n * WAD) / 100n); + expect(applyOffsetBps(WAD, 'sell', 100)).toBe((101n * WAD) / 100n); + expect(applyOffsetBps(WAD, 'buy', 0)).toBe(WAD); + expect(applyOffsetBps(WAD, 'sell', 0)).toBe(WAD); + }); + + it('buy box implies every corner has price ≤ P', () => { + const k = 1_000n * WAD * (1_000n * WAD); + const target = (99n * WAD) / 100n; + const box = rectangleForTarget(k, target, 'buy'); + const worst = (box.r1Max * WAD) / box.r0Min; + expect(worst).toBeLessThanOrEqual(target); + expect(box.r0Max).toBeGreaterThan(box.r0Min); + expect(box.r1Max).toBeGreaterThan(box.r1Min); + expect((box.r0Max * 1000n) / box.r0Min).toBeGreaterThanOrEqual(1050n); + expect((box.r0Max * 1000n) / box.r0Min).toBeLessThanOrEqual(1070n); + + const { predicates } = priceValidity(PAIR, k, target, 'buy'); + expect(predicates).toHaveLength(4); + expect(predicates[0]).toMatchObject({ + type: 'storage', + params: { address: PAIR, op: '>=', mask: toWord(RESERVE0_MASK) }, + }); + expect(predicates[1].params.op).toBe('<='); + expect(predicates[1].params.mask).toBe(toWord(RESERVE0_MASK)); + expect(predicates[2].params.op).toBe('>='); + expect(predicates[2].params.mask).toBe(toWord(RESERVE1_MASK)); + expect(predicates[3].params.op).toBe('<='); + const r1MaxValue = BigInt(predicates[3].params.value); + expect(r1MaxValue).toBe(box.r1Max << RESERVE_BITS); + expect((r1MaxValue & ~RESERVE1_MASK) === 0n).toBe(true); + }); + + it('sell box implies every corner has price ≥ P', () => { + const k = 1_000n * WAD * (1_000n * WAD); + const target = (101n * WAD) / 100n; + const box = rectangleForTarget(k, target, 'sell'); + const worst = (box.r1Min * WAD) / box.r0Max; + expect(worst).toBeGreaterThanOrEqual(target); + expect(box.r0Max).toBeGreaterThan(box.r0Min); + expect(box.r1Max).toBeGreaterThan(box.r1Min); + + const { predicates } = priceValidity(PAIR, k, target, 'sell'); + expect(predicates).toHaveLength(4); + expect(predicates[0].params.op).toBe('>='); + expect(predicates[1].params.op).toBe('<='); + expect(predicates[2].params.op).toBe('>='); + expect(predicates[3].params.op).toBe('<='); + }); + + it('pretty-prints validity JSON with compact hex', () => { + const k = 1_000n * WAD * (1_000n * WAD); + const { predicates } = priceValidity(PAIR, k, WAD, 'buy'); + const pretty = prettyValidity(predicates); + expect(pretty).toContain('"slot": "0x8"'); + expect(pretty).not.toContain('0x00000000'); + }); + + it('spot price is reserve1/reserve0 in wad', () => { + expect(priceWad(100n, 99n)).toBe((99n * WAD) / 100n); + }); +}); diff --git a/app/vibenet/demos/validity/lib/predicates.ts b/app/vibenet/demos/validity/lib/predicates.ts new file mode 100644 index 0000000..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..dec906f --- /dev/null +++ b/app/vibenet/demos/validity/lib/quote.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { WAD } from './constants'; +import { ammPriceFromQuote, ammSide, clampToCondition, quoteFromPreSwapReserves, quoteWad, swapOuts, vibeIsToken0 } from './quote'; + +const deployment = { + tokenA: '0x000000000000000000000000000000000000000a' as const, + token0: '0x000000000000000000000000000000000000000a' as const, + token1: '0x000000000000000000000000000000000000000b' as const, +}; + +describe('quote', () => { + it('treats tokenA as VIBE', () => { + expect(vibeIsToken0(deployment)).toBe(true); + expect(vibeIsToken0({ ...deployment, token0: deployment.token1 })).toBe(false); + }); + + it('quotes USDV per VIBE regardless of Uni v2 sort', () => { + const vibe = 2_000_000n; + const usdv = 140_000n; + expect(quoteWad(vibe, usdv, true)).toBe((usdv * WAD) / vibe); + expect(quoteWad(usdv, vibe, false)).toBe((usdv * WAD) / vibe); + }); + + it('round-trips quote ↔ AMM price when VIBE is token1', () => { + const quote = (7n * WAD) / 100n; + const amm = ammPriceFromQuote(quote, false); + expect(ammPriceFromQuote(amm, false)).toBe(quote); + expect(ammSide('buy', false)).toBe('sell'); + }); + + it('sends USDV out when dumping VIBE and VIBE is token0', () => { + expect(swapOuts({ vibeToken0: true, sellVibe: true, amountOut: 5n })).toEqual({ + amount0Out: 0n, + amount1Out: 5n, + }); + }); + + it('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..b814186 --- /dev/null +++ b/app/vibenet/demos/validity/lib/quote.ts @@ -0,0 +1,84 @@ +import type { Address } from 'viem'; + +import { WAD } from './constants'; +import { priceWad } from './predicates'; +import type { Deployment, Side } from './types'; + +export const VIBE_NAME = 'VIBE'; +export const VIBE_SYMBOL = 'VIBE'; +export const USDV_NAME = 'Vibe USD'; +export const USDV_SYMBOL = 'USDV'; + +export function vibeIsToken0(deployment: Pick): boolean { + return deployment.token0.toLowerCase() === deployment.tokenA.toLowerCase(); +} + +/** USDV per VIBE. tokenA is always VIBE, tokenB is always USDV. */ +export function quoteWad( + reserve0: bigint, + reserve1: bigint, + vibeToken0: boolean, +): bigint { + return vibeToken0 ? priceWad(reserve0, reserve1) : priceWad(reserve1, reserve0); +} + +export function ammPriceFromQuote(quote: bigint, vibeToken0: boolean): bigint { + if (vibeToken0) return quote; + if (quote === 0n) return 0n; + return (WAD * WAD) / quote; +} + +export function ammSide(side: Side, vibeToken0: boolean): Side { + if (vibeToken0) return side; + return side === 'buy' ? 'sell' : 'buy'; +} + +export function 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..759f949 --- /dev/null +++ b/app/vibenet/demos/validity/lib/rpc.ts @@ -0,0 +1,111 @@ +import { + createPublicClient, + createWalletClient, + custom, + type Account, + type Chain, + type Hex, + type PublicClient, + type WalletClient, +} from 'viem'; + +import { RPC_PATH, STATUS_PATH } from './constants'; +import type { ChainStatus, ValidityPredicate } from './types'; + +export 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 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/store.test.ts b/app/vibenet/demos/validity/lib/store.test.ts new file mode 100644 index 0000000..5440f8a --- /dev/null +++ b/app/vibenet/demos/validity/lib/store.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { parseStored } from './store'; + +describe('parseStored', () => { + it('reads v2 deployment-only state', () => { + const parsed = parseStored( + JSON.stringify({ + v: 2, + chainId: 84538453, + genesisHash: '0xabc', + accountId: 'acct-1', + makerAccountIds: ['m1', 'm2'], + }), + ); + expect(parsed).toEqual({ + v: 2, + chainId: 84538453, + genesisHash: '0xabc', + accountId: 'acct-1', + makerAccountIds: ['m1', 'm2'], + deployment: undefined, + }); + }); + + it('drops v1 Validity-specific keys', () => { + const parsed = parseStored( + JSON.stringify({ + v: 1, + chainId: 1, + genesisHash: '0x1', + userKey: `0x${'11'.repeat(32)}`, + botKeys: [`0x${'22'.repeat(32)}`, `0x${'33'.repeat(32)}`], + }), + ); + expect(parsed).toEqual({ v: 2, chainId: 1, genesisHash: '0x1' }); + expect(parsed && 'userKey' in parsed).toBe(false); + }); +}); diff --git a/app/vibenet/demos/validity/lib/store.ts b/app/vibenet/demos/validity/lib/store.ts new file mode 100644 index 0000000..d275823 --- /dev/null +++ b/app/vibenet/demos/validity/lib/store.ts @@ -0,0 +1,116 @@ +import { LEGACY_STORAGE_KEYS, STORAGE_KEY } from './constants'; +import type { Deployment } from './types'; + +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; +}; + +function isAddress(value: unknown): value is `0x${string}` { + return typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value); +} + +function parseDeployment(value: unknown): Deployment | undefined { + if (!value || typeof value !== 'object') return undefined; + const d = value as Record; + if ( + !isAddress(d.tokenA) || + !isAddress(d.tokenB) || + !isAddress(d.token0) || + !isAddress(d.token1) || + !isAddress(d.factory) || + !isAddress(d.pair) || + !isAddress(d.helper) + ) { + return undefined; + } + return { + tokenA: d.tokenA, + tokenB: d.tokenB, + token0: d.token0, + token1: d.token1, + factory: d.factory, + pair: d.pair, + helper: d.helper, + }; +} + +function isId(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +export function loadState(): StoredState | null { + if (typeof window === 'undefined') return null; + try { + 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 the pool: v1 keyed off Validity-specific EOAs that no longer exist. + 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) 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), + }; + } + // 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 { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); +} + +export function dropDeployment(state: StoredState): StoredState { + return { + v: 2, + chainId: state.chainId, + genesisHash: state.genesisHash, + accountId: state.accountId, + makerAccountIds: state.makerAccountIds, + }; +} + +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..db969f1 --- /dev/null +++ b/app/vibenet/demos/validity/lib/stream.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import { headNumber } from './stream'; + +describe('headNumber', () => { + it('reads a hex block number', () => { + expect(headNumber({ number: '0x6fb4' })).toBe(28596n); + }); + + it('rejects a missing number', () => { + expect(headNumber({ number: 'nope' as `0x${string}` })).toBeNull(); + }); +}); diff --git a/app/vibenet/demos/validity/lib/stream.ts b/app/vibenet/demos/validity/lib/stream.ts new file mode 100644 index 0000000..349a8ff --- /dev/null +++ b/app/vibenet/demos/validity/lib/stream.ts @@ -0,0 +1,126 @@ +type Hex = `0x${string}`; + +type JsonRpcSuccess = { id?: unknown; result?: unknown; error?: { message?: string } }; +type SubscriptionNote = { + method?: string; + params?: { subscription?: string; result?: unknown }; +}; + +export type StreamHead = { + number: Hex; + timestamp?: Hex; + hash?: Hex; + baseFeePerGas?: Hex; +}; + +export type StreamLog = { + address: Hex; + topics: Hex[]; + data: Hex; + transactionHash?: Hex; + blockNumber?: Hex; +}; + +type Pending = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; +}; + +/** Browser JSON-RPC WebSocket with eth_subscribe. */ +export function connectJsonRpcStream(url: string) { + const ws = new WebSocket(url); + const pending = new Map(); + const listeners = new Map void>(); + let nextId = 1; + let opened = false; + let onClose: (() => void) | undefined; + + const ready = new Promise((resolve, reject) => { + const timer = window.setTimeout(() => reject(new Error('WebSocket timed out')), 8_000); + ws.addEventListener('open', () => { + window.clearTimeout(timer); + opened = true; + resolve(); + }); + ws.addEventListener('error', () => { + window.clearTimeout(timer); + if (!opened) reject(new Error('WebSocket failed')); + }); + }); + + ws.addEventListener('message', (event) => { + let body: JsonRpcSuccess & SubscriptionNote; + try { + body = JSON.parse(String(event.data)) as JsonRpcSuccess & SubscriptionNote; + } catch { + return; + } + if (body.method === 'eth_subscription') { + const id = body.params?.subscription; + if (id) listeners.get(id)?.(body.params?.result); + return; + } + if (typeof body.id !== 'number') return; + const waiter = pending.get(body.id); + if (!waiter) return; + pending.delete(body.id); + if (body.error?.message) waiter.reject(new Error(body.error.message)); + else waiter.resolve(body.result); + }); + + ws.addEventListener('close', () => { + for (const waiter of pending.values()) waiter.reject(new Error('WebSocket closed')); + pending.clear(); + onClose?.(); + }); + + const request = async (method: string, params: unknown[]): Promise => { + await ready; + if (ws.readyState !== WebSocket.OPEN) throw new Error('WebSocket closed'); + const id = nextId; + nextId += 1; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + try { + ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })); + } catch (err) { + pending.delete(id); + reject(err instanceof Error ? err : new Error('WebSocket send failed')); + } + }); + }; + + const subscribe = async (params: unknown[], onResult: (result: unknown) => void): Promise<() => void> => { + const subId = await request('eth_subscribe', params); + if (typeof subId !== 'string') throw new Error('eth_subscribe returned no id'); + listeners.set(subId, onResult); + return () => { + listeners.delete(subId); + if (ws.readyState === WebSocket.OPEN) { + void request('eth_unsubscribe', [subId]).catch(() => {}); + } + }; + }; + + return { + ready, + request, + subscribe, + setOnClose: (handler: () => void) => { + onClose = handler; + }, + close: () => { + onClose = undefined; + listeners.clear(); + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) ws.close(); + }, + }; +} + +export function headNumber(head: StreamHead): bigint | null { + try { + return BigInt(head.number); + } catch { + return null; + } +} diff --git a/app/vibenet/demos/validity/lib/types.ts b/app/vibenet/demos/validity/lib/types.ts new file mode 100644 index 0000000..8f1ca83 --- /dev/null +++ b/app/vibenet/demos/validity/lib/types.ts @@ -0,0 +1,109 @@ +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; +}; + +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/public/AGENTS.md b/public/AGENTS.md index 70e7a18..f007d1b 100644 --- a/public/AGENTS.md +++ b/public/AGENTS.md @@ -27,7 +27,7 @@ Machine-readable entry point for agents working with Base Chain network state. | /upgrades/changelog | per release | re-fetch before stating an activation status | | /vibenet/faucet | monthly | stable within a session | | /api/snapshots | daily | re-fetch every session; never cache across sessions | -| /, /vibenet, /vibenet/demos/account, /vibenet/demos/b20 | infrequent | stable within a session | +| /, /vibenet, /vibenet/demos/account, /vibenet/demos/b20, /vibenet/demos/validity | infrequent | stable within a session | ## Machine-readable endpoints @@ -88,6 +88,7 @@ Discovered from the Next.js app directory. - [/vibenet](https://chain.base.org/vibenet) — Explore Vibenet, the Base devnet for testing in-flight protocol features. - [/vibenet/demos/account](https://chain.base.org/vibenet/demos/account) — Create native account abstraction accounts from in-browser keys, fund them from the faucet, and inspect balances on Vibenet. - [/vibenet/demos/b20](https://chain.base.org/vibenet/demos/b20) — Explore, configure, and issue Base-native B20 tokens on Vibenet. +- [/vibenet/demos/validity](https://chain.base.org/vibenet/demos/validity) — Attach conditions to a transaction. A simulated pool shows how a swap waits, lands, or expires. - [/vibenet/explorer](https://chain.base.org/vibenet/explorer) — Browse blocks, transactions, and addresses on the Vibenet devnet. - [/vibenet/faucet](https://chain.base.org/vibenet/faucet) — Request testnet tokens on Vibenet to fund accounts and try in-flight Base features. diff --git a/public/llms-full.txt b/public/llms-full.txt index 540e390..2567977 100644 --- a/public/llms-full.txt +++ b/public/llms-full.txt @@ -21,6 +21,7 @@ - [Vibenet · Base Chain](https://chain.base.org/vibenet): Explore Vibenet, the Base devnet for testing in-flight protocol features. - [Accounts · Vibenet](https://chain.base.org/vibenet/demos/account): Create native account abstraction accounts from in-browser keys, fund them from the faucet, and inspect balances on Vibenet. - [Tokens · Vibenet](https://chain.base.org/vibenet/demos/b20): Explore, configure, and issue Base-native B20 tokens on Vibenet. +- [Validity · Vibenet](https://chain.base.org/vibenet/demos/validity): Attach conditions to a transaction. A simulated pool shows how a swap waits, lands, or expires. - [Explorer · Vibenet](https://chain.base.org/vibenet/explorer): Browse blocks, transactions, and addresses on the Vibenet devnet. (changes daily; re-fetch before relying on it) - [Faucet · Vibenet](https://chain.base.org/vibenet/faucet): Request testnet tokens on Vibenet to fund accounts and try in-flight Base features. (changes monthly; re-fetch before relying on it) diff --git a/public/llms.txt b/public/llms.txt index 77223d3..80acc38 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -19,6 +19,7 @@ Freshness: /snapshots and /vibenet/explorer change daily. /vibenet/faucet change - [Vibenet · Base Chain](https://chain.base.org/vibenet): Explore Vibenet, the Base devnet for testing in-flight protocol features. - [Accounts · Vibenet](https://chain.base.org/vibenet/demos/account): Create native account abstraction accounts from in-browser keys, fund them from the faucet, and inspect balances on Vibenet. - [Tokens · Vibenet](https://chain.base.org/vibenet/demos/b20): Explore, configure, and issue Base-native B20 tokens on Vibenet. +- [Validity · Vibenet](https://chain.base.org/vibenet/demos/validity): Attach conditions to a transaction. A simulated pool shows how a swap waits, lands, or expires. - [Explorer · Vibenet](https://chain.base.org/vibenet/explorer): Browse blocks, transactions, and addresses on the Vibenet devnet. - [Faucet · Vibenet](https://chain.base.org/vibenet/faucet): Request testnet tokens on Vibenet to fund accounts and try in-flight Base features.