diff --git a/e2e/swap-across-zones.test.ts b/e2e/swap-across-zones.test.ts index 9c513ca4..a7650cc8 100644 --- a/e2e/swap-across-zones.test.ts +++ b/e2e/swap-across-zones.test.ts @@ -54,7 +54,9 @@ test('swap pathUSD from Zone A into betaUSD on Zone B', async ({ page }) => { timeout: 120000, }) await expect( - page.getByText('Withdraw 25 pathUSD from Zone A, swap it, and route betaUSD into Zone B.').first(), + page + .getByText('Withdraw 25 pathUSD from Zone A, swap it, and route betaUSD into Zone B.') + .first(), ).toBeVisible() await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }) diff --git a/package.json b/package.json index 6a8c7005..57b9f698 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.18", + "ox": "0.14.20", "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 35423bcc..5eeb72bd 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.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)) + 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.20(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.18 - version: 0.14.18(typescript@5.9.3)(zod@4.3.6) + specifier: 0.14.20 + version: 0.14.20(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.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)) + 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.20(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) @@ -3157,8 +3157,8 @@ packages: typescript: optional: true - ox@0.14.18: - resolution: {integrity: sha512-1Irk/tvMsw7xJDuCTT/u9azSjz0YX9hrYFgJOacIuFwibaW2zZBXAMrpzegndYb5o8GLpxB6/0qro4/c40q6VQ==} + ox@0.14.20: + resolution: {integrity: sha512-rby38C3nDn8eQkf29Zgw4hkCZJ64Qqi0zRPWL8ENUQ7JVuoITqrVtwWQgM/He19SCMUEc7hS/Sjw0jIOSLJhOw==} peerDependencies: typescript: '>=5.4.0' peerDependenciesMeta: @@ -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.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/connectors@8.0.1(@wagmi/core@3.4.2(@tanstack/query-core@5.99.0)(@types/react@19.2.14)(ox@0.14.20(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.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)) + '@wagmi/core': 3.4.2(@tanstack/query-core@5.99.0)(@types/react@19.2.14)(ox@0.14.20(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.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))': + '@wagmi/core@3.4.2(@tanstack/query-core@5.99.0)(@types/react@19.2.14)(ox@0.14.20(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.18(typescript@5.9.3)(zod@4.3.6) + ox: 0.14.20(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.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)): + 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.20(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.18(typescript@5.9.3)(zod@4.3.6) + ox: 0.14.20(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.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)) + '@wagmi/core': 3.4.2(@tanstack/query-core@5.99.0)(@types/react@19.2.14)(ox@0.14.20(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: @@ -7285,7 +7285,7 @@ snapshots: transitivePeerDependencies: - zod - ox@0.14.18(typescript@5.9.3)(zod@4.3.6): + ox@0.14.20(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.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)): + 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.20(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.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)) + '@wagmi/connectors': 8.0.1(@wagmi/core@3.4.2(@tanstack/query-core@5.99.0)(@types/react@19.2.14)(ox@0.14.20(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.20(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.18(typescript@5.9.3)(zod@4.3.6) + ox: 0.14.20(typescript@5.9.3)(zod@4.3.6) transitivePeerDependencies: - typescript - zod diff --git a/src/components/guides/Demo.tsx b/src/components/guides/Demo.tsx index 67390091..ad8f8dcb 100644 --- a/src/components/guides/Demo.tsx +++ b/src/components/guides/Demo.tsx @@ -57,12 +57,12 @@ function getExplorerHost() { return tempoModerato.blockExplorers.default.url } -export function ExplorerLink({ hash }: { hash: string }) { +export function ExplorerLink({ hash, inline = false }: { hash: string; inline?: boolean }) { const { trackExternalLinkClick } = usePostHogTracking() const url = `${getExplorerHost()}/tx/${hash}` return ( -
+
+
diff --git a/src/components/guides/VirtualAddressesFastDemo.tsx b/src/components/guides/VirtualAddressesFastDemo.tsx new file mode 100644 index 00000000..3a3d89a3 --- /dev/null +++ b/src/components/guides/VirtualAddressesFastDemo.tsx @@ -0,0 +1,630 @@ +'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, + createWalletClient, + formatUnits, + type Hex, + http, + parseUnits, + type Transport, + zeroAddress, +} from 'viem' +import { mnemonicToAccount } from 'viem/accounts' +import { tempoDevnet, tempoLocalnet, tempoModerato } from 'viem/chains' +import { Abis, Actions, tempoActions, withFeePayer } from 'viem/tempo' +import { Button, ExplorerAccountLink, ExplorerLink, 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 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 +const PREMINED_DEMO_MASTER_SALT = + '0x00000000000000000000000000000000000000000000000000000000a559642c' as const +const PREMINED_DEMO_MASTER_ID = '0xb385a519' as const +const PREMINED_DEMO_REGISTRATION_HASH = + '0x00000000b385a5196cd77effb51c3c46f09634f47dc1ecd4cbef7acb61ec404b' as const + +function normalizeUserTag(value: string): Hex | null { + const trimmed = value.trim().toLowerCase() + if (!trimmed) return null + + const withoutPrefix = trimmed.startsWith('0x') ? trimmed.slice(2) : trimmed + if (!/^[0-9a-f]{1,12}$/.test(withoutPrefix)) return null + + return `0x${withoutPrefix.padStart(12, '0')}` as Hex +} + +type RegistrationResult = { + masterAddress: Address + masterId: Hex + registrationHash: Hex + salt: Hex + txHash?: Hex + virtualAddress: Address +} + +type SendResult = { + after: { + master: string + virtual: string + } + before: { + master: string + virtual: string + } + events: Array<{ + amount: string + from: Address + to: Address + }> + sender: Address + txHash: Hex +} + +export function VirtualAddressesFastDemo() { + 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 hasExplorerLink = isModerato || Boolean(import.meta.env.VITE_EXPLORER_OVERRIDE) + + const [registration, setRegistration] = React.useState(null) + const [sendResult, setSendResult] = React.useState(null) + const [userTagInput, setUserTagInput] = React.useState(DEMO_USER_TAG) + + const demoAdmin = React.useMemo(() => mnemonicToAccount(TEST_MNEMONIC), []) + const demoMaster = React.useMemo(() => mnemonicToAccount(TEST_MNEMONIC, { addressIndex: 1 }), []) + const demoSender = React.useMemo(() => mnemonicToAccount(TEST_MNEMONIC, { addressIndex: 2 }), []) + + 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 demoMasterWalletClient = React.useMemo(() => { + if (!runtimeChain) return null + + return createWalletClient({ + account: demoMaster, + chain: runtimeChain, + transport: isLocalnet + ? http() + : withFeePayer( + http(runtimeChain.rpcUrls.default.http[0]), + http(isDevnet ? DEVNET_SPONSOR_URL : MODERATO_SPONSOR_URL), + ), + }) + }, [demoMaster, isDevnet, isLocalnet, runtimeChain]) + + 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 baseRegistration = React.useMemo( + (): RegistrationResult => ({ + masterAddress: demoMaster.address, + masterId: PREMINED_DEMO_MASTER_ID, + registrationHash: PREMINED_DEMO_REGISTRATION_HASH, + salt: PREMINED_DEMO_MASTER_SALT, + virtualAddress: VirtualAddress.from({ + masterId: PREMINED_DEMO_MASTER_ID, + userTag: DEMO_USER_TAG, + }), + }), + [demoMaster.address], + ) + + const normalizedUserTag = React.useMemo(() => normalizeUserTag(userTagInput), [userTagInput]) + + const customVirtualAddress = React.useMemo(() => { + if (!registration || !normalizedUserTag) return null + + return VirtualAddress.from({ + masterId: registration.masterId, + userTag: normalizedUserTag, + }) + }, [normalizedUserTag, registration]) + + 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 (requiredTokens.includes(alphaUsd)) { + await Actions.token.transferSync(adminClient, { + account: demoAdmin, + amount: parseUnits('1000', 6), + chain: tempoLocalnet, + to: target, + token: alphaUsd, + }) + } + return + } + + if (!demoSenderClient) throw new Error('Runtime clients 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(demoSenderClient as unknown as Client, { + account: target, + }) + await Promise.all(requiredTokens.map((token) => waitForTokenBalance(target, token))) + }, + [ + demoAdmin, + demoAdminClient, + demoSenderClient, + 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 (!publicClient || !demoMasterWalletClient) throw new Error('Runtime clients unavailable.') + + await ensureAccountFunded(demoMaster.address, isPublicTestnet ? [] : [alphaUsd]) + + const registeredMaster = (await publicClient.readContract({ + address: VIRTUAL_REGISTRY_ADDRESS, + abi: Abis.addressRegistry, + functionName: 'getMaster', + args: [PREMINED_DEMO_MASTER_ID], + })) as Address + + if (registeredMaster.toLowerCase() === demoMaster.address.toLowerCase()) + return baseRegistration + if (registeredMaster.toLowerCase() !== zeroAddress) + throw new Error( + 'The pre-mined demo master id is already registered to a different address.', + ) + + const txHash = await demoMasterWalletClient.writeContract({ + address: VIRTUAL_REGISTRY_ADDRESS, + abi: Abis.addressRegistry, + functionName: 'registerVirtualMaster', + args: [PREMINED_DEMO_MASTER_SALT], + ...(isPublicTestnet ? { feePayer: true } : {}), + }) + + await publicClient.waitForTransactionReceipt({ hash: txHash }) + + return { + ...baseRegistration, + txHash, + } + }, + 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('Prepare the demo master first.') + if (!customVirtualAddress) + throw new Error('Enter a valid user tag to derive a virtual address.') + 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: [registration.masterAddress], + }) as Promise, + publicClient.readContract({ + address: pathUsd, + abi: Abis.tip20, + functionName: 'balanceOf', + args: [customVirtualAddress], + }) as Promise, + ]) + + const { receipt } = await demoSenderClient.token.transferSync({ + amount, + ...(isPublicTestnet ? { feePayer: true } : {}), + to: customVirtualAddress, + token: pathUsd, + }) + + const [masterAfter, virtualAfter] = await Promise.all([ + publicClient.readContract({ + address: pathUsd, + abi: Abis.tip20, + functionName: 'balanceOf', + args: [registration.masterAddress], + }) as Promise, + publicClient.readContract({ + address: pathUsd, + abi: Abis.tip20, + functionName: 'balanceOf', + args: [customVirtualAddress], + }) 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 tokenSymbol = 'pathUSD' + + return ( +
+ registerMutation.mutate()} + type="button" + variant={isSupported ? 'accent' : 'default'} + > + {registerMutation.isPending ? 'Preparing demo master…' : 'Prepare demo master'} + + } + completed={Boolean(registration)} + error={registerMutation.error} + number={1} + title="Use a docs-managed master with a pre-mined valid salt." + > +
+ {!isSupported ? ( +
+ Run docs against Tempo testnet or localnet to use this live preview. +
+ ) : registration ? ( +
+
+ master wallet: + + {registration.masterAddress} + +
+
+ masterId: + {registration.masterId} +
+
+ pre-mined salt: + + {registration.salt} + +
+
+ virtual address: +
+
+
+ + { + setUserTagInput(event.target.value) + setSendResult(null) + }} + placeholder="0x000000000001" + spellCheck={false} + value={userTagInput} + /> +
+ {normalizedUserTag && customVirtualAddress ? ( + <> +
+ Normalized user tag: + {normalizedUserTag} +
+
+ + {customVirtualAddress} + + {hasExplorerLink && ( + + )} +
+ + ) : ( +
+ Enter 1 to 12 hex characters, with or without{' '} + 0x. +
+ )} +
+
+
+ {registration.txHash ? ( +
+ registration tx: + + {registration.txHash} + +
+ ) : ( +
+ This docs-managed wallet was already registered, so the tab can skip straight to + the transfer. +
+ )} +
+ ) : registerMutation.isPending ? ( +
+ This tab skips live mining and submits a pre-mined valid TIP-1022 salt for a + docs-managed wallet so you can get to the forwarding flow immediately. +
+ ) : ( +
+ Click Prepare demo master to use a shared + docs-managed wallet with pre-mined valid TIP-1022 salt. +
+ )} +
+
+ + 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={2} + title="Send from another address to the virtual address and watch it land in the registered wallet." + > +
+ {registration ? ( +
+
+ demo sender: +
+ + {demoSender.address} + + {hasExplorerLink && } +
+
+
+ virtual address: +
+ + {customVirtualAddress} + + {customVirtualAddress && hasExplorerLink && ( + + )} +
+
+
+ registered wallet: +
+ + {registration.masterAddress} + + {hasExplorerLink && ( + + )} +
+
+ + {sendResult ? ( + <> +
+ transfer tx: +
+ + {sendResult.txHash} + + {hasExplorerLink && } +
+
+
+
+ master balance: + + {sendResult.before.master} → {sendResult.after.master} + +
+
+
+ Transfer events in this receipt +
+ Treat the sender → virtual and{' '} + virtual → master pair as one + logical deposit to the registered wallet. Other transfer logs in the receipt, + like fees, are separate. +
+ {sendResult.events.map((event, index) => ( +
+ + {StringFormatter.truncate(event.from, { start: 8, end: 6 })} →{' '} + {StringFormatter.truncate(event.to, { start: 8, end: 6 })} ({event.amount}{' '} + {tokenSymbol}) + +
+ ))} +
+ + ) : ( +
+ This tab uses a shared registered wallet and a pre-mined valid salt, so you can + focus on the forwarding behavior without waiting for salt mining to finish. Edit + the user tag to derive a different virtual address for the same registered wallet. +
+ )} +
+ ) : ( +
+ Prepare the demo master first. This step needs a registered master id and derived + virtual address. +
+ )} +
+
+
+ ) +} diff --git a/src/components/guides/VirtualAddressesLiveDemo.tsx b/src/components/guides/VirtualAddressesLiveDemo.tsx index a7259db2..fff6a53a 100644 --- a/src/components/guides/VirtualAddressesLiveDemo.tsx +++ b/src/components/guides/VirtualAddressesLiveDemo.tsx @@ -1,7 +1,7 @@ 'use client' import { useMutation } from '@tanstack/react-query' -import { VirtualAddress } from 'ox/tempo' +import { VirtualAddress, VirtualMaster } from 'ox/tempo' import * as React from 'react' import { type Address, @@ -21,7 +21,7 @@ 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 { Button, ExplorerAccountLink, ExplorerLink, 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' @@ -53,36 +53,9 @@ type MinerState = 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 @@ -114,6 +87,13 @@ function randomHex(size: number): Hex { return `0x${Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')}` as Hex } +function isAbortError(error: unknown): boolean { + return ( + error instanceof Error && + (error.name === 'AbortError' || error.message.toLowerCase().includes('aborted')) + ) +} + function PasskeyLogin() { const connect = useConnect() const disconnect = useDisconnect() @@ -164,6 +144,7 @@ export function VirtualAddressesLiveDemo() { const isModerato = !isLocalnet && !isDevnet const isPublicTestnet = isDevnet || isModerato const isSupported = isLocalnet || isPublicTestnet + const hasExplorerLink = isModerato || Boolean(import.meta.env.VITE_EXPLORER_OVERRIDE) const { address } = useConnection() const client = useClient() const { writeContractAsync } = useWriteContract() @@ -172,7 +153,7 @@ export function VirtualAddressesLiveDemo() { const [registration, setRegistration] = React.useState(null) const [sendResult, setSendResult] = React.useState(null) - const minerRef = React.useRef(null) + const abortControllerRef = React.useRef(null) const previousAddressRef = React.useRef
(undefined) const demoAdmin = React.useMemo(() => mnemonicToAccount(TEST_MNEMONIC), []) @@ -226,10 +207,8 @@ export function VirtualAddressesLiveDemo() { }) const stopMiner = React.useCallback(() => { - if (!minerRef.current) return - minerRef.current.postMessage({ type: 'stop' } satisfies MinerWorkerCommand) - minerRef.current.terminate() - minerRef.current = null + abortControllerRef.current?.abort() + abortControllerRef.current = null }, []) React.useEffect(() => { @@ -250,78 +229,50 @@ export function VirtualAddressesLiveDemo() { ) 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) - }, - ) + async (masterAddress: Address) => { + stopMiner() + setMinerState({ status: 'mining', totalAttempts: 0, hashesPerSecond: 0 }) + + const abortController = new AbortController() + abortControllerRef.current = abortController + + try { + const result = await VirtualMaster.mineSaltAsync({ + address: masterAddress, + onProgress: (progress) => { + setMinerState({ + status: 'mining', + totalAttempts: progress.attempts, + hashesPerSecond: Math.round(progress.rate), + }) + }, + signal: abortController.signal, + start: randomHex(32), + }) + + if (!result) throw new Error('Unable to find a valid TIP-1022 salt.') + + setMinerState({ + status: 'found', + salt: result.salt, + masterId: result.masterId, + registrationHash: result.registrationHash, + }) + + return { + masterId: result.masterId, + registrationHash: result.registrationHash, + salt: result.salt, + } + } catch (error) { + if (isAbortError(error)) throw error + + const message = error instanceof Error ? error.message : 'Virtual master mining failed.' + setMinerState({ status: 'error', message }) + throw new Error(message) + } finally { + if (abortControllerRef.current === abortController) abortControllerRef.current = null + } }, [stopMiner], ) @@ -577,8 +528,9 @@ export function VirtualAddressesLiveDemo() { 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. + The demo auto-funds the passkey account, uses `VirtualMaster.mineSaltAsync` to mine + a valid salt with parallel workers when available, and sends the deposit from a + separate demo address.
@@ -601,7 +553,7 @@ export function VirtualAddressesLiveDemo() { ) : !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. + with `VirtualMaster.mineSaltAsync`, and prompt the passkey for registration.
) : registration ? (
@@ -643,8 +595,8 @@ export function VirtualAddressesLiveDemo() {
- The browser is searching for the 32-bit proof-of-work required by TIP-1022. This - usually takes a few minutes. + `VirtualMaster.mineSaltAsync` is searching for the 32-bit proof-of-work required by + TIP-1022 using parallel workers when the browser supports them.
) : minerState.status === 'found' ? ( @@ -698,26 +650,38 @@ export function VirtualAddressesLiveDemo() {
demo sender:{' '} - {demoSender.address} + + {demoSender.address} + {hasExplorerLink && } +
virtual address:{' '} - - {registration.virtualAddress} - + + + {registration.virtualAddress} + + {hasExplorerLink && } +
registered wallet:{' '} - {address} + + {address} + {hasExplorerLink && address && } +
{sendResult ? ( <>
transfer tx:{' '} - - {sendResult.txHash} - + + + {sendResult.txHash} + + {hasExplorerLink && } +
@@ -734,7 +698,14 @@ export function VirtualAddressesLiveDemo() {
- two-hop Transfer events + Transfer events in this receipt +
+ Treat the sender → virtual{' '} + and virtual → master pair as + one + logical deposit to the registered wallet. Other transfer logs in the receipt, + like fees, are separate. +
{sendResult.events.map((event, index) => (
diff --git a/src/components/guides/virtual-addresses/miner.worker.ts b/src/components/guides/virtual-addresses/miner.worker.ts deleted file mode 100644 index 572a6e01..00000000 --- a/src/components/guides/virtual-addresses/miner.worker.ts +++ /dev/null @@ -1,103 +0,0 @@ -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/virtual-addresses.mdx b/src/pages/guide/payments/virtual-addresses.mdx index 11a4d1a7..2fef5fef 100644 --- a/src/pages/guide/payments/virtual-addresses.mdx +++ b/src/pages/guide/payments/virtual-addresses.mdx @@ -4,8 +4,9 @@ description: Register a virtual-address master, derive deposit addresses offchai interactive: true --- -import { Card, Cards } from 'vocs' +import { Card, Cards, Tab, Tabs } from 'vocs' import * as Demo from '../../../components/guides/Demo.tsx' +import { VirtualAddressesFastDemo } from '../../../components/guides/VirtualAddressesFastDemo.tsx' import { VirtualAddressesLiveDemo } from '../../../components/guides/VirtualAddressesLiveDemo.tsx' import { MermaidDiagram } from '../../../components/MermaidDiagram' @@ -47,10 +48,41 @@ This walkthrough shows the full flow: 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 + + + +
+ +Use `VirtualMaster.mineSaltAsync` to register a master id for the passkey account you create in the demo. + +
+ +:::info +Mining the TIP-1022 salt can take 30+ seconds depending on your browser, hardware, and available worker parallelism. +::: + +
+ + + + +
+ +Use a docs-managed master with a pre-mined valid salt so you can skip the wait and jump straight to the forwarding flow. + +
+ + + + + + + + ## What to look for When the demo succeeds, you should see all of the following: