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 ? ( + + ) : ( +
+ + +
+ ) +} + +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 ? ( + + ) : registration ? null : ( + + ) + + 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. -Without virtual addresses, each customer deposit address holds a separate onchain balance that must be swept to the master wallet. With virtual addresses, TIP-20 forwarding routes deposits directly to the master wallet. -Without virtual addresses, each customer deposit address holds a separate onchain balance that must be swept to the master wallet. With virtual addresses, TIP-20 forwarding routes deposits directly to the master wallet. + 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. -