diff --git a/e2e/virtual-addresses.test.ts b/e2e/virtual-addresses.test.ts
new file mode 100644
index 00000000..5e3b57f7
--- /dev/null
+++ b/e2e/virtual-addresses.test.ts
@@ -0,0 +1,84 @@
+import { expect, test } from '@playwright/test'
+
+test('virtual addresses guide signs in and starts master registration', async ({ page }) => {
+ test.setTimeout(150000)
+
+ const client = await page.context().newCDPSession(page)
+ await client.send('WebAuthn.enable')
+ const { authenticatorId } = await client.send('WebAuthn.addVirtualAuthenticator', {
+ options: {
+ protocol: 'ctap2',
+ transport: 'internal',
+ hasResidentKey: true,
+ hasUserVerification: true,
+ isUserVerified: true,
+ },
+ })
+
+ try {
+ await page.goto('/guide/use-accounts/embed-passkeys')
+
+ const passkeySignUpButton = page.getByRole('button', { name: 'Sign up' }).first()
+ await expect(passkeySignUpButton).toBeVisible({ timeout: 90000 })
+ await passkeySignUpButton.click()
+
+ await expect(page.getByRole('button', { name: 'Sign out' }).first()).toBeVisible({
+ timeout: 30000,
+ })
+
+ await page.goto('/guide/payments/virtual-addresses')
+
+ await expect(
+ page.getByRole('heading', { name: 'Use virtual addresses for deposits' }),
+ ).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Sign out' }).first()).toBeVisible({
+ timeout: 30000,
+ })
+ await expect(page.getByText('Connected passkey account')).toBeVisible()
+
+ const registerButton = page.getByRole('button', { name: 'Register master id' }).first()
+ await expect(registerButton).toBeVisible()
+ await registerButton.click()
+
+ await expect
+ .poll(
+ async () => {
+ if (await page.getByRole('button', { name: 'Mining salt…' }).first().isVisible()) {
+ return 'mining'
+ }
+ if (await page.getByRole('button', { name: 'Confirm passkey…' }).first().isVisible()) {
+ return 'confirm'
+ }
+ if (await page.getByRole('button', { name: 'Registering…' }).first().isVisible()) {
+ return 'registering'
+ }
+ if (await page.getByText('registration tx:').isVisible()) return 'registered'
+ return null
+ },
+ {
+ timeout: 30000,
+ },
+ )
+ .not.toBeNull()
+
+ await expect
+ .poll(
+ async () => {
+ if (await page.getByText('hashes tried:').isVisible()) return 'mining'
+ if (
+ await page
+ .getByText('Waiting for the registration transaction to be confirmed.')
+ .isVisible()
+ ) {
+ return 'found'
+ }
+ if (await page.getByText('registration tx:').isVisible()) return 'registered'
+ return null
+ },
+ { timeout: 30000 },
+ )
+ .not.toBeNull()
+ } finally {
+ await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }).catch(() => {})
+ }
+})
diff --git a/package.json b/package.json
index b74959f7..6a8c7005 100644
--- a/package.json
+++ b/package.json
@@ -29,7 +29,7 @@
"cva": "1.0.0-beta.4",
"mermaid": "^11.14.0",
"monaco-editor": "^0.55.1",
- "ox": "~0.14.15",
+ "ox": "0.14.18",
"posthog-js": "^1.367.0",
"posthog-node": "^5.29.2",
"prool": "^0.2.4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 88008f1c..35423bcc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -39,7 +39,7 @@ importers:
version: 1.2.3(typescript@5.9.3)(zod@4.3.6)
accounts:
specifier: ^0.6.5
- version: 0.6.7(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(@types/react@19.2.14)(@wagmi/core@3.4.2(@tanstack/query-core@5.99.0)(@types/react@19.2.14)(ox@0.14.15(typescript@5.9.3)(zod@4.3.6))(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.48.0(typescript@5.9.3)(zod@4.3.6)))(express@5.2.1)(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.5))(viem@2.48.0(typescript@5.9.3)(zod@4.3.6))
+ version: 0.6.7(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(@types/react@19.2.14)(@wagmi/core@3.4.2(@tanstack/query-core@5.99.0)(@types/react@19.2.14)(ox@0.14.18(typescript@5.9.3)(zod@4.3.6))(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.48.0(typescript@5.9.3)(zod@4.3.6)))(express@5.2.1)(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.5))(viem@2.48.0(typescript@5.9.3)(zod@4.3.6))
cva:
specifier: 1.0.0-beta.4
version: 1.0.0-beta.4(typescript@5.9.3)
@@ -50,8 +50,8 @@ importers:
specifier: ^0.55.1
version: 0.55.1
ox:
- specifier: ~0.14.15
- version: 0.14.15(typescript@5.9.3)(zod@4.3.6)
+ specifier: 0.14.18
+ version: 0.14.18(typescript@5.9.3)(zod@4.3.6)
posthog-js:
specifier: ^1.367.0
version: 1.367.0
@@ -93,7 +93,7 @@ importers:
version: https://pkg.pr.new/wevm/vocs@2fb25c2(@cfworker/json-schema@4.1.1)(@types/react@19.2.14)(mermaid@11.14.0)(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.104.1))(react@19.2.5)(rollup@4.60.1)(typescript@5.9.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(waku@1.0.0-alpha.4(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.104.1))(react@19.2.5)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
wagmi:
specifier: ^3.6.1
- version: 3.6.1(@tanstack/query-core@5.99.0)(@tanstack/react-query@5.99.0(react@19.2.5))(@types/react@19.2.14)(ox@0.14.15(typescript@5.9.3)(zod@4.3.6))(react@19.2.5)(typescript@5.9.3)(viem@2.48.0(typescript@5.9.3)(zod@4.3.6))
+ version: 3.6.1(@tanstack/query-core@5.99.0)(@tanstack/react-query@5.99.0(react@19.2.5))(@types/react@19.2.14)(ox@0.14.18(typescript@5.9.3)(zod@4.3.6))(react@19.2.5)(typescript@5.9.3)(viem@2.48.0(typescript@5.9.3)(zod@4.3.6))
waku:
specifier: 1.0.0-alpha.4
version: 1.0.0-alpha.4(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.104.1))(react@19.2.5)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
@@ -3149,16 +3149,16 @@ packages:
typescript:
optional: true
- ox@0.14.15:
- resolution: {integrity: sha512-3TubCmbKen/cuZQzX0qDbOS5lojjdSZ90lqKxWIDWd5siuJ0IJBaTXMYs8eMPLcraqnOwGZazz3apHPGiRCkGQ==}
+ ox@0.14.17:
+ resolution: {integrity: sha512-jOzNb2Wlfzsr8z/GoCtd1bf6OSRuWuysvbhnHGD+7fV1WRbcBR6B0RYoe3xWnUedF7zp4l5APmS7CzAhUok/lA==}
peerDependencies:
typescript: '>=5.4.0'
peerDependenciesMeta:
typescript:
optional: true
- ox@0.14.17:
- resolution: {integrity: sha512-jOzNb2Wlfzsr8z/GoCtd1bf6OSRuWuysvbhnHGD+7fV1WRbcBR6B0RYoe3xWnUedF7zp4l5APmS7CzAhUok/lA==}
+ ox@0.14.18:
+ resolution: {integrity: sha512-1Irk/tvMsw7xJDuCTT/u9azSjz0YX9hrYFgJOacIuFwibaW2zZBXAMrpzegndYb5o8GLpxB6/0qro4/c40q6VQ==}
peerDependencies:
typescript: '>=5.4.0'
peerDependenciesMeta:
@@ -3911,7 +3911,7 @@ packages:
optional: true
vocs@https://pkg.pr.new/wevm/vocs@2fb25c2:
- resolution: {tarball: https://pkg.pr.new/wevm/vocs@2fb25c2}
+ resolution: {integrity: sha512-ks2EN0bid4JPpFiHH8LDtKn4fRII9HrpyeHmD49VoOmZcgFvK6FAQ5iryi8HeS1f7uZfk/UoFdaPKC0mnR7ebw==, tarball: https://pkg.pr.new/wevm/vocs@2fb25c2}
version: 0.0.0
hasBin: true
peerDependencies:
@@ -5434,14 +5434,14 @@ snapshots:
optionalDependencies:
react-server-dom-webpack: 19.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.104.1)
- '@wagmi/connectors@8.0.1(@wagmi/core@3.4.2(@tanstack/query-core@5.99.0)(@types/react@19.2.14)(ox@0.14.15(typescript@5.9.3)(zod@4.3.6))(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.48.0(typescript@5.9.3)(zod@4.3.6)))(typescript@5.9.3)(viem@2.48.0(typescript@5.9.3)(zod@4.3.6))':
+ '@wagmi/connectors@8.0.1(@wagmi/core@3.4.2(@tanstack/query-core@5.99.0)(@types/react@19.2.14)(ox@0.14.18(typescript@5.9.3)(zod@4.3.6))(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.48.0(typescript@5.9.3)(zod@4.3.6)))(typescript@5.9.3)(viem@2.48.0(typescript@5.9.3)(zod@4.3.6))':
dependencies:
- '@wagmi/core': 3.4.2(@tanstack/query-core@5.99.0)(@types/react@19.2.14)(ox@0.14.15(typescript@5.9.3)(zod@4.3.6))(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.5))(viem@2.48.0(typescript@5.9.3)(zod@4.3.6))
+ '@wagmi/core': 3.4.2(@tanstack/query-core@5.99.0)(@types/react@19.2.14)(ox@0.14.18(typescript@5.9.3)(zod@4.3.6))(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.5))(viem@2.48.0(typescript@5.9.3)(zod@4.3.6))
viem: 2.48.0(typescript@5.9.3)(zod@4.3.6)
optionalDependencies:
typescript: 5.9.3
- '@wagmi/core@3.4.2(@tanstack/query-core@5.99.0)(@types/react@19.2.14)(ox@0.14.15(typescript@5.9.3)(zod@4.3.6))(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.5))(viem@2.48.0(typescript@5.9.3)(zod@4.3.6))':
+ '@wagmi/core@3.4.2(@tanstack/query-core@5.99.0)(@types/react@19.2.14)(ox@0.14.18(typescript@5.9.3)(zod@4.3.6))(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.5))(viem@2.48.0(typescript@5.9.3)(zod@4.3.6))':
dependencies:
eventemitter3: 5.0.1
mipd: 0.0.7(typescript@5.9.3)
@@ -5449,7 +5449,7 @@ snapshots:
zustand: 5.0.0(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))
optionalDependencies:
'@tanstack/query-core': 5.99.0
- ox: 0.14.15(typescript@5.9.3)(zod@4.3.6)
+ ox: 0.14.18(typescript@5.9.3)(zod@4.3.6)
typescript: 5.9.3
transitivePeerDependencies:
- '@types/react'
@@ -5547,18 +5547,18 @@ snapshots:
mime-types: 3.0.2
negotiator: 1.0.0
- accounts@0.6.7(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(@types/react@19.2.14)(@wagmi/core@3.4.2(@tanstack/query-core@5.99.0)(@types/react@19.2.14)(ox@0.14.15(typescript@5.9.3)(zod@4.3.6))(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.48.0(typescript@5.9.3)(zod@4.3.6)))(express@5.2.1)(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.5))(viem@2.48.0(typescript@5.9.3)(zod@4.3.6)):
+ accounts@0.6.7(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(@types/react@19.2.14)(@wagmi/core@3.4.2(@tanstack/query-core@5.99.0)(@types/react@19.2.14)(ox@0.14.18(typescript@5.9.3)(zod@4.3.6))(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.48.0(typescript@5.9.3)(zod@4.3.6)))(express@5.2.1)(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.5))(viem@2.48.0(typescript@5.9.3)(zod@4.3.6)):
dependencies:
hono: 4.12.12
idb-keyval: 6.2.2
mipd: 0.0.7(typescript@5.9.3)
mppx: 0.5.12(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(express@5.2.1)(hono@4.12.12)(typescript@5.9.3)(viem@2.48.0(typescript@5.9.3)(zod@4.3.6))
- ox: 0.14.15(typescript@5.9.3)(zod@4.3.6)
+ ox: 0.14.18(typescript@5.9.3)(zod@4.3.6)
webauthx: 0.1.1(typescript@5.9.3)(zod@4.3.6)
zod: 4.3.6
zustand: 5.0.12(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))
optionalDependencies:
- '@wagmi/core': 3.4.2(@tanstack/query-core@5.99.0)(@types/react@19.2.14)(ox@0.14.15(typescript@5.9.3)(zod@4.3.6))(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.5))(viem@2.48.0(typescript@5.9.3)(zod@4.3.6))
+ '@wagmi/core': 3.4.2(@tanstack/query-core@5.99.0)(@types/react@19.2.14)(ox@0.14.18(typescript@5.9.3)(zod@4.3.6))(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.5))(viem@2.48.0(typescript@5.9.3)(zod@4.3.6))
react: 19.2.5
viem: 2.48.0(typescript@5.9.3)(zod@4.3.6)
transitivePeerDependencies:
@@ -7270,7 +7270,7 @@ snapshots:
transitivePeerDependencies:
- zod
- ox@0.14.15(typescript@5.9.3)(zod@4.3.6):
+ ox@0.14.17(typescript@5.9.3)(zod@4.3.6):
dependencies:
'@adraffy/ens-normalize': 1.11.1
'@noble/ciphers': 1.3.0
@@ -7285,7 +7285,7 @@ snapshots:
transitivePeerDependencies:
- zod
- ox@0.14.17(typescript@5.9.3)(zod@4.3.6):
+ ox@0.14.18(typescript@5.9.3)(zod@4.3.6):
dependencies:
'@adraffy/ens-normalize': 1.11.1
'@noble/ciphers': 1.3.0
@@ -8273,11 +8273,11 @@ snapshots:
w3c-keyname@2.2.8: {}
- wagmi@3.6.1(@tanstack/query-core@5.99.0)(@tanstack/react-query@5.99.0(react@19.2.5))(@types/react@19.2.14)(ox@0.14.15(typescript@5.9.3)(zod@4.3.6))(react@19.2.5)(typescript@5.9.3)(viem@2.48.0(typescript@5.9.3)(zod@4.3.6)):
+ wagmi@3.6.1(@tanstack/query-core@5.99.0)(@tanstack/react-query@5.99.0(react@19.2.5))(@types/react@19.2.14)(ox@0.14.18(typescript@5.9.3)(zod@4.3.6))(react@19.2.5)(typescript@5.9.3)(viem@2.48.0(typescript@5.9.3)(zod@4.3.6)):
dependencies:
'@tanstack/react-query': 5.99.0(react@19.2.5)
- '@wagmi/connectors': 8.0.1(@wagmi/core@3.4.2(@tanstack/query-core@5.99.0)(@types/react@19.2.14)(ox@0.14.15(typescript@5.9.3)(zod@4.3.6))(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.48.0(typescript@5.9.3)(zod@4.3.6)))(typescript@5.9.3)(viem@2.48.0(typescript@5.9.3)(zod@4.3.6))
- '@wagmi/core': 3.4.2(@tanstack/query-core@5.99.0)(@types/react@19.2.14)(ox@0.14.15(typescript@5.9.3)(zod@4.3.6))(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.5))(viem@2.48.0(typescript@5.9.3)(zod@4.3.6))
+ '@wagmi/connectors': 8.0.1(@wagmi/core@3.4.2(@tanstack/query-core@5.99.0)(@types/react@19.2.14)(ox@0.14.18(typescript@5.9.3)(zod@4.3.6))(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.5))(viem@2.48.0(typescript@5.9.3)(zod@4.3.6)))(typescript@5.9.3)(viem@2.48.0(typescript@5.9.3)(zod@4.3.6))
+ '@wagmi/core': 3.4.2(@tanstack/query-core@5.99.0)(@types/react@19.2.14)(ox@0.14.18(typescript@5.9.3)(zod@4.3.6))(react@19.2.5)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.5))(viem@2.48.0(typescript@5.9.3)(zod@4.3.6))
react: 19.2.5
use-sync-external-store: 1.4.0(react@19.2.5)
viem: 2.48.0(typescript@5.9.3)(zod@4.3.6)
@@ -8333,7 +8333,7 @@ snapshots:
webauthx@0.1.1(typescript@5.9.3)(zod@4.3.6):
dependencies:
- ox: 0.14.15(typescript@5.9.3)(zod@4.3.6)
+ ox: 0.14.18(typescript@5.9.3)(zod@4.3.6)
transitivePeerDependencies:
- typescript
- zod
diff --git a/src/components/guides/VirtualAddressesLiveDemo.tsx b/src/components/guides/VirtualAddressesLiveDemo.tsx
new file mode 100644
index 00000000..a7259db2
--- /dev/null
+++ b/src/components/guides/VirtualAddressesLiveDemo.tsx
@@ -0,0 +1,767 @@
+'use client'
+
+import { useMutation } from '@tanstack/react-query'
+import { VirtualAddress } from 'ox/tempo'
+import * as React from 'react'
+import {
+ type Address,
+ type Chain,
+ type Client,
+ createClient,
+ createPublicClient,
+ formatUnits,
+ type Hex,
+ http,
+ parseUnits,
+ type Transport,
+} from 'viem'
+import { mnemonicToAccount, privateKeyToAccount } from 'viem/accounts'
+import { tempoDevnet, tempoLocalnet, tempoModerato } from 'viem/chains'
+import { Abis, Actions, tempoActions, withFeePayer } from 'viem/tempo'
+import { useClient, useConnect, useConnection, useDisconnect, useWriteContract } from 'wagmi'
+import { Hooks } from 'wagmi/tempo'
+import { useWebAuthnConnector } from '../../wagmi.config'
+import { Button, Logout, Step, StringFormatter } from './Demo'
+import { alphaUsd, pathUsd } from './tokens'
+
+const TEST_MNEMONIC = 'test test test test test test test test test test test junk'
+const DEMO_SENDER_KEY =
+ '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' as const
+const VIRTUAL_REGISTRY_ADDRESS = '0xfDC0000000000000000000000000000000000000' as const
+const DEMO_USER_TAG = '0x000000000001' as const
+const DEVNET_SPONSOR_URL = 'https://sponsor.devnet.tempo.xyz' as const
+const MODERATO_SPONSOR_URL = 'https://sponsor.moderato.tempo.xyz' as const
+const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' as const
+
+type RegistrationResult = {
+ masterId: Hex
+ registrationHash: Hex
+ salt: Hex
+ txHash: Hex
+ virtualAddress: Address
+}
+
+type MinerState =
+ | { status: 'idle' }
+ | {
+ status: 'mining'
+ totalAttempts: number
+ hashesPerSecond: number
+ }
+ | {
+ status: 'found'
+ salt: string
+ masterId: string
+ registrationHash: string
+ attempts: number
+ }
+ | { status: 'error'; message: string }
+
+type MinerWorkerCommand =
+ | {
+ type: 'start'
+ batchSize: number
+ masterAddress: Address
+ startHex: Hex
+ }
+ | { type: 'stop' }
+
+type MinerWorkerMessage =
+ | { type: 'ready' }
+ | {
+ type: 'progress'
+ attempts: number
+ hashesPerSecond: number
+ }
+ | {
+ type: 'found'
+ attempts: number
+ saltHex: string
+ masterIdHex: string
+ registrationHashHex: string
+ }
+ | { type: 'stopped'; attempts: number }
+ | { type: 'error'; message: string }
+
+type SendResult = {
+ after: {
+ master: string
+ virtual: string
+ }
+ before: {
+ master: string
+ virtual: string
+ }
+ events: Array<{
+ amount: string
+ from: Address
+ to: Address
+ }>
+ sender: Address
+ txHash: Hex
+}
+
+function formatCount(value: number): string {
+ if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}B`
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
+ return value.toString()
+}
+
+function randomHex(size: number): Hex {
+ const bytes = new Uint8Array(size)
+ crypto.getRandomValues(bytes)
+ return `0x${Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')}` as Hex
+}
+
+function PasskeyLogin() {
+ const connect = useConnect()
+ const disconnect = useDisconnect()
+ const connector = useWebAuthnConnector()
+
+ return connect.isPending ? (
+
+ Check prompt
+
+ ) : (
+
+ {
+ await disconnect.disconnectAsync().catch(() => {})
+ connect.connect({ connector })
+ }}
+ type="button"
+ >
+ Sign in
+
+ {
+ await disconnect.disconnectAsync().catch(() => {})
+ connect.connect({
+ connector,
+ capabilities: {
+ label: 'Tempo Docs',
+ type: 'sign-up',
+ },
+ })
+ }}
+ type="button"
+ >
+ Sign up
+
+
+ )
+}
+
+export function VirtualAddressesLiveDemo() {
+ const tempoEnv = import.meta.env.VITE_TEMPO_ENV
+ const isLocalnet = tempoEnv === 'localnet'
+ const isDevnet = tempoEnv === 'devnet'
+ const isModerato = !isLocalnet && !isDevnet
+ const isPublicTestnet = isDevnet || isModerato
+ const isSupported = isLocalnet || isPublicTestnet
+ const { address } = useConnection()
+ const client = useClient()
+ const { writeContractAsync } = useWriteContract()
+
+ const [minerState, setMinerState] = React.useState({ status: 'idle' })
+ const [registration, setRegistration] = React.useState(null)
+ const [sendResult, setSendResult] = React.useState(null)
+
+ const minerRef = React.useRef(null)
+ const previousAddressRef = React.useRef(undefined)
+
+ const demoAdmin = React.useMemo(() => mnemonicToAccount(TEST_MNEMONIC), [])
+ const demoSender = React.useMemo(() => privateKeyToAccount(DEMO_SENDER_KEY), [])
+
+ const runtimeChain = React.useMemo(
+ () => (isLocalnet ? tempoLocalnet : isDevnet ? tempoDevnet : tempoModerato),
+ [isDevnet, isLocalnet],
+ )
+
+ const publicClient = React.useMemo(() => {
+ if (!runtimeChain) return null
+
+ return createPublicClient({
+ chain: runtimeChain,
+ transport: isLocalnet ? http() : http(runtimeChain.rpcUrls.default.http[0]),
+ })
+ }, [isLocalnet, runtimeChain])
+
+ const demoAdminClient = React.useMemo(() => {
+ if (!isLocalnet) return null
+
+ return createClient({
+ account: demoAdmin,
+ chain: tempoLocalnet,
+ transport: http(),
+ }).extend(tempoActions())
+ }, [demoAdmin, isLocalnet])
+
+ const demoSenderClient = React.useMemo(() => {
+ if (!runtimeChain) return null
+
+ return createClient({
+ account: demoSender,
+ chain: runtimeChain,
+ transport: isLocalnet
+ ? http()
+ : withFeePayer(
+ http(runtimeChain.rpcUrls.default.http[0]),
+ http(isDevnet ? DEVNET_SPONSOR_URL : MODERATO_SPONSOR_URL),
+ ),
+ }).extend(tempoActions())
+ }, [demoSender, isDevnet, isLocalnet, runtimeChain])
+
+ const { data: feeBalance } = Hooks.token.useGetBalance({
+ account: address,
+ token: alphaUsd,
+ query: {
+ enabled: Boolean(address && isLocalnet),
+ },
+ })
+
+ const stopMiner = React.useCallback(() => {
+ if (!minerRef.current) return
+ minerRef.current.postMessage({ type: 'stop' } satisfies MinerWorkerCommand)
+ minerRef.current.terminate()
+ minerRef.current = null
+ }, [])
+
+ React.useEffect(() => {
+ if (previousAddressRef.current === address) return
+
+ previousAddressRef.current = address as Address | undefined
+ stopMiner()
+ setMinerState({ status: 'idle' })
+ setRegistration(null)
+ setSendResult(null)
+ }, [address, stopMiner])
+
+ React.useEffect(
+ () => () => {
+ stopMiner()
+ },
+ [stopMiner],
+ )
+
+ const mineSalt = React.useCallback(
+ (masterAddress: Address) => {
+ return new Promise>(
+ (resolve, reject) => {
+ stopMiner()
+ setMinerState({ status: 'mining', totalAttempts: 0, hashesPerSecond: 0 })
+
+ const worker = new Worker(
+ new URL('./virtual-addresses/miner.worker.ts', import.meta.url),
+ {
+ type: 'module',
+ },
+ )
+ minerRef.current = worker
+
+ worker.onmessage = (event: MessageEvent) => {
+ const message = event.data
+
+ switch (message.type) {
+ case 'ready': {
+ break
+ }
+ case 'progress': {
+ setMinerState({
+ status: 'mining',
+ totalAttempts: message.attempts,
+ hashesPerSecond: message.hashesPerSecond,
+ })
+ break
+ }
+ case 'found': {
+ setMinerState({
+ status: 'found',
+ salt: message.saltHex,
+ masterId: message.masterIdHex,
+ registrationHash: message.registrationHashHex,
+ attempts: message.attempts,
+ })
+ stopMiner()
+ resolve({
+ masterId: message.masterIdHex as Hex,
+ registrationHash: message.registrationHashHex as Hex,
+ salt: message.saltHex as Hex,
+ })
+ break
+ }
+ case 'error': {
+ setMinerState({ status: 'error', message: message.message })
+ stopMiner()
+ reject(new Error(message.message))
+ break
+ }
+ case 'stopped': {
+ break
+ }
+ }
+ }
+
+ worker.onerror = (error) => {
+ const message = error.message || 'Virtual master mining failed.'
+ setMinerState({ status: 'error', message })
+ stopMiner()
+ reject(new Error(message))
+ }
+
+ worker.postMessage({
+ type: 'start',
+ batchSize: 100_000,
+ masterAddress,
+ startHex: randomHex(32),
+ } satisfies MinerWorkerCommand)
+ },
+ )
+ },
+ [stopMiner],
+ )
+
+ const getTokenBalance = React.useCallback(
+ async (target: Address, token: Address): Promise => {
+ if (!publicClient) throw new Error('Runtime client unavailable.')
+
+ return (await publicClient.readContract({
+ address: token,
+ abi: Abis.tip20,
+ functionName: 'balanceOf',
+ args: [target],
+ })) as bigint
+ },
+ [publicClient],
+ )
+
+ const waitForTokenBalance = React.useCallback(
+ async (target: Address, token: Address, timeoutMs = 120_000) => {
+ const startedAt = Date.now()
+
+ while (Date.now() - startedAt < timeoutMs) {
+ if ((await getTokenBalance(target, token)) > 0n) return
+ await new Promise((resolve) => setTimeout(resolve, 1_500))
+ }
+
+ throw new Error(`Timed out waiting for faucet funds for ${token}.`)
+ },
+ [getTokenBalance],
+ )
+
+ const ensureAccountFunded = React.useCallback(
+ async (target: Address, requiredTokens: Address[]) => {
+ if (!publicClient) return
+
+ if (isLocalnet) {
+ if (!demoAdminClient) return
+
+ const adminClient = demoAdminClient as unknown as Client
+
+ await publicClient
+ .request({
+ method: 'tempo_fundAddress' as never,
+ params: [target] as never,
+ })
+ .catch(() => {})
+
+ if (target === address && feeBalance && feeBalance > parseUnits('10', 6)) return
+
+ await Actions.token.transferSync(adminClient, {
+ account: demoAdmin,
+ amount: parseUnits('1000', 6),
+ chain: tempoLocalnet,
+ to: target,
+ token: alphaUsd,
+ })
+ return
+ }
+
+ if (!client) throw new Error('Client unavailable.')
+ if (requiredTokens.length === 0) return
+
+ const balances = await Promise.all(
+ requiredTokens.map((token) => getTokenBalance(target, token)),
+ )
+ if (balances.every((balance) => balance > 0n)) return
+
+ await Actions.faucet.fund(client as unknown as Client, {
+ account: target,
+ })
+ await Promise.all(requiredTokens.map((token) => waitForTokenBalance(target, token)))
+ },
+ [
+ address,
+ client,
+ demoAdmin,
+ demoAdminClient,
+ feeBalance,
+ getTokenBalance,
+ isLocalnet,
+ publicClient,
+ waitForTokenBalance,
+ ],
+ )
+
+ const registerMutation = useMutation({
+ mutationFn: async (): Promise => {
+ if (!isSupported) throw new Error('This live demo is available on Tempo testnet or localnet.')
+ if (!address) throw new Error('Sign in with a passkey first.')
+ if (!publicClient) throw new Error('Runtime client unavailable.')
+
+ const masterAddress = address as Address
+ await ensureAccountFunded(masterAddress, isPublicTestnet ? [] : [alphaUsd])
+ const mined = await mineSalt(masterAddress)
+
+ const txHash = await writeContractAsync({
+ address: VIRTUAL_REGISTRY_ADDRESS,
+ abi: Abis.addressRegistry,
+ functionName: 'registerVirtualMaster',
+ args: [mined.salt],
+ ...(isPublicTestnet ? { feePayer: true } : {}),
+ })
+
+ await publicClient.waitForTransactionReceipt({ hash: txHash })
+
+ return {
+ ...mined,
+ txHash,
+ virtualAddress: VirtualAddress.from({
+ masterId: mined.masterId,
+ userTag: DEMO_USER_TAG,
+ }),
+ }
+ },
+ onSuccess: (result) => {
+ setRegistration(result)
+ setSendResult(null)
+ },
+ })
+
+ const sendMutation = useMutation({
+ mutationFn: async (): Promise => {
+ if (!isSupported) throw new Error('This live demo is available on Tempo testnet or localnet.')
+ if (!registration) throw new Error('Register a master id first.')
+ if (!demoSenderClient || !publicClient) throw new Error('Runtime clients unavailable.')
+
+ const decimals = Number(
+ await publicClient.readContract({
+ address: pathUsd,
+ abi: Abis.tip20,
+ functionName: 'decimals',
+ }),
+ )
+ const amount = parseUnits('100', decimals)
+
+ await ensureAccountFunded(demoSender.address, isLocalnet ? [alphaUsd] : [pathUsd])
+
+ if (isLocalnet) {
+ if (!demoAdminClient) throw new Error('Localnet admin client unavailable.')
+
+ const adminClient = demoAdminClient as unknown as Client
+
+ await Actions.token.mint(adminClient, {
+ account: demoAdmin,
+ amount,
+ chain: tempoLocalnet,
+ to: demoSender.address,
+ token: pathUsd,
+ })
+ }
+
+ const [masterBefore, virtualBefore] = await Promise.all([
+ publicClient.readContract({
+ address: pathUsd,
+ abi: Abis.tip20,
+ functionName: 'balanceOf',
+ args: [address as Address],
+ }) as Promise,
+ publicClient.readContract({
+ address: pathUsd,
+ abi: Abis.tip20,
+ functionName: 'balanceOf',
+ args: [registration.virtualAddress],
+ }) as Promise,
+ ])
+
+ const { receipt } = await demoSenderClient.token.transferSync({
+ amount,
+ ...(isPublicTestnet ? { feePayer: true } : {}),
+ to: registration.virtualAddress,
+ token: pathUsd,
+ })
+
+ const [masterAfter, virtualAfter] = await Promise.all([
+ publicClient.readContract({
+ address: pathUsd,
+ abi: Abis.tip20,
+ functionName: 'balanceOf',
+ args: [address as Address],
+ }) as Promise,
+ publicClient.readContract({
+ address: pathUsd,
+ abi: Abis.tip20,
+ functionName: 'balanceOf',
+ args: [registration.virtualAddress],
+ }) as Promise,
+ ])
+
+ return {
+ after: {
+ master: formatUnits(masterAfter, decimals),
+ virtual: formatUnits(virtualAfter, decimals),
+ },
+ before: {
+ master: formatUnits(masterBefore, decimals),
+ virtual: formatUnits(virtualBefore, decimals),
+ },
+ events: receipt.logs
+ .filter(
+ (log) =>
+ log.address.toLowerCase() === pathUsd.toLowerCase() &&
+ log.topics[0] === TRANSFER_TOPIC,
+ )
+ .map((log) => ({
+ amount: formatUnits(BigInt(log.data), decimals),
+ from: `0x${log.topics[1]?.slice(26) ?? ''}` as Address,
+ to: `0x${log.topics[2]?.slice(26) ?? ''}` as Address,
+ })),
+ sender: demoSender.address,
+ txHash: receipt.transactionHash,
+ }
+ },
+ onSuccess: (result) => setSendResult(result),
+ })
+
+ const registerAction = !isSupported ? (
+
+ Devnet or localnet only
+
+ ) : registration ? null : (
+ registerMutation.mutate()}
+ type="button"
+ variant={address ? 'accent' : 'default'}
+ >
+ {registerMutation.isPending
+ ? minerState.status === 'mining'
+ ? 'Mining salt…'
+ : minerState.status === 'found'
+ ? 'Confirm passkey…'
+ : 'Registering…'
+ : 'Register master id'}
+
+ )
+
+ const tokenSymbol = 'pathUSD'
+
+ return (
+
+
:
}
+ completed={Boolean(address)}
+ number={1}
+ title="Sign in with a passkey and create a Tempo address."
+ >
+ {address && (
+
+
+ Connected passkey account
+ {address}
+
+ The demo auto-funds the passkey account, uses `ox` to grind the registration salt in
+ a background worker, and sends the deposit from a separate demo address.
+
+
+
+ )}
+
+
+
+
+ {!isSupported ? (
+
+ Run docs against Tempo testnet or localnet to use this live preview.
+
+ ) : !address ? (
+
+ Sign in first, then the demo will fund the account if needed, mine the required salt
+ with `VirtualMaster.mineSalt`, and prompt the passkey for registration.
+
+ ) : registration ? (
+
+
+ masterId: {' '}
+ {registration.masterId}
+
+
+ salt: {' '}
+
+ {registration.salt}
+
+
+
+ virtual address: {' '}
+
+ {registration.virtualAddress}
+
+
+
+ registration tx: {' '}
+
+ {registration.txHash}
+
+
+
+ ) : minerState.status === 'mining' ? (
+
+
+ hashes tried: {' '}
+
+ {formatCount(minerState.totalAttempts)}
+
+
+
+ hash rate: {' '}
+
+ {formatCount(minerState.hashesPerSecond)}/s
+
+
+
+ The browser is searching for the 32-bit proof-of-work required by TIP-1022. This
+ usually takes a few minutes.
+
+
+ ) : minerState.status === 'found' ? (
+
+
+ masterId: {' '}
+ {minerState.masterId}
+
+
+ salt found: {' '}
+
+ {minerState.salt}
+
+
+
Waiting for the registration transaction to be confirmed.
+
+ ) : (
+
+ Click Register master id to fund the passkey for
+ fees, mine a valid salt, and submit{' '}
+ registerVirtualMaster.
+
+ )}
+
+
+
+
sendMutation.mutate()}
+ type="button"
+ variant={registration && isSupported ? 'accent' : 'default'}
+ >
+ {sendMutation.isPending
+ ? `Sending ${tokenSymbol}…`
+ : sendResult
+ ? `Send another 100 ${tokenSymbol}`
+ : `Send 100 ${tokenSymbol}`}
+
+ }
+ completed={Boolean(sendResult)}
+ error={sendMutation.error}
+ number={3}
+ title="Send from another address to the virtual address and watch it land in the registered wallet."
+ >
+
+ {registration ? (
+
+
+ demo sender: {' '}
+ {demoSender.address}
+
+
+ virtual address: {' '}
+
+ {registration.virtualAddress}
+
+
+
+ registered wallet: {' '}
+ {address}
+
+
+ {sendResult ? (
+ <>
+
+ transfer tx: {' '}
+
+ {sendResult.txHash}
+
+
+
+
+ master balance: {' '}
+
+ {sendResult.before.master} → {sendResult.after.master}
+
+
+
+ virtual balance: {' '}
+
+ {sendResult.before.virtual} → {sendResult.after.virtual}
+
+
+
+
+
two-hop Transfer events
+ {sendResult.events.map((event, index) => (
+
+
+ {StringFormatter.truncate(event.from, { start: 8, end: 6 })} →{' '}
+ {StringFormatter.truncate(event.to, { start: 8, end: 6 })} ({event.amount}{' '}
+ {tokenSymbol})
+
+
+ ))}
+
+ >
+ ) : (
+
+ The demo uses `VirtualAddress.from` to derive a deposit address, funds a separate
+ sender if needed, transfers 100 {tokenSymbol} to the virtual address, and then
+ reads both balances so you can see the tokens land in the registered passkey
+ wallet.
+
+ )}
+
+ ) : (
+
+ Finish registration first. This step needs a master id and derived virtual address.
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/guides/virtual-addresses/miner.worker.ts b/src/components/guides/virtual-addresses/miner.worker.ts
new file mode 100644
index 00000000..572a6e01
--- /dev/null
+++ b/src/components/guides/virtual-addresses/miner.worker.ts
@@ -0,0 +1,103 @@
+import { VirtualMaster } from 'ox/tempo'
+import type { Address, Hex } from 'viem'
+
+type ToWorker =
+ | {
+ type: 'start'
+ batchSize: number
+ masterAddress: Address
+ startHex: Hex
+ }
+ | { type: 'stop' }
+
+type FromWorker =
+ | { type: 'ready' }
+ | {
+ type: 'progress'
+ attempts: number
+ hashesPerSecond: number
+ }
+ | {
+ type: 'found'
+ attempts: number
+ saltHex: string
+ masterIdHex: string
+ registrationHashHex: string
+ }
+ | { type: 'stopped'; attempts: number }
+ | { type: 'error'; message: string }
+
+function post(message: FromWorker) {
+ self.postMessage(message)
+}
+
+let running = false
+
+self.onmessage = (event: MessageEvent) => {
+ const message = event.data
+
+ if (message.type === 'stop') {
+ running = false
+ return
+ }
+
+ if (message.type !== 'start') return
+
+ running = true
+
+ const { masterAddress, startHex, batchSize } = message
+ let nextStart = BigInt(startHex)
+ let totalAttempts = 0
+ const startedAt = performance.now()
+
+ const mine = () => {
+ if (!running) {
+ post({ type: 'stopped', attempts: totalAttempts })
+ return
+ }
+
+ try {
+ const result = VirtualMaster.mineSalt({
+ address: masterAddress,
+ start: nextStart,
+ count: batchSize,
+ })
+
+ if (result) {
+ running = false
+ const attemptsInBatch = Number(BigInt(result.salt) - nextStart + 1n)
+
+ post({
+ type: 'found',
+ attempts: totalAttempts + attemptsInBatch,
+ saltHex: result.salt,
+ masterIdHex: result.masterId,
+ registrationHashHex: result.registrationHash,
+ })
+ return
+ }
+
+ totalAttempts += batchSize
+ nextStart += BigInt(batchSize)
+ const elapsed = performance.now() - startedAt
+ const hashesPerSecond = Math.round((totalAttempts / elapsed) * 1000)
+
+ post({
+ type: 'progress',
+ attempts: totalAttempts,
+ hashesPerSecond,
+ })
+
+ setTimeout(mine, 0)
+ } catch (error) {
+ running = false
+ post({
+ type: 'error',
+ message: error instanceof Error ? error.message : 'Virtual master mining failed.',
+ })
+ }
+ }
+
+ post({ type: 'ready' })
+ mine()
+}
diff --git a/src/pages/guide/payments/index.mdx b/src/pages/guide/payments/index.mdx
index ff4d34fd..635b1203 100644
--- a/src/pages/guide/payments/index.mdx
+++ b/src/pages/guide/payments/index.mdx
@@ -30,6 +30,13 @@ Send and receive payments using stablecoins on Tempo. Learn how to integrate pay
title="Attach a Transfer Memo"
/>
+
+
>TIP20: transfer(virtualAddress, amount)
+ TIP20->>Registry: resolve(masterId)
+ Registry-->>TIP20: master wallet
+ TIP20->>Master: credit balance
+ Note over TIP20: emits Transfer(sender → virtual, amount)
+ Note over TIP20: emits Transfer(virtual → master, amount)
+`} />
+
+The important behavior is:
+
+- the sender pays the **virtual address**
+- TIP-20 resolves that address to the registered **master wallet**
+- the **master wallet** receives the balance
+- the virtual address still appears in events, so you can attribute the deposit correctly
+
+## Live demo
+
+This walkthrough shows the full flow:
+
+1. sign in with a passkey and get a Tempo address
+2. register a master id for that address
+3. send `pathUSD` from a second address to a virtual address derived from that master id
+4. confirm that the balance lands in the registered wallet
+
+
+
+
+
+## What to look for
+
+When the demo succeeds, you should see all of the following:
+
+- the passkey wallet is shown as the registered master wallet
+- the virtual address is distinct from the master wallet
+- the sender transfers `pathUSD` to the virtual address
+- the master wallet balance increases
+- the virtual address TIP-20 balance remains `0`
+- the receipt shows the expected two-hop `Transfer` events
+
+## Derive deposit addresses offchain
+
+Once a master is registered, operators derive virtual addresses offchain from the `masterId` and their own customer tag.
+
+:::code-group
+
+```ts [virtualAddress.ts]
+import { VirtualAddress } from 'ox/tempo'
+
+const virtualAddress = VirtualAddress.from({
+ masterId,
+ userTag: '0x000000000001',
+})
+```
+
+:::
+
+In practice, the `userTag` is the operator's internal routing value for a customer, account, or payment reference.
+
+## Operational notes
+
+A few things matter in production:
+
+- virtual forwarding applies only to **TIP-20** transfer and mint paths
+- `balanceOf(virtualAddress)` stays `0`; use events and your own `userTag` mapping for attribution
+- policy checks apply to the **resolved master wallet**, not the literal virtual address
+- protocols that treat addresses literally should reject virtual addresses unless they explicitly support them
+
+## Learn more
+
+
+
+
+
+
diff --git a/src/pages/protocol/tip20/virtual-addresses.mdx b/src/pages/protocol/tip20/virtual-addresses.mdx
index 48d0d3f1..c4b083ee 100644
--- a/src/pages/protocol/tip20/virtual-addresses.mdx
+++ b/src/pages/protocol/tip20/virtual-addresses.mdx
@@ -4,6 +4,7 @@ description: Understand how TIP-20 virtual addresses work, why they remove sweep
---
import { Cards, Card } from 'vocs'
+import { MermaidDiagram } from '../../../components/MermaidDiagram'
import { StaticMermaidDiagram } from '../../../components/StaticMermaidDiagram'
# Virtual addresses for TIP-20 deposits
@@ -14,16 +15,26 @@ For exchanges, ramps, custodians, and payment processors, this changes the opera
## Why this feature exists
-Without virtual addresses, per-customer deposit addresses are operationally expensive. Each deposit address becomes a real onchain balance holder. Funds land there first, and then the operator has to sweep those funds into a central wallet.
+Without virtual addresses, per-customer deposit addresses are operationally expensive. Each deposit address becomes a real onchain balance holder. Funds land there first, and then the operator has to sweep those funds into a central wallet.
With virtual addresses, the customer-facing address is still unique, but it behaves like a routing alias. The protocol resolves it to the registered master wallet during the TIP-20 transfer itself.
-
-
+ B1[Separate onchain balance]
+ B1 --> S1[Sweep transaction]
+ S1 --> M1[Master wallet]
+ end
+
+ subgraph B[With virtual addresses]
+ D2[Customer virtual address] --> F2[TIP-20 forwarding]
+ F2 --> M2[Master wallet]
+ end
+`} />
This means:
-- you keep one deposit address per customer
+- you keep one deposit address per customer
- the master wallet receives the balance directly
- no sweep transaction is needed
- no separate TIP-20 balance is created for each deposit address
@@ -64,7 +75,7 @@ TIP-20 recognizes a virtual address by the fixed 10-byte middle marker. It then
When a sender transfers a covered TIP-20 token to a virtual address, the TIP-20 precompile detects the virtual format, looks up the registered master, and credits that master wallet.
-