From 7195ebaa9189aad5d6f40512973d92ced4733de0 Mon Sep 17 00:00:00 2001 From: Haardik H Date: Thu, 3 Sep 2026 17:03:43 -0400 Subject: [PATCH] refactor(vibenet): use shared race agent Co-authored-by: OpenCode --- AGENTS.md | 2 +- app/analytics/events.ts | 4 +- app/vibenet/demos/catalogue.ts | 2 +- .../lib/conditionalWithdrawal.ensure.test.ts | 46 -- .../lib/conditionalWithdrawal.test.ts | 72 +-- .../validity/lib/conditionalWithdrawal.ts | 100 +--- .../race-the-agent/RaceTheAgentDemo.tsx | 480 ++---------------- .../race-the-agent/comparison.test.ts | 43 -- .../validity/race-the-agent/comparison.ts | 52 +- 9 files changed, 84 insertions(+), 717 deletions(-) delete mode 100644 app/vibenet/demos/validity/lib/conditionalWithdrawal.ensure.test.ts diff --git a/AGENTS.md b/AGENTS.md index f60d6264..19daf88a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,7 +125,7 @@ This app uses Vercel Web Analytics. Two things must stay in place: | `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 | - | `trackValidityRace(attempt, status)` | `app/vibenet/demos/validity/race-the-agent/RaceTheAgentDemo.tsx` — validity/manual comparison and condition agent lifecycle | + | `trackValidityRace(attempt, status)` | `app/vibenet/demos/validity/race-the-agent/RaceTheAgentDemo.tsx` — validity/manual comparison attempts | 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 2a4fdebe..b65d81da 100644 --- a/app/analytics/events.ts +++ b/app/analytics/events.ts @@ -86,8 +86,8 @@ export function trackValidityOrder( } export function trackValidityRace( - attempt: 'validity' | 'manual' | 'agent', - status: 'started' | 'submitted' | 'success' | 'reverted' | 'expired' | 'stopped' | 'error', + attempt: 'validity' | 'manual', + status: 'submitted' | 'success' | 'reverted' | 'expired' | 'error', ): void { track('validity_race', { attempt, status }); } diff --git a/app/vibenet/demos/catalogue.ts b/app/vibenet/demos/catalogue.ts index 16fa3703..4b0746e6 100644 --- a/app/vibenet/demos/catalogue.ts +++ b/app/vibenet/demos/catalogue.ts @@ -76,7 +76,7 @@ export const DEMOS: DemoEntry[] = [ 'Submit a withdrawal before it is valid, then race a randomized onchain condition with an ordinary transaction sent by hand.', points: [ 'Compare the same permissionless withdrawal call two ways', - 'Watch a dedicated agent subaccount flip shared chain state', + 'Watch a shared background Vibenet agent flip chain state', 'Judge the result by inclusion blocks, not browser timing', ], available: true, diff --git a/app/vibenet/demos/validity/lib/conditionalWithdrawal.ensure.test.ts b/app/vibenet/demos/validity/lib/conditionalWithdrawal.ensure.test.ts deleted file mode 100644 index 2cfa750e..00000000 --- a/app/vibenet/demos/validity/lib/conditionalWithdrawal.ensure.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const mocks = vi.hoisted(() => ({ - hasCode: vi.fn(), -})); - -vi.mock('./singleton', () => ({ - CREATE2_DEPLOYER: '0x3333333333333333333333333333333333333333', - create2Address: () => '0x2222222222222222222222222222222222222222', - hasCode: mocks.hasCode, - singletonSalt: () => `0x${'11'.repeat(32)}`, -})); - -import { ensureConditionalWithdrawal } from './conditionalWithdrawal'; - -const VIBE = '0x1111111111111111111111111111111111111111'; -const WITHDRAWAL = '0x2222222222222222222222222222222222222222'; - -describe('ensureConditionalWithdrawal', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('accepts a correctly configured deployment created concurrently by another visitor', async () => { - mocks.hasCode.mockResolvedValueOnce(false).mockResolvedValueOnce(true).mockResolvedValueOnce(true); - const publicClient = { - readContract: vi.fn().mockResolvedValue(VIBE), - }; - const wallet = { - chain: {}, - sendTransaction: vi.fn().mockRejectedValue(new Error('CREATE2 duplicate')), - }; - - await expect(ensureConditionalWithdrawal({ - wallet: wallet as never, - publicClient: publicClient as never, - account: {} as never, - vibe: VIBE, - })).resolves.toBe(WITHDRAWAL); - expect(publicClient.readContract).toHaveBeenCalledWith(expect.objectContaining({ - address: WITHDRAWAL, - functionName: 'VIBE', - })); - expect(wallet.sendTransaction).toHaveBeenCalled(); - }); -}); diff --git a/app/vibenet/demos/validity/lib/conditionalWithdrawal.test.ts b/app/vibenet/demos/validity/lib/conditionalWithdrawal.test.ts index 01077df4..0680f58c 100644 --- a/app/vibenet/demos/validity/lib/conditionalWithdrawal.test.ts +++ b/app/vibenet/demos/validity/lib/conditionalWithdrawal.test.ts @@ -1,28 +1,23 @@ import { decodeFunctionData } from 'viem'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import artifact from './artifacts/ConditionalWithdrawal.json'; import { CONDITIONAL_WITHDRAWAL_AMOUNT, CONDITIONAL_WITHDRAWAL_ENABLED_MASK, CONDITIONAL_WITHDRAWAL_ENABLED_SLOT, - CONDITIONAL_WITHDRAWAL_FUNDING_TARGET, - CONDITIONAL_WITHDRAWAL_REFILL_THRESHOLD, CONDITIONAL_WITHDRAWAL_SALT, conditionalWithdrawalAbi, conditionalWithdrawalEnabledPredicate, - conditionalWithdrawalFundingAmount, - encodeConditionalWithdrawalFunding, encodeConditionalWithdraw, - encodeSetConditionalWithdrawalEnabled, predictConditionalWithdrawal, + probeConditionalWithdrawal, } from './conditionalWithdrawal'; -import { minterAbi, WAD } from './constants'; +import { WAD } from './constants'; import { toWord } from './predicates'; const VIBE = '0x1111111111111111111111111111111111111111'; const OTHER_VIBE = '0x2222222222222222222222222222222222222222'; -const MINTER = '0x3333333333333333333333333333333333333333'; const WITHDRAWAL = '0x4444444444444444444444444444444444444444'; describe('conditional withdrawal contract', () => { @@ -42,6 +37,24 @@ describe('conditional withdrawal contract', () => { expect(predictConditionalWithdrawal(OTHER_VIBE)).not.toBe(predictConditionalWithdrawal(VIBE)); }); + it('discovers only an existing singleton configured for the shared VIBE token', async () => { + const client = { + getCode: vi.fn().mockResolvedValue('0x1234'), + readContract: vi.fn().mockResolvedValue(VIBE), + }; + + await expect(probeConditionalWithdrawal(client as never, VIBE)).resolves.toBe( + predictConditionalWithdrawal(VIBE), + ); + expect(client.readContract).toHaveBeenCalledWith(expect.objectContaining({ + address: predictConditionalWithdrawal(VIBE), + functionName: 'VIBE', + })); + + client.readContract.mockResolvedValueOnce(OTHER_VIBE); + await expect(probeConditionalWithdrawal(client as never, VIBE)).resolves.toBeNull(); + }); + it('reads bool public enabled from slot 0 in the EIP-8130 predicate', () => { expect(CONDITIONAL_WITHDRAWAL_ENABLED_SLOT).toBe(0n); expect(CONDITIONAL_WITHDRAWAL_ENABLED_MASK).toBe(0xffn); @@ -57,7 +70,7 @@ describe('conditional withdrawal contract', () => { }); }); - it('encodes condition and fixed-withdrawal calls exactly', () => { + it('encodes the fixed withdrawal call exactly', () => { const functionNames = artifact.abi .filter((item) => item.type === 'function') .map((item) => item.name); @@ -66,13 +79,6 @@ describe('conditional withdrawal contract', () => { expect(functionNames).toContain('enabled'); expect(functionNames).toContain('setEnabled'); expect(functionNames).toContain('withdraw'); - expect(encodeSetConditionalWithdrawalEnabled(WITHDRAWAL, true)).toEqual({ - to: WITHDRAWAL, - data: `0x328d8f72${'0'.repeat(63)}1`, - }); - expect(encodeSetConditionalWithdrawalEnabled(WITHDRAWAL, false).data).toBe( - `0x328d8f72${'0'.repeat(64)}`, - ); expect(encodeConditionalWithdraw(WITHDRAWAL)).toEqual({ to: WITHDRAWAL, data: '0x3ccfd60b', @@ -84,37 +90,3 @@ describe('conditional withdrawal contract', () => { ).toBe('withdraw'); }); }); - -describe('conditional withdrawal funding', () => { - it('refills to two million VIBE only below the one million threshold', () => { - expect(CONDITIONAL_WITHDRAWAL_REFILL_THRESHOLD).toBe(1_000_000n * WAD); - expect(CONDITIONAL_WITHDRAWAL_FUNDING_TARGET).toBe(2_000_000n * WAD); - expect(conditionalWithdrawalFundingAmount(0n)).toBe(2_000_000n * WAD); - expect(conditionalWithdrawalFundingAmount(1_000_000n * WAD - 1n)).toBe(1_000_000n * WAD + 1n); - expect(conditionalWithdrawalFundingAmount(1_000_000n * WAD)).toBe(0n); - expect(conditionalWithdrawalFundingAmount(2_000_000n * WAD)).toBe(0n); - expect(() => conditionalWithdrawalFundingAmount(-1n)).toThrow(/cannot be negative/); - }); - - it('targets the existing open minter and mints directly to the singleton', () => { - const call = encodeConditionalWithdrawalFunding({ - minter: MINTER, - vibe: VIBE, - withdrawal: WITHDRAWAL, - balance: 0n, - }); - expect(call?.to).toBe(MINTER); - expect(call).not.toBeNull(); - const decoded = decodeFunctionData({ abi: minterAbi, data: call!.data }); - expect(decoded.functionName).toBe('mint'); - expect(decoded.args).toEqual([VIBE, WITHDRAWAL, CONDITIONAL_WITHDRAWAL_FUNDING_TARGET]); - expect( - encodeConditionalWithdrawalFunding({ - minter: MINTER, - vibe: VIBE, - withdrawal: WITHDRAWAL, - balance: CONDITIONAL_WITHDRAWAL_REFILL_THRESHOLD, - }), - ).toBeNull(); - }); -}); diff --git a/app/vibenet/demos/validity/lib/conditionalWithdrawal.ts b/app/vibenet/demos/validity/lib/conditionalWithdrawal.ts index 7959aeb5..4b950254 100644 --- a/app/vibenet/demos/validity/lib/conditionalWithdrawal.ts +++ b/app/vibenet/demos/validity/lib/conditionalWithdrawal.ts @@ -1,20 +1,16 @@ import { - concat, encodeDeployData, encodeFunctionData, type Abi, - type Account, type Address, type Hex, type PublicClient, - type WalletClient, } from 'viem'; import artifact from './artifacts/ConditionalWithdrawal.json'; -import { erc20Abi, minterAbi, WAD } from './constants'; +import { erc20Abi, WAD } from './constants'; import { storagePredicate } from './predicates'; import { - CREATE2_DEPLOYER, create2Address, hasCode, singletonSalt, @@ -28,8 +24,6 @@ export const conditionalWithdrawalBytecode = artifact.bytecode as Hex; export const CONDITIONAL_WITHDRAWAL_ENABLED_SLOT = 0n; export const CONDITIONAL_WITHDRAWAL_ENABLED_MASK = 0xffn; export const CONDITIONAL_WITHDRAWAL_AMOUNT = WAD; -export const CONDITIONAL_WITHDRAWAL_REFILL_THRESHOLD = 1_000_000n * WAD; -export const CONDITIONAL_WITHDRAWAL_FUNDING_TARGET = 2_000_000n * WAD; export const CONDITIONAL_WITHDRAWAL_SALT = singletonSalt('conditional-withdrawal'); export function conditionalWithdrawalInitCode(vibe: Address): Hex { @@ -58,84 +52,6 @@ export async function probeConditionalWithdrawal( : null; } -export async function ensureConditionalWithdrawal(args: { - wallet: WalletClient; - publicClient: PublicClient; - account: Account; - vibe: Address; - onProgress?: (label: string) => void; -}): Promise
{ - const { wallet, publicClient, account, vibe, onProgress } = args; - const live = await probeConditionalWithdrawal(publicClient, vibe); - if (live) return live; - - if (!(await hasCode(publicClient, CREATE2_DEPLOYER))) { - throw new Error('Vibenet CREATE2 deployer is not available.'); - } - onProgress?.('Deploying conditional withdrawal'); - try { - const hash = await wallet.sendTransaction({ - account, - chain: wallet.chain, - to: CREATE2_DEPLOYER, - data: concat([CONDITIONAL_WITHDRAWAL_SALT, conditionalWithdrawalInitCode(vibe)]), - gas: 750_000n, - }); - const receipt = await publicClient.waitForTransactionReceipt({ hash, pollingInterval: 500 }); - if (receipt.status === 'reverted') throw new Error('Conditional withdrawal deployment reverted.'); - } catch (error) { - // Another visitor may win the same CREATE2 deployment between our probe and send. - const deadline = Date.now() + 6_000; - while (Date.now() < deadline) { - const concurrent = await probeConditionalWithdrawal(publicClient, vibe); - if (concurrent) return concurrent; - await new Promise((resolve) => setTimeout(resolve, 300)); - } - throw error; - } - const configured = await probeConditionalWithdrawal(publicClient, vibe); - if (!configured) throw new Error('Conditional withdrawal deployed with an unexpected VIBE configuration.'); - return configured; -} - -export function conditionalWithdrawalFundingAmount(balance: bigint): bigint { - if (balance < 0n) throw new Error('Conditional withdrawal balance cannot be negative.'); - return balance < CONDITIONAL_WITHDRAWAL_REFILL_THRESHOLD - ? CONDITIONAL_WITHDRAWAL_FUNDING_TARGET - balance - : 0n; -} - -export function encodeConditionalWithdrawalFunding(args: { - minter: Address; - vibe: Address; - withdrawal: Address; - balance: bigint; -}): { to: Address; data: Hex } | null { - const amount = conditionalWithdrawalFundingAmount(args.balance); - if (amount === 0n) return null; - return { - to: args.minter, - data: encodeFunctionData({ - abi: minterAbi, - functionName: 'mint', - args: [args.vibe, args.withdrawal, amount], - }), - }; -} - -export async function prepareConditionalWithdrawalFunding( - client: PublicClient, - args: { minter: Address; vibe: Address; withdrawal: Address }, -): Promise<{ to: Address; data: Hex } | null> { - const balance = (await client.readContract({ - address: args.vibe, - abi: erc20Abi, - functionName: 'balanceOf', - args: [args.withdrawal], - })) as bigint; - return encodeConditionalWithdrawalFunding({ ...args, balance }); -} - export async function readConditionalWithdrawalState( client: PublicClient, vibe: Address, @@ -157,20 +73,6 @@ export async function readConditionalWithdrawalState( return { address, enabled, balance }; } -export function encodeSetConditionalWithdrawalEnabled( - withdrawal: Address, - enabled: boolean, -): { to: Address; data: Hex } { - return { - to: withdrawal, - data: encodeFunctionData({ - abi: conditionalWithdrawalAbi, - functionName: 'setEnabled', - args: [enabled], - }), - }; -} - export function encodeConditionalWithdraw(withdrawal: Address): { to: Address; data: Hex } { return { to: withdrawal, diff --git a/app/vibenet/demos/validity/race-the-agent/RaceTheAgentDemo.tsx b/app/vibenet/demos/validity/race-the-agent/RaceTheAgentDemo.tsx index 0dd1e94e..1adcc599 100644 --- a/app/vibenet/demos/validity/race-the-agent/RaceTheAgentDemo.tsx +++ b/app/vibenet/demos/validity/race-the-agent/RaceTheAgentDemo.tsx @@ -1,15 +1,13 @@ 'use client'; import { getTransactionReceipt as getAaTransactionReceipt } from '@aa'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { formatUnits, - parseEther, type Address, type Hex, type PublicClient, } from 'viem'; -import { privateKeyToAccount } from 'viem/accounts'; import { trackValidityRace } from '../../../../analytics/events'; import { Button } from '../../../../components/ui/Button'; @@ -22,15 +20,11 @@ import { AccountDemoShell } from '../../_components/AccountDemoShell'; import { DemoHeader } from '../../_components/DemoHeader'; import { ChevronIcon } from '../../_shared/dropdown'; import { newCallRow } from '../../account/library/calls'; -import type { StoredAccount } from '../../account/library/model'; import { aaReceiptSucceeded, type AaReceiptLike } from '../../account/library/receipt'; import { AccountEngineProvider, TxPendingError, useAccountEngine } from '../../account/useAccountEngine'; import { conditionalWithdrawalEnabledPredicate, encodeConditionalWithdraw, - encodeSetConditionalWithdrawalEnabled, - ensureConditionalWithdrawal, - prepareConditionalWithdrawalFunding, probeConditionalWithdrawal, readConditionalWithdrawalState, } from '../lib/conditionalWithdrawal'; @@ -38,9 +32,7 @@ import { noncelessFields } from '../../../library/aa'; import { describeValidityError, makePublicClient, - makeWalletClient, sendValidityTransaction, - VIBENET_CHAIN, type RpcSend, } from '../lib/rpc'; import { probeSingleton } from '../lib/singleton'; @@ -51,28 +43,16 @@ import { canSubmitValidity, isAttemptTerminal, preserveCompletedAttempt, - randomAgentDwellMs, RACE_VALIDITY_SECONDS, - scheduledAgentOpenBlock, - scheduledAgentPredicates, - shouldRestartConditionAgent, - shouldRunConditionAgent, shortHash, type Attempt, } from './comparison'; -const AGENT_LABEL = 'Validity condition agent'; -const OWNER_DEPLOY_GAS = parseEther('0.08'); -const OWNER_DEPLOY_SEND = '0.1'; -const AGENT_GAS_FLOOR = parseEther('0.01'); -const AGENT_GAS_SEND = '0.02'; -const ACTIVE_ACCOUNT_FUNDING_FLOOR = parseEther('0.2'); const RECEIPT_POLL_MS = 1_000; const STATE_FALLBACK_POLL_MS = 1_000; -const AGENT_RETRY_MS = 750; +const SHARED_INFRA_RETRY_MS = 1_000; type Observation = { enabled: boolean; block: bigint; at: number }; -type AgentPhase = 'Waiting' | 'Scheduling' | 'Opening' | 'Closing' | 'Retrying'; const EMPTY_ATTEMPT: Attempt = { status: 'idle' }; const CONTRACT_SNIPPET = `interface IERC20 { @@ -98,18 +78,6 @@ contract ConditionalWithdrawal { } }`; -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; -} - export function RaceTheAgentDemo() { return ( @@ -121,28 +89,16 @@ export function RaceTheAgentDemo() { function RaceTheAgentDemoInner() { const engine = useAccountEngine(); const acct = engine.acct; - const parent = useMemo( - () => (acct ? rootAccount(acct, engine.accounts) : null), - [acct, engine.accounts], - ); - const [genesisHash, setGenesisHash] = useState(null); const [client, setClient] = useState(null); const [withdrawal, setWithdrawal] = useState
(null); const [vibe, setVibe] = useState
(null); const [contractBalance, setContractBalance] = useState(null); const [observed, setObserved] = useState(null); const [observations, setObservations] = useState([]); - const [agent, setAgent] = useState(null); - const [agentRunning, setAgentRunning] = useState(false); - const [agentPhase, setAgentPhase] = useState('Waiting'); - const [agentRestartToken, setAgentRestartToken] = useState(0); - const [agentError, setAgentError] = useState(null); const [prepared, setPrepared] = useState(false); const [setupRunning, setSetupRunning] = useState(false); const [setupError, setSetupError] = useState(null); - const [setupRetry, setSetupRetry] = useState(0); const [busy, setBusy] = useState(false); - const [progress, setProgress] = useState(null); const [error, setError] = useState(null); const [validity, setValidity] = useState(EMPTY_ATTEMPT); const [validityHistory, setValidityHistory] = useState([]); @@ -152,38 +108,20 @@ function RaceTheAgentDemoInner() { const [manualAttemptCount, setManualAttemptCount] = useState(0); const [validBefore, setValidBefore] = useState(null); - const generationRef = useRef(0); const observedRef = useRef(null); const accountKeyRef = useRef(null); - const setupInFlightKeyRef = useRef(null); - const setupReadyKeyRef = useRef(null); - const setupFailedKeyRef = useRef(null); - const setupGenerationRef = useRef(0); const observationsScrollRef = useRef(null); const rpcSendRef = useRef(null); - const engineRef = useRef(engine); - engineRef.current = engine; observedRef.current = observed; useEffect(() => { - const accountKey = acct && parent ? `${acct.id}:${parent.id}` : null; + const accountKey = acct?.id ?? null; if (accountKeyRef.current === null) { accountKeyRef.current = accountKey; return; } if (accountKeyRef.current === accountKey) return; accountKeyRef.current = accountKey; - generationRef.current += 1; - setAgentRunning(false); - setAgentPhase('Waiting'); - setupGenerationRef.current += 1; - setupInFlightKeyRef.current = null; - setupReadyKeyRef.current = null; - setupFailedKeyRef.current = null; - setAgent(null); - setPrepared(false); - setSetupRunning(false); - setSetupError(null); setValidity(EMPTY_ATTEMPT); setValidityHistory([]); setValidityAttemptCount(0); @@ -192,9 +130,8 @@ function RaceTheAgentDemoInner() { setManualAttemptCount(0); setValidBefore(null); setError(null); - setAgentError(null); setObservations(observedRef.current ? [observedRef.current] : []); - }, [acct, parent]); + }, [acct]); const applyObservation = useCallback((next: Observation) => { observedRef.current = next; @@ -214,33 +151,45 @@ function RaceTheAgentDemoInner() { useEffect(() => { let cancelled = false; + let retryId: number | undefined; const nextClient = makePublicClient(() => rpcSendRef.current); - void (async () => { - const genesis = await nextClient.getBlock({ blockNumber: 0n }); - if (!genesis.hash) throw new Error('RPC did not return a genesis hash.'); - if (cancelled) return; - setGenesisHash(genesis.hash); - setClient(nextClient); - const deployment = await probeSingleton(nextClient).catch(() => null); - if (cancelled || !deployment) return; - setVibe(deployment.tokenA); - const live = await probeConditionalWithdrawal(nextClient, deployment.tokenA).catch(() => null); - if (cancelled || !live) return; - setWithdrawal(live); - const [state, block] = await Promise.all([ - readConditionalWithdrawalState(nextClient, deployment.tokenA), - nextClient.getBlockNumber({ cacheTime: 0 }), - ]); - if (cancelled) return; - setContractBalance(state.balance); - applyObservation({ enabled: state.enabled, block, at: Date.now() }); - })() - .catch((err: unknown) => { - if (!cancelled) setError(err instanceof Error ? err.message : 'Could not reach Vibenet.'); - }); + setClient(nextClient); + + const discover = async () => { + setSetupRunning(true); + try { + const deployment = await probeSingleton(nextClient); + const live = deployment + ? await probeConditionalWithdrawal(nextClient, deployment.tokenA) + : null; + if (cancelled) return; + if (!deployment || !live) { + setSetupError(null); + retryId = window.setTimeout(() => void discover(), SHARED_INFRA_RETRY_MS); + return; + } + const [state, block] = await Promise.all([ + readConditionalWithdrawalState(nextClient, deployment.tokenA), + nextClient.getBlockNumber({ cacheTime: 0 }), + ]); + if (cancelled) return; + setVibe(deployment.tokenA); + setWithdrawal(live); + setContractBalance(state.balance); + applyObservation({ enabled: state.enabled, block, at: Date.now() }); + setPrepared(true); + setSetupError(null); + setSetupRunning(false); + } catch (err) { + if (cancelled) return; + setSetupError(err instanceof Error ? err.message : 'Could not reach shared Vibenet infrastructure.'); + retryId = window.setTimeout(() => void discover(), SHARED_INFRA_RETRY_MS); + } + }; + void discover(); return () => { cancelled = true; - generationRef.current += 1; + if (retryId !== undefined) window.clearTimeout(retryId); }; }, [applyObservation]); @@ -382,178 +331,6 @@ function RaceTheAgentDemoInner() { }; }, [client, manual.hash, manual.status, settleFromReceipt]); - useEffect(() => { - if (!engine.hydrated || !acct || !parent || !genesisHash || !client) return; - const setupKey = `${VIBENET_CHAIN.id}:${genesisHash}:${acct.id}:${parent.id}`; - if (setupReadyKeyRef.current === setupKey || setupInFlightKeyRef.current === setupKey) return; - if (setupFailedKeyRef.current === setupKey) return; - - const generation = setupGenerationRef.current + 1; - setupGenerationRef.current = generation; - setupInFlightKeyRef.current = setupKey; - setPrepared(false); - setSetupRunning(true); - setSetupError(null); - setProgress('Checking shared contracts'); - - const isCurrent = () => setupGenerationRef.current === generation; - void (async () => { - try { - const currentEngine = engineRef.current; - const k1 = currentEngine.ownerSigners.find((signer) => signer.kind === 'k1' && signer.privateKey); - if (!k1?.privateKey) throw new Error('Setup needs a K1 owner key on this account. Add one in Accounts.'); - - if (isCurrent()) setProgress('Checking shared contracts'); - const deployment = await probeSingleton(client); - if (!deployment) throw new Error('The shared VIBE/USDV market is not ready yet.'); - let contract = await probeConditionalWithdrawal(client, deployment.tokenA); - - let activeBalance = await client.getBalance({ address: acct.address }); - if (activeBalance < ACTIVE_ACCOUNT_FUNDING_FLOOR) { - if (isCurrent()) setProgress('Waiting for account funding'); - activeBalance = await waitForBalance(client, acct.address, ACTIVE_ACCOUNT_FUNDING_FLOOR, 4_000); - } - if (activeBalance < ACTIVE_ACCOUNT_FUNDING_FLOOR) { - if (isCurrent()) setProgress('Funding the active account'); - await currentEngine.requestFaucet(); - const fundedBalance = await waitForBalance(client, acct.address, ACTIVE_ACCOUNT_FUNDING_FLOOR, 8_000); - if (fundedBalance < ACTIVE_ACCOUNT_FUNDING_FLOOR) { - throw new Error('The active account needs at least 0.2 ETH of setup and gas headroom. Top it up and retry.'); - } - } - - const owner = privateKeyToAccount(k1.privateKey); - const ownerBalance = await client.getBalance({ address: owner.address }); - if (ownerBalance < OWNER_DEPLOY_GAS) { - if (isCurrent()) setProgress('Funding the deploy key'); - await currentEngine.sendActiveCalls({ - calls: [{ to: owner.address, data: '0x', value: OWNER_DEPLOY_SEND }], - metadata: 'Race the Agent bootstrap', - }); - } - - const wallet = makeWalletClient(owner); - const reportProgress = (label: string) => { - if (isCurrent()) setProgress(label); - }; - if (!isCurrent()) return; - setVibe(deployment.tokenA); - - if (!contract) { - reportProgress('Preparing conditional withdrawal'); - contract = await ensureConditionalWithdrawal({ - wallet, - publicClient: client, - account: owner, - vibe: deployment.tokenA, - onProgress: reportProgress, - }); - } - if (!isCurrent()) return; - setWithdrawal(contract); - - const funding = await prepareConditionalWithdrawalFunding(client, { - minter: deployment.minter, - vibe: deployment.tokenA, - withdrawal: contract, - }); - if (funding) { - setProgress('Funding conditional withdrawal'); - const hash = await wallet.sendTransaction({ - account: owner, - chain: wallet.chain, - to: funding.to, - data: funding.data, - }); - const receipt = await client.waitForTransactionReceipt({ hash, pollingInterval: RECEIPT_POLL_MS }); - if (receipt.status === 'reverted') throw new Error('Singleton funding reverted.'); - } - if (!isCurrent()) return; - - let latestEngine = engineRef.current; - let conditionAgent = latestEngine.accounts.find( - (item) => item.parentId === parent.id && item.label === AGENT_LABEL, - ); - if (!conditionAgent) { - conditionAgent = latestEngine.doCreateSubAccount(AGENT_LABEL, { - withSpareKey: true, - parent, - })?.account; - } - if (!conditionAgent) throw new Error('Could not create the condition agent subaccount.'); - setAgent(conditionAgent); - - const agentSignerIds = new Set( - conditionAgent.owners.flatMap((ownerActor) => ownerActor.signerId ? [ownerActor.signerId] : []), - ); - const signerDeadline = Date.now() + 2_000; - while ( - isCurrent() && - !engineRef.current.signers.some((signer) => agentSignerIds.has(signer.id)) && - Date.now() < signerDeadline - ) { - await delay(50); - } - latestEngine = engineRef.current; - if (!latestEngine.signers.some((signer) => agentSignerIds.has(signer.id))) { - throw new Error('Could not load the condition agent signing key.'); - } - - let agentBalance = await client.getBalance({ address: conditionAgent.address }); - if (agentBalance < AGENT_GAS_FLOOR) { - setProgress('Waiting for condition agent funding'); - agentBalance = await waitForBalance(client, conditionAgent.address, AGENT_GAS_FLOOR, 4_000); - } - if (agentBalance < AGENT_GAS_FLOOR) { - setProgress('Funding the condition agent'); - latestEngine.autoFundNewAccount(conditionAgent.address); - agentBalance = await waitForBalance(client, conditionAgent.address, AGENT_GAS_FLOOR, 8_000); - } - if (agentBalance < AGENT_GAS_FLOOR) { - setProgress('Funding the condition agent from the active account'); - await latestEngine.sendActiveCalls({ - calls: [{ to: conditionAgent.address, data: '0x', value: AGENT_GAS_SEND }], - metadata: 'Race the Agent funding fallback', - }); - agentBalance = await waitForBalance(client, conditionAgent.address, AGENT_GAS_FLOOR, 8_000); - } - if (agentBalance < AGENT_GAS_FLOOR) { - throw new Error('The condition agent needs ETH for deployment and gas.'); - } - - setProgress('Deploying condition agent and disabling condition'); - const disable = encodeSetConditionalWithdrawalEnabled(contract, false); - await latestEngine.sendAccountCalls({ - account: conditionAgent, - calls: [{ to: disable.to, data: disable.data, value: '0' }], - metadata: 'Race the Agent setup', - }); - - await refreshPreparedState(client, deployment.tokenA, contract, applyObservation, setContractBalance); - if (!isCurrent()) return; - setupReadyKeyRef.current = setupKey; - setupFailedKeyRef.current = null; - setPrepared(true); - } catch (err) { - if (!isCurrent()) return; - setupFailedKeyRef.current = setupKey; - setSetupError(err instanceof Error ? err.message : 'Setup failed.'); - } finally { - if (setupInFlightKeyRef.current === setupKey) setupInFlightKeyRef.current = null; - if (isCurrent()) { - setSetupRunning(false); - setProgress(null); - } - } - })(); - }, [acct, applyObservation, client, engine.hydrated, genesisHash, parent, setupRetry]); - - const retrySetup = () => { - setupFailedKeyRef.current = null; - setSetupError(null); - setSetupRetry((attempt) => attempt + 1); - }; - const submitValidity = async () => { if ( !acct || @@ -633,94 +410,6 @@ function RaceTheAgentDemoInner() { } }; - useEffect(() => { - if (!shouldRunConditionAgent({ - prepared, - hasAgent: Boolean(agent), - hasClient: Boolean(client), - hasContract: Boolean(withdrawal), - }) || !agent || !client || !withdrawal || !vibe) return; - - const generation = generationRef.current + 1; - generationRef.current = generation; - setAgentRunning(true); - setAgentPhase('Waiting'); - setAgentError(null); - trackValidityRace('agent', 'started'); - const active = () => generationRef.current === generation; - - const ensureAgentFunding = async () => { - const agentBalance = await client.getBalance({ address: agent.address }); - if (agentBalance >= AGENT_GAS_FLOOR) return; - setAgentPhase('Retrying'); - engineRef.current.autoFundNewAccount(agent.address); - const funded = await waitForBalance(client, agent.address, AGENT_GAS_FLOOR, 8_000); - if (funded < AGENT_GAS_FLOOR) throw new Error('Condition agent needs ETH for gas.'); - }; - - const run = async () => { - while (active()) { - try { - await ensureAgentFunding(); - const block = await client.getBlockNumber({ cacheTime: 0 }); - if (!active()) break; - const openBlock = scheduledAgentOpenBlock(block, randomAgentDwellMs()); - const validity = scheduledAgentPredicates(withdrawal, openBlock); - const fields = noncelessFields(RACE_VALIDITY_SECONDS); - const open = encodeSetConditionalWithdrawalEnabled(withdrawal, true); - const close = encodeSetConditionalWithdrawalEnabled(withdrawal, false); - const seqOpt = { ...fields, assumeDeployed: true }; - - setAgentPhase('Scheduling'); - // The same signer cannot service two composition requests concurrently. - // Sign sequentially, then submit both scheduled transactions together. - const signedOpen = await engineRef.current.signAccountCalls({ - account: agent, - calls: [{ to: open.to, data: open.data, value: '0' }], - seqOpt, - metadata: `${AGENT_LABEL} open`, - }); - const signedClose = await engineRef.current.signAccountCalls({ - account: agent, - calls: [{ to: close.to, data: close.data, value: '0' }], - seqOpt, - metadata: `${AGENT_LABEL} close`, - }); - const closeSubmission = sendValidityTransaction(signedClose.serialized, validity.close); - const openSubmission = sendValidityTransaction(signedOpen.serialized, validity.open); - await Promise.all([closeSubmission, openSubmission]); - setAgentError(null); - setAgentPhase('Opening'); - await waitForScheduledClose( - openBlock, - active, - () => observedRef.current, - () => setAgentPhase('Closing'), - ); - if (active()) setAgentPhase('Waiting'); - } catch (err) { - if (!active()) break; - setAgentPhase('Retrying'); - setAgentError(err instanceof Error ? err.message : 'Condition update failed.'); - await delay(AGENT_RETRY_MS); - } - } - }; - void run().finally(() => { - if (!shouldRestartConditionAgent(prepared, active())) return; - setAgentRunning(false); - setAgentPhase('Retrying'); - setAgentRestartToken((token) => token + 1); - }); - - return () => { - if (generationRef.current === generation) generationRef.current += 1; - setAgentRunning(false); - setAgentPhase('Waiting'); - trackValidityRace('agent', 'stopped'); - }; - }, [agent, agentRestartToken, client, prepared, vibe, withdrawal]); - const withdrawNow = async () => { if (!withdrawal || !client || !observed || !canSubmitManual({ status: manual.status, @@ -839,21 +528,20 @@ function RaceTheAgentDemoInner() {
- {setupError ? ( -
- {setupError} - -
- ) : ( - - {progress ?? (agentRunning ? agentPhase : setupRunning ? 'Starting after setup' : 'Waiting for setup')} - - )} + + {prepared + ? 'Connected to the shared agent switch' + : setupError + ? `Shared infrastructure is not ready; retrying automatically. ${setupError}` + : setupRunning + ? 'Waiting for shared Vibenet infrastructure' + : 'Discovering shared Vibenet infrastructure'} +
- {(error || agentError) ? ( + {error ? (
- {error ?

{error}

: null} - {agentError ?

Agent retrying: {agentError}

: null} +

{error}

) : null} @@ -935,7 +622,7 @@ function RaceTheAgentDemoInner() {
{observations.length === 0 ? ( - State observations appear after automatic setup. + State observations appear when the shared agent singleton is ready. ) : observations.map((item, index) => (
void, - setBalance: (balance: bigint) => void, -): Promise { - const [state, block] = await Promise.all([ - readConditionalWithdrawalState(client, vibe), - client.getBlockNumber({ cacheTime: 0 }), - ]); - setBalance(state.balance); - applyObservation({ enabled: state.enabled, block, at: Date.now() }); -} - -async function waitForBalance( - client: PublicClient, - address: Address, - minimum: bigint, - timeoutMs = 5_000, -): Promise { - const deadline = Date.now() + timeoutMs; - let balance = 0n; - while (Date.now() < deadline) { - balance = await client.getBalance({ address }); - if (balance >= minimum) return balance; - await new Promise((resolve) => setTimeout(resolve, 400)); - } - return balance; -} - -async function waitForScheduledClose( - openBlock: bigint, - active: () => boolean, - observation: () => Observation | null, - onClosing: () => void, -): Promise { - let closing = false; - const deadline = Date.now() + (RACE_VALIDITY_SECONDS + 2) * 1_000; - while (active()) { - const current = observation(); - if (current && current.block >= openBlock && !closing) { - closing = true; - onClosing(); - } - if (current && current.block >= openBlock + 1n && !current.enabled) return; - if (Date.now() >= deadline) throw new Error('Scheduled close was not observed before expiry.'); - await delay(100); - } -} - -function delay(ms: number): Promise { - return new Promise((resolve) => window.setTimeout(resolve, ms)); -} - function extractHash(message: string): Hex | undefined { return message.match(/0x[0-9a-fA-F]{64}/)?.[0] as Hex | undefined; } diff --git a/app/vibenet/demos/validity/race-the-agent/comparison.test.ts b/app/vibenet/demos/validity/race-the-agent/comparison.test.ts index e007c698..0e500135 100644 --- a/app/vibenet/demos/validity/race-the-agent/comparison.test.ts +++ b/app/vibenet/demos/validity/race-the-agent/comparison.test.ts @@ -1,26 +1,15 @@ import { describe, expect, it } from 'vitest'; import { - AGENT_DISABLED_DWELL_MAX_MS, - AGENT_DISABLED_DWELL_MIN_MS, attemptHistoryRows, canSubmitManual, canSubmitValidity, isAttemptTerminal, preserveCompletedAttempt, - randomAgentDwellMs, RACE_VALIDITY_SECONDS, - scheduledAgentOpenBlock, - scheduledAgentPredicates, - shouldRunConditionAgent, - shouldRestartConditionAgent, type Attempt, } from './comparison'; import { noncelessFields } from '../../../library/aa'; -import { conditionalWithdrawalEnabledPredicate } from '../lib/conditionalWithdrawal'; -import { blockNumberPredicate } from '../lib/predicates'; - -const WITHDRAWAL = '0x1111111111111111111111111111111111111111'; describe('isAttemptTerminal', () => { it('only stops on final receipt or expiry states', () => { @@ -40,38 +29,6 @@ describe('race lifecycle predicates', () => { expect(fields.validBefore).toBe(BigInt(now + 15_000)); }); - it('runs the condition agent only when automatic setup resources are ready', () => { - expect(shouldRunConditionAgent({ prepared: true, hasAgent: true, hasClient: true, hasContract: true })).toBe(true); - expect(shouldRunConditionAgent({ prepared: false, hasAgent: true, hasClient: true, hasContract: true })).toBe(false); - expect(shouldRunConditionAgent({ prepared: true, hasAgent: false, hasClient: true, hasContract: true })).toBe(false); - expect(shouldRestartConditionAgent(true, true)).toBe(true); - expect(shouldRestartConditionAgent(false, true)).toBe(false); - expect(shouldRestartConditionAgent(true, false)).toBe(false); - }); - - it('converts bounded disabled dwell times to 200ms Vibenet schedule blocks', () => { - expect(AGENT_DISABLED_DWELL_MIN_MS).toBe(2_000); - expect(AGENT_DISABLED_DWELL_MAX_MS).toBe(10_000); - expect(randomAgentDwellMs(0)).toBe(AGENT_DISABLED_DWELL_MIN_MS); - expect(randomAgentDwellMs(0.999999)).toBe(AGENT_DISABLED_DWELL_MAX_MS); - expect(scheduledAgentOpenBlock(100n, 2_000)).toBe(110n); - expect(scheduledAgentOpenBlock(100n, 10_000)).toBe(150n); - expect(scheduledAgentOpenBlock(100n, 2_001)).toBe(111n); - }); - - it('opens only at the exact scheduled block and closes afterward when enabled', () => { - const predicates = scheduledAgentPredicates(WITHDRAWAL, 110n); - expect(predicates.open).toEqual([ - blockNumberPredicate('>=', 110n), - blockNumberPredicate('<=', 110n), - ]); - expect(predicates.close).toEqual([ - blockNumberPredicate('>=', 111n), - blockNumberPredicate('<=', 111n), - conditionalWithdrawalEnabledPredicate(WITHDRAWAL), - ]); - }); - it('allows retries after terminal attempts and preserves every completed attempt', () => { expect(canSubmitValidity('pending')).toBe(false); expect(canSubmitValidity('submitting')).toBe(false); diff --git a/app/vibenet/demos/validity/race-the-agent/comparison.ts b/app/vibenet/demos/validity/race-the-agent/comparison.ts index d2cd2b46..0c3ac67c 100644 --- a/app/vibenet/demos/validity/race-the-agent/comparison.ts +++ b/app/vibenet/demos/validity/race-the-agent/comparison.ts @@ -1,13 +1,6 @@ -import type { Address, Hex } from 'viem'; - -import { CANDLE_SAMPLE_MS } from '../lib/constants'; -import { conditionalWithdrawalEnabledPredicate } from '../lib/conditionalWithdrawal'; -import { blockNumberPredicate } from '../lib/predicates'; -import type { ValidityPredicate } from '../lib/types'; +import type { Hex } from 'viem'; export const RACE_VALIDITY_SECONDS = 15; -export const AGENT_DISABLED_DWELL_MIN_MS = 2_000; -export const AGENT_DISABLED_DWELL_MAX_MS = 10_000; export type AttemptStatus = 'idle' | 'submitting' | 'pending' | 'success' | 'reverted' | 'expired' | 'error'; @@ -76,49 +69,6 @@ export function attemptHistoryRows(current: Attempt, history: Attempt[]): Attemp }); } -function randomInteger(random: number, min: number, max: number): number { - const bounded = Math.min(Math.max(random, 0), 0.999999999); - return min + Math.floor(bounded * (max - min + 1)); -} - -export function randomAgentDwellMs(random = Math.random()): number { - return randomInteger(random, AGENT_DISABLED_DWELL_MIN_MS, AGENT_DISABLED_DWELL_MAX_MS); -} - -export function scheduledAgentOpenBlock(currentBlock: bigint, dwellMs: number): bigint { - return currentBlock + BigInt(Math.ceil(dwellMs / CANDLE_SAMPLE_MS)); -} - -export function scheduledAgentPredicates( - withdrawal: Address, - openBlock: bigint, -): { open: ValidityPredicate[]; close: ValidityPredicate[] } { - return { - open: [ - blockNumberPredicate('>=', openBlock), - blockNumberPredicate('<=', openBlock), - ], - close: [ - blockNumberPredicate('>=', openBlock + 1n), - blockNumberPredicate('<=', openBlock + 1n), - conditionalWithdrawalEnabledPredicate(withdrawal), - ], - }; -} - -export function shouldRunConditionAgent(args: { - prepared: boolean; - hasAgent: boolean; - hasClient: boolean; - hasContract: boolean; -}): boolean { - return args.prepared && args.hasAgent && args.hasClient && args.hasContract; -} - -export function shouldRestartConditionAgent(setupValid: boolean, generationActive: boolean): boolean { - return setupValid && generationActive; -} - export function shortHash(hash?: Hex): string { return hash ? `${hash.slice(0, 8)}…${hash.slice(-6)}` : 'Not submitted'; }