diff --git a/.github/.gitleaks.toml b/.github/.gitleaks.toml index d14dbce93..9b6b0173c 100644 --- a/.github/.gitleaks.toml +++ b/.github/.gitleaks.toml @@ -1,6 +1,13 @@ [extend] useDefault = true +[allowlist] +description = "Well-known Anvil/Hardhat test private keys (not secrets)" +paths = [ + '''apps/virtual-addresses/src/lib/demo-client\.ts''', + '''apps/virtual-addresses/README\.md''', +] + [[rules]] id = "rpc-auth-credentials" description = "RPC auth credentials in config files" diff --git a/.gitignore b/.gitignore index 7fc9cc4fc..05d1b44b8 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ _artifacts _tmp _verify .pnpm-store +.dev.vars diff --git a/apps/virtual-addresses/.env.example b/apps/virtual-addresses/.env.example new file mode 100644 index 000000000..7241b6af2 --- /dev/null +++ b/apps/virtual-addresses/.env.example @@ -0,0 +1,4 @@ +# Pre-funded accounts for the walkthrough demo (server-side only) +# These accounts must be funded with PathUSD on Tempo Devnet +EXCHANGE_PRIVATE_KEY=0x... +SENDER_PRIVATE_KEY=0x... diff --git a/apps/virtual-addresses/README.md b/apps/virtual-addresses/README.md new file mode 100644 index 000000000..8e85b6d12 --- /dev/null +++ b/apps/virtual-addresses/README.md @@ -0,0 +1,111 @@ +# Virtual Addresses — TIP-1022 Demo + +Interactive demo for [TIP-1022](https://github.com/tempoxyz/tempo/pull/3286) virtual addresses on Tempo. Three views: + +- **Intro** — TIP-1022 docs landing page: motivation, address layout, protocol flow, properties, and security considerations. +- **Registry** — Mine a salt, register as a virtual-address master, derive deposit addresses, and test transfers. Works with a connected wallet or manual address input. +- **Walkthrough** — Animated visual walkthrough of the full TIP-1022 flow (register → derive → send → resolve → forward), with real on-chain transactions using pre-funded test accounts. + +## Prerequisites + +- [Node.js](https://nodejs.org/) ≥ 24 +- [pnpm](https://pnpm.io/) ≥ 10 + +## 1. Install dependencies + +From the monorepo root: + +```bash +pnpm install +``` + +## 2. Configure environment + +Create a `.dev.vars` file with funded devnet account keys: + +``` +EXCHANGE_PRIVATE_KEY=0x... +SENDER_PRIVATE_KEY=0x... +RPC_URL=https://rpc.devnet.tempoxyz.dev +``` + +The private keys must correspond to accounts funded with PathUSD on Tempo Devnet. Set these as Cloudflare Worker secrets for production, or use org-level secrets. + +## 3. Run the app + +```bash +pnpm --filter virtual-addresses dev +``` + +Open [http://localhost:3002](http://localhost:3002). + +## Architecture + +``` +src/ +├── app.tsx # Hash-based router: #intro | #registry | #walkthrough +├── server.ts # Hono API routes (Cloudflare Workers) +├── comps/ +│ ├── header.tsx # Navigation links + wallet connect +│ ├── intro-view.tsx # TIP-1022 docs landing page +│ ├── registry-view.tsx # Salt miner flow (4 steps) +│ ├── step-mine.tsx # Multi-threaded WASM salt miner +│ ├── step-register.tsx # On-chain registration +│ ├── step-generate.tsx # Offline address derivation +│ ├── step-transfer.tsx # Demo transfer +│ ├── address-anatomy.tsx # Color-coded virtual address breakdown +│ └── walkthrough/ +│ ├── walkthrough-demo.tsx # 3-column layout + speed controls +│ ├── exchange-panel.tsx # Exchange actor panel +│ ├── protocol-panel.tsx # TIP-1022 registry + TIP-20 precompile +│ ├── sender-panel.tsx # Sender actor panel +│ ├── guide-overlay.tsx # Spotlight walkthrough (intro + post-demo) +│ ├── event-log.tsx # Animated transaction log +│ └── status-badge.tsx # State indicator +├── store/ +│ └── walkthrough-store.ts # Zustand state machine with speed control +└── lib/ + ├── abi.ts # Contract ABIs + addresses + ├── demo-client.ts # Fetch wrapper for server API (walkthrough) + ├── virtual-address.ts # Address building/decoding utilities + ├── walkthrough-types.ts # Demo state types + ├── wagmi.ts # Wagmi config (tempoDevnet) + ├── miner.worker.ts # WASM keccak256 mining (hash-wasm) + ├── miner.pool.ts # Web Worker pool manager + ├── miner.protocol.ts # Worker message protocol + ├── use-miner.ts # React hook for miner + └── css.ts # cx() utility +``` + +## How the walkthrough works + +1. **Fund** — Pre-funds exchange and sender accounts with PathUSD via `tempo_fundAddress` + `Actions.token.mint` +2. **Register** — Calls `registerVirtualMaster(salt)` with a pre-mined salt +3. **Derive** — Builds a virtual address offline: `[masterId][FDFDFD...FD magic][userTag]` +4. **Transfer** — Sender sends 100 PathUSD to the virtual address +5. **Resolve** — TIP-20 precompile detects magic bytes, looks up masterId → master address +6. **Forward** — Tokens credited to master; two Transfer events emitted + +All walkthrough RPC calls are handled server-side via the Hono API routes — the client calls `/api/demo/*` endpoints. + +## Key addresses + +| Contract | Address | +|----------|---------| +| PathUSD (TIP-20) | `0x20C0000000000000000000000000000000000000` | +| Virtual Registry | `0xfDC0000000000000000000000000000000000000` | + +## Virtual address format + +``` +0x [masterId 4B] [magic 10B: FDFDFDFDFDFDFDFDFDFD] [userTag 6B] + ^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^ + blue purple green +``` + +## Checks + +```bash +pnpm --filter virtual-addresses check # biome lint + type check +pnpm --filter virtual-addresses build # production build +``` diff --git a/apps/virtual-addresses/env.d.ts b/apps/virtual-addresses/env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/apps/virtual-addresses/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/virtual-addresses/index.html b/apps/virtual-addresses/index.html new file mode 100644 index 000000000..b93d17bfc --- /dev/null +++ b/apps/virtual-addresses/index.html @@ -0,0 +1,17 @@ + + + + + + Virtual Addresses — TIP-1022 Demo + + + +
+ + + diff --git a/apps/virtual-addresses/package.json b/apps/virtual-addresses/package.json new file mode 100644 index 000000000..2e3fee28d --- /dev/null +++ b/apps/virtual-addresses/package.json @@ -0,0 +1,42 @@ +{ + "name": "virtual-addresses", + "private": true, + "type": "module", + "imports": { + "#*": "./src/*" + }, + "scripts": { + "postinstall": "pnpm gen:types", + "dev": "vite dev --port 3002", + "build": "vite build", + "check": "pnpm check:biome && pnpm check:types", + "check:biome": "biome check --write --unsafe", + "check:types": "tsgo --project tsconfig.json --noEmit", + "gen:types": "wrangler types" + }, + "dependencies": { + "@noble/hashes": "^1.7.2", + "@tailwindcss/vite": "catalog:", + "@tanstack/react-query": "catalog:", + "framer-motion": "catalog:", + "hash-wasm": "catalog:", + "hono": "catalog:", + "ox": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "tailwindcss": "catalog:", + "viem": "catalog:", + "wagmi": "catalog:", + "zustand": "catalog:" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "catalog:", + "@iconify/json": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "unplugin-icons": "catalog:", + "vite": "catalog:", + "wrangler": "catalog:" + } +} diff --git a/apps/virtual-addresses/src/app.tsx b/apps/virtual-addresses/src/app.tsx new file mode 100644 index 000000000..562c1e2a9 --- /dev/null +++ b/apps/virtual-addresses/src/app.tsx @@ -0,0 +1,50 @@ +import type * as React from 'react' +import { useCallback, useEffect, useState } from 'react' +import { Header } from '#comps/header' +import { IntroView } from '#comps/intro-view' +import { RegistryView } from '#comps/registry-view' +import { WalkthroughDemo } from '#comps/walkthrough/walkthrough-demo' + +const TABS: ReadonlySet = new Set([ + 'intro', + 'registry', + 'walkthrough', +]) + +function parseHash(): Header.Tab { + const raw = window.location.hash.replace('#', '') + return TABS.has(raw as Header.Tab) ? (raw as Header.Tab) : 'intro' +} + +function useHashTab(): [Header.Tab, (tab: Header.Tab) => void] { + const [tab, setTab] = useState(parseHash) + + useEffect(() => { + const onHash = (): void => setTab(parseHash()) + window.addEventListener('hashchange', onHash) + return () => window.removeEventListener('hashchange', onHash) + }, []) + + const navigate = useCallback((next: Header.Tab) => { + window.location.hash = next + }, []) + + return [tab, navigate] +} + +export function App(): React.JSX.Element { + const [activeTab, setActiveTab] = useHashTab() + + return ( +
+
+ {activeTab === 'intro' ? ( + + ) : activeTab === 'registry' ? ( + + ) : ( + + )} +
+ ) +} diff --git a/apps/virtual-addresses/src/comps/address-anatomy.tsx b/apps/virtual-addresses/src/comps/address-anatomy.tsx new file mode 100644 index 000000000..c25c884c5 --- /dev/null +++ b/apps/virtual-addresses/src/comps/address-anatomy.tsx @@ -0,0 +1,43 @@ +import type * as React from 'react' + +export function AddressAnatomy(props: AddressAnatomy.Props): React.JSX.Element { + const { address } = props + + // Virtual address: [0x][4-byte masterId][10-byte magic][6-byte userTag] + // Hex positions: [0:2][2:10][10:30][30:42] + const prefix = address.slice(0, 2) + const masterId = address.slice(2, 10) + const magic = address.slice(10, 30) + const userTag = address.slice(30, 42) + + return ( +
+
+ {prefix} + {masterId} + {magic} + {userTag} +
+
+ + + masterId + + + + magic + + + + userTag + +
+
+ ) +} + +export declare namespace AddressAnatomy { + type Props = { + address: string + } +} diff --git a/apps/virtual-addresses/src/comps/header.tsx b/apps/virtual-addresses/src/comps/header.tsx new file mode 100644 index 000000000..567a6d524 --- /dev/null +++ b/apps/virtual-addresses/src/comps/header.tsx @@ -0,0 +1,108 @@ +import * as React from 'react' +import { + useAccount, + useConnect, + useConnectors, + useDisconnect, + type Connector, +} from 'wagmi' +import { cx } from '#lib/css' +import { formatAddress } from '#lib/virtual-address' +import type { Address } from 'viem' + +export function Header(props: Header.Props): React.JSX.Element { + const { activeTab } = props + const { address, isConnected } = useAccount() + const connect = useConnect() + const allConnectors = useConnectors() as readonly Connector[] + const { disconnect } = useDisconnect() + + const connector = React.useMemo(() => { + const branded = allConnectors.find( + (c) => c.id !== 'injected' && c.name !== 'Injected', + ) + return branded ?? allConnectors[0] ?? null + }, [allConnectors]) + + return ( +
+
+ + Virtual Addresses + + + TIP-1022 + + +
+
+ Tempo Localnet + {activeTab === 'registry' && + (isConnected && address ? ( + + ) : connector ? ( + + ) : ( + + No wallet detected + + ))} +
+
+ ) +} + +export declare namespace Header { + type Tab = 'intro' | 'registry' | 'walkthrough' + type Props = { + activeTab: Tab + onTabChange: (tab: Tab) => void + } +} diff --git a/apps/virtual-addresses/src/comps/intro-view.tsx b/apps/virtual-addresses/src/comps/intro-view.tsx new file mode 100644 index 000000000..bae5af302 --- /dev/null +++ b/apps/virtual-addresses/src/comps/intro-view.tsx @@ -0,0 +1,240 @@ +import type * as React from 'react' +import { AddressAnatomy } from '#comps/address-anatomy' + +const EXAMPLE_ADDRESS = '07A3B1C2FDFDFDFDFDFDFDFDFDFDFDFDFDD4E5A7C3F19E' + +export function IntroView(): React.JSX.Element { + return ( +
+ {/* Hero */} +
+

+ TIP-1022: Virtual Addresses +

+

+ Precompile-native virtual addresses that auto-forward TIP-20 deposits + to a registered master wallet, eliminating sweep transactions entirely + for exchanges, ramps, and payment processors. +

+
+ + {/* Cards — each full width */} +
+ {/* Motivation */} + +
+ + + +
+
+ + {/* Address Layout — horizontal: anatomy left, field descriptions right */} + +
+
+ +
+
+ + + +
+
+
+ + {/* How It Works */} + +
    + + Master calls registerVirtualMaster(salt) on the + registry precompile. The salt must satisfy a 32-bit proof-of-work. + One-time on-chain call. + + + Operator concatenates{' '} + masterId +{' '} + magic +{' '} + userTag off-chain. No + transaction needed. Unlimited deposit addresses. + + + Sender transfers TIP-20 tokens to a virtual address. The + precompile detects magic bytes, extracts masterId, looks up + master, and credits the master wallet directly. + + + Two Transfer events emitted for audit trail: +
    +
    emit Transfer(sender, virtualAddr, amount)
    +
    emit Transfer(virtualAddr, master, amount)
    +
    +
    +
+
+ + {/* Key Properties */} + +
+ + + + + + + + +
+
+ + {/* Security & Limitations */} + +
+ + Non-TIP-20 tokens sent to virtual addresses credit the literal + address and may be irrecoverable. + + + Wallets and explorers should display full addresses to distinguish + masterId and userTag. + + + Registrations are immutable. Use an upgradeable proxy or multisig + for key rotation. + + + TIP-403 policies are evaluated on the resolved master, not the + virtual alias. + +
+
+
+
+ ) +} + +// ── Primitives ────────────────────────────────────────────────────── + +function Card(props: { + title: string + className?: string + children: React.ReactNode +}): React.JSX.Element { + return ( +
+

