diff --git a/AGENTS.md b/AGENTS.md index 6971364..111f1f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,6 +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 | 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 531e6fa..2a4fdeb 100644 --- a/app/analytics/events.ts +++ b/app/analytics/events.ts @@ -84,3 +84,10 @@ export function trackValidityOrder( ): void { track('validity_order', { side, status }); } + +export function trackValidityRace( + attempt: 'validity' | 'manual' | 'agent', + status: 'started' | 'submitted' | 'success' | 'reverted' | 'expired' | 'stopped' | 'error', +): void { + track('validity_race', { attempt, status }); +} diff --git a/app/navigation.test.ts b/app/navigation.test.ts index 5419743..6328419 100644 --- a/app/navigation.test.ts +++ b/app/navigation.test.ts @@ -96,5 +96,6 @@ describe('titleForPath', () => { it('uses catalogue labels for grouped and nested demos', () => { expect(titleForPath('/vibenet/demos/validity')).toBe('Validity Transactions'); expect(titleForPath('/vibenet/demos/validity/conditional-swaps')).toBe('Conditional Swaps'); + expect(titleForPath('/vibenet/demos/validity/race-the-agent')).toBe('Race the Agent'); }); }); diff --git a/app/sitemap.test.ts b/app/sitemap.test.ts index c500afc..a0ac9ba 100644 --- a/app/sitemap.test.ts +++ b/app/sitemap.test.ts @@ -3,10 +3,11 @@ import { describe, expect, it } from 'vitest'; import sitemap from './sitemap'; describe('sitemap', () => { - it('indexes the Validity Transactions group and its Conditional Swaps demo', () => { + it('indexes the Validity Transactions group and both nested demos', () => { const urls = sitemap().map((entry) => entry.url); expect(urls).toContain('https://chain.base.org/vibenet/demos/validity'); expect(urls).toContain('https://chain.base.org/vibenet/demos/validity/conditional-swaps'); + expect(urls).toContain('https://chain.base.org/vibenet/demos/validity/race-the-agent'); }); }); diff --git a/app/sitemap.ts b/app/sitemap.ts index d70a940..bbc6d61 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -27,6 +27,7 @@ export default function sitemap(): MetadataRoute.Sitemap { { path: '/vibenet/demos/b20', priority: 0.5, changeFrequency: 'weekly' }, { path: '/vibenet/demos/validity', priority: 0.5, changeFrequency: 'weekly' }, { path: '/vibenet/demos/validity/conditional-swaps', priority: 0.5, changeFrequency: 'weekly' }, + { path: '/vibenet/demos/validity/race-the-agent', priority: 0.5, changeFrequency: 'weekly' }, ]; return routes.map(({ path, priority, changeFrequency }) => ({ diff --git a/app/vibenet/demos/account/library/receipt.test.ts b/app/vibenet/demos/account/library/receipt.test.ts new file mode 100644 index 0000000..4da16f9 --- /dev/null +++ b/app/vibenet/demos/account/library/receipt.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; + +import { aaReceiptSucceeded } from './receipt'; + +describe('aaReceiptSucceeded', () => { + it('requires both the outer transaction and every AA phase to succeed', () => { + expect(aaReceiptSucceeded({ status: 'success', eip8130: { phaseStatuses: ['0x1'] } })).toBe(true); + expect(aaReceiptSucceeded({ status: 'success' })).toBe(true); + expect(aaReceiptSucceeded({ status: '0x1', eip8130: { phaseStatuses: ['0x1', '0x1'] } })).toBe(true); + expect(aaReceiptSucceeded({ status: 'reverted', eip8130: { phaseStatuses: ['0x1'] } })).toBe(false); + expect(aaReceiptSucceeded({ status: '0x0', eip8130: { phaseStatuses: ['0x1'] } })).toBe(false); + expect(aaReceiptSucceeded({ status: 'success', eip8130: { phaseStatuses: ['0x1', '0x0'] } })).toBe(false); + }); +}); diff --git a/app/vibenet/demos/account/library/receipt.ts b/app/vibenet/demos/account/library/receipt.ts new file mode 100644 index 0000000..f61e997 --- /dev/null +++ b/app/vibenet/demos/account/library/receipt.ts @@ -0,0 +1,12 @@ +import { allPhasesSucceeded, type Hex } from '@aa'; + +export type AaReceiptLike = { + status?: 'success' | 'reverted' | Hex; + eip8130?: { phaseStatuses?: readonly Hex[] }; +}; + +/** An EIP-8130 transaction succeeds only when its outer tx and every call phase succeed. */ +export function aaReceiptSucceeded(receipt: AaReceiptLike): boolean { + if (receipt.status === 'reverted' || receipt.status === '0x0') return false; + return allPhasesSucceeded(receipt.eip8130 ?? {}); +} diff --git a/app/vibenet/demos/account/useAccountEngine.tsx b/app/vibenet/demos/account/useAccountEngine.tsx index e317790..752d020 100644 --- a/app/vibenet/demos/account/useAccountEngine.tsx +++ b/app/vibenet/demos/account/useAccountEngine.tsx @@ -67,6 +67,7 @@ import { vibenetApi } from '../../library/client'; import { ACCOUNT_RPC_URL } from '../../library/config'; import { type DemoChain, deploymentFromContracts, estimateTxGas, getDemoChain } from './library/chains'; import { buildPhases, type CallRow, newCallRow, safeGasLimit, valueBearingCallCount } from './library/calls'; +import { aaReceiptSucceeded } from './library/receipt'; import { type AppPolicy, type AppSessionKey, @@ -803,10 +804,7 @@ function useAccountEngineCore() { const awaitInclusion = async (txHash: Hex, timeout = 30_000): Promise => { try { const receipt = await waitForTransactionReceipt(makeRpcClient() as never, { hash: txHash, timeout }); - if (receipt.status === '0x0') throw new Error(`Transaction reverted onchain (${txHash}).`); - const phases = receipt.eip8130?.phaseStatuses ?? []; - const failedPhase = phases.findIndex((s: Hex) => s === '0x0'); - if (failedPhase !== -1) throw new Error(`Phase ${failedPhase} reverted (tx ${txHash}).`); + if (!aaReceiptSucceeded(receipt)) throw new Error(`Transaction reverted onchain (${txHash}).`); } catch (err) { if ((err as Error)?.message?.includes('timed out')) throw new TxPendingError(txHash); throw err; @@ -1174,16 +1172,14 @@ function useAccountEngineCore() { return signer; }; - const sendAccountCalls = async ({ + const signAccountCalls = async ({ account, calls, - wait = true, seqOpt, metadata, }: { account: StoredAccount; calls: { to: Address; data: Hex; value?: string }[]; - wait?: boolean; seqOpt?: { nonceSequence?: bigint; nonceKey?: bigint; @@ -1193,10 +1189,10 @@ function useAccountEngineCore() { maxPriorityFeePerGas?: bigint; }; metadata?: string; - }): Promise<{ hash: Hex; serialized: Hex; nextSeq: number }> => { + }): Promise<{ serialized: Hex; nextSeq: number }> => { if (!calls.length) throw new Error('No calls to send.'); const signer = signerForAccount(account); - const { serialized, nextSeq } = await signComposed( + return signComposed( account, signer, calls.map((call) => newCallRow({ to: call.to, data: call.data, value: call.value ?? '0' })), @@ -1207,6 +1203,16 @@ function useAccountEngineCore() { undefined, seqOpt, ); + }; + + const sendAccountCalls = async ({ + wait = true, + ...signArgs + }: Parameters[0] & { + wait?: boolean; + }): Promise<{ hash: Hex; serialized: Hex; nextSeq: number }> => { + const { account } = signArgs; + const { serialized, nextSeq } = await signAccountCalls(signArgs); if (wait) { const hash = await broadcast8130(serialized); applyLandedBundle(account, nextSeq, []); @@ -2079,6 +2085,7 @@ function useAccountEngineCore() { // Signing engine (also used by each surface's own Transact flow) broadcast8130, signComposed, + signAccountCalls, sendActiveCalls, sendAccountCalls, sendActiveCallsBatches, diff --git a/app/vibenet/demos/catalogue.test.ts b/app/vibenet/demos/catalogue.test.ts index dd72425..12f029d 100644 --- a/app/vibenet/demos/catalogue.test.ts +++ b/app/vibenet/demos/catalogue.test.ts @@ -47,13 +47,14 @@ describe('DEMOS', () => { it('lists Validity Transactions as a top-level group', () => { const validity = listedDemos().find((demo) => demo.href === '/vibenet/demos/validity'); expect(validity?.title).toBe('Validity Transactions'); - expect(validity?.children?.map((demo) => demo.title)).toEqual(['Conditional Swaps']); + expect(validity?.children?.map((demo) => demo.title)).toEqual(['Conditional Swaps', 'Race the Agent']); }); }); describe('demoForPath', () => { it('finds nested demos without flattening them onto the Vibenet grid', () => { expect(demoForPath('/vibenet/demos/validity/conditional-swaps')?.title).toBe('Conditional Swaps'); + expect(demoForPath('/vibenet/demos/validity/race-the-agent')?.title).toBe('Race the Agent'); expect(listedDemos().some((demo) => demo.title === 'Conditional Swaps')).toBe(false); }); }); @@ -75,6 +76,16 @@ describe('demoBreadcrumb', () => { }); }); + it('resolves the second nested validity demo', () => { + expect(demoBreadcrumb('/vibenet/demos/validity/race-the-agent')).toEqual({ + middle: { + label: 'Validity Transactions', + href: '/vibenet/demos/validity', + }, + childLabel: 'Race the Agent', + }); + }); + it('falls back to readable labels for unregistered nested routes', () => { expect(demoBreadcrumb('/vibenet/demos/trading/stop-loss')).toEqual({ middle: { label: 'Trading', href: '/vibenet/demos/trading' }, diff --git a/app/vibenet/demos/catalogue.ts b/app/vibenet/demos/catalogue.ts index f854d87..6aa8b24 100644 --- a/app/vibenet/demos/catalogue.ts +++ b/app/vibenet/demos/catalogue.ts @@ -80,6 +80,18 @@ export const DEMOS: DemoEntry[] = [ ], available: true, }, + { + href: '/vibenet/demos/validity/race-the-agent', + title: 'Race the Agent', + summary: + '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', + 'Judge the result by inclusion blocks, not browser timing', + ], + available: true, + }, ], }, ]; diff --git a/app/vibenet/demos/validity/lib/annotate.test.ts b/app/vibenet/demos/validity/lib/annotate.test.ts index ace7fdd..f28602e 100644 --- a/app/vibenet/demos/validity/lib/annotate.test.ts +++ b/app/vibenet/demos/validity/lib/annotate.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { annotatedValidity, reviewClauses } from './annotate'; import { WAD } from './constants'; -import { blockExpiryPredicate, priceValidity } from './predicates'; +import { blockExpiryPredicate, priceValidity, storagePredicate } from './predicates'; const PAIR = '0x1111111111111111111111111111111111111111'; @@ -41,6 +41,21 @@ describe('annotatedValidity', () => { expect(notes).toContain('L2 block 18422105'); expect(notes.some((note) => note?.includes('at most'))).toBe(true); }); + + it('uses neutral labels for a full-mask non-AMM storage slot', () => { + const predicate = storagePredicate(PAIR, 123n, (1n << 256n) - 1n, '=', 1n); + const notes = annotatedValidity([predicate]).map((row) => row.note).filter(Boolean); + expect(notes).toContain('Storage condition'); + expect(notes).toContain('Contract whose storage is read'); + expect(notes).toContain('Keep the selected bits'); + expect(notes.some((note) => /reserve/i.test(note ?? ''))).toBe(false); + expect(reviewClauses([predicate])).toEqual([ + { + title: 'Storage condition', + detail: 'Include only if the selected value is exactly — 1', + }, + ]); + }); }); describe('reviewClauses', () => { diff --git a/app/vibenet/demos/validity/lib/annotate.ts b/app/vibenet/demos/validity/lib/annotate.ts index 16e6bcd..94017e6 100644 --- a/app/vibenet/demos/validity/lib/annotate.ts +++ b/app/vibenet/demos/validity/lib/annotate.ts @@ -57,11 +57,20 @@ 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() : formatReserve(decodeReserve(value, mask), symbol); + const reserve = slot === PAIR_RESERVES_SLOT ? reserveFromMask(mask) : null; + if (reserve === null) { + return { + type: 'Storage condition', + address: 'Contract whose storage is read', + slot: `Storage slot ${slot.toString()}`, + mask: 'Keep the selected bits', + op: `Include only if the selected value is ${comparePhrase(predicate.params.op)}`, + value: value.toString(), + }; + } + const symbol = tokenForReserve(reserve, vibeToken0); + const half = reserve === 0 ? 'low 112 bits' : 'high 112 bits'; + const amount = formatReserve(decodeReserve(value, mask), symbol); return { type: `${boundWord(predicate.params.op)} on the ${symbol} reserve`, address: 'The simulated VIBE/USDV pair', diff --git a/app/vibenet/demos/validity/lib/artifacts/ConditionalWithdrawal.json b/app/vibenet/demos/validity/lib/artifacts/ConditionalWithdrawal.json new file mode 100644 index 0000000..9953d93 --- /dev/null +++ b/app/vibenet/demos/validity/lib/artifacts/ConditionalWithdrawal.json @@ -0,0 +1,24 @@ +{ + "compiler": { + "version": "0.8.24+commit.e11b9ed9" + }, + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + } + }, + "command": "npx --yes solc@0.8.24 --optimize --optimize-runs 200 --bin --abi -o app/vibenet/demos/validity/lib/contracts/ConditionalWithdrawal.sol", + "source": "../contracts/ConditionalWithdrawal.sol", + "abi": [ + {"inputs":[{"internalType":"contract IERC20","name":"vibe","type":"address"}],"stateMutability":"nonpayable","type":"constructor"}, + {"inputs":[],"name":"ENABLED_SLOT","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"view","type":"function"}, + {"inputs":[],"name":"VIBE","outputs":[{"internalType":"contract IERC20","name":"","type":"address"}],"stateMutability":"view","type":"function"}, + {"inputs":[],"name":"WITHDRAWAL_AMOUNT","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}, + {"inputs":[],"name":"enabled","outputs":[{"internalType":"bool","name":"value","type":"bool"}],"stateMutability":"view","type":"function"}, + {"inputs":[],"name":"flip","outputs":[{"internalType":"bool","name":"value","type":"bool"}],"stateMutability":"nonpayable","type":"function"}, + {"inputs":[{"internalType":"bool","name":"value","type":"bool"}],"name":"setEnabled","outputs":[],"stateMutability":"nonpayable","type":"function"}, + {"inputs":[],"name":"withdraw","outputs":[],"stateMutability":"nonpayable","type":"function"} + ], + "bytecode": "0x60a060405234801561000f575f80fd5b506040516103b03803806103b083398101604081905261002e9161003f565b6001600160a01b031660805261006c565b5f6020828403121561004f575f80fd5b81516001600160a01b0381168114610065575f80fd5b9392505050565b60805161032661008a5f395f8181608301526101d101526103265ff3fe608060405234801561000f575f80fd5b506004361061007a575f3560e01c8063328d8f7211610058578063328d8f72146101065780633ccfd60b14610127578063848606331461012f578063cde4efa91461013e575f80fd5b806304a3b7cd1461007e57806304c879d6146100c2578063238dafe0146100e4575b5f80fd5b6100a57f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b0390911681526020015b60405180910390f35b6100d65f805160206102d183398151915281565b6040519081526020016100b9565b5f805160206102d18339815191525415155b60405190151581526020016100b9565b610125610114366004610293565b5f805160206102d183398151915255565b005b610125610158565b6100d6670de0b6b3a764000081565b5f805160206102d1833981519152805415908190556100f6565b5f805160206102d1833981519152546101ae5760405162461bcd60e51b81526020600482015260136024820152721dda5d1a191c985dd85b08191a5cd8589b1959606a1b60448201526064015b60405180910390fd5b60405163a9059cbb60e01b8152336004820152670de0b6b3a764000060248201527f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03169063a9059cbb906044016020604051808303815f875af115801561021f573d5f803e3d5ffd5b505050506040513d601f19601f8201168201806040525081019061024391906102b5565b6102815760405162461bcd60e51b815260206004820152600f60248201526e1d1c985b9cd9995c8819985a5b1959608a1b60448201526064016101a5565b565b8015158114610290575f80fd5b50565b5f602082840312156102a3575f80fd5b81356102ae81610283565b9392505050565b5f602082840312156102c5575f80fd5b81516102ae8161028356fea91a9aee734204743335c443df931dcb220441d8aa6c1355dc61503a4bec3129a264697066735822122086d03dd0ac93876dcc7d8450420e92071a584f9b6452b41d07282203bd45a11f64736f6c63430008180033" +} diff --git a/app/vibenet/demos/validity/lib/conditionalWithdrawal.ensure.test.ts b/app/vibenet/demos/validity/lib/conditionalWithdrawal.ensure.test.ts new file mode 100644 index 0000000..0fd9f8e --- /dev/null +++ b/app/vibenet/demos/validity/lib/conditionalWithdrawal.ensure.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + ensureCreate2Contract: vi.fn(), + ensureCreate2Deployer: vi.fn(), + hasCode: vi.fn(), +})); + +vi.mock('./singleton', () => ({ + create2Address: () => '0x2222222222222222222222222222222222222222', + ensureCreate2Contract: mocks.ensureCreate2Contract, + ensureCreate2Deployer: mocks.ensureCreate2Deployer, + hasCode: mocks.hasCode, + singletonSalt: () => `0x${'11'.repeat(32)}`, +})); + +import { ensureConditionalWithdrawal } from './conditionalWithdrawal'; + +const VIBE = '0x1111111111111111111111111111111111111111'; +const WITHDRAWAL = '0x2222222222222222222222222222222222222222'; + +describe('ensureConditionalWithdrawal', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.ensureCreate2Deployer.mockResolvedValue(undefined); + }); + + it('accepts a correctly configured deployment created concurrently by another visitor', async () => { + mocks.hasCode.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mocks.ensureCreate2Contract.mockRejectedValue(new Error('CREATE2 duplicate')); + const publicClient = { + readContract: vi.fn().mockResolvedValue(VIBE), + }; + + await expect(ensureConditionalWithdrawal({ + 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', + })); + }); +}); diff --git a/app/vibenet/demos/validity/lib/conditionalWithdrawal.test.ts b/app/vibenet/demos/validity/lib/conditionalWithdrawal.test.ts new file mode 100644 index 0000000..ab7e459 --- /dev/null +++ b/app/vibenet/demos/validity/lib/conditionalWithdrawal.test.ts @@ -0,0 +1,119 @@ +import { decodeFunctionData, keccak256, toBytes } from 'viem'; +import { describe, expect, it } 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, + encodeFlipConditionalWithdrawal, + encodeSetConditionalWithdrawalEnabled, + predictConditionalWithdrawal, +} from './conditionalWithdrawal'; +import { minterAbi, WAD } from './constants'; +import { toWord } from './predicates'; + +const VIBE = '0x1111111111111111111111111111111111111111'; +const OTHER_VIBE = '0x2222222222222222222222222222222222222222'; +const MINTER = '0x3333333333333333333333333333333333333333'; +const WITHDRAWAL = '0x4444444444444444444444444444444444444444'; + +describe('conditional withdrawal contract', () => { + it('commits reproducible compiler metadata and nonempty creation bytecode', () => { + expect(artifact.compiler.version).toBe('0.8.24+commit.e11b9ed9'); + expect(artifact.settings.optimizer).toEqual({ enabled: true, runs: 200 }); + expect(artifact.command).toContain('solc@0.8.24 --optimize --optimize-runs 200'); + expect(artifact.bytecode).toMatch(/^0x[0-9a-f]+$/); + expect(artifact.bytecode.length).toBeGreaterThan(100); + }); + + it('pins the CREATE2 salt and address for a given shared VIBE token', () => { + expect(CONDITIONAL_WITHDRAWAL_SALT).toBe( + '0x75dea569b8cc7d9ea45d7d95a5d6bed33e1e378a31715724342462f8226adc8b', + ); + expect(predictConditionalWithdrawal(VIBE)).toBe('0x7AE1BFB6116D154a0a27961a5d19C544D02015a9'); + expect(predictConditionalWithdrawal(OTHER_VIBE)).not.toBe(predictConditionalWithdrawal(VIBE)); + }); + + it('pins the stable storage word and exact enabled=true EIP-8130 predicate', () => { + expect(CONDITIONAL_WITHDRAWAL_ENABLED_SLOT).toBe( + keccak256(toBytes('vibenet.validity.conditional-withdrawal.enabled.v1')), + ); + expect(CONDITIONAL_WITHDRAWAL_ENABLED_MASK).toBe((1n << 256n) - 1n); + expect(conditionalWithdrawalEnabledPredicate(WITHDRAWAL)).toEqual({ + type: 'storage', + params: { + address: WITHDRAWAL, + slot: CONDITIONAL_WITHDRAWAL_ENABLED_SLOT, + mask: toWord((1n << 256n) - 1n), + op: '=', + value: toWord(1n), + }, + }); + }); + + it('encodes condition and fixed-withdrawal calls exactly', () => { + expect(encodeSetConditionalWithdrawalEnabled(WITHDRAWAL, true)).toEqual({ + to: WITHDRAWAL, + data: `0x328d8f72${'0'.repeat(63)}1`, + }); + expect(encodeSetConditionalWithdrawalEnabled(WITHDRAWAL, false).data).toBe( + `0x328d8f72${'0'.repeat(64)}`, + ); + expect(encodeFlipConditionalWithdrawal(WITHDRAWAL)).toEqual({ + to: WITHDRAWAL, + data: '0xcde4efa9', + }); + expect(encodeConditionalWithdraw(WITHDRAWAL)).toEqual({ + to: WITHDRAWAL, + data: '0x3ccfd60b', + }); + expect(CONDITIONAL_WITHDRAWAL_AMOUNT).toBe(WAD); + expect( + decodeFunctionData({ abi: conditionalWithdrawalAbi, data: encodeConditionalWithdraw(WITHDRAWAL).data }) + .functionName, + ).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 new file mode 100644 index 0000000..d1b806e --- /dev/null +++ b/app/vibenet/demos/validity/lib/conditionalWithdrawal.ts @@ -0,0 +1,195 @@ +import { + 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 { storagePredicate } from './predicates'; +import { + create2Address, + ensureCreate2Contract, + ensureCreate2Deployer, + hasCode, + singletonSalt, +} from './singleton'; +import type { StoragePredicate } from './types'; + +export const conditionalWithdrawalAbi = artifact.abi as Abi; +export const conditionalWithdrawalBytecode = artifact.bytecode as Hex; + +export const CONDITIONAL_WITHDRAWAL_ENABLED_SLOT = + '0xa91a9aee734204743335c443df931dcb220441d8aa6c1355dc61503a4bec3129' as Hex; +export const CONDITIONAL_WITHDRAWAL_ENABLED_MASK = (1n << 256n) - 1n; +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 { + return encodeDeployData({ + abi: conditionalWithdrawalAbi, + bytecode: conditionalWithdrawalBytecode, + args: [vibe], + }); +} + +export function predictConditionalWithdrawal(vibe: Address): Address { + return create2Address(CONDITIONAL_WITHDRAWAL_SALT, conditionalWithdrawalInitCode(vibe)); +} + +export async function probeConditionalWithdrawal( + client: PublicClient, + vibe: Address, +): Promise
{ + const address = predictConditionalWithdrawal(vibe); + if (!(await hasCode(client, address))) return null; + const configuredVibe = await client + .readContract({ address, abi: conditionalWithdrawalAbi, functionName: 'VIBE' }) + .catch(() => null); + return typeof configuredVibe === 'string' && configuredVibe.toLowerCase() === vibe.toLowerCase() + ? address + : 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; + + await ensureCreate2Deployer(wallet, publicClient, account, onProgress); + onProgress?.('Deploying conditional withdrawal'); + try { + await ensureCreate2Contract( + wallet, + publicClient, + account, + CONDITIONAL_WITHDRAWAL_SALT, + conditionalWithdrawalInitCode(vibe), + 'Conditional withdrawal', + 750_000n, + ); + } 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, +): Promise<{ address: Address; enabled: boolean; balance: bigint }> { + const address = predictConditionalWithdrawal(vibe); + const [enabled, balance] = await Promise.all([ + client.readContract({ + address, + abi: conditionalWithdrawalAbi, + functionName: 'enabled', + }) as Promise, + client.readContract({ + address: vibe, + abi: erc20Abi, + functionName: 'balanceOf', + args: [address], + }) as Promise, + ]); + 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 encodeFlipConditionalWithdrawal(withdrawal: Address): { to: Address; data: Hex } { + return { + to: withdrawal, + data: encodeFunctionData({ abi: conditionalWithdrawalAbi, functionName: 'flip' }), + }; +} + +export function encodeConditionalWithdraw(withdrawal: Address): { to: Address; data: Hex } { + return { + to: withdrawal, + data: encodeFunctionData({ abi: conditionalWithdrawalAbi, functionName: 'withdraw' }), + }; +} + +/** EIP-8130 condition requiring the stable enabled bit to equal true. */ +export function conditionalWithdrawalEnabledPredicate(withdrawal: Address): StoragePredicate { + return storagePredicate( + withdrawal, + BigInt(CONDITIONAL_WITHDRAWAL_ENABLED_SLOT), + CONDITIONAL_WITHDRAWAL_ENABLED_MASK, + '=', + 1n, + ); +} diff --git a/app/vibenet/demos/validity/lib/contracts/ConditionalWithdrawal.sol b/app/vibenet/demos/validity/lib/contracts/ConditionalWithdrawal.sol new file mode 100644 index 0000000..253fc64 --- /dev/null +++ b/app/vibenet/demos/validity/lib/contracts/ConditionalWithdrawal.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +interface IERC20 { + function transfer(address to, uint256 amount) external returns (bool); +} + +/// @notice Permissionless condition switch backed by one stable storage word. +contract ConditionalWithdrawal { + /// @dev Stable forever: keccak256("vibenet.validity.conditional-withdrawal.enabled.v1"). + bytes32 public constant ENABLED_SLOT = + 0xa91a9aee734204743335c443df931dcb220441d8aa6c1355dc61503a4bec3129; + uint256 public constant WITHDRAWAL_AMOUNT = 1 ether; + + IERC20 public immutable VIBE; + + constructor(IERC20 vibe) { + VIBE = vibe; + } + + function enabled() public view returns (bool value) { + bytes32 slot = ENABLED_SLOT; + assembly { + value := iszero(iszero(sload(slot))) + } + } + + function setEnabled(bool value) external { + bytes32 slot = ENABLED_SLOT; + assembly { + sstore(slot, value) + } + } + + function flip() external returns (bool value) { + bytes32 slot = ENABLED_SLOT; + assembly { + value := iszero(sload(slot)) + sstore(slot, value) + } + } + + function withdraw() external { + require(enabled(), "withdrawal disabled"); + require(VIBE.transfer(msg.sender, WITHDRAWAL_AMOUNT), "transfer failed"); + } +} diff --git a/app/vibenet/demos/validity/lib/predicates.test.ts b/app/vibenet/demos/validity/lib/predicates.test.ts index d91d0a1..6229aea 100644 --- a/app/vibenet/demos/validity/lib/predicates.test.ts +++ b/app/vibenet/demos/validity/lib/predicates.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest'; import { RESERVE0_MASK, RESERVE1_MASK, RESERVE_BITS, WAD } from './constants'; import { applyOffsetBps, + blockExpiryPredicate, + blockNumberPredicate, formatPrice, prettyValidity, priceValidity, @@ -86,4 +88,15 @@ describe('predicates', () => { expect(pretty).not.toContain('0x00000000'); }); + it('builds generic block predicates without changing block expiry behavior', () => { + expect(blockNumberPredicate('>=', 42n)).toEqual({ + type: 'block_number', + params: { op: '>=', value: toWord(42n) }, + }); + expect(blockExpiryPredicate(42n)).toEqual({ + type: 'block_number', + params: { op: '<=', value: toWord(42n) }, + }); + }); + }); diff --git a/app/vibenet/demos/validity/lib/predicates.ts b/app/vibenet/demos/validity/lib/predicates.ts index 24c9299..7049236 100644 --- a/app/vibenet/demos/validity/lib/predicates.ts +++ b/app/vibenet/demos/validity/lib/predicates.ts @@ -154,9 +154,13 @@ export function priceValidity( return { rectangle, predicates }; } -export function blockExpiryPredicate(maxBlock: bigint): ValidityPredicate { +export function blockNumberPredicate(op: ValidityOperator, block: bigint): ValidityPredicate { return { type: 'block_number', - params: { op: '<=', value: toWord(maxBlock) }, + params: { op, value: toWord(block) }, }; } + +export function blockExpiryPredicate(maxBlock: bigint): ValidityPredicate { + return blockNumberPredicate('<=', maxBlock); +} diff --git a/app/vibenet/demos/validity/lib/singleton.ts b/app/vibenet/demos/validity/lib/singleton.ts index 97c1827..8d70c42 100644 --- a/app/vibenet/demos/validity/lib/singleton.ts +++ b/app/vibenet/demos/validity/lib/singleton.ts @@ -89,7 +89,7 @@ export function singletonInitCodes(): { }; } -function create2Address(salt: Hex, initCode: Hex): Address { +export function create2Address(salt: Hex, initCode: Hex): Address { return getContractAddress({ bytecode: initCode, from: CREATE2_DEPLOYER, @@ -298,7 +298,7 @@ export async function ensureCreate2Deployer( await waitForBytecode(publicClient, CREATE2_DEPLOYER, 'CREATE2 deployer'); } -async function ensureCreate2Contract( +export async function ensureCreate2Contract( wallet: WalletClient, publicClient: PublicClient, account: Account, diff --git a/app/vibenet/demos/validity/metadata.test.ts b/app/vibenet/demos/validity/metadata.test.ts index bbebcc2..8c13650 100644 --- a/app/vibenet/demos/validity/metadata.test.ts +++ b/app/vibenet/demos/validity/metadata.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest'; import { metadata as groupMetadata } from './layout'; import { metadata as conditionalSwapsMetadata } from './conditional-swaps/layout'; +import { metadata as raceTheAgentMetadata } from './race-the-agent/layout'; describe('validity route metadata', () => { it('names the group and nested demo independently', () => { expect(groupMetadata.title).toBe('Validity Transactions · Vibenet'); expect(conditionalSwapsMetadata.title).toBe('Conditional Swaps · Validity Transactions'); + expect(raceTheAgentMetadata.title).toBe('Race the Agent · Validity Transactions'); }); }); diff --git a/app/vibenet/demos/validity/race-the-agent/RaceTheAgentDemo.tsx b/app/vibenet/demos/validity/race-the-agent/RaceTheAgentDemo.tsx new file mode 100644 index 0000000..fe0319d --- /dev/null +++ b/app/vibenet/demos/validity/race-the-agent/RaceTheAgentDemo.tsx @@ -0,0 +1,1209 @@ +'use client'; + +import { getTransactionReceipt as getAaTransactionReceipt } from '@aa'; +import { useCallback, useEffect, useMemo, 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'; +import { Card } from '../../../../components/ui/Card'; +import { cn } from '../../../../components/ui/cn'; +import { Text } from '../../../../components/ui/Text'; +import { VIBENET_EXPLORER_PATH, VIBENET_WS_URL } from '../../../library/config'; +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 { 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'; +import { noncelessFields } from '../../../library/aa'; +import { rootAccount } from '../lib/makers'; +import { + describeValidityError, + makePublicClient, + makeWalletClient, + sendValidityTransaction, + VIBENET_CHAIN, + type RpcSend, +} from '../lib/rpc'; +import { ensureSingleton, probeSingleton } from '../lib/singleton'; +import { connectJsonRpcStream, headNumber, type StreamHead } from '../lib/stream'; +import { + attemptHistoryRows, + canResetRace, + canSubmitManual, + canSubmitValidity, + comparisonResult, + 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; + +type Observation = { enabled: boolean; block: bigint; at: number }; +type AgentPhase = 'Waiting' | 'Scheduling' | 'Opening' | 'Closing' | 'Retrying'; + +const EMPTY_ATTEMPT: Attempt = { status: 'idle' }; + +export function RaceTheAgentDemo() { + return ( + + + + ); +} + +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 [streamLive, setStreamLive] = useState(false); + 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([]); + const [validityAttemptCount, setValidityAttemptCount] = useState(0); + const [manual, setManual] = useState(EMPTY_ATTEMPT); + const [manualHistory, setManualHistory] = useState([]); + const [manualAttemptCount, setManualAttemptCount] = useState(0); + const [validBefore, setValidBefore] = useState(null); + + const generationRef = useRef(0); + const observedRef = useRef(null); + const validityRef = useRef(validity); + 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; + validityRef.current = validity; + observedRef.current = observed; + + useEffect(() => { + const accountKey = acct && parent ? `${acct.id}:${parent.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); + setManual(EMPTY_ATTEMPT); + setManualHistory([]); + setManualAttemptCount(0); + setValidBefore(null); + setError(null); + setAgentError(null); + setObservations(observedRef.current ? [observedRef.current] : []); + }, [acct, parent]); + + const applyObservation = useCallback((next: Observation) => { + observedRef.current = next; + setObserved(next); + setObservations((previous) => { + const last = previous.at(-1); + if (last?.enabled === next.enabled) return previous; + return [...previous, next].slice(-12); + }); + }, []); + + useEffect(() => { + const scroller = observationsScrollRef.current; + if (!scroller) return; + scroller.scrollTo({ left: scroller.scrollWidth, behavior: 'smooth' }); + }, [observations.length]); + + useEffect(() => { + let cancelled = false; + 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.'); + }); + return () => { + cancelled = true; + generationRef.current += 1; + }; + }, [applyObservation]); + + useEffect(() => { + if (!client || !vibe || !withdrawal) return; + let cancelled = false; + let inFlight = false; + let pollId: number | undefined; + let stream: ReturnType | undefined; + + const syncState = async (block?: bigint) => { + if (cancelled || inFlight) return; + inFlight = true; + try { + const [state, observedBlock] = await Promise.all([ + readConditionalWithdrawalState(client, vibe), + block === undefined + ? client.getBlockNumber({ cacheTime: 0 }) + : Promise.resolve(block), + ]); + if (cancelled) return; + setContractBalance(state.balance); + applyObservation({ enabled: state.enabled, block: observedBlock, at: Date.now() }); + } catch { + // Keep the last observed state while the feed reconnects or polling recovers. + } finally { + inFlight = false; + } + }; + + const startPoll = () => { + if (pollId !== undefined) return; + rpcSendRef.current = null; + setStreamLive(false); + void syncState(); + pollId = window.setInterval(() => void syncState(), STATE_FALLBACK_POLL_MS); + }; + + 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); + await stream.subscribe(['newHeads'], (raw) => { + const block = headNumber(raw as StreamHead); + if (block !== null) void syncState(block); + }); + if (cancelled) { + stream.close(); + return; + } + setStreamLive(true); + void syncState(); + }; + + if (VIBENET_WS_URL) { + void startStream(VIBENET_WS_URL).catch(() => { + rpcSendRef.current = null; + stream?.close(); + if (!cancelled) startPoll(); + }); + } else { + startPoll(); + } + + return () => { + cancelled = true; + rpcSendRef.current = null; + if (pollId !== undefined) window.clearInterval(pollId); + stream?.close(); + setStreamLive(false); + }; + }, [applyObservation, client, vibe, withdrawal]); + + const settleFromReceipt = useCallback(( + attempt: 'validity' | 'manual', + receipt: AaReceiptLike & { blockNumber: bigint | Hex }, + ) => { + const nextStatus = aaReceiptSucceeded(receipt) ? 'success' : 'reverted'; + const patch = (current: Attempt): Attempt => ({ + ...current, + status: nextStatus, + includedAt: Date.now(), + includedBlock: BigInt(receipt.blockNumber), + error: nextStatus === 'success' ? undefined : current.error, + }); + if (attempt === 'validity') { + setValidity(patch); + } + else setManual(patch); + trackValidityRace(attempt, nextStatus); + }, []); + + useEffect(() => { + if (!client || !validity.hash || isAttemptTerminal(validity.status)) return; + let cancelled = false; + const poll = () => { + void getAaTransactionReceipt(client as never, { hash: validity.hash! }) + .then((receipt) => { + if (cancelled) return; + if (receipt) settleFromReceipt('validity', receipt as AaReceiptLike & { blockNumber: bigint | Hex }); + else if (validBefore !== null && Date.now() > validBefore + RECEIPT_POLL_MS) { + setValidity((current) => ({ ...current, status: 'expired' })); + trackValidityRace('validity', 'expired'); + } + }) + .catch(() => { + if (!cancelled && validBefore !== null && Date.now() > validBefore + RECEIPT_POLL_MS) { + setValidity((current) => ({ ...current, status: 'expired' })); + trackValidityRace('validity', 'expired'); + } + }); + }; + poll(); + const id = window.setInterval(poll, RECEIPT_POLL_MS); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, [client, settleFromReceipt, validBefore, validity.hash, validity.status]); + + useEffect(() => { + if (!client || !manual.hash || isAttemptTerminal(manual.status)) return; + let cancelled = false; + const poll = () => { + void getAaTransactionReceipt(client as never, { hash: manual.hash! }) + .then((receipt) => { + if (!cancelled && receipt) { + settleFromReceipt('manual', receipt as AaReceiptLike & { blockNumber: bigint | Hex }); + } + }) + .catch(() => {}); + }; + poll(); + const id = window.setInterval(poll, RECEIPT_POLL_MS); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, [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'); + let deployment = await probeSingleton(client); + let contract = deployment + ? await probeConditionalWithdrawal(client, deployment.tokenA) + : null; + + 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 (!deployment) { + reportProgress('Deploying shared VIBE contracts'); + deployment = await ensureSingleton({ wallet, publicClient: client, account: owner, onProgress: reportProgress }); + } + 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 || + !engine.activeSigner || + !client || + !vibe || + !withdrawal || + observed?.enabled || + !prepared || + !canSubmitValidity(validity.status) + ) return; + setBusy(true); + setError(null); + try { + const [fresh, block] = await Promise.all([ + readConditionalWithdrawalState(client, vibe), + client.getBlockNumber({ cacheTime: 0 }), + ]); + if (fresh.enabled) { + applyObservation({ enabled: true, block, at: Date.now() }); + setError('The condition changed before signing. Wait for disabled and try again.'); + return; + } + const attemptNumber = validityAttemptCount + 1; + setValidityHistory((history) => preserveCompletedAttempt(history, validity)); + setValidityAttemptCount(attemptNumber); + const fields = noncelessFields(RACE_VALIDITY_SECONDS); + const expiresAt = Number(fields.validBefore); + setValidBefore(expiresAt); + setValidity({ number: attemptNumber, status: 'submitting', submittedAt: Date.now(), submittedBlock: block }); + const call = encodeConditionalWithdraw(withdrawal); + const { serialized } = await engine.signComposed( + acct, + engine.activeSigner, + [newCallRow({ to: call.to, data: call.data, value: '0' })], + [], + null, + undefined, + undefined, + undefined, + { + nonceKey: fields.nonceKey, + nonceSequence: fields.nonceSequence, + validBefore: fields.validBefore, + }, + ); + const [beforeSend, beforeSendBlock] = await Promise.all([ + readConditionalWithdrawalState(client, vibe), + client.getBlockNumber({ cacheTime: 0 }), + ]); + applyObservation({ enabled: beforeSend.enabled, block: beforeSendBlock, at: Date.now() }); + if (beforeSend.enabled) { + setValidity((current) => ({ + ...current, + status: 'error', + error: 'Condition enabled after signing; transaction was not sent.', + })); + setValidBefore(null); + setError('The condition became enabled after signing, so this transaction was not sent. Wait for disabled and submit again.'); + return; + } + const hash = await sendValidityTransaction(serialized, [ + conditionalWithdrawalEnabledPredicate(withdrawal), + ]); + setValidity((current) => ({ ...current, status: 'pending', hash })); + trackValidityRace('validity', 'submitted'); + } catch (err) { + setValidity((current) => ({ + ...current, + status: 'error', + error: describeValidityError(err), + })); + trackValidityRace('validity', 'error'); + setError(describeValidityError(err)); + } finally { + setBusy(false); + } + }; + + 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, + prepared, + hasAccount: Boolean(acct), + hasClient: Boolean(client), + hasContract: Boolean(withdrawal), + observedEnabled: observed?.enabled ?? null, + })) return; + const attemptNumber = manualAttemptCount + 1; + setManualHistory((history) => preserveCompletedAttempt(history, manual)); + setManualAttemptCount(attemptNumber); + setManual({ + number: attemptNumber, + status: 'submitting', + submittedAt: Date.now(), + submittedBlock: observed.block, + }); + trackValidityRace('manual', 'submitted'); + try { + const call = encodeConditionalWithdraw(withdrawal); + const result = await engine.sendActiveCalls({ + calls: [{ to: call.to, data: call.data, value: '0' }], + metadata: 'Race the Agent manual withdrawal', + }); + setManual((current) => ({ ...current, status: 'pending', hash: result.hash })); + } catch (err) { + if (err instanceof TxPendingError) { + setManual((current) => ({ ...current, status: 'pending', hash: err.txHash })); + } else { + const message = err instanceof Error ? err.message : 'Manual withdrawal failed.'; + const hash = extractHash(message); + if (hash) { + setManual((current) => ({ ...current, status: 'pending', hash, error: message })); + } else { + setManual((current) => ({ ...current, status: 'error', error: message })); + trackValidityRace('manual', 'error'); + } + } + } + }; + + const reset = () => { + if (!canResetRace(validityRef.current, validBefore)) { + setError(`This validity transaction is still pending and can land until its ${RACE_VALIDITY_SECONDS}-second expiry. Keep watching the receipt and chain state.`); + return; + } + setValidity(EMPTY_ATTEMPT); + setValidityHistory([]); + setValidityAttemptCount(0); + setManual(EMPTY_ATTEMPT); + setManualHistory([]); + setManualAttemptCount(0); + setValidBefore(null); + setError(null); + setAgentError(null); + setObservations(observedRef.current ? [observedRef.current] : []); + }; + + const result = comparisonResult(validity, manual); + const resetAllowed = canResetRace(validity, validBefore); + const readyToSubmit = prepared && observed?.enabled === false && canSubmitValidity(validity.status); + const readyToWithdraw = canSubmitManual({ + status: manual.status, + prepared, + hasAccount: Boolean(acct), + hasClient: Boolean(client), + hasContract: Boolean(withdrawal), + observedEnabled: observed?.enabled ?? null, + }); + const validityAttempts = attemptHistoryRows(validity, validityHistory); + const manualAttempts = attemptHistoryRows(manual, manualHistory); + + return ( + + + Reset race + + ) : undefined} + /> + + +
+ +
+ + {prepared ? 'Race setup ready' : setupError ? 'Automatic setup needs attention' : setupRunning ? 'Preparing race automatically' : 'Waiting to start setup'} + + + {setupError ?? progress ?? (prepared + ? agentRunning ? `Condition agent: ${agentPhase}` : 'Singleton funded; restarting condition agent.' + : 'Waiting for account and chain state.')} + +
+
+ {setupError ? : null} +
+ +
+ +
+
+ Shared onchain switch + Withdrawal condition +
+ +
+ +
+
+ {observed?.enabled ? : null} +
+ storage + {observed ? (observed.enabled ? '1' : '0') : '—'} +
+
+
+ +
+ + + + +
+ + {streamLive + ? 'WebSocket observations follow each Vibenet head at roughly 200ms cadence.' + : 'WebSocket unavailable; state observations are polling every second.'}{' '} + Inclusion blocks below remain the primary ordering evidence. + +
+ + +
+ Guided race + Submit first. React second. +
+
+ + + {agentRunning ? agentPhase : setupRunning ? 'Starting after setup' : 'Waiting for setup'} + + + 0} + > + + + 0} + > + + +
+ {(error || agentError) ? ( +
+ {error ?

{error}

: null} + {agentError ?

Agent retrying: {agentError}

: null} +
+ ) : null} +
+
+ +
+
+
+ Comparison + Same call, different timing model +
+ +
+
+ + +
+
+ + +
+
+ +
+ + Observed chain state +
+
+ {observations.length === 0 ? ( + State observations appear after automatic setup. + ) : observations.map((item, index) => ( +
+
+ + {item.enabled ? 'Enabled' : 'Disabled'} + + #{item.block.toString()} + {formatTime(item.at)} +
+ {index < observations.length - 1 ? : null} +
+ ))} +
+
+
+ + What the result means + Blocks beat stopwatches. + + The timestamps show when this browser sampled state or received a receipt. They are useful context, not authoritative sequencing. The lower included block landed first; the same block is a tie at this resolution. + + {withdrawal ? ( +
+ Global singleton + +
+ ) : null} +
+
+
+ ); +} + +async function refreshPreparedState( + client: PublicClient, + vibe: Address, + withdrawal: Address, + applyObservation: (observation: Observation) => 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; +} + +function formatTime(at: number): string { + return new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +function formatCompactVibe(value: bigint): string { + const full = formatUnits(value, 18); + const [whole] = full.split('.'); + return Number(whole).toLocaleString(undefined, { notation: 'compact', maximumFractionDigits: 1 }); +} + +function ConditionPill({ enabled }: { enabled: boolean | null }) { + return ( + + {enabled === null ? 'unobserved' : enabled ? 'enabled · 1' : 'disabled · 0'} + + ); +} + +function AttemptHistoryCard({ title, attempts }: { title: string; attempts: Attempt[] }) { + return ( + +
+ {title} + {attempts.length} total +
+
+ {attempts.length === 0 ? ( + No previous attempts yet. + ) : attempts.map((attempt, index) => ( +
+
+ Attempt #{attempt.number ?? '?'} + + {attempt.includedBlock !== undefined ? `Included in block #${attempt.includedBlock.toLocaleString()}` : 'No inclusion receipt'} + +
+
+ {attempt.hash ? ( + + {shortHash(attempt.hash)} + + ) : null} + +
+
+ ))} +
+
+ ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function RaceStep({ + number, + title, + detail, + active, + complete, + children, +}: { + number: string; + title: string; + detail: string; + active: boolean; + complete: boolean; + children: React.ReactNode; +}) { + return ( +
+
+ {number} + +
+ {title} + {detail} +
{children}
+
+ ); +} + +function AttemptCard({ + label, + headline, + description, + attempt, + accent, +}: { + label: string; + headline: string; + description: string; + attempt: Attempt; + accent: 'blue' | 'green'; +}) { + const explorer = attempt.hash ? `${VIBENET_EXPLORER_PATH}/tx/${attempt.hash}` : null; + return ( + + +
+
+ {label} + {headline} +
+ +
+ {description} +
+ + + + +
+ {attempt.error ? {attempt.error} : null} + {explorer ? ( + + View transaction in explorer + + ) : null} +
+ ); +} + +function StatusPill({ status }: { status: Attempt['status'] }) { + const positive = status === 'success'; + const negative = status === 'reverted' || status === 'expired' || status === 'error'; + return ( + + {status} + + ); +} + +function ResultPill({ result, validity, manual }: { result: ReturnType; validity: Attempt; manual: Attempt }) { + let label = 'Race in progress'; + if (result === 'validity-first') label = 'Validity landed first'; + if (result === 'manual-first') label = 'Manual landed first'; + if (result === 'same-block') label = 'Same inclusion block'; + if (result === 'validity-only') label = 'Only validity succeeded'; + if (result === 'manual-only') label = 'Only manual succeeded'; + if (result === 'neither-succeeded') label = 'Neither transaction succeeded'; + if (result === 'none' && validity.status === 'idle' && manual.status === 'idle') label = 'Not started'; + return {label}; +} diff --git a/app/vibenet/demos/validity/race-the-agent/comparison.test.ts b/app/vibenet/demos/validity/race-the-agent/comparison.test.ts new file mode 100644 index 0000000..821037f --- /dev/null +++ b/app/vibenet/demos/validity/race-the-agent/comparison.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest'; + +import { + AGENT_DISABLED_DWELL_MAX_MS, + AGENT_DISABLED_DWELL_MIN_MS, + attemptHistoryRows, + canResetRace, + canSubmitManual, + canSubmitValidity, + comparisonResult, + 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'; + +function success(block: bigint): Attempt { + return { status: 'success', includedBlock: block }; +} + +describe('comparisonResult', () => { + it('uses inclusion block ordering instead of browser timestamps', () => { + expect(comparisonResult(success(10n), { ...success(11n), includedAt: 1 })).toBe('validity-first'); + expect(comparisonResult({ ...success(12n), includedAt: 1 }, success(11n))).toBe('manual-first'); + expect(comparisonResult(success(12n), success(12n))).toBe('same-block'); + }); + + it('describes one-sided and unfinished outcomes', () => { + expect(comparisonResult(success(10n), { status: 'reverted' })).toBe('validity-only'); + expect(comparisonResult({ status: 'expired' }, success(10n))).toBe('manual-only'); + expect(comparisonResult(success(10n), { status: 'idle' })).toBe('none'); + expect(comparisonResult({ status: 'pending' }, { status: 'idle' })).toBe('none'); + expect(comparisonResult({ status: 'expired' }, { status: 'idle' })).toBe('neither-succeeded'); + expect(comparisonResult({ status: 'reverted' }, { status: 'error' })).toBe('neither-succeeded'); + }); +}); + +describe('isAttemptTerminal', () => { + it('only stops on final receipt or expiry states', () => { + expect(isAttemptTerminal('success')).toBe(true); + expect(isAttemptTerminal('reverted')).toBe(true); + expect(isAttemptTerminal('expired')).toBe(true); + expect(isAttemptTerminal('pending')).toBe(false); + expect(isAttemptTerminal('error')).toBe(true); + }); +}); + +describe('race lifecycle predicates', () => { + it('uses a conservative nonce-free validity window below the protocol maximum', () => { + const now = 1_700_000_000_000; + const fields = noncelessFields(RACE_VALIDITY_SECONDS, now); + expect(RACE_VALIDITY_SECONDS).toBe(15); + expect(fields.validBefore).toBe(BigInt(now + 15_000)); + }); + + it('blocks reset only while a submitted validity transaction can still land', () => { + expect(canResetRace({ status: 'pending' }, 20_000, 10_000)).toBe(false); + expect(canResetRace({ status: 'pending' }, null, 20_001)).toBe(false); + expect(canResetRace({ status: 'pending' }, 20_000, 20_001)).toBe(true); + expect(canResetRace({ status: 'expired' }, 20_000, 10_000)).toBe(true); + }); + + 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); + expect(canSubmitValidity('expired')).toBe(true); + expect(canSubmitValidity('success')).toBe(true); + const manual = (status: Attempt['status'], observedEnabled: boolean | null) => canSubmitManual({ + status, + prepared: true, + hasAccount: true, + hasClient: true, + hasContract: true, + observedEnabled, + }); + expect(manual('pending', false)).toBe(false); + expect(manual('submitting', true)).toBe(false); + expect(manual('success', false)).toBe(true); + expect(manual('reverted', true)).toBe(true); + expect(manual('idle', null)).toBe(false); + const current: Attempt = { number: 5, status: 'expired' }; + const history: Attempt[] = [ + { number: 4, status: 'success' }, + { number: 3, status: 'reverted' }, + { number: 2, status: 'error' }, + { number: 1, status: 'success' }, + ]; + expect(preserveCompletedAttempt(history, current)).toEqual([ + current, + ...history, + ]); + expect(preserveCompletedAttempt([], { status: 'pending' })).toEqual([]); + }); + + it('shows the live attempt immediately before prior attempts without duplicates', () => { + const current: Attempt = { number: 2, status: 'pending' }; + const prior: Attempt[] = [ + { number: 2, status: 'submitting' }, + { number: 1, status: 'success' }, + ]; + expect(attemptHistoryRows(current, prior)).toEqual([ + current, + { number: 1, status: 'success' }, + ]); + expect(attemptHistoryRows({ status: 'idle' }, prior.slice(1))).toEqual(prior.slice(1)); + expect(attemptHistoryRows({ number: 1, status: 'submitting' }, [])).toEqual([ + { number: 1, status: 'submitting' }, + ]); + }); +}); diff --git a/app/vibenet/demos/validity/race-the-agent/comparison.ts b/app/vibenet/demos/validity/race-the-agent/comparison.ts new file mode 100644 index 0000000..2371db9 --- /dev/null +++ b/app/vibenet/demos/validity/race-the-agent/comparison.ts @@ -0,0 +1,157 @@ +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'; + +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'; + +export type Attempt = { + number?: number; + status: AttemptStatus; + hash?: Hex; + submittedAt?: number; + submittedBlock?: bigint; + includedAt?: number; + includedBlock?: bigint; + error?: string; +}; + +export type ComparisonResult = + | 'validity-first' + | 'manual-first' + | 'same-block' + | 'validity-only' + | 'manual-only' + | 'neither-succeeded' + | 'none'; + +export function comparisonResult(validity: Attempt, manual: Attempt): ComparisonResult { + const validityLanded = validity.status === 'success' && validity.includedBlock !== undefined; + const manualLanded = manual.status === 'success' && manual.includedBlock !== undefined; + if (validityLanded && manualLanded) { + if (validity.includedBlock! < manual.includedBlock!) return 'validity-first'; + if (manual.includedBlock! < validity.includedBlock!) return 'manual-first'; + return 'same-block'; + } + if (validityLanded && isAttemptTerminal(manual.status)) return 'validity-only'; + if (manualLanded && isAttemptTerminal(validity.status)) return 'manual-only'; + if ( + (!validityLanded && isAttemptTerminal(validity.status) && manual.status === 'idle') || + (!validityLanded && !manualLanded && isAttemptTerminal(validity.status) && isAttemptTerminal(manual.status)) + ) { + return 'neither-succeeded'; + } + return 'none'; +} + +export function isAttemptTerminal(status: AttemptStatus): boolean { + return status === 'success' || status === 'reverted' || status === 'expired' || status === 'error'; +} + +export function canResetRace(validity: Attempt, validBefore: number | null, now = Date.now()): boolean { + if (validity.status !== 'pending') return true; + return validBefore !== null && now > validBefore; +} + +export function canSubmitAttempt(status: AttemptStatus): boolean { + return status !== 'pending' && status !== 'submitting'; +} + +export const canSubmitValidity = canSubmitAttempt; + +export function canSubmitManual(args: { + status: AttemptStatus; + prepared: boolean; + hasAccount: boolean; + hasClient: boolean; + hasContract: boolean; + observedEnabled: boolean | null; +}): boolean { + return ( + args.prepared && + args.hasAccount && + args.hasClient && + args.hasContract && + args.observedEnabled !== null && + canSubmitAttempt(args.status) + ); +} + +export function preserveCompletedAttempt( + history: Attempt[], + current: Attempt, +): Attempt[] { + if (!isAttemptTerminal(current.status)) return history; + return [current, ...history]; +} + +/** Live current attempt followed by prior attempts, newest first and without duplicates. */ +export function attemptHistoryRows(current: Attempt, history: Attempt[]): Attempt[] { + const candidates = current.status === 'idle' ? history : [current, ...history]; + const seen = new Set(); + return candidates.filter((attempt, index) => { + const key = attempt.number !== undefined + ? `number:${attempt.number}` + : attempt.hash + ? `hash:${attempt.hash.toLowerCase()}` + : attempt.submittedAt !== undefined + ? `submitted:${attempt.submittedAt}` + : `row:${index}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +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'; +} diff --git a/app/vibenet/demos/validity/race-the-agent/layout.tsx b/app/vibenet/demos/validity/race-the-agent/layout.tsx new file mode 100644 index 0000000..0454712 --- /dev/null +++ b/app/vibenet/demos/validity/race-the-agent/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import type { ReactNode } from 'react'; + +export const metadata: Metadata = { + title: 'Race the Agent · Validity Transactions', + description: + 'Compare a manually timed VIBE withdrawal with a validity transaction that is already waiting for its onchain condition.', +}; + +export default function RaceTheAgentLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/app/vibenet/demos/validity/race-the-agent/page.tsx b/app/vibenet/demos/validity/race-the-agent/page.tsx new file mode 100644 index 0000000..086b4e3 --- /dev/null +++ b/app/vibenet/demos/validity/race-the-agent/page.tsx @@ -0,0 +1,5 @@ +import { RaceTheAgentDemo } from './RaceTheAgentDemo'; + +export default function RaceTheAgentPage() { + return ; +} diff --git a/public/AGENTS.md b/public/AGENTS.md index b86c490..61bf7e3 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, /vibenet/demos/validity, /vibenet/demos/validity/conditional-swaps | infrequent | stable within a session | +| /, /vibenet, /vibenet/demos/account, /vibenet/demos/b20, /vibenet/demos/validity, /vibenet/demos/validity/conditional-swaps, /vibenet/demos/validity/race-the-agent | infrequent | stable within a session | ## Machine-readable endpoints @@ -90,6 +90,7 @@ Discovered from the Next.js app directory. - [/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) — Explore Vibenet demos built with transactions that execute only while their onchain validity conditions hold. - [/vibenet/demos/validity/conditional-swaps](https://chain.base.org/vibenet/demos/validity/conditional-swaps) — Place a validity-backed swap on Vibenet that waits for a target price, then fills or expires as the market moves. +- [/vibenet/demos/validity/race-the-agent](https://chain.base.org/vibenet/demos/validity/race-the-agent) — Compare a manually timed VIBE withdrawal with a validity transaction that is already waiting for its onchain condition. - [/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 84e1cda..88faaa3 100644 --- a/public/llms-full.txt +++ b/public/llms-full.txt @@ -23,6 +23,7 @@ - [Tokens · Vibenet](https://chain.base.org/vibenet/demos/b20): Explore, configure, and issue Base-native B20 tokens on Vibenet. - [Validity Transactions · Vibenet](https://chain.base.org/vibenet/demos/validity): Explore Vibenet demos built with transactions that execute only while their onchain validity conditions hold. - [Conditional Swaps · Validity Transactions](https://chain.base.org/vibenet/demos/validity/conditional-swaps): Place a validity-backed swap on Vibenet that waits for a target price, then fills or expires as the market moves. +- [Race the Agent · Validity Transactions](https://chain.base.org/vibenet/demos/validity/race-the-agent): Compare a manually timed VIBE withdrawal with a validity transaction that is already waiting for its onchain condition. - [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 1d93dc4..a4904b5 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -21,6 +21,7 @@ Freshness: /snapshots and /vibenet/explorer change daily. /vibenet/faucet change - [Tokens · Vibenet](https://chain.base.org/vibenet/demos/b20): Explore, configure, and issue Base-native B20 tokens on Vibenet. - [Validity Transactions · Vibenet](https://chain.base.org/vibenet/demos/validity): Explore Vibenet demos built with transactions that execute only while their onchain validity conditions hold. - [Conditional Swaps · Validity Transactions](https://chain.base.org/vibenet/demos/validity/conditional-swaps): Place a validity-backed swap on Vibenet that waits for a target price, then fills or expires as the market moves. +- [Race the Agent · Validity Transactions](https://chain.base.org/vibenet/demos/validity/race-the-agent): Compare a manually timed VIBE withdrawal with a validity transaction that is already waiting for its onchain condition. - [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.