-
+
+
+
Virtual Addresses
TIP-1022
-
-
- Tempo Moderato
- {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"
+ onClick={() => onTabChange('registry')}
+ className={cx(
+ 'px-3 py-1.5 rounded-lg text-sm transition-colors',
+ activeTab === 'registry'
+ ? 'bg-surface-2 text-text-primary font-medium'
+ : 'text-text-tertiary hover:text-text-secondary',
+ )}
>
-
- {formatAddress(address as Address)}
+ Registry
- ) : (
connect({ connector: injected() })}
- className="px-4 py-1.5 rounded-lg bg-accent text-black text-sm font-medium hover:bg-accent-hover transition-colors"
+ onClick={() => onTabChange('walkthrough')}
+ className={cx(
+ 'px-3 py-1.5 rounded-lg text-sm transition-colors',
+ activeTab === 'walkthrough'
+ ? 'bg-surface-2 text-text-primary font-medium'
+ : 'text-text-tertiary hover:text-text-secondary',
+ )}
>
- Connect Wallet
+ Walkthrough
- )}
+
+
+
+ Tempo Moderato
+ {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 = 'registry' | 'walkthrough'
+ type Props = {
+ activeTab: Tab
+ onTabChange: (tab: Tab) => void
+ }
+}
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-mine.tsx b/apps/virtual-addresses/src/comps/step-mine.tsx
index ba92e95f9..3c7a26730 100644
--- a/apps/virtual-addresses/src/comps/step-mine.tsx
+++ b/apps/virtual-addresses/src/comps/step-mine.tsx
@@ -1,17 +1,28 @@
import type * as React from 'react'
+import { useState } from 'react'
import { useAccount } from 'wagmi'
import { cx } from '#lib/css'
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 } = useAccount()
+ const { address: walletAddress } = useAccount()
+ const [manualAddress, setManualAddress] = useState('')
const isMining = minerState.status === 'mining'
const isFound = minerState.status === 'found'
+ const effectiveAddress =
+ walletAddress ?? (isValidAddress(manualAddress) ? manualAddress : null)
+
function formatNumber(n: number): string {
- if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`
+ 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()
@@ -22,29 +33,33 @@ export function StepMine(props: StepMine.Props): React.JSX.Element {
Mine Salt
- Find a valid salt for your address via 32-bit proof-of-work. This runs
- entirely in your browser using{' '}
- {typeof navigator !== 'undefined'
- ? Math.max(1, Math.min(8, (navigator.hardwareConcurrency ?? 4) - 1))
- : '?'}{' '}
- Web Workers.
+ Find a valid salt for your address via 32-bit proof-of-work.
- {!address && (
-
- Connect your wallet first. The salt is mined for your connected
- address.
-
- )}
-
- {address && (
+ {walletAddress ? (
-
Master Address
+
Master Address (wallet)
- {address}
+ {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
+
+ )}
+
)}
{isMining && (
@@ -100,11 +115,11 @@ export function StepMine(props: StepMine.Props): React.JSX.Element {
{!isMining && !isFound && (
address && onStart(address)}
+ disabled={!effectiveAddress}
+ onClick={() => effectiveAddress && onStart(effectiveAddress)}
className={cx(
'flex-1 py-2.5 rounded-lg text-sm font-medium transition-colors',
- address
+ effectiveAddress
? 'bg-accent text-black hover:bg-accent-hover'
: 'bg-surface-2 text-text-tertiary cursor-not-allowed',
)}
@@ -126,8 +141,8 @@ export function StepMine(props: StepMine.Props): React.JSX.Element {
{isMining && (
- Mining with {minerState.workerCount} workers⦠expect ~1-3 min on
- modern hardware
+ Mining in-browser with {minerState.workerCount} Web Workers β expect
+ ~3 min on modern hardware
)}
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..efc8ab37d
--- /dev/null
+++ b/apps/virtual-addresses/src/comps/walkthrough/exchange-panel.tsx
@@ -0,0 +1,306 @@
+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 */}
+
+
+ {/* 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..59633f2d2
--- /dev/null
+++ b/apps/virtual-addresses/src/comps/walkthrough/sender-panel.tsx
@@ -0,0 +1,227 @@
+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 */}
+
+
+ {/* 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..ee0449fe6
--- /dev/null
+++ b/apps/virtual-addresses/src/comps/walkthrough/walkthrough-demo.tsx
@@ -0,0 +1,104 @@
+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 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
+
+
+
+
+ {/* 3-column grid */}
+
+
+
+
+ )
+}
diff --git a/apps/virtual-addresses/src/lib/abi.ts b/apps/virtual-addresses/src/lib/abi.ts
index 8dc6ae645..d1e394e86 100644
--- a/apps/virtual-addresses/src/lib/abi.ts
+++ b/apps/virtual-addresses/src/lib/abi.ts
@@ -1,8 +1,8 @@
export const VIRTUAL_REGISTRY_ADDRESS =
- '0xFDC0000000000000000000000000000000000000' as const
+ '0xfDC0000000000000000000000000000000000000' as const
export const PATH_USD_ADDRESS =
- '0x20c0000000000000000000000000000000000000' as const
+ '0x20C0000000000000000000000000000000000000' as const
export const virtualRegistryAbi = [
{
diff --git a/apps/virtual-addresses/src/lib/miner.pool.ts b/apps/virtual-addresses/src/lib/miner.pool.ts
index 1d0d498ba..e0d8e6398 100644
--- a/apps/virtual-addresses/src/lib/miner.pool.ts
+++ b/apps/virtual-addresses/src/lib/miner.pool.ts
@@ -87,12 +87,14 @@ export function createMinerPool(options: MinerPoolOptions) {
}
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: msg.attempts,
+ attempts: total,
minedForAddress: masterAddress,
})
// Stop all other workers
diff --git a/apps/virtual-addresses/src/lib/miner.worker.ts b/apps/virtual-addresses/src/lib/miner.worker.ts
index 601ab5c3e..6ea1d625d 100644
--- a/apps/virtual-addresses/src/lib/miner.worker.ts
+++ b/apps/virtual-addresses/src/lib/miner.worker.ts
@@ -1,4 +1,4 @@
-import { keccak_256 } from '@noble/hashes/sha3'
+import { createKeccak } from 'hash-wasm'
import type { ToWorker, FromWorker } from './miner.protocol'
function post(msg: FromWorker) {
@@ -24,7 +24,7 @@ function bytesToHex(bytes: Uint8Array): string {
let running = false
-self.onmessage = (e: MessageEvent) => {
+self.onmessage = async (e: MessageEvent) => {
const msg = e.data
if (msg.type === 'stop') {
@@ -43,6 +43,9 @@ self.onmessage = (e: MessageEvent) => {
batchSize,
} = msg
+ // Init WASM hasher once
+ const hasher = await createKeccak(256)
+
const addrBytes = hexToBytes(masterAddress)
const seedBytes = hexToBytes(seedHex)
@@ -78,7 +81,10 @@ self.onmessage = (e: MessageEvent) => {
salt[31] = lo & 0xff
input.set(salt, 20)
- const hash = keccak_256(input)
+
+ 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) {
diff --git a/apps/virtual-addresses/src/lib/wagmi.ts b/apps/virtual-addresses/src/lib/wagmi.ts
index e26c67431..a5be744db 100644
--- a/apps/virtual-addresses/src/lib/wagmi.ts
+++ b/apps/virtual-addresses/src/lib/wagmi.ts
@@ -2,7 +2,9 @@ import { http, createConfig } from 'wagmi'
import { tempoModerato } from 'viem/chains'
export const wagmiConfig = createConfig({
+ multiInjectedProviderDiscovery: true,
chains: [tempoModerato],
+ connectors: [],
transports: {
[tempoModerato.id]: http(),
},
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/server.ts b/apps/virtual-addresses/src/server.ts
index 670012029..227917974 100644
--- a/apps/virtual-addresses/src/server.ts
+++ b/apps/virtual-addresses/src/server.ts
@@ -1,15 +1,218 @@
import { Hono } from 'hono'
+import {
+ createPublicClient,
+ createWalletClient,
+ http,
+ formatUnits,
+ parseUnits,
+ keccak256,
+ encodePacked,
+ type Address,
+ type Hex,
+} from 'viem'
+import { privateKeyToAccount } from 'viem/accounts'
+import { tempoModerato } from 'viem/chains'
+import {
+ virtualRegistryAbi,
+ tip20Abi,
+ VIRTUAL_REGISTRY_ADDRESS,
+ PATH_USD_ADDRESS,
+} from '#lib/abi'
type Env = {
Bindings: {
ASSETS: Fetcher
+ EXCHANGE_PRIVATE_KEY: string
+ SENDER_PRIVATE_KEY: string
+ EXPLORER_URL: string
}
}
const app = new Hono()
+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 publicClient = createPublicClient({
+ chain: tempoModerato,
+ transport: http(),
+ })
+
+ // Compute expected masterId: keccak256(encodePacked(address, salt)) β bytes[4:8]
+ const hash = keccak256(
+ encodePacked(['address', 'bytes32'], [keys.exchange.address, salt]),
+ )
+ const masterId = `0x${hash.slice(10, 18)}` as Hex // bytes[4:8]
+
+ // Check if already registered
+ const existingMaster = (await publicClient.readContract({
+ address: VIRTUAL_REGISTRY_ADDRESS,
+ abi: virtualRegistryAbi,
+ functionName: 'getMaster',
+ args: [masterId],
+ })) as Address
+
+ const zeroAddr = '0x0000000000000000000000000000000000000000'
+ if (existingMaster.toLowerCase() !== zeroAddr) {
+ return c.json({
+ txHash: null,
+ blockNumber: null,
+ masterId,
+ exchangeAddress: keys.exchange.address,
+ alreadyRegistered: true,
+ })
+ }
+
+ // Not registered yet β register on-chain
+ const walletClient = createWalletClient({
+ account: keys.exchange,
+ chain: tempoModerato,
+ transport: http(),
+ })
+
+ 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,
+ })
+})
+
+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 walletClient = createWalletClient({
+ account: keys.sender,
+ chain: tempoModerato,
+ transport: http(),
+ })
+
+ const publicClient = createPublicClient({
+ chain: tempoModerato,
+ transport: http(),
+ })
+
+ 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,
+ })
+})
+
+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 virtualAddress = c.req.query('virtualAddress') as Address | undefined
+
+ const publicClient = createPublicClient({
+ chain: tempoModerato,
+ transport: http(),
+ })
+
+ 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,
+ })
+})
+
app.all('*', async (c) => {
return c.env.ASSETS.fetch(c.req.raw)
})
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..1e8644d13
--- /dev/null
+++ b/apps/virtual-addresses/src/store/walkthrough-store.ts
@@ -0,0 +1,264 @@
+import { create } from 'zustand'
+import type { Address, Hex } from 'viem'
+import { buildVirtualAddress, randomUserTag } from '#lib/virtual-address'
+import type {
+ DemoState,
+ DemoStep,
+ WalkthroughData,
+} from '#lib/walkthrough-types'
+
+// Pre-mined salt for anvil account 0 (0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266)
+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
+ 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()
+ const params = new URLSearchParams()
+ if (data.virtualAddress) params.set('virtualAddress', data.virtualAddress)
+ const res = await fetch(`/api/demo/balance?${params}`)
+ const json = (await res.json()) as {
+ exchange: string
+ sender: string
+ virtual: string
+ exchangeAddress: Address | null
+ senderAddress: Address | null
+ }
+ set((s) => ({
+ data: {
+ ...s.data,
+ exchangeBalance: json.exchange,
+ senderBalance: json.sender,
+ virtualBalance: json.virtual,
+ exchangeAddress: json.exchangeAddress ?? s.data.exchangeAddress,
+ senderAddress: json.senderAddress ?? s.data.senderAddress,
+ },
+ }))
+ }
+
+ async function advanceStep() {
+ const { step } = get()
+
+ switch (step) {
+ case 'idle': {
+ await fetchBalances()
+ set({ step: 'register-start', demoState: 'registering' })
+ scheduleNext(1500)
+ break
+ }
+
+ case 'register-start': {
+ set((s) => ({
+ step: 'register-tx',
+ txPending: true,
+ data: { ...s.data, salt: DEMO_SALT },
+ }))
+ try {
+ const res = await fetch('/api/demo/register', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ salt: DEMO_SALT }),
+ })
+ const json = (await res.json()) as {
+ txHash: string
+ blockNumber: number
+ masterId: Hex
+ exchangeAddress: Address
+ }
+ set((s) => ({
+ step: 'register-confirmed',
+ txPending: false,
+ data: {
+ ...s.data,
+ registerTxHash: json.txHash,
+ masterId: json.masterId,
+ exchangeAddress: json.exchangeAddress,
+ },
+ }))
+ scheduleNext(1500)
+ } catch {
+ set({ txPending: false })
+ }
+ 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 })
+ try {
+ const res = await fetch('/api/demo/transfer', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ virtualAddress: d.virtualAddress,
+ amount: '100',
+ }),
+ })
+ const json = (await res.json()) as {
+ txHash: string
+ blockNumber: number
+ events: { from: string; to: string; amount: string }[]
+ }
+ const transferEvents = json.events.map((e, i) => ({
+ ...e,
+ label: i === 0 ? 'sender β virtual' : 'virtual β exchange',
+ txHash: json.txHash,
+ }))
+ set((s) => ({
+ txPending: false,
+ data: {
+ ...s.data,
+ transferTxHash: json.txHash,
+ transferEvents,
+ },
+ }))
+ set({ step: 'resolve-detect', demoState: 'resolving' })
+ scheduleNext(1200)
+ } catch {
+ set({ txPending: false })
+ }
+ 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,
+ data: { ...initialData },
+
+ startDemo() {
+ clearTimer()
+ set({
+ step: 'idle',
+ demoState: 'idle',
+ txPending: false,
+ data: { ...initialData },
+ })
+ advanceStep()
+ },
+
+ setSpeed(speed: number) {
+ set({ speed })
+ },
+
+ reset() {
+ clearTimer()
+ set({
+ step: 'idle',
+ demoState: 'idle',
+ speed: 1,
+ txPending: false,
+ data: { ...initialData },
+ })
+ },
+ }
+})
diff --git a/apps/virtual-addresses/src/styles.css b/apps/virtual-addresses/src/styles.css
index 830be88bb..74b4f520e 100644
--- a/apps/virtual-addresses/src/styles.css
+++ b/apps/virtual-addresses/src/styles.css
@@ -79,3 +79,16 @@ body {
@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/wrangler.json b/apps/virtual-addresses/wrangler.json
index 4a99b4ea2..67e05624e 100644
--- a/apps/virtual-addresses/wrangler.json
+++ b/apps/virtual-addresses/wrangler.json
@@ -9,5 +9,8 @@
"assets": {
"directory": "./dist/client",
"binding": "ASSETS"
+ },
+ "vars": {
+ "EXPLORER_URL": "https://explore.moderato.tempo.xyz"
}
}
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
From 3a3e317e516cf1cd3b4193d66d684927c6b3a595 Mon Sep 17 00:00:00 2001
From: 0xrusowsky <0xrusowsky@proton.me>
Date: Thu, 26 Mar 2026 22:12:17 +0100
Subject: [PATCH 3/8] chore: update lockfile for new dependencies
Amp-Thread-ID: https://ampcode.com/threads/T-019d2b87-dec9-7156-8823-bb58c11aa9ce
---
pnpm-lock.yaml | 104 ++++++++++++++++++++++++++++++++++++++-----------
1 file changed, 82 insertions(+), 22 deletions(-)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9830a22e1..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:
@@ -771,6 +780,12 @@ importers:
'@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
@@ -792,6 +807,9 @@ importers:
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:'
@@ -4585,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==}
@@ -4705,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'}
@@ -5359,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'}
@@ -6711,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==}
@@ -9486,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
@@ -10431,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:
@@ -10537,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
@@ -11405,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: {}
@@ -12618,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)
@@ -12808,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
From fe53f1303b33d0ab45ce6016c0c6012d4ee8f682 Mon Sep 17 00:00:00 2001
From: 0xrusowsky <0xrusowsky@proton.me>
Date: Thu, 26 Mar 2026 23:46:06 +0100
Subject: [PATCH 4/8] virtual-addresses: local node support + auto-fund + error
handling
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Switch to tempoLocalnet (chain 1337) for local TIP-1022 dev
- Client-side RPC via demo-client.ts (bypasses workerd sandbox)
- Vite /rpc proxy to avoid CORS (localhost:3002 β localhost:8545)
- Auto-fund connected wallet with PathUSD on connect
- Tempo tx type 0x76 for all writeContract calls
- Idempotent getMaster check handles empty/zero responses
- Error banner in walkthrough with actual error messages
- Allowlist anvil test keys in gitleaks config
- README with full setup instructions
Amp-Thread-ID: https://ampcode.com/threads/T-019d2b87-dec9-7156-8823-bb58c11aa9ce
---
.github/.gitleaks.toml | 7 +
apps/virtual-addresses/README.md | 144 ++++++++
apps/virtual-addresses/src/comps/header.tsx | 2 +-
.../virtual-addresses/src/comps/step-mine.tsx | 64 +++-
.../src/comps/walkthrough/exchange-panel.tsx | 9 +-
.../src/comps/walkthrough/sender-panel.tsx | 9 +-
.../comps/walkthrough/walkthrough-demo.tsx | 26 ++
apps/virtual-addresses/src/lib/demo-client.ts | 224 ++++++++++++
apps/virtual-addresses/src/lib/wagmi.ts | 6 +-
apps/virtual-addresses/src/server.ts | 323 +++++++++++-------
.../src/store/walkthrough-store.ts | 101 +++---
apps/virtual-addresses/vite.config.ts | 9 +
apps/virtual-addresses/wrangler.json | 2 +-
13 files changed, 734 insertions(+), 192 deletions(-)
create mode 100644 apps/virtual-addresses/README.md
create mode 100644 apps/virtual-addresses/src/lib/demo-client.ts
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/apps/virtual-addresses/README.md b/apps/virtual-addresses/README.md
new file mode 100644
index 000000000..fc124dae3
--- /dev/null
+++ b/apps/virtual-addresses/README.md
@@ -0,0 +1,144 @@
+# Virtual Addresses β TIP-1022 Demo
+
+Interactive demo for [TIP-1022](https://github.com/tempoxyz/tempo/pull/3286) virtual addresses on Tempo. Two views:
+
+- **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
+- [Rust](https://rustup.rs/) toolchain (for building the Tempo node)
+- [just](https://github.com/casey/just) (`brew install just`)
+
+## 1. Start a local Tempo node
+
+The demo requires a local Tempo node with TIP-1022 (T3 hardfork) enabled. Clone the tempo repo and checkout the TIP-1022 branch:
+
+```bash
+cd /path/to/tempo
+git fetch origin pull/3286/head:tip-1022
+git checkout tip-1022
+```
+
+**Important:** The TIP-1022 registry precompile (`0xfDC0...`) must be allocated in the dev genesis. Add it to `crates/chainspec/src/genesis/dev.json` if not present:
+
+```json
+"0xfdc0000000000000000000000000000000000000": {
+ "nonce": "0x0",
+ "balance": "0x0",
+ "code": "0xef"
+}
+```
+
+Then touch the spec file to force a rebuild and start the node:
+
+```bash
+touch crates/chainspec/src/spec.rs
+cd scripts
+rm -rf data logs
+just tempo-dev-up
+```
+
+The node starts at `http://localhost:8545` (chain ID 1337) with anvil test accounts pre-funded.
+
+## 2. Install dependencies
+
+From the monorepo root:
+
+```bash
+pnpm install
+```
+
+## 3. Configure environment
+
+The `.dev.vars` file should already exist with anvil test keys:
+
+```
+EXCHANGE_PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
+SENDER_PRIVATE_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
+RPC_URL=http://localhost:8545
+```
+
+These are the standard anvil accounts 0 and 1 β safe for local development only.
+
+## 4. Run the app
+
+```bash
+pnpm --filter virtual-addresses dev
+```
+
+Open [http://localhost:3002](http://localhost:3002).
+
+## Architecture
+
+```
+src/
+βββ app.tsx # Tab switcher: Registry | Walkthrough
+βββ server.ts # Hono API routes (Cloudflare Workers)
+βββ comps/
+β βββ header.tsx # Tab navigation + wallet connect
+β βββ 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 # Client-side viem RPC calls for walkthrough
+ βββ virtual-address.ts # Address building/decoding utilities
+ βββ walkthrough-types.ts # Demo state types
+ βββ wagmi.ts # Wagmi config (tempoLocalnet)
+ βββ 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 (hardcoded for anvil account 0)
+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 RPC calls go through vite's `/rpc` proxy to bypass CORS (`localhost:3002/rpc` β `localhost:8545`).
+
+## Key addresses
+
+| Contract | Address |
+|----------|---------|
+| PathUSD (TIP-20) | `0x20C0000000000000000000000000000000000000` |
+| Virtual Registry | `0xfDC0000000000000000000000000000000000000` |
+| Exchange (anvil 0) | `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` |
+| Sender (anvil 1) | `0x70997970C51812dc3A010C7d01b50e0d17dc79C8` |
+
+## 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/src/comps/header.tsx b/apps/virtual-addresses/src/comps/header.tsx
index b13747553..73a1fb40b 100644
--- a/apps/virtual-addresses/src/comps/header.tsx
+++ b/apps/virtual-addresses/src/comps/header.tsx
@@ -61,7 +61,7 @@ export function Header(props: Header.Props): React.JSX.Element {