{props.title}

+ {props.children} +
+ ) +} + +function Reason(props: { label: string; text: string }): React.JSX.Element { + return ( +
+ + +
+ {props.label} + -- + {props.text} +
+
+ ) +} + +function FieldDesc(props: { + color: string + name: string + bytes: string + detail: string +}): React.JSX.Element { + return ( +
+ +
+
+ {props.name} + ({props.bytes}) +
+
{props.detail}
+
+
+ ) +} + +function Step(props: { + n: number + title: string + children: React.ReactNode +}): React.JSX.Element { + return ( +
  • +
    + + {props.n} + + {props.title} +
    +
    {props.children}
    +
  • + ) +} + +function Code(props: { children: React.ReactNode }): React.JSX.Element { + return ( + + {props.children} + + ) +} + +function Property(props: { k: string; v: string }): React.JSX.Element { + return ( +
    +
    {props.k}
    +
    {props.v}
    +
    + ) +} + +function Notice(props: { + level: 'warn' | 'info' + children: React.ReactNode +}): React.JSX.Element { + const isWarn = props.level === 'warn' + return ( +
    + + {isWarn ? 'warn' : 'info'} + + {props.children} +
    + ) +} diff --git a/apps/virtual-addresses/src/comps/registry-view.tsx b/apps/virtual-addresses/src/comps/registry-view.tsx new file mode 100644 index 000000000..efc0d77cf --- /dev/null +++ b/apps/virtual-addresses/src/comps/registry-view.tsx @@ -0,0 +1,133 @@ +import * as React from 'react' +import { useState, useCallback } from 'react' +import type { Address } from 'viem' +import { StepIndicator } from '#comps/step-indicator' +import { StepMine } from '#comps/step-mine' +import { StepRegister } from '#comps/step-register' +import { StepGenerate } from '#comps/step-generate' +import { StepTransfer } from '#comps/step-transfer' +import { useMiner } from '#lib/use-miner' + +const STEPS = ['Mine Salt', 'Register', 'Generate', 'Transfer'] + +export function RegistryView(): React.JSX.Element { + const miner = useMiner() + + const [currentStep, setCurrentStep] = useState(1) + const [registrationTx, setRegistrationTx] = useState(null) + const [selectedVirtualAddress, setSelectedVirtualAddress] = + useState
    (null) + + const handleStartMining = useCallback( + (addr: string) => { + miner.start(addr) + }, + [miner.start], + ) + + const handleRegistered = useCallback((txHash: string) => { + setRegistrationTx(txHash) + setCurrentStep(3) + }, []) + + const handleSelectAddress = useCallback((addr: Address) => { + setSelectedVirtualAddress(addr) + setCurrentStep(4) + }, []) + + React.useEffect(() => { + if (miner.state.status === 'found' && currentStep === 1) { + setCurrentStep(2) + } + }, [miner.state.status, currentStep]) + + const minedSalt = miner.state.status === 'found' ? miner.state.salt : null + const minedMasterId = + miner.state.status === 'found' ? miner.state.masterId : null + const minedForAddress = + miner.state.status === 'found' ? miner.state.minedForAddress : null + + return ( +
    +
    +

    + Virtual Address Registry +

    +

    + Register your address as a virtual-address master. Mine a valid salt, + register on-chain, then derive unlimited deposit addresses offline. +

    +
    + + + +
    + {currentStep >= 1 && ( + + )} + + {currentStep >= 2 && minedSalt && minedMasterId && minedForAddress && ( + + )} + + {currentStep >= 3 && + minedMasterId && + minedForAddress && + registrationTx && ( + + )} + + {currentStep >= 4 && selectedVirtualAddress && minedForAddress && ( + + )} +
    + + +
    + ) +} diff --git a/apps/virtual-addresses/src/comps/step-generate.tsx b/apps/virtual-addresses/src/comps/step-generate.tsx new file mode 100644 index 000000000..73adef6be --- /dev/null +++ b/apps/virtual-addresses/src/comps/step-generate.tsx @@ -0,0 +1,102 @@ +import type * as React from 'react' +import { useState } from 'react' +import { buildVirtualAddress, randomUserTag } from '#lib/virtual-address' +import { AddressAnatomy } from './address-anatomy' +import type { Hex, Address } from 'viem' + +export function StepGenerate(props: StepGenerate.Props): React.JSX.Element { + const { masterId, masterAddress, onSelectAddress } = props + const [addresses, setAddresses] = useState< + Array<{ userTag: Hex; address: Address }> + >([]) + + function generateAddress() { + const userTag = randomUserTag() + const addr = buildVirtualAddress(masterId as Hex, userTag) + setAddresses((prev) => [...prev, { userTag, address: addr }]) + } + + return ( +
    +
    +

    + Generate Virtual Addresses +

    +

    + Derive deposit addresses offline from your masterId. Each uses a + unique 6-byte userTag. No on-chain transaction needed. +

    +
    + +
    +
    Master ID
    +
    {masterId}
    +
    + + + + {addresses.length > 0 && ( +
    + {addresses.map(({ userTag, address }) => ( +
    +
    +
    Tag: {userTag}
    + +
    + + + View on explorer ↗ + +
    + ))} +
    + )} + + {addresses.length === 0 && ( +
    + No addresses generated yet. Click the button above. +
    + )} + +
    + How it works: Virtual + addresses are{' '} + [masterId] + + [FDFDFDFDFDFDFDFDFDFD] + + [userTag]. When anyone + sends TIP-20 tokens to this address, the protocol auto-forwards to your + master wallet — {masterAddress.slice(0, 8)}… +
    +
    + ) +} + +export declare namespace StepGenerate { + type Props = { + masterId: string + masterAddress: string + onSelectAddress: (address: Address) => void + } +} diff --git a/apps/virtual-addresses/src/comps/step-indicator.tsx b/apps/virtual-addresses/src/comps/step-indicator.tsx new file mode 100644 index 000000000..004932ae2 --- /dev/null +++ b/apps/virtual-addresses/src/comps/step-indicator.tsx @@ -0,0 +1,56 @@ +import * as React from 'react' +import { cx } from '#lib/css' + +export function StepIndicator(props: StepIndicator.Props): React.JSX.Element { + const { steps, currentStep } = props + + return ( +
    + {steps.map((label, i) => { + const step = i + 1 + const isActive = step === currentStep + const isComplete = step < currentStep + return ( + + {i > 0 && ( +
    + )} +
    +
    + {isComplete ? '✓' : step} +
    + +
    + + ) + })} +
    + ) +} + +export declare namespace StepIndicator { + type Props = { + steps: string[] + currentStep: number + } +} diff --git a/apps/virtual-addresses/src/comps/step-mine.tsx b/apps/virtual-addresses/src/comps/step-mine.tsx new file mode 100644 index 000000000..3c18b8ea4 --- /dev/null +++ b/apps/virtual-addresses/src/comps/step-mine.tsx @@ -0,0 +1,220 @@ +import * as React from 'react' +import { useState } from 'react' +import { useAccount } from 'wagmi' +import type { Address } from 'viem' +import { cx } from '#lib/css' +import { demoFund } from '#lib/demo-client' +import type { MinerState } from '#lib/miner.pool' + +const ZERO_ADDR = `0x${'0'.repeat(40)}` + +function isValidAddress(s: string): boolean { + return /^0x[0-9a-fA-F]{40}$/.test(s) && s !== ZERO_ADDR +} + +export function StepMine(props: StepMine.Props): React.JSX.Element { + const { minerState, onStart, onStop } = props + const { address: walletAddress } = useAccount() + const [manualAddress, setManualAddress] = useState('') + + const [funding, setFunding] = useState(false) + const [fundResult, setFundResult] = useState(null) + + const isMining = minerState.status === 'mining' + const isFound = minerState.status === 'found' + + const effectiveAddress = + walletAddress ?? (isValidAddress(manualAddress) ? manualAddress : null) + + // Auto-fund when wallet connects + React.useEffect(() => { + if (!walletAddress) return + let cancelled = false + setFunding(true) + demoFund(walletAddress as Address) + .then((result) => { + if (cancelled) return + if (result.funded.length > 0) { + setFundResult( + `Funded ${result.funded.length} account(s) with 10,000 PathUSD`, + ) + } + }) + .catch(() => {}) + .finally(() => { + if (!cancelled) setFunding(false) + }) + return () => { + cancelled = true + } + }, [walletAddress]) + + async function handleFund() { + if (!effectiveAddress) return + setFunding(true) + setFundResult(null) + try { + const result = await demoFund(effectiveAddress as Address) + setFundResult( + result.funded.length > 0 + ? `Funded ${result.funded.length} account(s) with 10,000 PathUSD` + : 'Accounts already funded', + ) + } catch { + setFundResult('Fund request failed — is the devnet reachable?') + } + setFunding(false) + } + + function formatNumber(n: number): string { + if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B` + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K` + return n.toString() + } + + return ( +
    +
    +

    Mine Salt

    +

    + Find a valid salt for your address via 32-bit proof-of-work. +

    +
    + + {walletAddress ? ( +
    +
    Master Address (wallet)
    +
    + {walletAddress} +
    +
    + ) : ( +
    +
    Master Address
    + setManualAddress(e.target.value)} + placeholder="0x… (connect wallet or paste address)" + className="w-full bg-surface-2 border border-border rounded-lg px-4 py-2.5 font-mono text-sm text-text-primary focus:outline-none focus:border-accent transition-colors" + /> + {manualAddress && !isValidAddress(manualAddress) && ( +
    + Invalid address — must be 0x followed by 40 hex characters +
    + )} +
    + )} + + {effectiveAddress && !isMining && !isFound && ( + + )} + + {fundResult && ( +
    + {fundResult} +
    + )} + + {isMining && ( +
    +
    +
    Hashes Tried
    +
    + {formatNumber(minerState.totalAttempts)} +
    +
    +
    +
    Hash Rate
    +
    + {formatNumber(minerState.hashesPerSecond)}/s +
    +
    +
    + )} + + {isFound && ( +
    +
    + Valid salt found +
    +
    +
    Salt
    +
    + {minerState.salt} +
    +
    +
    +
    Master ID
    +
    + {minerState.masterId} +
    +
    +
    +
    Attempts
    +
    + {formatNumber(minerState.attempts)} +
    +
    +
    + )} + + {minerState.status === 'error' && ( +
    + {minerState.message} +
    + )} + +
    + {!isMining && !isFound && ( + + )} + {isMining && ( + + )} +
    + + {isMining && ( +
    +
    + Mining in-browser with {minerState.workerCount} Web Workers — expect + ~3 min on modern hardware +
    + )} +
    + ) +} + +export declare namespace StepMine { + type Props = { + minerState: MinerState + onStart: (address: string) => void + onStop: () => void + } +} diff --git a/apps/virtual-addresses/src/comps/step-register.tsx b/apps/virtual-addresses/src/comps/step-register.tsx new file mode 100644 index 000000000..27d5adf30 --- /dev/null +++ b/apps/virtual-addresses/src/comps/step-register.tsx @@ -0,0 +1,136 @@ +import * as React from 'react' +import { + useAccount, + useWriteContract, + useWaitForTransactionReceipt, +} from 'wagmi' +import { cx } from '#lib/css' +import { VIRTUAL_REGISTRY_ADDRESS, virtualRegistryAbi } from '#lib/abi' +import type { Hex } from 'viem' + +export function StepRegister(props: StepRegister.Props): React.JSX.Element { + const { salt, masterId, minedForAddress, onRegistered } = props + const { address } = useAccount() + + const addressMismatch = + address?.toLowerCase() !== minedForAddress.toLowerCase() + + const { + writeContract, + data: txHash, + isPending: isWriting, + error: writeError, + } = useWriteContract() + + const { isLoading: isConfirming, isSuccess: isConfirmed } = + useWaitForTransactionReceipt({ + hash: txHash, + }) + + React.useEffect(() => { + if (isConfirmed && txHash) { + onRegistered(txHash) + } + }, [isConfirmed, txHash, onRegistered]) + + function handleRegister() { + writeContract({ + address: VIRTUAL_REGISTRY_ADDRESS, + abi: virtualRegistryAbi, + functionName: 'registerVirtualMaster', + args: [salt as Hex], + }) + } + + const isPending = isWriting || isConfirming + const isDisabled = isPending || !address || addressMismatch + + return ( +
    +
    +

    Register Master

    +

    + Register your address as a virtual-address master on the TIP-1022 + registry precompile. +

    +
    + +
    +
    +
    Salt
    +
    + {salt} +
    +
    +
    +
    Master ID
    +
    {masterId}
    +
    +
    + + {addressMismatch && ( +
    + Salt was mined for{' '} + {minedForAddress} but your + connected wallet is{' '} + {address}. Switch back to + register. +
    + )} + + {writeError && ( +
    + {writeError.message.slice(0, 200)} +
    + )} + + {isConfirmed && txHash && ( +
    +
    + Master registered on-chain +
    +
    +
    Transaction
    + + {txHash} ↗ + +
    +
    + )} + + {!isConfirmed && ( + + )} +
    + ) +} + +export declare namespace StepRegister { + type Props = { + salt: string + masterId: string + minedForAddress: string + onRegistered: (txHash: string) => void + } +} diff --git a/apps/virtual-addresses/src/comps/step-transfer.tsx b/apps/virtual-addresses/src/comps/step-transfer.tsx new file mode 100644 index 000000000..8b87ddb98 --- /dev/null +++ b/apps/virtual-addresses/src/comps/step-transfer.tsx @@ -0,0 +1,224 @@ +import * as React from 'react' +import { useState } from 'react' +import { + useAccount, + useWriteContract, + useWaitForTransactionReceipt, + useReadContracts, +} from 'wagmi' +import { parseUnits, formatUnits, type Address } from 'viem' +import { cx } from '#lib/css' +import { PATH_USD_ADDRESS, tip20Abi } from '#lib/abi' +import { AddressAnatomy } from './address-anatomy' + +export function StepTransfer(props: StepTransfer.Props): React.JSX.Element { + const { virtualAddress, masterAddress } = props + const { address: sender } = useAccount() + const [amount, setAmount] = useState('1') + + const { data: balances, refetch: refetchBalances } = useReadContracts({ + contracts: [ + { + address: PATH_USD_ADDRESS, + abi: tip20Abi, + functionName: 'balanceOf', + args: [masterAddress as Address], + }, + { + address: PATH_USD_ADDRESS, + abi: tip20Abi, + functionName: 'balanceOf', + args: [virtualAddress], + }, + { + address: PATH_USD_ADDRESS, + abi: tip20Abi, + functionName: 'balanceOf', + args: [ + (sender ?? '0x0000000000000000000000000000000000000000') as Address, + ], + }, + ], + }) + + const masterBalance = balances?.[0]?.result as bigint | undefined + const virtualBalance = balances?.[1]?.result as bigint | undefined + const senderBalance = balances?.[2]?.result as bigint | undefined + + const { + writeContract, + data: txHash, + isPending: isWriting, + error: writeError, + reset: resetWrite, + } = useWriteContract() + + const { isLoading: isConfirming, isSuccess: isConfirmed } = + useWaitForTransactionReceipt({ hash: txHash }) + + React.useEffect(() => { + if (isConfirmed) { + refetchBalances() + } + }, [isConfirmed, refetchBalances]) + + const parsedAmount = React.useMemo(() => { + try { + const val = parseUnits(amount, 18) + return val > 0n ? val : null + } catch { + return null + } + }, [amount]) + + function handleTransfer() { + if (!sender || !parsedAmount) return + resetWrite() + writeContract({ + address: PATH_USD_ADDRESS, + abi: tip20Abi, + functionName: 'transfer', + args: [virtualAddress, parsedAmount], + }) + } + + function fmt(val: bigint | undefined): string { + if (val === undefined) return '—' + return formatUnits(val, 18) + } + + const isPending = isWriting || isConfirming + const isDisabled = isPending || !sender || !parsedAmount + + return ( +
    +
    +

    Demo Transfer

    +

    + Send PathUSD to the virtual address. The protocol auto-forwards to the + master — no sweep transaction needed. +

    +
    + +
    +
    +
    Sending to Virtual Address
    + +
    + +
    +
    +
    Sender Balance
    +
    {fmt(senderBalance)}
    +
    PathUSD
    +
    +
    +
    Virtual Balance
    +
    + {fmt(virtualBalance)} +
    +
    PathUSD
    +
    +
    +
    Master Balance
    +
    + {fmt(masterBalance)} +
    +
    PathUSD
    +
    +
    +
    + +
    +
    Amount (PathUSD)
    + setAmount(e.target.value)} + className="w-full bg-surface-2 border border-border rounded-lg px-4 py-2.5 font-mono text-sm text-text-primary focus:outline-none focus:border-accent transition-colors" + placeholder="1.0" + /> +
    + + {writeError && ( +
    + {writeError.message.slice(0, 200)} +
    + )} + + {isConfirmed && txHash && ( +
    +
    + Transfer complete — tokens forwarded to master +
    + + {txHash} ↗ + +
    + Notice: virtual address balance is{' '} + + {fmt(virtualBalance)} + {' '} + while master balance is{' '} + + {fmt(masterBalance)} + +
    +
    + )} + + + + {!isConfirmed && ( +
    +

    + What happens: The + TIP-20 precompile detects the virtual address format, resolves the + masterId to your registered master, and credits the master directly. + Two Transfer events are emitted: +

    +
      +
    1. + + Transfer(sender → virtual, amount) + +
    2. +
    3. + + Transfer(virtual → master, amount) + +
    4. +
    +
    + )} +
    + ) +} + +export declare namespace StepTransfer { + type Props = { + virtualAddress: Address + masterAddress: string + } +} diff --git a/apps/virtual-addresses/src/comps/walkthrough/event-log.tsx b/apps/virtual-addresses/src/comps/walkthrough/event-log.tsx new file mode 100644 index 000000000..b229c8a12 --- /dev/null +++ b/apps/virtual-addresses/src/comps/walkthrough/event-log.tsx @@ -0,0 +1,95 @@ +import type * as React from 'react' +import { motion, AnimatePresence } from 'framer-motion' + +export function EventLog(props: EventLog.Props): React.JSX.Element { + const { entries } = props + + return ( +
    + + {entries.map((entry, i) => ( + +
    + + {entry.type} + + {entry.txHash && ( + + {entry.txHash.slice(0, 10)}… + + )} +
    +
    + {entry.message} +
    +
    + ))} +
    +
    + ) +} + +function typeColor(type: string): string { + switch (type) { + case 'register': + return 'var(--color-accent)' + case 'transfer': + return 'var(--color-positive)' + case 'balance': + return 'var(--color-warning)' + default: + return 'var(--color-text-tertiary)' + } +} + +export declare namespace EventLog { + type Entry = { + id?: string + type: 'register' | 'transfer' | 'balance' + message: string + txHash?: string + } + type Props = { + entries: Entry[] + } +} diff --git a/apps/virtual-addresses/src/comps/walkthrough/exchange-panel.tsx b/apps/virtual-addresses/src/comps/walkthrough/exchange-panel.tsx new file mode 100644 index 000000000..c39dd69f1 --- /dev/null +++ b/apps/virtual-addresses/src/comps/walkthrough/exchange-panel.tsx @@ -0,0 +1,301 @@ +import * as React from 'react' +import { motion, AnimatePresence } from 'framer-motion' + +import { useWalkthroughStore } from '#store/walkthrough-store' +import { StatusBadge } from './status-badge' +import { EventLog } from './event-log' +import type { EventLog as EventLogNs } from './event-log' + +const activeSteps = new Set([ + 'register-start', + 'register-mining', + 'register-tx', + 'register-confirmed', + 'balances-final', +]) + +function formatNumber(n: number): string { + if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B` + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K` + return n.toString() +} + +function stepMessage(step: string, txPending: boolean): string { + switch (step) { + case 'register-start': + return 'Preparing registration…' + case 'register-mining': + return 'Mining valid salt (32-bit PoW)…' + case 'register-tx': + return txPending ? 'Registering on-chain…' : 'Awaiting confirmation…' + case 'register-confirmed': + return 'Master registered ✓' + case 'balances-final': + return 'Balance updated' + default: + return 'Idle' + } +} + +export function ExchangePanel(): React.JSX.Element { + const step = useWalkthroughStore((s) => s.step) + const demoState = useWalkthroughStore((s) => s.demoState) + const txPending = useWalkthroughStore((s) => s.txPending) + const data = useWalkthroughStore((s) => s.data) + + const isActive = activeSteps.has(step) + + const logEntries = React.useMemo(() => { + const entries: EventLogNs.Entry[] = [] + if (data.registerTxHash) { + entries.push({ + id: 'reg', + type: 'register', + message: `registerVirtualMaster(${data.salt})`, + txHash: data.registerTxHash, + }) + } + if (data.masterId) { + entries.push({ + id: 'mid', + type: 'register', + message: `MasterRegistered → ${data.masterId}`, + }) + } + if (step === 'balances-final' || demoState === 'complete') { + entries.push({ + id: 'bal', + type: 'balance', + message: `PathUSD balance: ${data.exchangeBalance}`, + }) + } + return entries + }, [ + data.registerTxHash, + data.salt, + data.masterId, + data.exchangeBalance, + step, + demoState, + ]) + + return ( + + {/* Header */} +
    + Exchange + +
    + +
    + {/* Address */} +
    +
    + Master Address +
    + {data.exchangeAddress ? ( +
    + {data.exchangeAddress} +
    + ) : ( +
    + )} +
    + + {/* Registration section */} +
    +
    + Registration +
    + +
    + {/* Salt */} +
    +
    + salt +
    + + {data.salt ? ( + + {data.salt} + + ) : ( +
    + )} +
    +
    + + {/* Master ID */} +
    +
    + masterId +
    + + {data.masterId ? ( + + {data.masterId} + + ) : ( +
    + )} +
    +
    + + {/* Mining progress */} + + {step === 'register-mining' && data.miningProgress && ( + +
    + + Mining with {data.miningProgress.workerCount} workers… +
    +
    + + {formatNumber(data.miningProgress.totalAttempts)} hashes + + + {formatNumber(data.miningProgress.hashesPerSecond)}/s + +
    +
    + )} +
    + + {/* Tx status */} + + {txPending && step === 'register-tx' && ( + + + Registering on-chain… + + )} + +
    +
    + + {/* Balance section */} +
    +
    + PathUSD Balance +
    + + + {data.exchangeBalance} + + +
    + + {/* Status */} +
    + {stepMessage(step, txPending)} +
    + + {/* Event log */} + {logEntries.length > 0 && } +
    +
    + ) +} diff --git a/apps/virtual-addresses/src/comps/walkthrough/guide-overlay.tsx b/apps/virtual-addresses/src/comps/walkthrough/guide-overlay.tsx new file mode 100644 index 000000000..b853ff6bf --- /dev/null +++ b/apps/virtual-addresses/src/comps/walkthrough/guide-overlay.tsx @@ -0,0 +1,440 @@ +import * as React from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { useWalkthroughStore } from '#store/walkthrough-store' + +const STORAGE_KEY = 'virtual-addresses-guide-seen' + +type GuideStep = { + target: string + additionalTargets?: string[] + title: string + body: string + tooltip: 'right' | 'left' | 'below' +} + +const INTRO_STEPS: GuideStep[] = [ + { + target: '[data-guide="exchange"]', + title: 'Exchange — Master Registration', + body: 'The exchange registers as a virtual-address master on-chain. It mines a salt, calls the registry precompile, and receives a 4-byte masterId.', + tooltip: 'right', + }, + { + target: '[data-guide="protocol"]', + title: 'TIP-1022 Protocol Layer', + body: 'The Virtual Registry maps masterId → master address. The TIP-20 precompile detects virtual addresses and auto-forwards tokens to the registered master.', + tooltip: 'right', + }, + { + target: '[data-guide="sender"]', + title: 'Sender', + body: "A sender sends PathUSD to a virtual address. They don't need to know it's virtual — it looks like any other address.", + tooltip: 'left', + }, + { + target: '[data-guide="start-demo"]', + title: 'Ready to Go', + body: 'Click Start Demo to run the full flow with real on-chain transactions on Tempo Moderato testnet.', + tooltip: 'below', + }, +] + +const POST_DEMO_STEPS: GuideStep[] = [ + { + target: '[data-guide-section="registration"]', + title: '1. Register Virtual Master', + body: 'The exchange calls registerVirtualMaster(salt) on the registry precompile. The salt is a 32-byte value whose keccak256 hash (with the address) has 4 leading zero bytes.', + tooltip: 'right', + }, + { + target: '[data-guide-section="registration"]', + title: '2. Receive masterId', + body: 'The registry returns a 4-byte masterId derived from bytes 4-8 of the hash. This ID uniquely identifies the exchange as a virtual-address master.', + tooltip: 'right', + }, + { + target: '[data-guide-section="virtual-address"]', + title: '3. Derive Virtual Address', + body: 'Virtual addresses are derived offline: [masterId][FDFDFDFDFDFDFDFDFDFD magic][userTag]. No on-chain transaction needed — generate unlimited deposit addresses instantly.', + tooltip: 'left', + }, + { + target: '[data-guide-section="transfer"]', + title: '4. Send to Virtual Address', + body: "The sender calls transfer(virtualAddress, amount) on the TIP-20 token — a standard ERC-20 transfer. The sender doesn't need to know the address is virtual.", + tooltip: 'left', + }, + { + target: '[data-guide-section="precompile"]', + title: '5. Magic Bytes Detected', + body: 'The TIP-20 precompile checks bytes 4-14 of the recipient. If they match the FDFD…FD magic pattern, it identifies the address as virtual.', + tooltip: 'right', + }, + { + target: '[data-guide-section="precompile"]', + title: '6. Resolve masterId', + body: 'The precompile extracts the 4-byte masterId from the virtual address and looks up the registered master address in the registry.', + tooltip: 'right', + }, + { + target: '[data-guide-section="precompile"]', + title: '7. Forward to Master', + body: 'Tokens are credited directly to the master address. The virtual address balance stays at 0 — no sweep transaction needed.', + tooltip: 'right', + }, + { + target: '[data-guide-section="balance"]', + title: '8. Two Transfer Events', + body: 'Two Transfer events are emitted: Transfer(sender → virtual) and Transfer(virtual → master). This preserves the audit trail while the master receives the funds.', + tooltip: 'right', + additionalTargets: ['[data-guide-section="precompile"]'], + }, +] + +type GuideMode = 'intro' | 'post-demo' | null + +type TargetRect = { + top: number + left: number + width: number + height: number +} + +export function GuideOverlay(): React.JSX.Element | null { + const demoState = useWalkthroughStore((s) => s.demoState) + const prevDemoState = React.useRef(demoState) + const [mode, setMode] = React.useState(null) + const [stepIndex, setStepIndex] = React.useState(0) + const [targetRect, setTargetRect] = React.useState(null) + const rafRef = React.useRef(0) + + // Show intro on first visit + React.useEffect(() => { + const seen = localStorage.getItem(STORAGE_KEY) + if (!seen) { + setMode('intro') + setStepIndex(0) + } + }, []) + + // Post-demo trigger when settlement completes + React.useEffect(() => { + if (prevDemoState.current !== 'complete' && demoState === 'complete') { + setTimeout(() => { + setMode('post-demo') + setStepIndex(0) + }, 1500) + } + prevDemoState.current = demoState + }, [demoState]) + + const steps = + mode === 'intro' ? INTRO_STEPS : mode === 'post-demo' ? POST_DEMO_STEPS : [] + const step = steps[stepIndex] ?? null + + // Measure target element + const measureTarget = React.useCallback(() => { + if (!step) { + setTargetRect(null) + return + } + const el = document.querySelector(step.target) + if (!el) { + setTargetRect(null) + return + } + + const rect = el.getBoundingClientRect() + let combined = { + top: rect.top, + left: rect.left, + right: rect.right, + bottom: rect.bottom, + } + + if (step.additionalTargets) { + for (const selector of step.additionalTargets) { + const el2 = document.querySelector(selector) + if (el2) { + const rect2 = el2.getBoundingClientRect() + combined = { + top: Math.min(combined.top, rect2.top), + left: Math.min(combined.left, rect2.left), + right: Math.max(combined.right, rect2.right), + bottom: Math.max(combined.bottom, rect2.bottom), + } + } + } + } + + setTargetRect({ + top: combined.top, + left: combined.left, + width: combined.right - combined.left, + height: combined.bottom - combined.top, + }) + }, [step]) + + React.useEffect(() => { + measureTarget() + + const handleResize = () => { + cancelAnimationFrame(rafRef.current) + rafRef.current = requestAnimationFrame(measureTarget) + } + + window.addEventListener('resize', handleResize) + window.addEventListener('scroll', handleResize, true) + const interval = setInterval(measureTarget, 500) + + return () => { + window.removeEventListener('resize', handleResize) + window.removeEventListener('scroll', handleResize, true) + clearInterval(interval) + cancelAnimationFrame(rafRef.current) + } + }, [measureTarget]) + + const advance = React.useCallback(() => { + if (stepIndex < steps.length - 1) { + setStepIndex(stepIndex + 1) + } else { + setMode(null) + setStepIndex(0) + if (mode === 'intro') { + localStorage.setItem(STORAGE_KEY, 'true') + } + } + }, [stepIndex, steps.length, mode]) + + const skip = React.useCallback(() => { + setMode(null) + setStepIndex(0) + if (mode === 'intro') { + localStorage.setItem(STORAGE_KEY, 'true') + } + }, [mode]) + + if (!mode || !step) return null + + const pad = 8 + const vh = window.innerHeight + const vw = window.innerWidth + + const spot = targetRect + ? { + top: targetRect.top - pad, + left: targetRect.left - pad, + width: targetRect.width + pad * 2, + height: targetRect.height + pad * 2, + } + : null + + const tooltipWidth = 320 + const tooltipHeight = 200 + const tooltipPos: React.CSSProperties = {} + if (spot) { + if (step.tooltip === 'right') { + tooltipPos.left = spot.left + spot.width + 16 + const centerY = spot.top + spot.height / 2 - tooltipHeight / 2 + tooltipPos.top = Math.max(16, Math.min(centerY, vh - tooltipHeight - 16)) + } else if (step.tooltip === 'left') { + tooltipPos.left = spot.left - tooltipWidth - 16 + const centerY = spot.top + spot.height / 2 - tooltipHeight / 2 + tooltipPos.top = Math.max(16, Math.min(centerY, vh - tooltipHeight - 16)) + } else { + const spaceBelow = vh - (spot.top + spot.height) + if (spaceBelow > tooltipHeight) { + tooltipPos.top = spot.top + spot.height + 12 + } else { + tooltipPos.top = spot.top - tooltipHeight - 12 + } + tooltipPos.left = Math.max( + 16, + Math.min(spot.left, vw - tooltipWidth - 16), + ) + } + } else { + tooltipPos.top = '50%' + tooltipPos.left = '50%' + tooltipPos.transform = 'translate(-50%, -50%)' + } + + const isLast = stepIndex === steps.length - 1 + const isPostDemo = mode === 'post-demo' + + return ( + + + {/* Click-catcher — catches all clicks, advances step */} + + +
    + + + + ) +} diff --git a/apps/virtual-addresses/src/comps/walkthrough/protocol-panel.tsx b/apps/virtual-addresses/src/comps/walkthrough/protocol-panel.tsx new file mode 100644 index 000000000..566d0afe0 --- /dev/null +++ b/apps/virtual-addresses/src/comps/walkthrough/protocol-panel.tsx @@ -0,0 +1,457 @@ +import type * as React from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { useWalkthroughStore } from '#store/walkthrough-store' +import { AddressAnatomy } from '#comps/address-anatomy' +import { formatAddress } from '#lib/virtual-address' +import { StatusBadge } from './status-badge' + +const registrySteps = new Set([ + 'register-start', + 'register-mining', + 'register-tx', + 'register-confirmed', +]) +const precompileSteps = new Set([ + 'send-tx', + 'resolve-detect', + 'resolve-lookup', + 'resolve-forward', + 'transfer-events', +]) + +function FlowDot(props: { + active: boolean + reverse?: boolean +}): React.JSX.Element { + const { active, reverse } = props + return ( +
    + {active && ( + + )} +
    + ) +} + +function registryMessage(step: string): string { + switch (step) { + case 'register-start': + return 'Preparing registration…' + case 'register-mining': + return 'Mining salt — 32-bit PoW in progress…' + case 'register-tx': + return 'registerVirtualMaster(salt) — tx pending…' + case 'register-confirmed': + return 'MasterRegistered event emitted ✓' + default: + return '' + } +} + +function precompileMessage(step: string): string { + switch (step) { + case 'send-tx': + return 'transfer() received for virtual address' + case 'resolve-detect': + return 'Magic bytes detected — virtual address!' + case 'resolve-lookup': + return 'Looking up masterId → master address' + case 'resolve-forward': + return 'Forwarding tokens to master' + case 'transfer-events': + return 'Two Transfer events emitted ✓' + default: + return '' + } +} + +export function ProtocolPanel(): React.JSX.Element { + const step = useWalkthroughStore((s) => s.step) + const demoState = useWalkthroughStore((s) => s.demoState) + const data = useWalkthroughStore((s) => s.data) + + const registryActive = registrySteps.has(step) + const precompileActive = precompileSteps.has(step) + + return ( +
    + {/* Header */} +
    + TIP-1022 Protocol + +
    + +
    + {/* Virtual Registry box */} + +
    + + + Virtual Registry + +
    + + {/* Flow line */} +
    + + Exchange + + + + ⇄ + + + + Registry + +
    + + {/* Active message */} + + {registryActive && ( + + {registryMessage(step)} + + )} + + + {/* Completed items */} + {!registryActive && data.masterId && ( +
    +
    + ✓ Registered +
    +
    + {data.masterId} + {' → '} + {data.exchangeAddress ? ( + + {formatAddress(data.exchangeAddress)} + + ) : ( + '…' + )} +
    +
    + )} +
    + + {/* TIP-20 Precompile box */} + +
    + + + TIP-20 Precompile + +
    + + {/* Flow line: Sender → Virtual → Master */} +
    + + Sender + + + + Virtual + + + + Master + +
    + + {/* Active message */} + + {precompileActive && ( + + {precompileMessage(step)} + + )} + + + {/* Virtual address anatomy */} + + {data.virtualAddress && + (step === 'resolve-detect' || + step === 'resolve-lookup' || + step === 'resolve-forward' || + step === 'transfer-events' || + step === 'balances-final' || + demoState === 'complete') && ( + +
    + Resolving +
    + +
    + )} +
    + + {/* Transfer events */} + + {(step === 'transfer-events' || + step === 'balances-final' || + demoState === 'complete') && + data.transferEvents.map((evt, i) => ( + + + Transfer + +
    + + {formatAddress(evt.from as `0x${string}`)} + + {' → '} + + {formatAddress(evt.to as `0x${string}`)} + + + {' '} + {evt.amount} PathUSD + +
    +
    + ))} +
    +
    +
    +
    + ) +} diff --git a/apps/virtual-addresses/src/comps/walkthrough/sender-panel.tsx b/apps/virtual-addresses/src/comps/walkthrough/sender-panel.tsx new file mode 100644 index 000000000..de50cb126 --- /dev/null +++ b/apps/virtual-addresses/src/comps/walkthrough/sender-panel.tsx @@ -0,0 +1,222 @@ +import * as React from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { useWalkthroughStore } from '#store/walkthrough-store' +import { AddressAnatomy } from '#comps/address-anatomy' +import { StatusBadge } from './status-badge' +import { EventLog } from './event-log' +import type { EventLog as EventLogNs } from './event-log' + +const activeSteps = new Set([ + 'send-start', + 'send-tx', + 'derive-virtual', + 'derive-anatomy', +]) + +function stepMessage(step: string, txPending: boolean): string { + switch (step) { + case 'derive-virtual': + return 'Deriving virtual address…' + case 'derive-anatomy': + return 'Address structure breakdown' + case 'send-start': + return 'Preparing transfer…' + case 'send-tx': + return txPending ? 'Transaction pending…' : 'Sending PathUSD…' + default: + return 'Idle' + } +} + +export function SenderPanel(): React.JSX.Element { + const step = useWalkthroughStore((s) => s.step) + const demoState = useWalkthroughStore((s) => s.demoState) + const txPending = useWalkthroughStore((s) => s.txPending) + const data = useWalkthroughStore((s) => s.data) + + const isActive = activeSteps.has(step) + + const logEntries = React.useMemo(() => { + const entries: EventLogNs.Entry[] = [] + if ( + data.virtualAddress && + (step === 'derive-anatomy' || + step === 'send-start' || + step === 'send-tx' || + demoState === 'sending' || + demoState === 'resolving' || + demoState === 'complete') + ) { + entries.push({ + id: 'derive', + type: 'register', + message: `Virtual: ${data.virtualAddress.slice(0, 14)}…`, + }) + } + if (data.transferTxHash) { + entries.push({ + id: 'tx', + type: 'transfer', + message: `Sent 100 PathUSD to virtual`, + txHash: data.transferTxHash, + }) + } + return entries + }, [data.virtualAddress, data.transferTxHash, step, demoState]) + + return ( + + {/* Header */} +
    + Sender + +
    + +
    + {/* Address */} +
    +
    + Sender Address +
    + {data.senderAddress ? ( +
    + {data.senderAddress} +
    + ) : ( +
    + )} +
    + + {/* Virtual address anatomy */} + + {data.virtualAddress && + (step === 'derive-anatomy' || + step === 'send-start' || + step === 'send-tx' || + demoState === 'sending' || + demoState === 'resolving' || + demoState === 'complete') && ( + +
    + Virtual Address +
    + +
    + )} +
    + + {/* Transfer section */} +
    +
    + Transfer +
    + +
    +
    +
    + PathUSD +
    +
    + {data.senderBalance} +
    +
    + + + {txPending && step === 'send-tx' && ( + + + Sending to virtual address… + + )} + + + {data.transferTxHash && ( +
    + ✓ Transfer confirmed +
    + )} +
    +
    + + {/* Status */} +
    + {stepMessage(step, txPending)} +
    + + {/* Event log */} + {logEntries.length > 0 && } +
    +
    + ) +} diff --git a/apps/virtual-addresses/src/comps/walkthrough/status-badge.tsx b/apps/virtual-addresses/src/comps/walkthrough/status-badge.tsx new file mode 100644 index 000000000..943d0770b --- /dev/null +++ b/apps/virtual-addresses/src/comps/walkthrough/status-badge.tsx @@ -0,0 +1,45 @@ +import type * as React from 'react' + +const stateColors: Record = { + idle: 'var(--color-text-tertiary)', + registering: 'var(--color-accent)', + deriving: 'var(--color-virtual-magic)', + sending: 'var(--color-accent)', + resolving: 'var(--color-positive)', + complete: 'var(--color-positive)', +} + +export function StatusBadge(props: StatusBadge.Props): React.JSX.Element { + const { state } = props + const color = stateColors[state] ?? 'var(--color-text-tertiary)' + + return ( + + + {state} + + ) +} + +export declare namespace StatusBadge { + type Props = { state: string } +} diff --git a/apps/virtual-addresses/src/comps/walkthrough/walkthrough-demo.tsx b/apps/virtual-addresses/src/comps/walkthrough/walkthrough-demo.tsx new file mode 100644 index 000000000..a6ad4fce5 --- /dev/null +++ b/apps/virtual-addresses/src/comps/walkthrough/walkthrough-demo.tsx @@ -0,0 +1,130 @@ +import type * as React from 'react' +import { cx } from '#lib/css' +import { useWalkthroughStore } from '#store/walkthrough-store' +import { ExchangePanel } from './exchange-panel' +import { ProtocolPanel } from './protocol-panel' +import { SenderPanel } from './sender-panel' +import { GuideOverlay } from './guide-overlay' + +const SPEEDS = [0.1, 0.5, 1, 2] as const + +export function WalkthroughDemo(): React.JSX.Element { + const speed = useWalkthroughStore((s) => s.speed) + const demoState = useWalkthroughStore((s) => s.demoState) + const error = useWalkthroughStore((s) => s.error) + const setSpeed = useWalkthroughStore((s) => s.setSpeed) + const startDemo = useWalkthroughStore((s) => s.startDemo) + const reset = useWalkthroughStore((s) => s.reset) + + const isRunning = demoState !== 'idle' && demoState !== 'complete' + + return ( +
    + {/* Control bar */} +
    + {/* Speed selector */} +
    + + Speed + + {SPEEDS.map((s) => ( + + ))} +
    + + {/* Actions */} +
    + + +
    +
    + + {/* Error banner */} + {error && ( +
    + {error} + +
    + )} + + {/* 3-column grid */} +
    + + + +
    + + +
    + ) +} diff --git a/apps/virtual-addresses/src/lib/abi.ts b/apps/virtual-addresses/src/lib/abi.ts new file mode 100644 index 000000000..d1e394e86 --- /dev/null +++ b/apps/virtual-addresses/src/lib/abi.ts @@ -0,0 +1,98 @@ +export const VIRTUAL_REGISTRY_ADDRESS = + '0xfDC0000000000000000000000000000000000000' as const + +export const PATH_USD_ADDRESS = + '0x20C0000000000000000000000000000000000000' as const + +export const virtualRegistryAbi = [ + { + type: 'function', + name: 'registerVirtualMaster', + stateMutability: 'nonpayable', + inputs: [{ name: 'salt', type: 'bytes32' }], + outputs: [{ name: 'masterId', type: 'bytes4' }], + }, + { + type: 'function', + name: 'getMaster', + stateMutability: 'view', + inputs: [{ name: 'masterId', type: 'bytes4' }], + outputs: [{ name: '', type: 'address' }], + }, + { + type: 'function', + name: 'resolveVirtualAddress', + stateMutability: 'view', + inputs: [{ name: 'virtualAddr', type: 'address' }], + outputs: [{ name: 'master', type: 'address' }], + }, + { + type: 'function', + name: 'isVirtualAddress', + stateMutability: 'pure', + inputs: [{ name: 'addr', type: 'address' }], + outputs: [{ name: '', type: 'bool' }], + }, + { + type: 'function', + name: 'decodeVirtualAddress', + stateMutability: 'pure', + inputs: [{ name: 'addr', type: 'address' }], + outputs: [ + { name: 'isVirtual', type: 'bool' }, + { name: 'masterId', type: 'bytes4' }, + { name: 'userTag', type: 'bytes6' }, + ], + }, + { + type: 'event', + name: 'MasterRegistered', + inputs: [ + { indexed: true, name: 'masterId', type: 'bytes4' }, + { indexed: true, name: 'masterAddress', type: 'address' }, + ], + }, +] as const + +export const tip20Abi = [ + { + type: 'function', + name: 'transfer', + stateMutability: 'nonpayable', + inputs: [ + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool' }], + }, + { + type: 'function', + name: 'balanceOf', + stateMutability: 'view', + inputs: [{ name: 'account', type: 'address' }], + outputs: [{ name: '', type: 'uint256' }], + }, + { + type: 'function', + name: 'decimals', + stateMutability: 'view', + inputs: [], + outputs: [{ name: '', type: 'uint8' }], + }, + { + type: 'function', + name: 'symbol', + stateMutability: 'view', + inputs: [], + outputs: [{ name: '', type: 'string' }], + }, + { + type: 'event', + name: 'Transfer', + inputs: [ + { indexed: true, name: 'from', type: 'address' }, + { indexed: true, name: 'to', type: 'address' }, + { indexed: false, name: 'amount', type: 'uint256' }, + ], + }, +] as const diff --git a/apps/virtual-addresses/src/lib/css.ts b/apps/virtual-addresses/src/lib/css.ts new file mode 100644 index 000000000..376467cd5 --- /dev/null +++ b/apps/virtual-addresses/src/lib/css.ts @@ -0,0 +1,5 @@ +export function cx( + ...classes: Array +): string { + return classes.filter(Boolean).join(' ') +} diff --git a/apps/virtual-addresses/src/lib/demo-client.ts b/apps/virtual-addresses/src/lib/demo-client.ts new file mode 100644 index 000000000..7d99c1ef6 --- /dev/null +++ b/apps/virtual-addresses/src/lib/demo-client.ts @@ -0,0 +1,54 @@ +import type { Address, Hex } from 'viem' + +export async function demoRegister(salt: Hex) { + const res = await fetch('/api/demo/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ salt }), + }) + if (!res.ok) throw new Error('Register failed') + return res.json() as Promise<{ + txHash: string | null + blockNumber: number | null + masterId: string + exchangeAddress: string + alreadyRegistered: boolean + }> +} + +export async function demoTransfer(virtualAddress: Address, amount: string) { + const res = await fetch('/api/demo/transfer', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ virtualAddress, amount }), + }) + if (!res.ok) throw new Error('Transfer failed') + return res.json() as Promise<{ + txHash: string + blockNumber: number + events: { from: Address; to: Address; amount: string }[] + }> +} + +export async function demoBalance(virtualAddress?: Address) { + const params = virtualAddress ? `?virtualAddress=${virtualAddress}` : '' + const res = await fetch(`/api/demo/balance${params}`) + if (!res.ok) throw new Error('Balance fetch failed') + return res.json() as Promise<{ + exchange: string + sender: string + virtual: string + exchangeAddress: string | null + senderAddress: string | null + }> +} + +export async function demoFund(address?: Address) { + const res = await fetch('/api/fund', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address }), + }) + if (!res.ok) throw new Error('Fund failed') + return res.json() as Promise<{ funded: string[] }> +} diff --git a/apps/virtual-addresses/src/lib/miner.pool.ts b/apps/virtual-addresses/src/lib/miner.pool.ts new file mode 100644 index 000000000..e0d8e6398 --- /dev/null +++ b/apps/virtual-addresses/src/lib/miner.pool.ts @@ -0,0 +1,147 @@ +import type { FromWorker, ToWorker } from './miner.protocol' + +export type MinerState = + | { status: 'idle' } + | { + status: 'mining' + totalAttempts: number + hashesPerSecond: number + workerCount: number + } + | { + status: 'found' + salt: string + masterId: string + hash: string + attempts: number + minedForAddress: string + } + | { status: 'error'; message: string } + +export type MinerPoolOptions = { + masterAddress: string + workerCount?: number + onStateChange: (state: MinerState) => void +} + +export function createMinerPool(options: MinerPoolOptions) { + const { masterAddress, onStateChange } = options + const workerCount = + options.workerCount ?? + Math.max(1, Math.min(8, (navigator.hardwareConcurrency ?? 4) - 1)) + + const workers: Worker[] = [] + const workerAttempts = new Map() + const workerHps = new Map() + let stopped = false + + // Random 24-byte seed shared across workers + const seedBytes = new Uint8Array(24) + crypto.getRandomValues(seedBytes) + const seedHex = `0x${Array.from(seedBytes, (b) => b.toString(16).padStart(2, '0')).join('')}` + + const batchSize = 100_000 + + function aggregateProgress() { + let total = 0 + let hps = 0 + for (const a of workerAttempts.values()) total += a + for (const h of workerHps.values()) hps += h + return { total, hps } + } + + function start() { + stopped = false + + onStateChange({ + status: 'mining', + totalAttempts: 0, + hashesPerSecond: 0, + workerCount, + }) + + for (let i = 0; i < workerCount; i++) { + const worker = new Worker(new URL('./miner.worker.ts', import.meta.url), { + type: 'module', + }) + + worker.onmessage = (e: MessageEvent) => { + const msg = e.data + if (stopped && msg.type !== 'stopped') return + + switch (msg.type) { + case 'ready': { + break + } + case 'progress': { + workerAttempts.set(msg.workerId, msg.attempts) + workerHps.set(msg.workerId, msg.hashesPerSecond) + const { total, hps } = aggregateProgress() + onStateChange({ + status: 'mining', + totalAttempts: total, + hashesPerSecond: hps, + workerCount, + }) + break + } + case 'found': { + stopped = true + workerAttempts.set(msg.workerId, msg.attempts) + const { total } = aggregateProgress() + onStateChange({ + status: 'found', + salt: msg.saltHex, + masterId: msg.masterIdHex, + hash: msg.hashHex, + attempts: total, + minedForAddress: masterAddress, + }) + // Stop all other workers + for (const w of workers) { + w.postMessage({ type: 'stop' } satisfies ToWorker) + } + setTimeout(() => { + for (const w of workers) w.terminate() + }, 100) + break + } + case 'error': { + onStateChange({ status: 'error', message: msg.message }) + break + } + } + } + + worker.onerror = (err) => { + if (stopped) return + onStateChange({ status: 'error', message: err.message }) + } + + const startMsg: ToWorker = { + type: 'start', + workerId: i, + masterAddress, + seedHex, + startCounter: i, + stride: workerCount, + batchSize, + } + worker.postMessage(startMsg) + workers.push(worker) + } + } + + function stop() { + stopped = true + for (const w of workers) { + w.postMessage({ type: 'stop' } satisfies ToWorker) + } + setTimeout(() => { + for (const w of workers) w.terminate() + }, 100) + onStateChange({ status: 'idle' }) + } + + return { start, stop, workerCount } +} diff --git a/apps/virtual-addresses/src/lib/miner.protocol.ts b/apps/virtual-addresses/src/lib/miner.protocol.ts new file mode 100644 index 000000000..ac5e48710 --- /dev/null +++ b/apps/virtual-addresses/src/lib/miner.protocol.ts @@ -0,0 +1,30 @@ +export type ToWorker = + | { + type: 'start' + workerId: number + masterAddress: string + seedHex: string + startCounter: number + stride: number + batchSize: number + } + | { type: 'stop' } + +export type FromWorker = + | { type: 'ready'; workerId: number } + | { + type: 'progress' + workerId: number + attempts: number + hashesPerSecond: number + } + | { + type: 'found' + workerId: number + attempts: number + saltHex: string + masterIdHex: string + hashHex: string + } + | { type: 'stopped'; workerId: number; attempts: number } + | { type: 'error'; workerId: number; message: string } diff --git a/apps/virtual-addresses/src/lib/miner.worker.ts b/apps/virtual-addresses/src/lib/miner.worker.ts new file mode 100644 index 000000000..6ea1d625d --- /dev/null +++ b/apps/virtual-addresses/src/lib/miner.worker.ts @@ -0,0 +1,125 @@ +import { createKeccak } from 'hash-wasm' +import type { ToWorker, FromWorker } from './miner.protocol' + +function post(msg: FromWorker) { + self.postMessage(msg) +} + +function hexToBytes(hex: string): Uint8Array { + const clean = hex.startsWith('0x') ? hex.slice(2) : hex + const bytes = new Uint8Array(clean.length / 2) + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16) + } + return bytes +} + +function bytesToHex(bytes: Uint8Array): string { + let hex = '0x' + for (const b of bytes) { + hex += b.toString(16).padStart(2, '0') + } + return hex +} + +let running = false + +self.onmessage = async (e: MessageEvent) => { + const msg = e.data + + if (msg.type === 'stop') { + running = false + return + } + + if (msg.type === 'start') { + running = true + const { + workerId, + masterAddress, + seedHex, + startCounter, + stride, + batchSize, + } = msg + + // Init WASM hasher once + const hasher = await createKeccak(256) + + const addrBytes = hexToBytes(masterAddress) + const seedBytes = hexToBytes(seedHex) + + // Input buffer: 20 bytes address + 32 bytes salt = 52 bytes + const input = new Uint8Array(52) + input.set(addrBytes, 0) + + // Salt = 24-byte seed + 8-byte counter + const salt = new Uint8Array(32) + salt.set(seedBytes.slice(0, 24), 0) + + let counter = startCounter + let totalAttempts = 0 + const startTime = performance.now() + + const mine = () => { + if (!running) { + post({ type: 'stopped', workerId, attempts: totalAttempts }) + return + } + + for (let i = 0; i < batchSize; i++) { + // Write counter into last 8 bytes of salt (big-endian) + const lo = counter & 0xffffffff + const hi = (counter / 0x100000000) >>> 0 + salt[24] = (hi >>> 24) & 0xff + salt[25] = (hi >>> 16) & 0xff + salt[26] = (hi >>> 8) & 0xff + salt[27] = hi & 0xff + salt[28] = (lo >>> 24) & 0xff + salt[29] = (lo >>> 16) & 0xff + salt[30] = (lo >>> 8) & 0xff + salt[31] = lo & 0xff + + input.set(salt, 20) + + hasher.init() + hasher.update(input) + const hash = hasher.digest('binary') + + // Check 32-bit PoW: first 4 bytes must be zero + if (hash[0] === 0 && hash[1] === 0 && hash[2] === 0 && hash[3] === 0) { + running = false + const masterIdHex = bytesToHex(hash.slice(4, 8)) + post({ + type: 'found', + workerId, + attempts: totalAttempts + i + 1, + saltHex: bytesToHex(salt), + masterIdHex, + hashHex: bytesToHex(hash), + }) + return + } + + counter += stride + } + + totalAttempts += batchSize + const elapsed = performance.now() - startTime + const hps = Math.round((totalAttempts / elapsed) * 1000) + + post({ + type: 'progress', + workerId, + attempts: totalAttempts, + hashesPerSecond: hps, + }) + + // Yield to event loop for stop messages + setTimeout(mine, 0) + } + + post({ type: 'ready', workerId }) + mine() + } +} diff --git a/apps/virtual-addresses/src/lib/use-miner.ts b/apps/virtual-addresses/src/lib/use-miner.ts new file mode 100644 index 000000000..8f6bb616f --- /dev/null +++ b/apps/virtual-addresses/src/lib/use-miner.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { createMinerPool, type MinerState } from './miner.pool' + +export function useMiner() { + const [state, setState] = useState({ status: 'idle' }) + const poolRef = useRef | null>(null) + + useEffect(() => { + return () => { + poolRef.current?.stop() + poolRef.current = null + } + }, []) + + const start = useCallback((masterAddress: string) => { + poolRef.current?.stop() + const pool = createMinerPool({ + masterAddress, + onStateChange: setState, + }) + poolRef.current = pool + pool.start() + }, []) + + const stop = useCallback(() => { + poolRef.current?.stop() + poolRef.current = null + }, []) + + const reset = useCallback(() => { + poolRef.current?.stop() + poolRef.current = null + setState({ status: 'idle' }) + }, []) + + return { state, start, stop, reset } +} diff --git a/apps/virtual-addresses/src/lib/virtual-address.ts b/apps/virtual-addresses/src/lib/virtual-address.ts new file mode 100644 index 000000000..f811a6096 --- /dev/null +++ b/apps/virtual-addresses/src/lib/virtual-address.ts @@ -0,0 +1,38 @@ +import type { Address, Hex } from 'viem' + +const VIRTUAL_MAGIC = 'fdfdfdfdfdfdfdfdfdfd' + +export function isVirtualAddress(addr: Address): boolean { + return addr.slice(10, 30).toLowerCase() === VIRTUAL_MAGIC +} + +export function buildVirtualAddress(masterId: Hex, userTag: Hex): Address { + const mid = masterId.slice(2) + const tag = userTag.slice(2) + if (mid.length !== 8) + throw new Error('masterId must be bytes4 (0x + 8 hex chars)') + if (tag.length !== 12) + throw new Error('userTag must be bytes6 (0x + 12 hex chars)') + return `0x${mid}${VIRTUAL_MAGIC}${tag}` as Address +} + +export function decodeVirtualAddress(addr: Address): { + masterId: Hex + userTag: Hex +} | null { + if (!isVirtualAddress(addr)) return null + return { + masterId: `0x${addr.slice(2, 10)}` as Hex, + userTag: `0x${addr.slice(30, 42)}` as Hex, + } +} + +export function randomUserTag(): Hex { + const bytes = new Uint8Array(6) + crypto.getRandomValues(bytes) + return `0x${Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')}` as Hex +} + +export function formatAddress(addr: Address): string { + return `${addr.slice(0, 6)}…${addr.slice(-4)}` +} diff --git a/apps/virtual-addresses/src/lib/wagmi.ts b/apps/virtual-addresses/src/lib/wagmi.ts new file mode 100644 index 000000000..0ddf67f26 --- /dev/null +++ b/apps/virtual-addresses/src/lib/wagmi.ts @@ -0,0 +1,17 @@ +import { http, createConfig } from 'wagmi' +import { tempoDevnet } from 'viem/chains' + +export const wagmiConfig = createConfig({ + multiInjectedProviderDiscovery: true, + chains: [tempoDevnet], + connectors: [], + transports: { + [tempoDevnet.id]: http(tempoDevnet.rpcUrls.default.http[0]), + }, +}) + +declare module 'wagmi' { + interface Register { + config: typeof wagmiConfig + } +} diff --git a/apps/virtual-addresses/src/lib/walkthrough-types.ts b/apps/virtual-addresses/src/lib/walkthrough-types.ts new file mode 100644 index 000000000..1eee90a80 --- /dev/null +++ b/apps/virtual-addresses/src/lib/walkthrough-types.ts @@ -0,0 +1,56 @@ +import type { Address, Hex } from 'viem' + +export type DemoState = + | 'idle' + | 'registering' + | 'deriving' + | 'sending' + | 'resolving' + | 'complete' + +export type DemoStep = + | 'idle' + | 'register-start' + | 'register-mining' + | 'register-tx' + | 'register-confirmed' + | 'derive-virtual' + | 'derive-anatomy' + | 'send-start' + | 'send-tx' + | 'resolve-detect' + | 'resolve-lookup' + | 'resolve-forward' + | 'transfer-events' + | 'balances-final' + | 'complete' + +export type TransferEvent = { + from: string + to: string + amount: string + label: string + txHash?: string +} + +export type MiningProgress = { + totalAttempts: number + hashesPerSecond: number + workerCount: number +} + +export type WalkthroughData = { + exchangeAddress: Address | null + senderAddress: Address | null + salt: Hex | null + masterId: Hex | null + miningProgress: MiningProgress | null + virtualAddress: Address | null + userTag: Hex | null + registerTxHash: string | null + transferTxHash: string | null + exchangeBalance: string + senderBalance: string + virtualBalance: string + transferEvents: TransferEvent[] +} diff --git a/apps/virtual-addresses/src/main.tsx b/apps/virtual-addresses/src/main.tsx new file mode 100644 index 000000000..3197db24b --- /dev/null +++ b/apps/virtual-addresses/src/main.tsx @@ -0,0 +1,22 @@ +import * as React from 'react' +import { createRoot } from 'react-dom/client' +import { WagmiProvider } from 'wagmi' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { App } from '#app' +import { wagmiConfig } from '#lib/wagmi' +import './styles.css' + +const queryClient = new QueryClient() + +const root = document.getElementById('root') +if (!root) throw new Error('missing #root') + +createRoot(root).render( + + + + + + + , +) diff --git a/apps/virtual-addresses/src/server.ts b/apps/virtual-addresses/src/server.ts new file mode 100644 index 000000000..e8ab2e8aa --- /dev/null +++ b/apps/virtual-addresses/src/server.ts @@ -0,0 +1,307 @@ +import { Hono } from 'hono' +import { + createPublicClient, + createWalletClient, + http, + formatUnits, + parseUnits, + keccak256, + encodePacked, + type Address, + type Hex, +} from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { tempoDevnet } from 'viem/chains' +import { Actions, tempoActions } from 'viem/tempo' +import { + virtualRegistryAbi, + tip20Abi, + VIRTUAL_REGISTRY_ADDRESS, + PATH_USD_ADDRESS, +} from '#lib/abi' + +const FUND_AMOUNT = parseUnits('10000', 18) + +type Env = { + Bindings: { + ASSETS: Fetcher + EXCHANGE_PRIVATE_KEY: string + SENDER_PRIVATE_KEY: string + EXPLORER_URL: string + RPC_URL: string + } +} + +const app = new Hono() + +function getRpcUrl(c: { env: Env['Bindings'] }): string { + return c.env.RPC_URL || tempoDevnet.rpcUrls.default.http[0] +} + +function requireKeys(c: { env: Env['Bindings'] }) { + if (!c.env.EXCHANGE_PRIVATE_KEY || !c.env.SENDER_PRIVATE_KEY) { + return { ok: false as const } + } + return { + ok: true as const, + exchange: privateKeyToAccount(c.env.EXCHANGE_PRIVATE_KEY as Hex), + sender: privateKeyToAccount(c.env.SENDER_PRIVATE_KEY as Hex), + } +} + +const MISSING_KEYS_MSG = + 'EXCHANGE_PRIVATE_KEY and SENDER_PRIVATE_KEY must be set. Add them to .dev.vars for local dev or as Worker secrets for production.' + +app.get('/api/health', (c) => c.json({ ok: true })) + +app.post('/api/demo/register', async (c) => { + const keys = requireKeys(c) + if (!keys.ok) return c.json({ error: MISSING_KEYS_MSG }, 500) + + const { salt } = (await c.req.json()) as { salt: Hex } + const rpcUrl = getRpcUrl(c) + + try { + const publicClient = createPublicClient({ + chain: tempoDevnet, + transport: http(rpcUrl), + }) + + const hash = keccak256( + encodePacked(['address', 'bytes32'], [keys.exchange.address, salt]), + ) + const masterId = `0x${hash.slice(10, 18)}` as Hex + + let alreadyRegistered = false + try { + const existingMaster = (await publicClient.readContract({ + address: VIRTUAL_REGISTRY_ADDRESS, + abi: virtualRegistryAbi, + functionName: 'getMaster', + args: [masterId], + })) as Address + const zeroAddr = '0x0000000000000000000000000000000000000000' + alreadyRegistered = existingMaster.toLowerCase() !== zeroAddr + } catch { + // getMaster returns empty for unregistered + } + + if (alreadyRegistered) { + return c.json({ + txHash: null, + blockNumber: null, + masterId, + exchangeAddress: keys.exchange.address, + alreadyRegistered: true, + }) + } + + const walletClient = createWalletClient({ + account: keys.exchange, + chain: tempoDevnet, + transport: http(rpcUrl), + }) + + const txHash = await walletClient.writeContract({ + address: VIRTUAL_REGISTRY_ADDRESS, + abi: virtualRegistryAbi, + functionName: 'registerVirtualMaster', + args: [salt], + }) + + const receipt = await publicClient.waitForTransactionReceipt({ + hash: txHash, + }) + + return c.json({ + txHash, + blockNumber: Number(receipt.blockNumber), + masterId, + exchangeAddress: keys.exchange.address, + alreadyRegistered: false, + }) + } catch { + return c.json({ error: 'Node unreachable' }, 503) + } +}) + +app.post('/api/demo/transfer', async (c) => { + const keys = requireKeys(c) + if (!keys.ok) return c.json({ error: MISSING_KEYS_MSG }, 500) + + const { virtualAddress, amount } = (await c.req.json()) as { + virtualAddress: Address + amount: string + } + const rpcUrl = getRpcUrl(c) + + try { + const walletClient = createWalletClient({ + account: keys.sender, + chain: tempoDevnet, + transport: http(rpcUrl), + }) + + const publicClient = createPublicClient({ + chain: tempoDevnet, + transport: http(rpcUrl), + }) + + const parsedAmount = parseUnits(amount, 18) + + const hash = await walletClient.writeContract({ + address: PATH_USD_ADDRESS, + abi: tip20Abi, + functionName: 'transfer', + args: [virtualAddress, parsedAmount], + }) + + const receipt = await publicClient.waitForTransactionReceipt({ hash }) + + const events = receipt.logs + .filter( + (log) => + log.address.toLowerCase() === PATH_USD_ADDRESS.toLowerCase() && + log.topics[0] === + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + ) + .map((log) => ({ + from: `0x${log.topics[1]?.slice(26) ?? ''}` as Address, + to: `0x${log.topics[2]?.slice(26) ?? ''}` as Address, + amount: formatUnits(BigInt(log.data), 18), + })) + + return c.json({ + txHash: hash, + blockNumber: Number(receipt.blockNumber), + events, + }) + } catch { + return c.json({ error: 'Node unreachable' }, 503) + } +}) + +app.get('/api/demo/balance', async (c) => { + const keys = requireKeys(c) + if (!keys.ok) { + return c.json({ + exchange: '0', + sender: '0', + virtual: '0', + exchangeAddress: null, + senderAddress: null, + }) + } + + const rpcUrl = getRpcUrl(c) + const virtualAddress = c.req.query('virtualAddress') as Address | undefined + + const publicClient = createPublicClient({ + chain: tempoDevnet, + transport: http(rpcUrl), + }) + + try { + const [exchangeBal, senderBal] = await Promise.all([ + publicClient.readContract({ + address: PATH_USD_ADDRESS, + abi: tip20Abi, + functionName: 'balanceOf', + args: [keys.exchange.address], + }), + publicClient.readContract({ + address: PATH_USD_ADDRESS, + abi: tip20Abi, + functionName: 'balanceOf', + args: [keys.sender.address], + }), + ]) + + let virtualBal = 0n + if (virtualAddress) { + virtualBal = (await publicClient.readContract({ + address: PATH_USD_ADDRESS, + abi: tip20Abi, + functionName: 'balanceOf', + args: [virtualAddress], + })) as bigint + } + + return c.json({ + exchange: formatUnits(exchangeBal as bigint, 18), + sender: formatUnits(senderBal as bigint, 18), + virtual: formatUnits(virtualBal, 18), + exchangeAddress: keys.exchange.address, + senderAddress: keys.sender.address, + }) + } catch { + return c.json({ + exchange: '0', + sender: '0', + virtual: '0', + exchangeAddress: keys.exchange.address, + senderAddress: keys.sender.address, + }) + } +}) + +app.post('/api/fund', async (c) => { + const keys = requireKeys(c) + if (!keys.ok) return c.json({ error: MISSING_KEYS_MSG }, 500) + + const { address } = (await c.req.json()) as { address?: Address } + const rpcUrl = getRpcUrl(c) + + const walletClient = createWalletClient({ + account: keys.exchange, + chain: tempoDevnet, + transport: http(rpcUrl), + }).extend(tempoActions()) + + const publicClient = createPublicClient({ + chain: tempoDevnet, + transport: http(rpcUrl), + }).extend(tempoActions()) + + const targets = [keys.exchange.address, keys.sender.address] + if (address) targets.push(address) + + try { + const funded: string[] = [] + + for (const target of targets) { + const bal = (await publicClient.readContract({ + address: PATH_USD_ADDRESS, + abi: tip20Abi, + functionName: 'balanceOf', + args: [target], + })) as bigint + + if (bal < FUND_AMOUNT / 2n) { + await publicClient + .request({ + method: 'tempo_fundAddress' as 'eth_chainId', + params: [target as `0x${string}`] as never, + }) + .catch(() => {}) + + await Actions.token.mint(walletClient, { + token: PATH_USD_ADDRESS, + to: target, + amount: FUND_AMOUNT, + }) + funded.push(target) + } + } + + return c.json({ funded }) + } catch { + return c.json({ funded: [], error: 'Node unreachable' }, 503) + } +}) + +app.all('*', async (c) => { + return c.env.ASSETS.fetch(c.req.raw) +}) + +export default app diff --git a/apps/virtual-addresses/src/store/walkthrough-store.ts b/apps/virtual-addresses/src/store/walkthrough-store.ts new file mode 100644 index 000000000..a33ea87bf --- /dev/null +++ b/apps/virtual-addresses/src/store/walkthrough-store.ts @@ -0,0 +1,255 @@ +import { create } from 'zustand' +import type { Hex } from 'viem' +import { buildVirtualAddress, randomUserTag } from '#lib/virtual-address' +import { + demoRegister, + demoTransfer, + demoBalance, + demoFund, +} from '#lib/demo-client' +import type { + DemoState, + DemoStep, + WalkthroughData, +} from '#lib/walkthrough-types' + +// Pre-mined salt for the demo exchange account +const DEMO_SALT: Hex = + '0x45864ef08bed66119277f37508c74bf955512a70eef5f96000000000bcb326b6' + +let stepTimer: ReturnType | null = null + +const initialData: WalkthroughData = { + exchangeAddress: null, + senderAddress: null, + salt: null, + masterId: null, + miningProgress: null, + virtualAddress: null, + userTag: null, + registerTxHash: null, + transferTxHash: null, + exchangeBalance: '0', + senderBalance: '0', + virtualBalance: '0', + transferEvents: [], +} + +type WalkthroughStore = { + step: DemoStep + demoState: DemoState + speed: number + txPending: boolean + error: string | null + data: WalkthroughData + startDemo: () => void + setSpeed: (speed: number) => void + reset: () => void +} + +function clearTimer() { + if (stepTimer !== null) { + clearTimeout(stepTimer) + stepTimer = null + } +} + +export const useWalkthroughStore = create((set, get) => { + function scheduleNext(delay: number) { + clearTimer() + const { speed } = get() + stepTimer = setTimeout(() => advanceStep(), delay / speed) + } + + async function fetchBalances() { + const { data } = get() + try { + const result = await demoBalance(data.virtualAddress ?? undefined) + set((s) => ({ + data: { + ...s.data, + exchangeBalance: result.exchange, + senderBalance: result.sender, + virtualBalance: result.virtual, + exchangeAddress: result.exchangeAddress ?? s.data.exchangeAddress, + senderAddress: result.senderAddress ?? s.data.senderAddress, + }, + })) + } catch { + // Node unreachable + } + } + + async function advanceStep() { + const { step } = get() + + switch (step) { + case 'idle': { + await demoFund().catch(() => {}) + await fetchBalances() + set({ step: 'register-start', demoState: 'registering' }) + scheduleNext(1500) + break + } + + case 'register-start': { + set((s) => ({ + step: 'register-tx', + txPending: true, + error: null, + data: { ...s.data, salt: DEMO_SALT }, + })) + try { + const result = await demoRegister(DEMO_SALT) + set((s) => ({ + step: 'register-confirmed', + txPending: false, + data: { + ...s.data, + registerTxHash: result.txHash, + masterId: result.masterId, + exchangeAddress: result.exchangeAddress, + }, + })) + scheduleNext(1500) + } catch (e) { + set({ + txPending: false, + error: e instanceof Error ? e.message : 'Register failed', + }) + } + break + } + + case 'register-mining': + case 'register-tx': + break + + case 'register-confirmed': { + set({ step: 'derive-virtual', demoState: 'deriving' }) + scheduleNext(1200) + break + } + + case 'derive-virtual': { + const { data: d } = get() + if (d.masterId) { + const userTag = randomUserTag() + const virtualAddress = buildVirtualAddress(d.masterId, userTag) + set((s) => ({ + data: { ...s.data, userTag, virtualAddress }, + })) + } + set({ step: 'derive-anatomy' }) + scheduleNext(2000) + break + } + + case 'derive-anatomy': { + set({ step: 'send-start', demoState: 'sending' }) + scheduleNext(1200) + break + } + + case 'send-start': { + const { data: d } = get() + if (!d.virtualAddress) break + set({ step: 'send-tx', txPending: true, error: null }) + try { + const result = await demoTransfer(d.virtualAddress, '100') + const transferEvents = result.events.map((e, i) => ({ + ...e, + label: i === 0 ? 'sender → virtual' : 'virtual → exchange', + txHash: result.txHash, + })) + set((s) => ({ + txPending: false, + data: { + ...s.data, + transferTxHash: result.txHash, + transferEvents, + }, + })) + set({ step: 'resolve-detect', demoState: 'resolving' }) + scheduleNext(1200) + } catch (e) { + set({ + txPending: false, + error: e instanceof Error ? e.message : 'Transfer failed', + }) + } + break + } + + case 'resolve-detect': { + set({ step: 'resolve-lookup' }) + scheduleNext(1200) + break + } + + case 'resolve-lookup': { + set({ step: 'resolve-forward' }) + scheduleNext(1200) + break + } + + case 'resolve-forward': { + set({ step: 'transfer-events' }) + scheduleNext(1500) + break + } + + case 'transfer-events': { + await fetchBalances() + set({ step: 'balances-final' }) + scheduleNext(2000) + break + } + + case 'balances-final': { + set({ step: 'complete', demoState: 'complete' }) + break + } + + case 'complete': + break + } + } + + return { + step: 'idle', + demoState: 'idle', + speed: 1, + txPending: false, + error: null, + data: { ...initialData }, + + startDemo() { + clearTimer() + set({ + step: 'idle', + demoState: 'idle', + txPending: false, + error: null, + data: { ...initialData }, + }) + advanceStep() + }, + + setSpeed(speed: number) { + set({ speed }) + }, + + reset() { + clearTimer() + set({ + step: 'idle', + demoState: 'idle', + speed: 1, + txPending: false, + error: null, + data: { ...initialData }, + }) + }, + } +}) diff --git a/apps/virtual-addresses/src/styles.css b/apps/virtual-addresses/src/styles.css new file mode 100644 index 000000000..74b4f520e --- /dev/null +++ b/apps/virtual-addresses/src/styles.css @@ -0,0 +1,94 @@ +@import "tailwindcss"; + +@theme { + --color-bg: #0a0a0a; + --color-surface: #141414; + --color-surface-2: #1a1a1a; + --color-surface-3: #222222; + --color-border: #2a2a2a; + --color-border-active: #404040; + + --color-text-primary: #f5f5f5; + --color-text-secondary: #888888; + --color-text-tertiary: #555555; + + --color-accent: #60a5fa; + --color-accent-hover: #93c5fd; + --color-positive: #22c55e; + --color-negative: #ef4444; + --color-warning: #f59e0b; + + --color-virtual-magic: #a78bfa; + --color-master-id: #60a5fa; + --color-user-tag: #34d399; + + --font-mono: "SF Mono", "Cascadia Code", "Fira Code", ui-monospace, monospace; + --font-sans: "Inter", "SF Pro", system-ui, sans-serif; +} + +html, +body { + background-color: var(--color-bg); + color: var(--color-text-primary); + font-family: var(--font-sans); +} + +@utility text-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-text-tertiary); +} + +@utility glass-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 12px; +} + +@utility glass-card-active { + background: var(--color-surface); + border: 1px solid var(--color-border-active); + border-radius: 12px; +} + +@keyframes pulse-glow { + 0%, + 100% { + opacity: 0.4; + } + 50% { + opacity: 1; + } +} + +@utility animate-pulse-glow { + animation: pulse-glow 2s ease-in-out infinite; +} + +@keyframes hash-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@utility animate-hash-spin { + animation: hash-spin 1s linear infinite; +} + +@keyframes flash-entry { + 0% { + background: rgba(96, 165, 250, 0.1); + } + 100% { + background: transparent; + } +} + +@utility animate-flash-entry { + animation: flash-entry 0.8s ease-out forwards; +} diff --git a/apps/virtual-addresses/tsconfig.json b/apps/virtual-addresses/tsconfig.json new file mode 100644 index 000000000..d6a60ca76 --- /dev/null +++ b/apps/virtual-addresses/tsconfig.json @@ -0,0 +1,25 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "target": "ES2024", + "jsx": "react-jsx", + "module": "ESNext", + "lib": ["ES2024", "DOM", "DOM.Iterable", "WebWorker"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": false, + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "resolvePackageJsonImports": true, + "resolveJsonModule": true, + "paths": { + "#*": ["./src/*"] + } + }, + "files": ["env.d.ts", "worker-configuration.d.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/virtual-addresses/vite.config.ts b/apps/virtual-addresses/vite.config.ts new file mode 100644 index 000000000..bb17eae11 --- /dev/null +++ b/apps/virtual-addresses/vite.config.ts @@ -0,0 +1,25 @@ +import { cloudflare } from '@cloudflare/vite-plugin' +import tailwind from '@tailwindcss/vite' +import react from '@vitejs/plugin-react' +import Icons from 'unplugin-icons/vite' +import { defineConfig } from 'vite' + +export default defineConfig({ + resolve: { + alias: { + '#': './src', + }, + }, + plugins: [ + cloudflare({ viteEnvironment: { name: 'ssr' } }), + tailwind(), + Icons({ compiler: 'jsx', jsx: 'react' }), + react(), + ], + worker: { + format: 'es', + }, + build: { + minify: 'oxc', + }, +}) diff --git a/apps/virtual-addresses/wrangler.json b/apps/virtual-addresses/wrangler.json new file mode 100644 index 000000000..e76ef9ffd --- /dev/null +++ b/apps/virtual-addresses/wrangler.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://esm.sh/wrangler/config-schema.json", + "name": "virtual-addresses", + "compatibility_date": "2026-03-01", + "compatibility_flags": ["nodejs_compat"], + "main": "./src/server.ts", + "workers_dev": true, + "preview_urls": true, + "assets": { + "directory": "./dist/client", + "binding": "ASSETS" + }, + "vars": { + "EXPLORER_URL": "https://explore.devnet.tempo.xyz", + "RPC_URL": "https://rpc.devnet.tempoxyz.dev" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1a4fda83..d9a71cf18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,9 +150,15 @@ catalogs: esbuild: specifier: ^0.27.4 version: 0.27.4 + framer-motion: + specifier: ^12.38.0 + version: 12.38.0 globals: specifier: ^17.4.0 version: 17.4.0 + hash-wasm: + specifier: ^4.12.0 + version: 4.12.0 hono: specifier: ^4.12.8 version: 4.12.8 @@ -234,6 +240,9 @@ catalogs: zod: specifier: ^4.3.6 version: 4.3.6 + zustand: + specifier: ^5.0.12 + version: 5.0.12 importers: @@ -760,6 +769,73 @@ importers: specifier: 'catalog:' version: 4.75.0(@cloudflare/workers-types@4.20260317.1)(bufferutil@4.1.0)(utf-8-validate@5.0.10) + apps/virtual-addresses: + dependencies: + '@noble/hashes': + specifier: ^1.7.2 + version: 1.8.0 + '@tailwindcss/vite': + specifier: 'catalog:' + version: 4.2.2(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + '@tanstack/react-query': + specifier: 'catalog:' + version: 5.91.2(react@19.2.4) + framer-motion: + specifier: 'catalog:' + version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + hash-wasm: + specifier: 'catalog:' + version: 4.12.0 + hono: + specifier: 'catalog:' + version: 4.12.8 + ox: + specifier: 'catalog:' + version: 0.14.6(typescript@5.9.3)(zod@4.3.6) + react: + specifier: 'catalog:' + version: 19.2.4 + react-dom: + specifier: 'catalog:' + version: 19.2.4(react@19.2.4) + tailwindcss: + specifier: 'catalog:' + version: 4.2.2 + viem: + specifier: 'catalog:' + version: 2.47.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + wagmi: + specifier: 'catalog:' + version: 3.5.0(@tanstack/query-core@5.91.2)(@tanstack/react-query@5.91.2(react@19.2.4))(@types/react@19.2.14)(ox@0.14.6(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(viem@2.47.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + zustand: + specifier: 'catalog:' + version: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + devDependencies: + '@cloudflare/vite-plugin': + specifier: 'catalog:' + version: 1.29.1(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20260317.1)(wrangler@4.75.0(@cloudflare/workers-types@4.20260317.1)(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + '@iconify/json': + specifier: 'catalog:' + version: 2.2.452 + '@types/react': + specifier: 'catalog:' + version: 19.2.14 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: 'catalog:' + version: 6.0.1(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + unplugin-icons: + specifier: 'catalog:' + version: 23.0.1(@svgr/core@8.1.0(typescript@5.9.3))(@vue/compiler-sfc@3.5.30) + vite: + specifier: 'catalog:' + version: 8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + wrangler: + specifier: 'catalog:' + version: 4.75.0(@cloudflare/workers-types@4.20260317.1)(bufferutil@4.1.0)(utf-8-validate@5.0.10) + packages/rpc-utils: dependencies: viem: @@ -4527,6 +4603,20 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + framer-motion@12.38.0: + resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -4647,6 +4737,9 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + hash-wasm@4.12.0: + resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -5301,6 +5394,12 @@ packages: peerDependencies: monaco-editor: '>=0.36' + motion-dom@12.38.0: + resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} + + motion-utils@12.36.0: + resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -6653,6 +6752,24 @@ packages: use-sync-external-store: optional: true + zustand@5.0.12: + resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -9428,29 +9545,13 @@ snapshots: dependencies: vue: 3.5.30(typescript@5.9.3) - '@wagmi/connectors@7.2.1(@wagmi/core@3.4.0(@tanstack/query-core@5.91.2)(@types/react@19.2.14)(ox@0.14.6(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.4))(viem@2.47.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(typescript@5.9.3)(viem@2.47.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))': + '@wagmi/connectors@7.2.1(@wagmi/core@3.4.0(@tanstack/query-core@5.91.2)(@types/react@19.2.14)(ox@0.14.6(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(typescript@5.9.3)(viem@2.47.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))': dependencies: '@wagmi/core': 3.4.0(@tanstack/query-core@5.91.2)(@types/react@19.2.14)(ox@0.14.6(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.4))(viem@2.47.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) viem: 2.47.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) optionalDependencies: typescript: 5.9.3 - '@wagmi/core@3.4.0(@tanstack/query-core@5.91.2)(@types/react@19.2.14)(ox@0.14.6(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))': - dependencies: - eventemitter3: 5.0.1 - mipd: 0.0.7(typescript@5.9.3) - viem: 2.47.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) - zustand: 5.0.0(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.4.0(react@19.2.4)) - optionalDependencies: - '@tanstack/query-core': 5.91.2 - ox: 0.14.6(typescript@5.9.3)(zod@4.3.6) - typescript: 5.9.3 - transitivePeerDependencies: - - '@types/react' - - immer - - react - - use-sync-external-store - '@wagmi/core@3.4.0(@tanstack/query-core@5.91.2)(@types/react@19.2.14)(ox@0.14.6(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.4))(viem@2.47.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))': dependencies: eventemitter3: 5.0.1 @@ -10373,6 +10474,15 @@ snapshots: dependencies: fetch-blob: 3.2.0 + framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + motion-dom: 12.38.0 + motion-utils: 12.36.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + fs-constants@1.0.0: {} fsevents@2.3.3: @@ -10479,6 +10589,8 @@ snapshots: dependencies: has-symbols: 1.1.0 + hash-wasm@4.12.0: {} + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -11347,6 +11459,12 @@ snapshots: vscode-uri: 3.1.0 yaml: 2.8.2 + motion-dom@12.38.0: + dependencies: + motion-utils: 12.36.0 + + motion-utils@12.36.0: {} + mrmime@2.0.1: {} ms@2.1.3: {} @@ -12560,8 +12678,8 @@ snapshots: wagmi@3.5.0(@tanstack/query-core@5.91.2)(@tanstack/react-query@5.91.2(react@19.2.4))(@types/react@19.2.14)(ox@0.14.6(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(viem@2.47.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)): dependencies: '@tanstack/react-query': 5.91.2(react@19.2.4) - '@wagmi/connectors': 7.2.1(@wagmi/core@3.4.0(@tanstack/query-core@5.91.2)(@types/react@19.2.14)(ox@0.14.6(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.4))(viem@2.47.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(typescript@5.9.3)(viem@2.47.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) - '@wagmi/core': 3.4.0(@tanstack/query-core@5.91.2)(@types/react@19.2.14)(ox@0.14.6(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + '@wagmi/connectors': 7.2.1(@wagmi/core@3.4.0(@tanstack/query-core@5.91.2)(@types/react@19.2.14)(ox@0.14.6(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(typescript@5.9.3)(viem@2.47.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + '@wagmi/core': 3.4.0(@tanstack/query-core@5.91.2)(@types/react@19.2.14)(ox@0.14.6(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.4))(viem@2.47.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) react: 19.2.4 use-sync-external-store: 1.4.0(react@19.2.4) viem: 2.47.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) @@ -12750,13 +12868,13 @@ snapshots: zod@4.3.6: {} - zustand@5.0.0(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.4.0(react@19.2.4)): + zustand@5.0.0(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): optionalDependencies: '@types/react': 19.2.14 react: 19.2.4 - use-sync-external-store: 1.4.0(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) - zustand@5.0.0(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): optionalDependencies: '@types/react': 19.2.14 react: 19.2.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4f455f4be..52d13af33 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -52,7 +52,9 @@ catalog: dotenv: ^17.3.1 eruda: ^3.4.3 esbuild: ^0.27.4 + framer-motion: ^12.38.0 globals: ^17.4.0 + hash-wasm: ^4.12.0 hono: ^4.12.8 hono-rate-limiter: ^0.5.3 idxs: ^0.0.6 @@ -80,6 +82,7 @@ catalog: wagmi: ^3.5.0 wrangler: ^4.75.0 zod: ^4.3.6 + zustand: ^5.0.12 catalogMode: strict