Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ This app uses Vercel Web Analytics. Two things must stay in place:
| `trackB20PromptCopy(module, prompt)` | `app/vibenet/demos/b20/components/CopyPromptButton.tsx` — copy AI prompt |
| `trackExplorerChainSelect(chain)` | `app/internal-explorer/components/ChainToggle.tsx` — chain toggle |
| `trackExplorerActiveBlockJump(chain, jump)` | `app/internal-explorer/components/ActiveBlockButton.tsx` — zeronet latest/previous active block |
| `trackValidityOrder(side, status)` | `app/vibenet/demos/validity/ValidityDemo.tsx` — conditional swap submit / include / expiry / replace |

Add a helper (and a row here) for a new key journey; remove the helper if you
remove its surface. Confirm the wiring with `grep -rn "analytics/events" app`.
Expand Down
7 changes: 7 additions & 0 deletions app/analytics/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,10 @@ export function trackExplorerChainSelect(chain: string): void {
export function trackExplorerActiveBlockJump(chain: string, jump: 'latest' | 'previous'): void {
track('explorer_active_block_jump', { chain, jump });
}

export function trackValidityOrder(
side: string,
status: 'submitted' | 'filled' | 'expired' | 'replaced' | 'error',
): void {
track('validity_order', { side, status });
}
119 changes: 119 additions & 0 deletions app/api/vibenet/validity/candles/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { NextResponse } from 'next/server';
import {
decodeFunctionResult,
encodeEventTopics,
encodeFunctionData,
parseAbi,
toHex,
type Address,
} from 'viem';

import { VIBENET_RPC_URL } from '../../../../vibenet/library/config';
import { pairAbi } from '../../../../vibenet/demos/validity/lib/constants';
import { quoteWad } from '../../../../vibenet/demos/validity/lib/quote';
import {
isAddress,
lookbackBlocks,
needsLogBackfill,
parseTapeSamples,
readTape,
samplesFromSyncLogs,
writeTape,
type RpcLog,
type TapeSample,
} from '../../../../vibenet/demos/validity/lib/tape';

const SYNC_TOPIC = encodeEventTopics({
abi: parseAbi(['event Sync(uint112 reserve0, uint112 reserve1)']),
eventName: 'Sync',
})[0];

type JsonRpcResponse = { result?: unknown; error?: { message?: string } };

async function rpc<T>(method: string, params: unknown[]): Promise<T | null> {
const response = await fetch(VIBENET_RPC_URL, {
method: 'POST',
cache: 'no-store',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
});
const body = ((await response.json().catch(() => null)) ?? {}) as JsonRpcResponse;
if (body.error?.message || body.result === undefined || body.result === null) return null;
return body.result as T;
}

async function backfillFromLogs(pair: Address, vibeToken0: boolean, now: number): Promise<TapeSample[]> {
const latestHex = await rpc<string>('eth_blockNumber', []);
if (!latestHex) return [];
let latest: bigint;
try {
latest = BigInt(latestHex);
} catch {
return [];
}
const lookback = lookbackBlocks();
const from = latest > lookback ? latest - lookback : 0n;
const logs = await rpc<RpcLog[]>('eth_getLogs', [
{
address: pair,
fromBlock: toHex(from),
toBlock: 'latest',
topics: [SYNC_TOPIC],
},
]);
if (!logs?.length) return [];
return samplesFromSyncLogs({ logs, pair, vibeToken0, latestBlock: latest, now });
}

async function currentMid(pair: Address, vibeToken0: boolean): Promise<number | null> {
const data = encodeFunctionData({ abi: pairAbi, functionName: 'getReserves' });
const raw = await rpc<`0x${string}`>('eth_call', [{ to: pair, data }, 'latest']);
if (!raw) return null;
try {
const decoded = decodeFunctionResult({
abi: pairAbi,
functionName: 'getReserves',
data: raw,
}) as [bigint, bigint, number];
const price = Number(quoteWad(decoded[0], decoded[1], vibeToken0)) / 1e18;
return Number.isFinite(price) && price > 0 ? price : null;
} catch {
return null;
}
}

export async function GET(request: Request) {
const url = new URL(request.url);
const pair = url.searchParams.get('pair');
if (!isAddress(pair)) {
return NextResponse.json({ error: 'pair required' }, { status: 400 });
}
const vibeToken0 = url.searchParams.get('vibeToken0') !== '0';
const now = Date.now();
let samples = readTape(pair);
if (needsLogBackfill(samples, now)) {
const fromLogs = await backfillFromLogs(pair, vibeToken0, now);
if (fromLogs.length > 0) samples = writeTape(pair, fromLogs);
}
const mid = await currentMid(pair, vibeToken0);
if (mid !== null) samples = writeTape(pair, [{ t: now, price: mid }]);
return NextResponse.json(
{ samples },
{ headers: { 'Cache-Control': 'no-store' } },
);
}

export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'invalid json' }, { status: 400 });
}
const record = body && typeof body === 'object' ? (body as { pair?: unknown; samples?: unknown }) : {};
if (!isAddress(typeof record.pair === 'string' ? record.pair : null)) {
return NextResponse.json({ error: 'pair required' }, { status: 400 });
}
const samples = writeTape(record.pair as Address, parseTapeSamples(record.samples));
return NextResponse.json({ ok: true, count: samples.length });
}
1 change: 1 addition & 0 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
{ path: '/vibenet/faucet', priority: 0.5, changeFrequency: 'monthly' },
{ path: '/vibenet/demos/account', priority: 0.5, changeFrequency: 'weekly' },
{ path: '/vibenet/demos/b20', priority: 0.5, changeFrequency: 'weekly' },
{ path: '/vibenet/demos/validity', priority: 0.5, changeFrequency: 'weekly' },
];

