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 (
+
+
+
+ Tempo Localnet
+ {activeTab === 'registry' &&
+ (isConnected && address ? (
+ disconnect()}
+ className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-surface-2 border border-border text-sm font-mono text-text-secondary hover:border-border-active transition-colors"
+ >
+
+ {formatAddress(address as Address)}
+
+ ) : connector ? (
+ connect.mutate({ connector })}
+ className="px-4 py-1.5 rounded-lg bg-accent text-black text-sm font-medium hover:bg-accent-hover transition-colors"
+ >
+ Connect Wallet
+
+ ) : (
+
+ 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}
+
+
+
+ + Generate Deposit Address
+
+
+ {addresses.length > 0 && (
+
+ {addresses.map(({ userTag, address }) => (
+
+
+
Tag: {userTag}
+
onSelectAddress(address)}
+ className="text-xs text-accent hover:text-accent-hover transition-colors"
+ >
+ Use for demo transfer →
+
+
+
+
+ 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}
+
+
+ {label}
+
+
+
+ )
+ })}
+
+ )
+}
+
+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 && (
+
+ {funding ? 'Funding…' : 'Fund with PathUSD (devnet)'}
+
+ )}
+
+ {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 && (
+ effectiveAddress && onStart(effectiveAddress)}
+ className={cx(
+ 'flex-1 py-2.5 rounded-lg text-sm font-medium transition-colors',
+ effectiveAddress
+ ? 'bg-accent text-black hover:bg-accent-hover'
+ : 'bg-surface-2 text-text-tertiary cursor-not-allowed',
+ )}
+ >
+ Start Mining
+
+ )}
+ {isMining && (
+
+ Stop Mining
+
+ )}
+
+
+ {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.
+
+
+
+
+
+
+
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
+
+
+
+ )}
+
+ {!isConfirmed && (
+
+ {isWriting
+ ? 'Confirm in wallet…'
+ : isConfirming
+ ? 'Confirming…'
+ : 'Register Virtual Master'}
+
+ )}
+
+ )
+}
+
+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)}
+
+
+
+ )}
+
+
+ {isWriting
+ ? 'Confirm in wallet…'
+ : isConfirming
+ ? 'Confirming…'
+ : `Send ${amount} PathUSD to virtual address`}
+
+
+ {!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:
+
+
+
+
+ Transfer(sender → virtual, amount)
+
+
+
+
+ Transfer(virtual → master, amount)
+
+
+
+
+ )}
+
+ )
+}
+
+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 */}
+
+
+ {/* Spotlight */}
+ {spot ? (
+
+ ) : (
+
+ )}
+
+ {/* Tooltip */}
+
+
+ {isPostDemo ? 'How it works' : 'Tour'} — {stepIndex + 1} /{' '}
+ {steps.length}
+
+
+
+ {step.title}
+
+
+
+ {step.body}
+
+
+
+ {
+ e.stopPropagation()
+ skip()
+ }}
+ style={{
+ height: 28,
+ padding: '0 10px',
+ border: 'none',
+ background: 'transparent',
+ color: 'var(--color-text-tertiary)',
+ fontSize: 11,
+ letterSpacing: '0.03em',
+ cursor: 'pointer',
+ fontFamily: 'inherit',
+ }}
+ >
+ {isPostDemo ? 'Close' : 'Skip'}
+
+ {
+ e.stopPropagation()
+ advance()
+ }}
+ style={{
+ height: 28,
+ padding: '0 16px',
+ border: isLast
+ ? 'none'
+ : '1px solid var(--color-border-active)',
+ borderRadius: 6,
+ background: isLast ? 'var(--color-accent)' : 'transparent',
+ color: isLast ? '#000' : 'var(--color-text-primary)',
+ fontSize: 11,
+ letterSpacing: '0.03em',
+ textTransform: 'uppercase',
+ cursor: 'pointer',
+ fontFamily: 'inherit',
+ fontWeight: 600,
+ }}
+ >
+ {isLast ? (isPostDemo ? 'Done' : 'Start Demo') : 'Next'}
+
+
+
+
+
+ )
+}
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) => (
+ setSpeed(s)}
+ className={cx(
+ 'px-2.5 py-1 rounded text-xs font-mono transition-colors',
+ speed === s
+ ? 'bg-accent text-black font-semibold'
+ : 'bg-surface-2 text-text-tertiary hover:text-text-secondary',
+ )}
+ >
+ {s}x
+
+ ))}
+
+
+ {/* Actions */}
+
+
+ {demoState === 'complete' ? 'Run Again' : 'Start Demo'}
+
+
+ Reset
+
+
+
+
+ {/* Error banner */}
+ {error && (
+
+ {error}
+
+ Reset
+
+
+ )}
+
+ {/* 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