return routes.map(({ path, priority, changeFrequency }) => ({
Expand Down
11 changes: 10 additions & 1 deletion app/vibenet/components/ExplorerLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useAccountNames } from './useAccountNames';

type ExplorerLinkProps = {
kind: 'tx' | 'address' | 'block';
value: string;
value: string | null | undefined;
/** Override the displayed text (defaults to a shortened hash/address). */
label?: string;
className?: string;
Expand All @@ -18,8 +18,17 @@ type ExplorerLinkProps = {
// Internal link into the Vibenet explorer for a tx / address / block. When the
// target is a known local account, its name is shown in place of the hash (with
// the truncated address alongside) so saved accounts are recognisable at a glance.
// A missing value renders a muted placeholder — pending txs often omit
// blockHash / from until they are included.
export function ExplorerLink({ kind, value, label, className }: ExplorerLinkProps) {
const names = useAccountNames();
if (!value) {
return (
<span className={cn('font-mono text-bds-gray-60 dark:text-bds-gray-40', className)}>
{label ?? '—'}
</span>
);
}
const name = kind === 'address' ? names[value.toLowerCase()] : undefined;

return (
Expand Down
109 changes: 93 additions & 16 deletions app/vibenet/demos/account/useAccountEngine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -887,8 +887,12 @@ function useAccountEngineCore() {
// pins them here instead.
seqOpt?: {
nonceSequence?: bigint;
nonceKey?: bigint;
validBefore?: bigint;
assumeDeployed?: boolean;
estimateRevert?: 'fallback' | 'throw' | 'force';
maxFeePerGas?: bigint;
maxPriorityFeePerGas?: bigint;
},
): Promise<{ serialized: Hex; nextSeq: number }> => {
const signer = await buildSigner(signerWS);
Expand Down Expand Up @@ -956,11 +960,12 @@ function useAccountEngineCore() {
const plainCallCount = Math.max(totalCalls - heavyCallCount, 1);
const wire = encodeWalletCalls({ account: account.address, calls: phases });

const nonceKey = seqOpt?.nonceKey ?? 0n;
const nonceSequence =
seqOpt?.nonceSequence ??
(await getTransactionCount(makeRpcClient(), {
address: account.address as Address,
nonceKey: 0n,
nonceKey,
}));

// Authenticator hint so estimateGas shapes the senderAuth stub for the
Expand Down Expand Up @@ -1055,10 +1060,11 @@ function useAccountEngineCore() {
accountChanges,
calls: wire,
metadata: meta,
nonceKey: 0n,
nonceKey,
nonceSequence,
maxFeePerGas: 1_000_000_000n,
maxPriorityFeePerGas: 1_000_000n,
...(seqOpt?.validBefore !== undefined ? { validBefore: seqOpt.validBefore } : {}),
maxFeePerGas: seqOpt?.maxFeePerGas ?? 1_000_000_000n,
maxPriorityFeePerGas: seqOpt?.maxPriorityFeePerGas ?? 1_000_000n,
gas: gasLimit,
// A local payer signs `payerAuth` here, so don't stub it out.
...(payerOpt ? { payer: payerOpt.address, ...(payerOpt.localSigner ? {} : { payerAuth: '0x' as Hex }) } : {}),
Expand Down Expand Up @@ -1148,6 +1154,72 @@ function useAccountEngineCore() {
return { hash, serialized, mode: tokenGas ? 'token' : 'self' };
};

// Sign + broadcast from a specific stored account (not necessarily the active
// one). Validity's simulated makers are delegated sub-accounts; switching
// `activeAccountId` to send from them would steal the user's selection.
const signerForAccount = (account: StoredAccount): WalletSigner => {
const parent = account.parentId ? (accounts.find((item) => item.id === account.parentId) ?? null) : null;
const ownerIds = new Set<string>();
for (const owner of account.owners) if (owner.signerId) ownerIds.add(owner.signerId);
if (parent) for (const owner of parent.owners) if (owner.signerId) ownerIds.add(owner.signerId);
const candidates = signers.filter((signer) => ownerIds.has(signer.id));
const spare = candidates.find(
(signer) =>
signer.kind === 'k1' &&
signer.privateKey &&
account.owners.some((owner) => owner.signerId === signer.id),
);
const signer = spare ?? candidates[0];
if (!signer) throw new Error(`No local owner key found for ${account.label}.`);
return signer;
};

const sendAccountCalls = async ({
account,
calls,
wait = true,
seqOpt,
metadata,
}: {
account: StoredAccount;
calls: { to: Address; data: Hex; value?: string }[];
wait?: boolean;
seqOpt?: {
nonceSequence?: bigint;
nonceKey?: bigint;
validBefore?: bigint;
assumeDeployed?: boolean;
maxFeePerGas?: bigint;
maxPriorityFeePerGas?: bigint;
};
metadata?: string;
}): Promise<{ hash: Hex; serialized: Hex; nextSeq: number }> => {
if (!calls.length) throw new Error('No calls to send.');
const signer = signerForAccount(account);
const { serialized, nextSeq } = await signComposed(
account,
signer,
calls.map((call) => newCallRow({ to: call.to, data: call.data, value: call.value ?? '0' })),
[],
null,
metadata?.trim() ? toHex(metadata.trim()) : undefined,
undefined,
undefined,
seqOpt,
);
if (wait) {
const hash = await broadcast8130(serialized);
applyLandedBundle(account, nextSeq, []);
return { hash, serialized, nextSeq };
}
const hash = (await makeRpcClient().request({
method: 'eth_sendRawTransaction',
params: [serialized],
})) as Hex;
applyLandedBundle(account, nextSeq, []);
return { hash, serialized, nextSeq };
};

/**
* Run several transactions from the active account back to back.
*
Expand Down Expand Up @@ -1871,10 +1943,14 @@ function useAccountEngineCore() {
// Derive + store a delegated sub-account (its own address, controlled by this
// account via key.delegate). `withSpareKey` also mints a fresh owner key you
// hold, so you can spend from the sub-account without your main keys.
const doCreateSubAccount = (label: string, opts?: { withSpareKey?: boolean }): AppSubAccount | null => {
if (!acct) return null;
const doCreateSubAccount = (
label: string,
opts?: { withSpareKey?: boolean; parent?: StoredAccount },
): { sub: AppSubAccount; account: StoredAccount } | null => {
const parent = opts?.parent ?? acct;
if (!parent) return null;
const subSalt = randomHex32() as Hex;
const actors = [key.delegate(acct.address)];
const actors = [key.delegate(parent.address)];
const signerIds: string[] = [];
let spare: WalletSigner | null = null;
if (opts?.withSpareKey) {
Expand All @@ -1892,11 +1968,11 @@ function useAccountEngineCore() {
});
const sub: AppSubAccount = {
id: crypto.randomUUID(),
label: label.trim() || `Sub-account ${acct.subAccounts.length + 1}`,
label: label.trim() || `Sub-account ${parent.subAccounts.length + 1}`,
salt: subSalt,
address: subAddress,
signerIds,
delegateTo: acct.address,
delegateTo: parent.address,
createdAt: Date.now(),
};
// Selectable account record for the sub. The on-chain owner is the parent (via
Expand All @@ -1906,19 +1982,19 @@ function useAccountEngineCore() {
// owner and stays selectable on its own.
const delegateActor: StoredActor = {
signerId: '',
actorId: key.delegate(acct.address).actorId,
actorId: key.delegate(parent.address).actorId,
authenticator: canonicalAuthenticators.delegate,
kind: 'k1',
label: `${acct.label} (delegate)`,
identity: acct.address,
label: `${parent.label} (delegate)`,
identity: parent.address,
scope: 0,
};
const subStoredActors = sortActors([delegateActor, ...(spare ? [toStoredActor(spare)] : [])]);
const subRecord: StoredAccount = {
id: crypto.randomUUID(),
label: sub.label,
type: 'smart',
parentId: acct.id,
parentId: parent.id,
saltField: '',
salt: subSalt,
address: subAddress,
Expand All @@ -1930,17 +2006,17 @@ function useAccountEngineCore() {
subAccounts: [],
createdAt: Date.now(),
};
updateAccount(acct.id, (a) => ({ ...a, subAccounts: [...a.subAccounts, sub] }));
updateAccount(parent.id, (a) => ({ ...a, subAccounts: [...a.subAccounts, sub] }));
setAccounts((prev) => [...prev, subRecord]);
pushActivity({
kind: 'subaccount',
title: `Sub-account created · ${sub.label}`,
detail: `Delegates to ${short(acct.address)}`,
detail: `Delegates to ${short(parent.address)}`,
changes: ['owner: this account', ...(spare ? [`owner: ${spare.label}`] : [])],
account: subAddress,
});
autoFundNewAccount(subAddress);
return sub;
return { sub, account: subRecord };
};

return {
Expand Down Expand Up @@ -2004,6 +2080,7 @@ function useAccountEngineCore() {
broadcast8130,
signComposed,
sendActiveCalls,
sendAccountCalls,
sendActiveCallsBatches,
applyLandedBundle,
pendingBundleFor,
Expand Down
11 changes: 8 additions & 3 deletions app/vibenet/demos/catalogue.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest';

import { DEMOS, demoLabel } from './catalogue';
import { DEMOS, demoLabel, listedDemos } from './catalogue';

describe('demoLabel', () => {
it('uses the catalogue entry so the crumb matches the demo name', () => {
expect(demoLabel('account')).toBe('Account');
it('prefers shortTitle for the validity demo', () => {
expect(demoLabel('validity')).toBe('Validity');
});

it('prefers shortTitle over title when both are set', () => {
Expand Down Expand Up @@ -41,4 +41,9 @@ describe('DEMOS', () => {
const hrefs = DEMOS.map((d) => d.href);
expect(new Set(hrefs).size).toBe(hrefs.length);
});

it('keeps Validity off the Vibenet demos grid while the route still resolves', () => {
expect(listedDemos().some((demo) => demo.href === '/vibenet/demos/validity')).toBe(false);
expect(demoLabel('validity')).toBe('Validity');
});
});
Loading
Loading