diff --git a/e2e/deposit-to-a-zone.test.ts b/e2e/deposit-to-a-zone.test.ts
new file mode 100644
index 00000000..56b2e73e
--- /dev/null
+++ b/e2e/deposit-to-a-zone.test.ts
@@ -0,0 +1,54 @@
+import { expect, test } from '@playwright/test'
+
+test('prepare zone access and deposit to Zone A', async ({ page }) => {
+ test.setTimeout(180000)
+
+ 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,
+ },
+ })
+
+ await page.goto('/guide/private-zones/deposit-to-a-zone')
+
+ const signUpButton = page.getByRole('button', { name: 'Sign up' }).first()
+ await expect(signUpButton).toBeVisible({ timeout: 90000 })
+ await signUpButton.click()
+
+ await expect(page.getByRole('button', { name: 'Sign out' }).first()).toBeVisible({
+ timeout: 30000,
+ })
+
+ const authorizeButton = page
+ .getByRole('button', { name: /^Authoriz(?:e|ing) Zone A reads$/i })
+ .first()
+ await expect(authorizeButton).toBeVisible({ timeout: 30000 })
+ await expect(authorizeButton).toBeEnabled({ timeout: 90000 })
+ await authorizeButton.click()
+
+ const getFundsButton = page.getByRole('button', { name: /^Get testnet pathUSD$/i }).first()
+ const depositButton = page.getByRole('button', { name: /^Deposit 100 pathUSD$/i }).first()
+
+ if (await getFundsButton.isVisible()) {
+ await getFundsButton.click()
+ await expect(depositButton).toBeVisible({ timeout: 90000 })
+ }
+
+ await depositButton.click()
+
+ await expect(
+ page
+ .locator('div[data-completed="true"]', {
+ has: page.getByText('Wait for Zone A to credit the deposit.'),
+ })
+ .first(),
+ ).toBeVisible({ timeout: 120000 })
+
+ await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId })
+})
diff --git a/e2e/send-tokens-across-zones.test.ts b/e2e/send-tokens-across-zones.test.ts
new file mode 100644
index 00000000..5a88b4a2
--- /dev/null
+++ b/e2e/send-tokens-across-zones.test.ts
@@ -0,0 +1,81 @@
+import { expect, test } from '@playwright/test'
+
+test('send pathUSD from Zone A into Zone B', async ({ page }) => {
+ test.setTimeout(240000)
+
+ 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,
+ },
+ })
+
+ await page.goto('/guide/private-zones/send-tokens-across-zones')
+
+ const signUpButton = page.getByRole('button', { name: 'Sign up' }).first()
+ await expect(signUpButton).toBeVisible({ timeout: 90000 })
+ await signUpButton.click()
+
+ await expect(page.getByRole('button', { name: 'Sign out' }).first()).toBeVisible({
+ timeout: 30000,
+ })
+
+ const authorizeSourceButton = page
+ .getByRole('button', { name: /^Authoriz(?:e|ing) Zone A reads$/i })
+ .first()
+ await expect(authorizeSourceButton).toBeVisible({ timeout: 30000 })
+ await expect(authorizeSourceButton).toBeEnabled({ timeout: 90000 })
+ await authorizeSourceButton.click()
+
+ const getFundsButton = page.getByRole('button', { name: /^Get testnet pathUSD$/i }).first()
+ const topUpButton = page.getByRole('button', { name: /^Approve \+ top up Zone A$/i }).first()
+ const sendButton = page.getByRole('button', { name: /^Send 25 pathUSD into Zone B$/i }).first()
+ const authorizeTargetButton = page
+ .getByRole('button', { name: /^Authoriz(?:e|ing) Zone B reads$/i })
+ .first()
+
+ await expect
+ .poll(
+ async () =>
+ (await getFundsButton.isVisible()) ||
+ (await topUpButton.isVisible()) ||
+ (await sendButton.isVisible()),
+ { timeout: 90000 },
+ )
+ .toBe(true)
+
+ if (await getFundsButton.isVisible()) {
+ await getFundsButton.click()
+ await expect
+ .poll(async () => (await topUpButton.isVisible()) || (await sendButton.isVisible()), {
+ timeout: 90000,
+ })
+ .toBe(true)
+ }
+
+ if (await topUpButton.isVisible()) {
+ await topUpButton.click()
+ }
+
+ await expect(sendButton).toBeVisible({ timeout: 120000 })
+ await sendButton.click()
+
+ await expect(authorizeTargetButton).toBeVisible({ timeout: 120000 })
+ await expect(authorizeTargetButton).toBeEnabled({ timeout: 90000 })
+ await authorizeTargetButton.click()
+
+ await expect(
+ page
+ .locator('div[data-completed="true"]', {
+ has: page.getByText('Authorize private reads in Zone B and confirm the pathUSD balance.'),
+ })
+ .first(),
+ ).toBeVisible({ timeout: 120000 })
+
+ await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId })
+})
diff --git a/e2e/send-tokens-within-a-zone.test.ts b/e2e/send-tokens-within-a-zone.test.ts
new file mode 100644
index 00000000..a8b9a743
--- /dev/null
+++ b/e2e/send-tokens-within-a-zone.test.ts
@@ -0,0 +1,68 @@
+import { expect, test } from '@playwright/test'
+
+test('prepare zone balance and send tokens within Zone A', async ({ page }) => {
+ test.setTimeout(180000)
+
+ 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,
+ },
+ })
+
+ await page.goto('/guide/private-zones/send-tokens-within-a-zone')
+
+ const signUpButton = page.getByRole('button', { name: 'Sign up' }).first()
+ await expect(signUpButton).toBeVisible({ timeout: 90000 })
+ await signUpButton.click()
+
+ await expect(page.getByRole('button', { name: 'Sign out' }).first()).toBeVisible({
+ timeout: 30000,
+ })
+
+ const authorizeButton = page
+ .getByRole('button', { name: /^Authoriz(?:e|ing) Zone A reads$/i })
+ .first()
+ await expect(authorizeButton).toBeVisible({ timeout: 30000 })
+ await expect(authorizeButton).toBeEnabled({ timeout: 90000 })
+ await authorizeButton.click()
+
+ const getFundsButton = page.getByRole('button', { name: /^Get testnet pathUSD$/i }).first()
+ const topUpButton = page.getByRole('button', { name: /^Approve \+ top up Zone A$/i }).first()
+ const sendButton = page.getByRole('button', { name: /^Send 25 pathUSD$/i }).first()
+
+ await expect
+ .poll(
+ async () =>
+ (await getFundsButton.isVisible()) ||
+ (await topUpButton.isVisible()) ||
+ (await sendButton.isVisible()),
+ { timeout: 90000 },
+ )
+ .toBe(true)
+
+ if (await getFundsButton.isVisible()) {
+ await getFundsButton.click()
+ await expect(topUpButton).toBeVisible({ timeout: 90000 })
+ }
+
+ if (await topUpButton.isVisible()) await topUpButton.click()
+ await expect(sendButton).toBeVisible({ timeout: 120000 })
+
+ await sendButton.click()
+
+ await expect(
+ page
+ .locator('div[data-completed="true"]', {
+ has: page.getByText('Wait for Zone A to show the updated private balance.'),
+ })
+ .first(),
+ ).toBeVisible({ timeout: 120000 })
+
+ await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId })
+})
diff --git a/e2e/swap-across-zones.test.ts b/e2e/swap-across-zones.test.ts
new file mode 100644
index 00000000..2cf3edcf
--- /dev/null
+++ b/e2e/swap-across-zones.test.ts
@@ -0,0 +1,83 @@
+import { expect, test } from '@playwright/test'
+
+test('swap pathUSD from Zone A into betaUSD on Zone B', async ({ page }) => {
+ test.setTimeout(240000)
+
+ 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,
+ },
+ })
+
+ await page.goto('/guide/private-zones/swap-across-zones')
+
+ const signUpButton = page.getByRole('button', { name: 'Sign up' }).first()
+ await expect(signUpButton).toBeVisible({ timeout: 90000 })
+ await signUpButton.click()
+
+ await expect(page.getByRole('button', { name: 'Sign out' }).first()).toBeVisible({
+ timeout: 30000,
+ })
+
+ const authorizeSourceButton = page
+ .getByRole('button', { name: /^Authoriz(?:e|ing) Zone A reads$/i })
+ .first()
+ await expect(authorizeSourceButton).toBeVisible({ timeout: 30000 })
+ await expect(authorizeSourceButton).toBeEnabled({ timeout: 90000 })
+ await authorizeSourceButton.click()
+
+ const getFundsButton = page.getByRole('button', { name: /^Get testnet pathUSD$/i }).first()
+ const topUpButton = page.getByRole('button', { name: /^Approve \+ top up Zone A$/i }).first()
+ const swapButton = page
+ .getByRole('button', { name: /^Swap 25 pathUSD into Zone B betaUSD$/i })
+ .first()
+ const authorizeTargetButton = page
+ .getByRole('button', { name: /^Authoriz(?:e|ing) Zone B reads$/i })
+ .first()
+
+ await expect
+ .poll(
+ async () =>
+ (await getFundsButton.isVisible()) ||
+ (await topUpButton.isVisible()) ||
+ (await swapButton.isVisible()),
+ { timeout: 90000 },
+ )
+ .toBe(true)
+
+ if (await getFundsButton.isVisible()) {
+ await getFundsButton.click()
+ await expect
+ .poll(async () => (await topUpButton.isVisible()) || (await swapButton.isVisible()), {
+ timeout: 90000,
+ })
+ .toBe(true)
+ }
+
+ if (await topUpButton.isVisible()) {
+ await topUpButton.click()
+ }
+
+ await expect(swapButton).toBeVisible({ timeout: 120000 })
+ await swapButton.click()
+
+ await expect(authorizeTargetButton).toBeVisible({ timeout: 120000 })
+ await expect(authorizeTargetButton).toBeEnabled({ timeout: 90000 })
+ await authorizeTargetButton.click()
+
+ await expect(
+ page
+ .locator('div[data-completed="true"]', {
+ has: page.getByText('Authorize private reads in Zone B and confirm the betaUSD balance.'),
+ })
+ .first(),
+ ).toBeVisible({ timeout: 120000 })
+
+ await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId })
+})
diff --git a/e2e/withdraw-from-a-zone.test.ts b/e2e/withdraw-from-a-zone.test.ts
new file mode 100644
index 00000000..3f07c303
--- /dev/null
+++ b/e2e/withdraw-from-a-zone.test.ts
@@ -0,0 +1,79 @@
+import { expect, test } from '@playwright/test'
+
+test.describe.configure({ retries: 0, timeout: 120000 })
+
+test('prepare zone balance and withdraw from Zone A', async ({ page }) => {
+ test.setTimeout(180000)
+
+ 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,
+ },
+ })
+
+ await page.goto('/guide/private-zones/withdraw-from-a-zone')
+
+ const signUpButton = page.getByRole('button', { name: 'Sign up' }).first()
+ await expect(signUpButton).toBeVisible({ timeout: 45000 })
+ await signUpButton.click()
+
+ await expect(page.getByRole('button', { name: 'Sign out' }).first()).toBeVisible({
+ timeout: 20000,
+ })
+
+ const authorizeButton = page
+ .getByRole('button', { name: /^Authoriz(?:e|ing) Zone A reads$/i })
+ .first()
+ await expect(authorizeButton).toBeVisible({ timeout: 30000 })
+ await expect(authorizeButton).toBeEnabled({ timeout: 90000 })
+ await authorizeButton.click()
+
+ const getFundsButton = page.getByRole('button', { name: /^Get testnet pathUSD$/i }).first()
+ const topUpButton = page.getByRole('button', { name: /^Approve \+ top up Zone A$/i }).first()
+ const withdrawButton = page.getByRole('button', { name: /^Withdraw 100 pathUSD$/i }).first()
+
+ await expect
+ .poll(
+ async () =>
+ (await getFundsButton.isVisible()) ||
+ (await topUpButton.isVisible()) ||
+ (await withdrawButton.isVisible()),
+ { timeout: 90000 },
+ )
+ .toBe(true)
+
+ if (await getFundsButton.isVisible()) {
+ await getFundsButton.click()
+ await expect
+ .poll(async () => (await topUpButton.isVisible()) || (await withdrawButton.isVisible()), {
+ timeout: 90000,
+ })
+ .toBe(true)
+ }
+
+ if (await topUpButton.isVisible()) {
+ await topUpButton.click()
+ }
+
+ await expect(withdrawButton).toBeVisible({ timeout: 90000 })
+
+ await withdrawButton.click()
+
+ await expect(
+ page
+ .locator('div[data-completed="true"]', {
+ has: page.getByText('Wait for pathUSD to settle back to your public balance.'),
+ })
+ .first(),
+ ).toBeVisible({
+ timeout: 120000,
+ })
+
+ await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId })
+})
diff --git a/package.json b/package.json
index 6941fce4..b74959f7 100644
--- a/package.json
+++ b/package.json
@@ -41,7 +41,7 @@
"tailwindcss": "^4.2.2",
"unplugin-auto-import": "^21.0.0",
"unplugin-icons": "^23.0.1",
- "viem": "^2.47.18",
+ "viem": "2.48.0",
"vocs": "https://pkg.pr.new/wevm/vocs@2fb25c2",
"wagmi": "^3.6.1",
"waku": "1.0.0-alpha.4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e1a68380..88008f1c 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.47.18(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.47.18(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.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))
cva:
specifier: 1.0.0-beta.4
version: 1.0.0-beta.4(typescript@5.9.3)
@@ -86,14 +86,14 @@ importers:
specifier: ^23.0.1
version: 23.0.1(@svgr/core@8.1.0(typescript@5.9.3))
viem:
- specifier: ^2.47.18
- version: 2.47.18(typescript@5.9.3)(zod@4.3.6)
+ specifier: 2.48.0
+ version: 2.48.0(typescript@5.9.3)(zod@4.3.6)
vocs:
specifier: https://pkg.pr.new/wevm/vocs@2fb25c2
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.47.18(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.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))
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,6 +3157,14 @@ packages:
typescript:
optional: true
+ ox@0.14.17:
+ resolution: {integrity: sha512-jOzNb2Wlfzsr8z/GoCtd1bf6OSRuWuysvbhnHGD+7fV1WRbcBR6B0RYoe3xWnUedF7zp4l5APmS7CzAhUok/lA==}
+ peerDependencies:
+ typescript: '>=5.4.0'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
package-manager-detector@1.6.0:
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
@@ -3832,8 +3840,8 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
- viem@2.47.18:
- resolution: {integrity: sha512-m3kr+/i8MddeY5fmB2y2v5B0vDL0x8R4v/8gai4Lh4jh8KOWlQqml7PFLtilNomoDm3mINxdA0JnYBJfknNoEg==}
+ viem@2.48.0:
+ resolution: {integrity: sha512-0uLzTAUNKPpY9Cf3OBCPdwClXx9CEHAkoVYnxMPdHt7cRI1DobMso+pHZvU7itD+hFwE4htmp9QfP+5lb+kn0g==}
peerDependencies:
typescript: '>=5.0.4'
peerDependenciesMeta:
@@ -5426,18 +5434,18 @@ 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.47.18(typescript@5.9.3)(zod@4.3.6)))(typescript@5.9.3)(viem@2.47.18(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.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))':
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.47.18(typescript@5.9.3)(zod@4.3.6))
- viem: 2.47.18(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))
+ 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.47.18(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))':
dependencies:
eventemitter3: 5.0.1
mipd: 0.0.7(typescript@5.9.3)
- viem: 2.47.18(typescript@5.9.3)(zod@4.3.6)
+ viem: 2.48.0(typescript@5.9.3)(zod@4.3.6)
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
@@ -5539,20 +5547,20 @@ 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.47.18(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.47.18(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.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)):
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.47.18(typescript@5.9.3)(zod@4.3.6))
+ 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)
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.47.18(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))
react: 19.2.5
- viem: 2.47.18(typescript@5.9.3)(zod@4.3.6)
+ viem: 2.48.0(typescript@5.9.3)(zod@4.3.6)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- '@types/react'
@@ -7168,11 +7176,11 @@ snapshots:
moo@0.5.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.47.18(typescript@5.9.3)(zod@4.3.6)):
+ 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)):
dependencies:
incur: 0.3.25
ox: 0.14.10(typescript@5.9.3)(zod@4.3.6)
- viem: 2.47.18(typescript@5.9.3)(zod@4.3.6)
+ viem: 2.48.0(typescript@5.9.3)(zod@4.3.6)
zod: 4.3.6
optionalDependencies:
'@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)
@@ -7277,6 +7285,21 @@ snapshots:
transitivePeerDependencies:
- zod
+ ox@0.14.17(typescript@5.9.3)(zod@4.3.6):
+ dependencies:
+ '@adraffy/ens-normalize': 1.11.1
+ '@noble/ciphers': 1.3.0
+ '@noble/curves': 1.9.1
+ '@noble/hashes': 1.8.0
+ '@scure/bip32': 1.7.0
+ '@scure/bip39': 1.6.0
+ abitype: 1.2.3(typescript@5.9.3)(zod@4.3.6)
+ eventemitter3: 5.0.1
+ optionalDependencies:
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - zod
+
package-manager-detector@1.6.0: {}
parent-module@1.0.1:
@@ -8084,7 +8107,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
- viem@2.47.18(typescript@5.9.3)(zod@4.3.6):
+ viem@2.48.0(typescript@5.9.3)(zod@4.3.6):
dependencies:
'@noble/curves': 1.9.1
'@noble/hashes': 1.8.0
@@ -8092,7 +8115,7 @@ snapshots:
'@scure/bip39': 1.6.0
abitype: 1.2.3(typescript@5.9.3)(zod@4.3.6)
isows: 1.0.7(ws@8.18.3)
- ox: 0.14.15(typescript@5.9.3)(zod@4.3.6)
+ ox: 0.14.17(typescript@5.9.3)(zod@4.3.6)
ws: 8.18.3
optionalDependencies:
typescript: 5.9.3
@@ -8250,14 +8273,14 @@ 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.47.18(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.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)):
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.47.18(typescript@5.9.3)(zod@4.3.6)))(typescript@5.9.3)(viem@2.47.18(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.47.18(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.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))
react: 19.2.5
use-sync-external-store: 1.4.0(react@19.2.5)
- viem: 2.47.18(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
transitivePeerDependencies:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 45dadfb4..fcfbc0f0 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,5 +1,5 @@
strictDepBuilds: true
-blockExoticSubdeps: true
+blockExoticSubdeps: false
trustPolicy: no-downgrade
minimumReleaseAge: 1440
@@ -13,6 +13,7 @@ minimumReleaseAgeExclude:
- accounts
- mppx
- incur
+ - ox
- viem
patchedDependencies:
diff --git a/public/learn/zones/diagram-deposit.svg b/public/learn/zones/diagram-deposit.svg
new file mode 100644
index 00000000..ccd1202b
--- /dev/null
+++ b/public/learn/zones/diagram-deposit.svg
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/learn/zones/diagram-node.svg b/public/learn/zones/diagram-node.svg
new file mode 100644
index 00000000..26b1d28c
--- /dev/null
+++ b/public/learn/zones/diagram-node.svg
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/learn/zones/diagram-overview.svg b/public/learn/zones/diagram-overview.svg
new file mode 100644
index 00000000..b51029e3
--- /dev/null
+++ b/public/learn/zones/diagram-overview.svg
@@ -0,0 +1,458 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/learn/zones/diagram-privacy.svg b/public/learn/zones/diagram-privacy.svg
new file mode 100644
index 00000000..d1282da3
--- /dev/null
+++ b/public/learn/zones/diagram-privacy.svg
@@ -0,0 +1,179 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/learn/zones/diagram-swap.svg b/public/learn/zones/diagram-swap.svg
new file mode 100644
index 00000000..336e5a33
--- /dev/null
+++ b/public/learn/zones/diagram-swap.svg
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/learn/zones/diagram-tip20.svg b/public/learn/zones/diagram-tip20.svg
new file mode 100644
index 00000000..bff69d5d
--- /dev/null
+++ b/public/learn/zones/diagram-tip20.svg
@@ -0,0 +1,152 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/learn/zones/diagram-withdraw.svg b/public/learn/zones/diagram-withdraw.svg
new file mode 100644
index 00000000..7f3baa2f
--- /dev/null
+++ b/public/learn/zones/diagram-withdraw.svg
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/guides/Demo.tsx b/src/components/guides/Demo.tsx
index 544d8fe6..6c21f5ce 100644
--- a/src/components/guides/Demo.tsx
+++ b/src/components/guides/Demo.tsx
@@ -1,11 +1,12 @@
'use client'
-import { useQueryClient } from '@tanstack/react-query'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
import type { VariantProps } from 'cva'
import * as React from 'react'
-import type { Address, BaseError } from 'viem'
-import { formatUnits } from 'viem'
+import { type Address, type BaseError, createClient, formatUnits } from 'viem'
import { tempoModerato } from 'viem/chains'
-import { useAccount, useConnect, useConnections, useDisconnect } from 'wagmi'
+import { tempoActions } from 'viem/tempo'
+import { http as zoneHttp, zoneModerato } from 'viem/tempo/zones'
+import { useAccount, useConnect, useConnections, useConnectorClient, useDisconnect } from 'wagmi'
import { Hooks } from 'wagmi/tempo'
import LucideCheck from '~icons/lucide/check'
import LucideCopy from '~icons/lucide/copy'
@@ -15,6 +16,12 @@ import LucideRotateCcw from '~icons/lucide/rotate-ccw'
import LucideWalletCards from '~icons/lucide/wallet-cards'
import { cva, cx } from '../../../cva.config'
import { usePostHogTracking } from '../../lib/posthog'
+import {
+ getZoneTransportConfig,
+ moderatoZoneRpcUrls,
+ stripRpcBasicAuth,
+} from '../../lib/private-zones.ts'
+import { useRootWebAuthnAccount } from '../../lib/useRootWebAuthnAccount.ts'
import { useTempoWalletConnector, useWebAuthnConnector } from '../../wagmi.config'
import { Container as ParentContainer } from '../Container'
import { alphaUsd } from './tokens'
@@ -24,6 +31,23 @@ export { alphaUsd, betaUsd, pathUsd, thetaUsd } from './tokens'
export const FAKE_RECIPIENT = '0xbeefcafe54750903ac1c8909323af7beb21ea2cb'
export const FAKE_RECIPIENT_2 = '0xdeadbeef54750903ac1c8909323af7beb21ea2cb'
+type ZoneBalance = {
+ label: string
+ token: Address
+ zone: number
+ feeToken?: Address | undefined
+}
+
+export function useHydrated() {
+ const [hydrated, setHydrated] = React.useState(false)
+
+ React.useEffect(() => {
+ setHydrated(true)
+ }, [])
+
+ return hydrated
+}
+
function getExplorerHost() {
const { VITE_TEMPO_ENV, VITE_EXPLORER_OVERRIDE } = import.meta.env
@@ -53,6 +77,30 @@ export function ExplorerLink({ hash }: { hash: string }) {
)
}
+export function ReceiptHash({ hash }: { hash: string }) {
+ const [copied, copyToClipboard] = useCopyToClipboard()
+ const { trackCopy } = usePostHogTracking()
+
+ return (
+
+ Receipt hash
+ {hash}
+ {
+ copyToClipboard(hash)
+ trackCopy('code', hash)
+ }}
+ aria-label={copied ? 'Copied receipt hash' : 'Copy receipt hash'}
+ title={copied ? 'Copied' : 'Copy receipt hash'}
+ >
+ {copied ? : }
+
+
+ )
+}
+
export function ExplorerAccountLink({ address }: { address: string }) {
const { trackExternalLinkClick } = usePostHogTracking()
const url = `${getExplorerHost()}/account/${address}`
@@ -86,6 +134,7 @@ export function Container(
footerVariant: 'balances'
tokens: Address[]
balanceSource?: 'webAuthn' | 'wallet' | undefined
+ zoneBalances?: ZoneBalance[] | undefined
}
| {
footerVariant: 'source'
@@ -128,7 +177,11 @@ export function Container(
const footerElement = React.useMemo(() => {
if (props.footerVariant === 'balances')
return (
-
+
)
if (props.footerVariant === 'source') return
return null
@@ -170,6 +223,12 @@ export function Container(
}
export namespace Container {
+ type ZoneClientLike = {
+ token: {
+ getBalance: (parameters: { account: Address; token: Address }) => Promise
+ }
+ }
+
function BalancesFooterItem(props: { address: Address; token: Address }) {
const queryClient = useQueryClient()
const { address, token } = props
@@ -224,21 +283,106 @@ export namespace Container {
)
}
- export function BalancesFooter(props: { address?: string | undefined; tokens: Address[] }) {
- const { address, tokens } = props
+ function ZoneBalancesFooterItem(props: ZoneBalance & { address: Address }) {
+ const { address, token, zone } = props
+ const { data: connectorClient } = useConnectorClient()
+ const { data: rootWebAuthnAccount } = useRootWebAuthnAccount()
+ const zoneRpcUrl =
+ moderatoZoneRpcUrls[zone as keyof typeof moderatoZoneRpcUrls] ??
+ (
+ connectorClient?.chain as
+ | { zones?: Record }
+ | undefined
+ )?.zones?.[zone]?.rpcUrls.default.http[0]
+ const zoneClient = React.useMemo(
+ () =>
+ rootWebAuthnAccount && zoneRpcUrl
+ ? (createClient({
+ account: rootWebAuthnAccount,
+ chain: zoneModerato(zone),
+ transport: zoneHttp(
+ stripRpcBasicAuth(zoneRpcUrl),
+ getZoneTransportConfig(zoneRpcUrl),
+ ),
+ }).extend(tempoActions()) as unknown as ZoneClientLike)
+ : undefined,
+ [rootWebAuthnAccount, zone, zoneRpcUrl],
+ )
+ const { data: metadata, isPending: metadataIsPending } = Hooks.token.useGetMetadata({
+ token,
+ })
+ const { data: balance, isPending: balanceIsPending } = useQuery({
+ enabled: Boolean(address && zoneClient),
+ queryKey: ['demo-zone-balance', address, zone, token],
+ queryFn: async () => {
+ if (!zoneClient) throw new Error('zone client not ready')
+
+ return zoneClient.token.getBalance({
+ account: address,
+ token,
+ })
+ },
+ refetchInterval: (query) => {
+ if (query.state.error || query.state.data === undefined) return false
+
+ return 1_500
+ },
+ refetchOnReconnect: false,
+ refetchOnWindowFocus: false,
+ retry: false,
+ staleTime: 1_000,
+ })
+
+ if (balanceIsPending || metadataIsPending || balance === undefined || metadata === undefined) {
+ return
+ }
+
+ return (
+
+ {formatUnits(balance, metadata.decimals)}
+ {metadata.symbol}
+
+ )
+ }
+
+ export function BalancesFooter(props: {
+ address?: string | undefined
+ tokens: Address[]
+ zoneBalances?: ZoneBalance[] | undefined
+ }) {
+ const { address, tokens, zoneBalances } = props
+ const personalBalanceLabel = tokens.length > 1 ? 'Personal balances' : 'Personal balance'
+
return (
-
-
Balances
-
-
- {address ? (
- tokens.map((token) => (
-
- ))
- ) : (
-
No account detected
- )}
+
+
+
{personalBalanceLabel}
+
+
+ {address ? (
+ tokens.map((token) => (
+
+ ))
+ ) : (
+ No account detected
+ )}
+
+ {address &&
+ zoneBalances &&
+ zoneBalances.length > 0 &&
+ zoneBalances.map((zoneBalance) => (
+
+
{zoneBalance.label} balance
+
+
+
+
+
+ ))}
)
}
@@ -359,13 +503,21 @@ export namespace StringFormatter {
export function Login() {
const connect = useConnect()
+ const hydrated = useHydrated()
const tempoWallet = useTempoWalletConnector()
const webAuthn = useWebAuthnConnector()
const isE2E = import.meta.env.VITE_E2E === 'true'
const connector = isE2E ? webAuthn : tempoWallet
+ if (!hydrated || !connector)
+ return (
+
+ Loading account
+
+ )
+
return (
-
+
{connect.isPending ? (
@@ -388,6 +540,11 @@ export function Login() {
Sign in
)}
+ {connect.error && (
+
+ {'shortMessage' in connect.error ? connect.error.shortMessage : connect.error.message}
+
+ )}
)
}
@@ -441,6 +598,8 @@ export function Button(
) {
const { className, disabled, render, size, static: static_, variant, ...rest } = props
const Element = render ? (p: typeof props) => React.cloneElement(render, p) : 'button'
+ const accessibilityProps = render ? { 'aria-disabled': disabled || undefined } : { disabled }
+
return (
)
diff --git a/src/components/guides/EmbedPasskeys.tsx b/src/components/guides/EmbedPasskeys.tsx
index 2ec96edd..93ebf792 100644
--- a/src/components/guides/EmbedPasskeys.tsx
+++ b/src/components/guides/EmbedPasskeys.tsx
@@ -1,38 +1,67 @@
'use client'
import { useAccount, useConnect, useDisconnect } from 'wagmi'
import { useWebAuthnConnector } from '../../wagmi.config'
-import { Button } from './Demo'
+import { Button, useHydrated } from './Demo'
export function EmbedPasskeys() {
const account = useAccount()
const connect = useConnect()
- const connector = useWebAuthnConnector()
const disconnect = useDisconnect()
+ const hydrated = useHydrated()
+ const connector = useWebAuthnConnector()
+ const busy = connect.isPending || disconnect.isPending
- if (account.address)
+ if (!hydrated || !connector)
return (
-
-
disconnect.disconnect()} variant="destructive">
- Sign out
-
+
+ Loading account
)
- if (connect.isPending)
+
+ if (busy)
return (
Check prompt
)
- if (!connector) return null
+
+ if (account.address)
+ return (
+
+ disconnect.disconnect({ connector: account.connector })}
+ variant="destructive"
+ >
+ Sign out
+
+
+ )
+
return
}
export function SignInButtons() {
const connect = useConnect()
- const connector = useWebAuthnConnector()
const disconnect = useDisconnect()
+ const hydrated = useHydrated()
+ const connector = useWebAuthnConnector()
+ const busy = connect.isPending || disconnect.isPending
const isE2E = import.meta.env.VITE_E2E === 'true'
+ if (!hydrated || !connector)
+ return (
+
+ Loading account
+
+ )
+
+ if (busy)
+ return (
+
+ Check prompt
+
+ )
+
return (
{})
connect.connect({
connector,
- capabilities: isE2E
- ? ({ method: 'register', name: 'Tempo Docs' } as any)
- : { label: 'Tempo Docs', type: 'sign-up' as const },
+ ...(isE2E
+ ? ({ capabilities: { method: 'register', name: 'Tempo Docs' } } as const)
+ : {
+ capabilities: {
+ label: 'Tempo Docs',
+ type: 'sign-up',
+ } as never,
+ }),
})
}}
type="button"
@@ -54,7 +88,17 @@ export function SignInButtons() {
variant="default"
onClick={async () => {
await disconnect.disconnectAsync().catch(() => {})
- connect.connect({ connector })
+ connect.connect(
+ isE2E
+ ? { connector }
+ : {
+ connector,
+ capabilities: {
+ label: 'Tempo Docs',
+ type: 'sign-in',
+ } as never,
+ },
+ )
}}
type="button"
>
diff --git a/src/components/guides/zones/DepositToZone.tsx b/src/components/guides/zones/DepositToZone.tsx
new file mode 100644
index 00000000..5df651ff
--- /dev/null
+++ b/src/components/guides/zones/DepositToZone.tsx
@@ -0,0 +1,512 @@
+'use client'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import * as React from 'react'
+import { createClient, custom, type Hex, parseAbi, parseUnits } from 'viem'
+import { Actions, tempoActions } from 'viem/tempo'
+import { http as zoneHttp, zoneModerato } from 'viem/tempo/zones'
+import { useConnection, useConnectorClient, usePublicClient } from 'wagmi'
+import { Hooks } from 'wagmi/tempo'
+import {
+ getZoneTransportConfig,
+ moderatoZoneRpcUrls,
+ stripRpcBasicAuth,
+} from '../../../lib/private-zones.ts'
+import { useRootWebAuthnAccount } from '../../../lib/useRootWebAuthnAccount.ts'
+import { useZoneAuthorization, type ZoneAuthClientLike } from '../../../lib/useZoneAuthorization.ts'
+import { Button, ExplorerLink, Logout, Step } from '../Demo'
+import { SignInButtons } from '../EmbedPasskeys'
+import { pathUsd } from '../tokens'
+
+const ZONE_LABEL = 'Zone A'
+const ZONE_ID = 6 as const
+const DEPOSIT_AMOUNT = parseUnits('100', 6)
+const zonePortalFeeAbi = parseAbi(['function calculateDepositFee() view returns (uint128)'])
+
+type DepositMode = 'plaintext' | 'encrypted'
+
+type ZoneClientLike = {
+ token: {
+ getBalance: (parameters: { account: Hex; token: Hex }) => Promise
+ }
+ zone: ZoneAuthClientLike['zone']
+}
+
+type RootChainWithZones = {
+ zones?: Record
+}
+
+type DepositSetup = {
+ depositFee: bigint
+}
+
+export function DepositToZone() {
+ const { address } = useConnection()
+ const [mode, setMode] = React.useState('plaintext')
+ const connected = Boolean(address)
+
+ return (
+ <>
+ : }
+ error={undefined}
+ number={1}
+ title="Create or use a passkey account on the public chain."
+ />
+
+
+
+ {address ? (
+
+ ) : (
+
+ )}
+ >
+ )
+}
+
+function ConnectedZoneFlow(props: { address: Hex; mode: DepositMode }) {
+ const { address, mode } = props
+ const queryClient = useQueryClient()
+ const { connector } = useConnection()
+ const { data: connectorClient } = useConnectorClient()
+ const { data: rootWebAuthnAccount } = useRootWebAuthnAccount()
+ const publicClient = usePublicClient()
+ const zonePortalAddress = (connectorClient?.chain as RootChainWithZones | undefined)?.zones?.[
+ ZONE_ID
+ ]?.portalAddress
+ const {
+ data: rootBalance,
+ isPending: rootBalanceIsPending,
+ refetch: refetchRootBalance,
+ } = Hooks.token.useGetBalance({
+ account: address,
+ token: pathUsd,
+ })
+
+ const zoneClient = React.useMemo(
+ () =>
+ rootWebAuthnAccount
+ ? (createClient({
+ account: rootWebAuthnAccount,
+ chain: zoneModerato(ZONE_ID),
+ transport: zoneHttp(
+ stripRpcBasicAuth(moderatoZoneRpcUrls[ZONE_ID]),
+ getZoneTransportConfig(moderatoZoneRpcUrls[ZONE_ID]),
+ ),
+ }).extend(tempoActions()) as unknown as ZoneClientLike)
+ : undefined,
+ [rootWebAuthnAccount],
+ )
+ const encryptedDepositClient = React.useMemo(
+ () =>
+ rootWebAuthnAccount && publicClient?.chain
+ ? createClient({
+ account: rootWebAuthnAccount,
+ chain: publicClient.chain,
+ transport: custom(publicClient),
+ })
+ : undefined,
+ [publicClient, rootWebAuthnAccount],
+ )
+ const encryptedDepositRequiresRootClient = connector?.id === 'webAuthn'
+ const encryptedDepositReady =
+ !encryptedDepositRequiresRootClient || Boolean(encryptedDepositClient)
+
+ const zoneAuthorization = useZoneAuthorization({
+ address,
+ chainId: zoneModerato(ZONE_ID).id,
+ queryKey: ['guide-private-zones-auth', address, ZONE_ID],
+ zoneClient,
+ })
+
+ React.useEffect(() => {
+ if (!zoneAuthorization.isAuthorized) return
+
+ void queryClient.invalidateQueries({
+ queryKey: ['demo-zone-balance', address, ZONE_ID],
+ })
+ }, [address, queryClient, zoneAuthorization.isAuthorized])
+
+ const depositSetupQuery = useQuery({
+ enabled: Boolean(
+ connectorClient && publicClient && zonePortalAddress && zoneAuthorization.isAuthorized,
+ ),
+ queryKey: ['guide-private-zones-deposit-setup', address, ZONE_ID, zonePortalAddress],
+ queryFn: async (): Promise => {
+ if (!publicClient) throw new Error('public client not ready')
+ if (!zonePortalAddress) throw new Error('zone portal address not configured')
+
+ return {
+ depositFee: await publicClient.readContract({
+ address: zonePortalAddress,
+ abi: zonePortalFeeAbi,
+ functionName: 'calculateDepositFee',
+ }),
+ }
+ },
+ staleTime: 30_000,
+ })
+
+ const fundMutation = useMutation({
+ mutationFn: async () => {
+ if (!connectorClient) throw new Error('connector client not ready')
+
+ await Actions.faucet.fundSync(connectorClient, {
+ account: address,
+ })
+ },
+ onSuccess: async () => {
+ await refetchRootBalance()
+ },
+ })
+
+ const depositMutation = useMutation({
+ mutationFn: async () => {
+ if (!connectorClient) throw new Error('connector client not ready')
+ if (!zoneClient) throw new Error('zone client not ready')
+ if (!depositSetupQuery.data) throw new Error('deposit setup not ready')
+
+ const startingZoneBalance = await zoneClient.token
+ .getBalance({
+ account: address,
+ token: pathUsd,
+ })
+ .catch(() => 0n)
+
+ const creditedAmount = getNetZoneDepositAmount(
+ DEPOSIT_AMOUNT,
+ depositSetupQuery.data.depositFee,
+ )
+
+ if (mode === 'encrypted' && encryptedDepositRequiresRootClient && !encryptedDepositClient)
+ throw new Error('encrypted deposit client not ready')
+
+ const receipt =
+ mode === 'encrypted'
+ ? (
+ await Actions.zone.encryptedDepositSync(
+ (encryptedDepositClient ?? connectorClient) as never,
+ {
+ account: (encryptedDepositClient ?? connectorClient).account,
+ amount: DEPOSIT_AMOUNT,
+ chain: (encryptedDepositClient ?? connectorClient).chain as never,
+ timeout: 60_000,
+ token: pathUsd,
+ zoneId: ZONE_ID,
+ } as never,
+ )
+ ).receipt
+ : (
+ await Actions.zone.depositSync(
+ connectorClient as never,
+ {
+ account: connectorClient.account,
+ amount: DEPOSIT_AMOUNT,
+ chain: connectorClient.chain as never,
+ token: pathUsd,
+ zoneId: ZONE_ID,
+ } as never,
+ )
+ ).receipt
+
+ return {
+ creditedAmount,
+ receipt,
+ startingZoneBalance,
+ }
+ },
+ onSuccess: async () => {
+ await refetchRootBalance()
+ await depositSetupQuery.refetch()
+ await zoneBalanceQuery.refetch()
+ },
+ })
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: switching modes should clear the previous submission state.
+ React.useEffect(() => {
+ depositMutation.reset()
+ }, [mode])
+
+ const rootReceipt = depositMutation.data?.receipt
+ const targetZoneBalance = depositMutation.data
+ ? depositMutation.data.startingZoneBalance + depositMutation.data.creditedAmount
+ : undefined
+
+ const zoneBalanceQuery = useQuery({
+ enabled: Boolean(
+ zoneClient && zoneAuthorization.isAuthorized && targetZoneBalance !== undefined,
+ ),
+ queryKey: ['guide-private-zones-zone-balance', address, ZONE_ID],
+ queryFn: async () => {
+ if (!zoneClient) throw new Error('zone client not ready')
+
+ try {
+ return await zoneClient.token.getBalance({
+ account: address,
+ token: pathUsd,
+ })
+ } catch {
+ return null
+ }
+ },
+ refetchInterval: (query) => {
+ if (targetZoneBalance === undefined || query.state.error) return false
+
+ return ((query.state.data as bigint | null | undefined) ?? 0n) >= targetZoneBalance
+ ? false
+ : 1_500
+ },
+ refetchOnReconnect: false,
+ refetchOnWindowFocus: false,
+ retry: false,
+ })
+
+ const hasRootBalance = Boolean(rootBalance && rootBalance > 0n)
+ const zoneDepositProcessed = Boolean(
+ targetZoneBalance !== undefined &&
+ typeof zoneBalanceQuery.data === 'bigint' &&
+ zoneBalanceQuery.data >= targetZoneBalance,
+ )
+ const authIsPreparing =
+ zoneAuthorization.isChecking || zoneAuthorization.authorizeMutation.isPending
+ const stepTwoAction = zoneAuthorization.isAuthorized ? undefined : (
+ zoneAuthorization.authorizeMutation.mutate()}
+ type="button"
+ variant={zoneClient ? 'accent' : 'default'}
+ >
+ {authIsPreparing
+ ? `Authorizing ${ZONE_LABEL} reads`
+ : zoneAuthorization.authorizeMutation.isError
+ ? 'Retry'
+ : `Authorize ${ZONE_LABEL} reads`}
+
+ )
+
+ let stepThreeAction: React.ReactNode
+ if (!hasRootBalance) {
+ stepThreeAction = (
+ fundMutation.mutate()}
+ type="button"
+ variant={zoneAuthorization.isAuthorized ? 'accent' : 'default'}
+ >
+ {fundMutation.isPending ? 'Getting pathUSD' : 'Get testnet pathUSD'}
+
+ )
+ } else if (depositSetupQuery.isError) {
+ stepThreeAction = (
+ depositSetupQuery.refetch()}
+ type="button"
+ variant="default"
+ >
+ Retry deposit checks
+
+ )
+ } else if (depositSetupQuery.isPending || depositSetupQuery.data === undefined) {
+ stepThreeAction = (
+
+ Checking deposit setup
+
+ )
+ } else {
+ stepThreeAction = (
+ depositMutation.mutate()}
+ type="button"
+ variant={zoneAuthorization.isAuthorized ? 'accent' : 'default'}
+ >
+ {mode === 'encrypted' && !encryptedDepositReady
+ ? 'Preparing encrypted deposit'
+ : getDepositActionLabel({ isPending: depositMutation.isPending })}
+
+ )
+ }
+
+ return (
+ <>
+
+
+
+ {rootReceipt && (
+
+
+
+
+ )}
+
+
+
+
+
+ Your public-chain deposit is already submitted. This last step polls the private{' '}
+ {ZONE_LABEL} balance every 1.5 seconds until the post-fee amount appears.
+
+
+
+ >
+ )
+}
+
+function DisconnectedZoneFlow(props: { mode: DepositMode }) {
+ const { mode } = props
+
+ return (
+ <>
+
+
+
+ >
+ )
+}
+
+function DepositModeSelector(props: { mode: DepositMode; onChange: (mode: DepositMode) => void }) {
+ const { mode, onChange } = props
+
+ return (
+
+
+
+
Deposit mode
+
+ Plaintext reveals both the recipient and memo of the deposit, while encrypted only lets
+ the sequencer see those details.
+
+
+
+ {[
+ ['plaintext', 'Plaintext'],
+ ['encrypted', 'Encrypted'],
+ ].map(([value, label]) => {
+ const selected = mode === value
+
+ return (
+ onChange(value as DepositMode)}
+ >
+ {label}
+
+ )
+ })}
+
+
+
+ )
+}
+
+function StepBody(props: React.PropsWithChildren) {
+ return (
+
+ )
+}
+
+function DetailLine(props: { label: string; value: string; dataTestId?: string | undefined }) {
+ const { dataTestId, label, value } = props
+
+ return (
+
+ {label}
+
+ {value}
+
+
+ )
+}
+
+function getDepositActionLabel(parameters: { isPending: boolean }) {
+ return parameters.isPending ? 'Depositing pathUSD' : 'Deposit 100 pathUSD'
+}
+
+function getSubmitStepTitle(mode: DepositMode) {
+ return mode === 'encrypted'
+ ? `Fund and submit the encrypted deposit for 100 pathUSD into ${ZONE_LABEL}.`
+ : `Fund and submit the deposit for 100 pathUSD into ${ZONE_LABEL}.`
+}
+
+function getConfirmationStepTitle(mode: DepositMode) {
+ return mode === 'encrypted'
+ ? `Wait for ${ZONE_LABEL} to credit the encrypted deposit.`
+ : `Wait for ${ZONE_LABEL} to credit the deposit.`
+}
+
+function getNetZoneDepositAmount(amount: bigint, depositFee: bigint) {
+ if (depositFee > amount) {
+ throw new Error(
+ `Zone portal deposit fee ${depositFee.toString()} is greater than deposit amount ${amount.toString()}.`,
+ )
+ }
+
+ return amount - depositFee
+}
diff --git a/src/components/guides/zones/SendTokensAcrossZones.tsx b/src/components/guides/zones/SendTokensAcrossZones.tsx
new file mode 100644
index 00000000..7841ed48
--- /dev/null
+++ b/src/components/guides/zones/SendTokensAcrossZones.tsx
@@ -0,0 +1,710 @@
+'use client'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import * as React from 'react'
+import { createClient, encodeAbiParameters, type Hex, parseAbiItem, parseUnits } from 'viem'
+import { Actions, tempoActions } from 'viem/tempo'
+import { http as zoneHttp, zoneModerato } from 'viem/tempo/zones'
+import { useConnection, useConnectorClient, usePublicClient } from 'wagmi'
+import { Hooks } from 'wagmi/tempo'
+import {
+ getZoneTransportConfig,
+ publicSettlementLookbackBlocks,
+ routerCallbackGasLimit,
+ stripRpcBasicAuth,
+ swapAndDepositRouter,
+ ZONE_A,
+ ZONE_B,
+ zeroBytes32,
+ zoneRpcSyncTimeout,
+} from '../../../lib/private-zones.ts'
+import { useRootWebAuthnAccount } from '../../../lib/useRootWebAuthnAccount.ts'
+import { useZoneAuthorization, type ZoneAuthClientLike } from '../../../lib/useZoneAuthorization.ts'
+import { Button, ExplorerLink, Logout, ReceiptHash, Step } from '../Demo'
+import { SignInButtons } from '../EmbedPasskeys'
+import { pathUsd } from '../tokens'
+import { useStickyStepCompletion } from './useStickyStepCompletion.ts'
+
+const TRANSFER_AMOUNT = parseUnits('25', 6)
+const ZONE_GAS_BUFFER = parseUnits('1', 6)
+
+const portalAbi = [
+ {
+ name: 'calculateDepositFee',
+ type: 'function',
+ stateMutability: 'view',
+ inputs: [],
+ outputs: [{ type: 'uint128' }],
+ },
+ {
+ name: 'isTokenEnabled',
+ type: 'function',
+ stateMutability: 'view',
+ inputs: [{ name: 'token', type: 'address' }],
+ outputs: [{ type: 'bool' }],
+ },
+] as const
+
+const targetDepositEvent = parseAbiItem(
+ 'event DepositMade(bytes32 indexed newCurrentDepositQueueHash, address indexed sender, address token, address to, uint128 netAmount, uint128 fee, bytes32 memo)',
+)
+
+type ZoneClientLike = {
+ token: {
+ getBalance: (parameters: { account: Hex; token: Hex }) => Promise
+ }
+ zone: {
+ getAuthorizationTokenInfo: ZoneAuthClientLike['zone']['getAuthorizationTokenInfo']
+ requestWithdrawalSync: (parameters: {
+ account: unknown
+ amount: bigint
+ data?: Hex
+ feeToken: Hex
+ gas?: bigint
+ timeout: number
+ to: Hex
+ token: Hex
+ }) => Promise<{ receipt: { blockNumber: bigint; transactionHash: Hex } }>
+ getWithdrawalFee: (parameters?: { gasLimit?: bigint | undefined }) => Promise
+ signAuthorizationToken: ZoneAuthClientLike['zone']['signAuthorizationToken']
+ }
+}
+
+export function SendTokensAcrossZones() {
+ const { address } = useConnection()
+ const connected = Boolean(address)
+
+ return (
+ <>
+ : }
+ error={undefined}
+ number={1}
+ title="Create or use a passkey account on the public chain."
+ />
+
+ {address ? (
+
+ ) : (
+
+ )}
+ >
+ )
+}
+
+function ConnectedZoneFlow(props: { address: Hex }) {
+ const { address } = props
+ const queryClient = useQueryClient()
+ const publicClient = usePublicClient()
+ const { data: connectorClient } = useConnectorClient()
+ const { data: rootWebAuthnAccount } = useRootWebAuthnAccount()
+ const {
+ data: rootBalance,
+ isPending: rootBalanceIsPending,
+ refetch: refetchRootBalance,
+ } = Hooks.token.useGetBalance({
+ account: address,
+ token: pathUsd,
+ })
+
+ const sourceZoneClient = React.useMemo(
+ () =>
+ rootWebAuthnAccount
+ ? (createClient({
+ account: rootWebAuthnAccount,
+ chain: zoneModerato(ZONE_A.id),
+ transport: zoneHttp(
+ stripRpcBasicAuth(ZONE_A.rpcUrl),
+ getZoneTransportConfig(ZONE_A.rpcUrl),
+ ),
+ }).extend(tempoActions()) as unknown as ZoneClientLike)
+ : undefined,
+ [rootWebAuthnAccount],
+ )
+ const targetZoneClient = React.useMemo(
+ () =>
+ rootWebAuthnAccount
+ ? (createClient({
+ account: rootWebAuthnAccount,
+ chain: zoneModerato(ZONE_B.id),
+ transport: zoneHttp(
+ stripRpcBasicAuth(ZONE_B.rpcUrl),
+ getZoneTransportConfig(ZONE_B.rpcUrl),
+ ),
+ }).extend(tempoActions()) as unknown as ZoneClientLike)
+ : undefined,
+ [rootWebAuthnAccount],
+ )
+
+ const sourceFooterQueryKey = React.useMemo(
+ () => ['demo-zone-balance', address, ZONE_A.id, pathUsd],
+ [address],
+ )
+ const targetFooterQueryKey = React.useMemo(
+ () => ['demo-zone-balance', address, ZONE_B.id, pathUsd],
+ [address],
+ )
+
+ const sourceZoneAuthorization = useZoneAuthorization({
+ address,
+ chainId: ZONE_A.chainId,
+ queryKey: ['guide-private-zones-cross-zone-send-source-auth', address, ZONE_A.id],
+ zoneClient: sourceZoneClient,
+ })
+
+ const sourceZoneBalanceQuery = useQuery({
+ enabled: Boolean(sourceZoneClient && sourceZoneAuthorization.isAuthorized),
+ queryKey: ['guide-private-zones-cross-zone-send-source-balance', address, ZONE_A.id],
+ queryFn: async () => {
+ if (!sourceZoneClient) throw new Error('Zone A client not ready')
+
+ return sourceZoneClient.token.getBalance({
+ account: address,
+ token: pathUsd,
+ })
+ },
+ staleTime: 30_000,
+ })
+
+ const transferPrereqsQuery = useQuery({
+ enabled: Boolean(connectorClient && publicClient && sourceZoneAuthorization.isAuthorized),
+ queryKey: ['guide-private-zones-cross-zone-send-prereqs', address, ZONE_A.id, ZONE_B.id],
+ queryFn: async () => {
+ if (!publicClient) throw new Error('public client not ready')
+ if (!sourceZoneClient) throw new Error('Zone A client not ready')
+
+ const [routedWithdrawalFee, targetDepositFee, targetTokenEnabled] = await Promise.all([
+ sourceZoneClient.zone.getWithdrawalFee({
+ gasLimit: routerCallbackGasLimit,
+ }),
+ publicClient.readContract({
+ address: ZONE_B.portalAddress,
+ abi: portalAbi,
+ functionName: 'calculateDepositFee',
+ }),
+ publicClient.readContract({
+ address: ZONE_B.portalAddress,
+ abi: portalAbi,
+ functionName: 'isTokenEnabled',
+ args: [pathUsd],
+ }),
+ ])
+
+ if (!targetTokenEnabled) {
+ throw new Error(`${ZONE_B.label} is not ready for pathUSD deposits yet.`)
+ }
+ if (TRANSFER_AMOUNT <= targetDepositFee) {
+ throw new Error(
+ `The ${ZONE_B.label} deposit fee is currently too high for this 25 pathUSD send.`,
+ )
+ }
+
+ return {
+ minimumTargetIncrease: TRANSFER_AMOUNT - targetDepositFee,
+ routedWithdrawalFee,
+ targetDepositFee,
+ }
+ },
+ staleTime: 30_000,
+ })
+
+ const requiredSourceZoneBalance = transferPrereqsQuery.data
+ ? TRANSFER_AMOUNT + transferPrereqsQuery.data.routedWithdrawalFee + ZONE_GAS_BUFFER
+ : undefined
+ const sourceZoneTopUpShortfall =
+ requiredSourceZoneBalance !== undefined &&
+ sourceZoneBalanceQuery.data !== undefined &&
+ sourceZoneBalanceQuery.data < requiredSourceZoneBalance
+ ? requiredSourceZoneBalance - sourceZoneBalanceQuery.data
+ : 0n
+ const hasEnoughSourceZoneBalance = Boolean(
+ requiredSourceZoneBalance !== undefined &&
+ sourceZoneBalanceQuery.data !== undefined &&
+ sourceZoneBalanceQuery.data >= requiredSourceZoneBalance,
+ )
+ const sourceZoneBalanceStepComplete = useStickyStepCompletion(hasEnoughSourceZoneBalance)
+
+ const fundMutation = useMutation({
+ mutationFn: async () => {
+ if (!connectorClient) throw new Error('connector client not ready')
+
+ await Actions.faucet.fundSync(connectorClient, {
+ account: address,
+ })
+ },
+ onSuccess: async () => {
+ await refetchRootBalance()
+ },
+ })
+
+ const topUpMutation = useMutation({
+ mutationFn: async () => {
+ if (!connectorClient) throw new Error('connector client not ready')
+ if (sourceZoneTopUpShortfall <= 0n) throw new Error('zone top-up is not required')
+
+ const { receipt } = await Actions.zone.depositSync(connectorClient as never, {
+ account: connectorClient.account,
+ amount: sourceZoneTopUpShortfall,
+ chain: connectorClient.chain as never,
+ token: pathUsd,
+ zoneId: ZONE_A.id,
+ })
+
+ return { receipt }
+ },
+ onSuccess: async () => {
+ await refetchRootBalance()
+ await sourceZoneBalanceQuery.refetch()
+ },
+ })
+
+ const sendMutation = useMutation({
+ mutationFn: async () => {
+ if (!connectorClient) throw new Error('connector client not ready')
+ if (!sourceZoneClient) throw new Error('Zone A client not ready')
+ if (!publicClient) throw new Error('public client not ready')
+ if (!rootWebAuthnAccount) throw new Error('root account not ready')
+ if (!transferPrereqsQuery.data) throw new Error('Send prerequisites are not ready')
+
+ const currentSourceBalance = await sourceZoneClient.token.getBalance({
+ account: address,
+ token: pathUsd,
+ })
+ if (
+ requiredSourceZoneBalance === undefined ||
+ currentSourceBalance < requiredSourceZoneBalance
+ ) {
+ throw new Error('Zone A needs more pathUSD before the send can start.')
+ }
+
+ const anchorBlock = await publicClient.getBlockNumber()
+
+ const { receipt } = await sourceZoneClient.zone.requestWithdrawalSync({
+ account: rootWebAuthnAccount,
+ amount: TRANSFER_AMOUNT,
+ data: encodeRouterCallback(address),
+ feeToken: pathUsd,
+ gas: routerCallbackGasLimit,
+ timeout: zoneRpcSyncTimeout,
+ to: swapAndDepositRouter,
+ token: pathUsd,
+ })
+
+ return {
+ anchorBlock,
+ minimumTargetIncrease: transferPrereqsQuery.data.minimumTargetIncrease,
+ receipt,
+ targetDepositFee: transferPrereqsQuery.data.targetDepositFee,
+ }
+ },
+ onSuccess: async () => {
+ await sourceZoneBalanceQuery.refetch()
+ await queryClient.invalidateQueries({ queryKey: sourceFooterQueryKey })
+ },
+ })
+
+ const settlementQuery = useQuery({
+ enabled: Boolean(
+ publicClient && sendMutation.isSuccess && sendMutation.data?.anchorBlock !== undefined,
+ ),
+ queryKey: [
+ 'guide-private-zones-cross-zone-send-settlement',
+ address,
+ sendMutation.data?.anchorBlock?.toString(),
+ ],
+ queryFn: async () => {
+ if (!publicClient) throw new Error('public client not ready')
+ if (!sendMutation.data) throw new Error('send submission not ready')
+
+ const fromBlock =
+ sendMutation.data.anchorBlock > publicSettlementLookbackBlocks
+ ? sendMutation.data.anchorBlock - publicSettlementLookbackBlocks
+ : 0n
+ const latest = await publicClient.getBlockNumber()
+ const logs = await publicClient.getLogs({
+ address: ZONE_B.portalAddress,
+ event: targetDepositEvent,
+ fromBlock,
+ toBlock: latest,
+ })
+
+ const match = logs.find((log) => {
+ const sender = log.args.sender
+ const token = log.args.token
+ const recipient = log.args.to
+ const netAmount = log.args.netAmount
+
+ return (
+ typeof sender === 'string' &&
+ typeof token === 'string' &&
+ typeof recipient === 'string' &&
+ typeof netAmount === 'bigint' &&
+ sender.toLowerCase() === swapAndDepositRouter.toLowerCase() &&
+ token.toLowerCase() === pathUsd.toLowerCase() &&
+ recipient.toLowerCase() === address.toLowerCase() &&
+ netAmount >= sendMutation.data.minimumTargetIncrease
+ )
+ })
+
+ return match ? { txHash: match.transactionHash } : null
+ },
+ refetchInterval: (query) => {
+ if (query.state.error || query.state.data) return false
+
+ return 2_000
+ },
+ refetchOnReconnect: false,
+ refetchOnWindowFocus: false,
+ retry: false,
+ })
+
+ const targetZoneAuthorization = useZoneAuthorization({
+ address,
+ chainId: ZONE_B.chainId,
+ queryKey: ['guide-private-zones-cross-zone-send-target-auth', address, ZONE_B.id],
+ zoneClient: targetZoneClient,
+ })
+
+ const targetZoneBalanceQuery = useQuery({
+ enabled: Boolean(
+ targetZoneClient && targetZoneAuthorization.isAuthorized && settlementQuery.data,
+ ),
+ queryKey: ['guide-private-zones-cross-zone-send-target-balance', address, ZONE_B.id],
+ queryFn: async () => {
+ if (!targetZoneClient) throw new Error('Zone B client not ready')
+
+ return targetZoneClient.token.getBalance({
+ account: address,
+ token: pathUsd,
+ })
+ },
+ staleTime: 30_000,
+ refetchOnReconnect: false,
+ refetchOnWindowFocus: false,
+ retry: false,
+ })
+
+ const hasRootBalance = Boolean(rootBalance && rootBalance > 0n)
+ const topUpReceipt = topUpMutation.data?.receipt
+ const routedSendReceipt = sendMutation.data?.receipt
+ const settlementTxHash = settlementQuery.data?.txHash
+ const targetBalanceReady = Boolean(
+ settlementQuery.data &&
+ targetZoneAuthorization.isAuthorized &&
+ targetZoneBalanceQuery.isSuccess,
+ )
+ const sourceAuthIsPreparing =
+ sourceZoneAuthorization.isChecking || sourceZoneAuthorization.authorizeMutation.isPending
+ const stepTwoAction = sourceZoneAuthorization.isAuthorized ? undefined : (
+ sourceZoneAuthorization.authorizeMutation.mutate()}
+ type="button"
+ variant={sourceZoneClient ? 'accent' : 'default'}
+ >
+ {sourceAuthIsPreparing
+ ? `Authorizing ${ZONE_A.label} reads`
+ : sourceZoneAuthorization.authorizeMutation.isError
+ ? 'Retry'
+ : `Authorize ${ZONE_A.label} reads`}
+
+ )
+
+ React.useEffect(() => {
+ if (!sourceZoneAuthorization.isAuthorized) return
+
+ void queryClient.invalidateQueries({ queryKey: sourceFooterQueryKey })
+ }, [queryClient, sourceFooterQueryKey, sourceZoneAuthorization.isAuthorized])
+
+ React.useEffect(() => {
+ if (!targetZoneAuthorization.isAuthorized) return
+
+ void queryClient.invalidateQueries({ queryKey: targetFooterQueryKey })
+ }, [queryClient, targetFooterQueryKey, targetZoneAuthorization.isAuthorized])
+
+ React.useEffect(() => {
+ if (!topUpMutation.isSuccess || sourceZoneBalanceStepComplete) return
+
+ const interval = window.setInterval(() => {
+ void sourceZoneBalanceQuery.refetch()
+ }, 1_500)
+
+ return () => window.clearInterval(interval)
+ }, [sourceZoneBalanceQuery, sourceZoneBalanceStepComplete, topUpMutation.isSuccess])
+
+ let stepThreeAction: React.ReactNode
+ if (sourceZoneBalanceStepComplete) {
+ stepThreeAction = undefined
+ } else if (sourceZoneBalanceQuery.isPending || transferPrereqsQuery.isPending) {
+ stepThreeAction = (
+
+ Checking Zone A
+
+ )
+ } else if (!hasEnoughSourceZoneBalance && !hasRootBalance) {
+ stepThreeAction = (
+ fundMutation.mutate()}
+ type="button"
+ variant={sourceZoneAuthorization.isAuthorized ? 'accent' : 'default'}
+ >
+ {fundMutation.isPending ? 'Getting pathUSD' : 'Get testnet pathUSD'}
+
+ )
+ } else if (!hasEnoughSourceZoneBalance) {
+ stepThreeAction = (
+ topUpMutation.mutate()}
+ type="button"
+ variant={sourceZoneAuthorization.isAuthorized ? 'accent' : 'default'}
+ >
+ {topUpMutation.isPending ? 'Approving + topping up Zone A' : 'Approve + top up Zone A'}
+
+ )
+ }
+
+ let stepFourAction: React.ReactNode
+ if (!sourceZoneBalanceStepComplete || transferPrereqsQuery.isPending) {
+ stepFourAction = undefined
+ } else if (transferPrereqsQuery.isError) {
+ stepFourAction = (
+ transferPrereqsQuery.refetch()}
+ type="button"
+ variant="default"
+ >
+ Retry send check
+
+ )
+ } else {
+ stepFourAction = (
+ sendMutation.mutate()}
+ type="button"
+ variant={sendMutation.isSuccess ? 'default' : 'accent'}
+ >
+ {sendMutation.isPending
+ ? 'Submitting routed send'
+ : sendMutation.isSuccess
+ ? 'Send submitted'
+ : 'Send 25 pathUSD into Zone B'}
+
+ )
+ }
+
+ let stepSixAction: React.ReactNode
+ if (!settlementQuery.data) {
+ stepSixAction = undefined
+ } else if (targetZoneBalanceQuery.isError) {
+ stepSixAction = (
+ targetZoneBalanceQuery.refetch()}
+ type="button"
+ variant="default"
+ >
+ Retry Zone B read
+
+ )
+ } else if (!targetZoneAuthorization.isAuthorized) {
+ stepSixAction = (
+ targetZoneAuthorization.authorizeMutation.mutate()}
+ type="button"
+ variant="accent"
+ >
+ {targetZoneAuthorization.authorizeMutation.isPending
+ ? 'Authorizing Zone B reads'
+ : 'Authorize Zone B reads'}
+
+ )
+ } else if (targetZoneBalanceQuery.isPending) {
+ stepSixAction = (
+
+ Reading Zone B pathUSD
+
+ )
+ }
+
+ return (
+ <>
+
+
+
+ {topUpReceipt && (
+
+
+
+
+ )}
+
+
+
+ {routedSendReceipt && sendMutation.data && (
+
+
+
+
+ )}
+
+
+
+ {settlementTxHash && (
+ {settlementTxHash && }
+ )}
+
+
+
+ >
+ )
+}
+
+function DisconnectedZoneFlow() {
+ return (
+ <>
+
+
+
+
+
+ >
+ )
+}
+
+function encodeRouterCallback(recipient: Hex) {
+ return encodeAbiParameters(
+ [
+ { type: 'bool' },
+ { type: 'address' },
+ { type: 'address' },
+ { type: 'address' },
+ { type: 'bytes32' },
+ { type: 'uint128' },
+ ],
+ [false, pathUsd, ZONE_B.portalAddress, recipient, zeroBytes32, 0n],
+ )
+}
+
+function StepBody(props: React.PropsWithChildren) {
+ return (
+
+ )
+}
+
+function DetailLine(props: { label: string; value: string; dataTestId?: string | undefined }) {
+ const { dataTestId, label, value } = props
+
+ return (
+
+ {label}
+
+ {value}
+
+
+ )
+}
diff --git a/src/components/guides/zones/SendTokensWithinZone.tsx b/src/components/guides/zones/SendTokensWithinZone.tsx
new file mode 100644
index 00000000..15f5d27b
--- /dev/null
+++ b/src/components/guides/zones/SendTokensWithinZone.tsx
@@ -0,0 +1,437 @@
+'use client'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import * as React from 'react'
+import { createClient, type Hex, parseUnits } from 'viem'
+import { Actions, tempoActions } from 'viem/tempo'
+import { http as zoneHttp, zoneModerato } from 'viem/tempo/zones'
+import { useConnection, useConnectorClient } from 'wagmi'
+import { Hooks } from 'wagmi/tempo'
+import {
+ getZoneTransportConfig,
+ moderatoZoneRpcUrls,
+ stripRpcBasicAuth,
+} from '../../../lib/private-zones.ts'
+import { useRootWebAuthnAccount } from '../../../lib/useRootWebAuthnAccount.ts'
+import { useZoneAuthorization, type ZoneAuthClientLike } from '../../../lib/useZoneAuthorization.ts'
+import { Button, ExplorerLink, FAKE_RECIPIENT, Logout, ReceiptHash, Step } from '../Demo'
+import { SignInButtons } from '../EmbedPasskeys'
+import { pathUsd } from '../tokens'
+import { useStickyStepCompletion } from './useStickyStepCompletion.ts'
+
+const ZONE_LABEL = 'Zone A'
+const ZONE_ID = 6 as const
+const TRANSFER_AMOUNT = parseUnits('25', 6)
+const ZONE_GAS_BUFFER = parseUnits('1', 6)
+
+type ZoneClientLike = {
+ token: {
+ getBalance: (parameters: { account: Hex; token: Hex }) => Promise
+ }
+ zone: ZoneAuthClientLike['zone']
+}
+
+export function SendTokensWithinZone() {
+ const { address } = useConnection()
+ const connected = Boolean(address)
+
+ return (
+ <>
+ : }
+ error={undefined}
+ number={1}
+ title="Create or use a passkey account on the public chain."
+ />
+
+ {address ? (
+
+ ) : (
+
+ )}
+ >
+ )
+}
+
+function ConnectedZoneFlow(props: { address: Hex }) {
+ const { address } = props
+ const queryClient = useQueryClient()
+ const { data: connectorClient } = useConnectorClient()
+ const { data: rootWebAuthnAccount } = useRootWebAuthnAccount()
+ const {
+ data: rootBalance,
+ isPending: rootBalanceIsPending,
+ refetch: refetchRootBalance,
+ } = Hooks.token.useGetBalance({
+ account: address,
+ token: pathUsd,
+ })
+
+ const zoneClient = React.useMemo(
+ () =>
+ rootWebAuthnAccount
+ ? (createClient({
+ account: rootWebAuthnAccount,
+ chain: zoneModerato(ZONE_ID),
+ transport: zoneHttp(
+ stripRpcBasicAuth(moderatoZoneRpcUrls[ZONE_ID]),
+ getZoneTransportConfig(moderatoZoneRpcUrls[ZONE_ID]),
+ ),
+ }).extend(tempoActions()) as unknown as ZoneClientLike)
+ : undefined,
+ [rootWebAuthnAccount],
+ )
+
+ const zoneAuthorization = useZoneAuthorization({
+ address,
+ chainId: zoneModerato(ZONE_ID).id,
+ queryKey: ['guide-private-zones-send-auth', address, ZONE_ID],
+ zoneClient,
+ })
+
+ React.useEffect(() => {
+ if (!zoneAuthorization.isAuthorized) return
+
+ void queryClient.invalidateQueries({
+ queryKey: ['demo-zone-balance', address, ZONE_ID],
+ })
+ }, [address, queryClient, zoneAuthorization.isAuthorized])
+
+ const zoneBalanceQuery = useQuery({
+ enabled: Boolean(zoneClient && zoneAuthorization.isAuthorized),
+ queryKey: ['guide-private-zones-send-zone-balance', address, ZONE_ID],
+ queryFn: async () => {
+ if (!zoneClient) throw new Error('zone client not ready')
+
+ return zoneClient.token.getBalance({
+ account: address,
+ token: pathUsd,
+ })
+ },
+ staleTime: 30_000,
+ })
+
+ const requiredZoneBalance = TRANSFER_AMOUNT + ZONE_GAS_BUFFER
+ const zoneTopUpShortfall =
+ zoneBalanceQuery.data !== undefined && zoneBalanceQuery.data < requiredZoneBalance
+ ? requiredZoneBalance - zoneBalanceQuery.data
+ : 0n
+ const hasEnoughZoneBalance = Boolean(
+ zoneBalanceQuery.data !== undefined && zoneBalanceQuery.data >= requiredZoneBalance,
+ )
+ const zoneBalanceStepComplete = useStickyStepCompletion(hasEnoughZoneBalance)
+
+ const fundMutation = useMutation({
+ mutationFn: async () => {
+ if (!connectorClient) throw new Error('connector client not ready')
+
+ await Actions.faucet.fundSync(connectorClient, {
+ account: address,
+ })
+ },
+ onSuccess: async () => {
+ await refetchRootBalance()
+ },
+ })
+
+ const topUpMutation = useMutation({
+ mutationFn: async () => {
+ if (!connectorClient) throw new Error('connector client not ready')
+ if (zoneTopUpShortfall <= 0n) throw new Error('zone top-up is not required')
+
+ const { receipt } = await Actions.zone.depositSync(connectorClient as never, {
+ account: connectorClient.account,
+ amount: zoneTopUpShortfall,
+ chain: connectorClient.chain as never,
+ token: pathUsd,
+ zoneId: ZONE_ID,
+ })
+
+ return { receipt }
+ },
+ onSuccess: async () => {
+ await refetchRootBalance()
+ await zoneBalanceQuery.refetch()
+ },
+ })
+
+ const transferMutation = useMutation({
+ mutationFn: async () => {
+ if (!connectorClient) throw new Error('connector client not ready')
+ if (!zoneClient) throw new Error('zone client not ready')
+ if (!rootWebAuthnAccount) throw new Error('root account not ready')
+
+ const currentZoneBalance = await zoneClient.token.getBalance({
+ account: address,
+ token: pathUsd,
+ })
+ if (currentZoneBalance < requiredZoneBalance) {
+ throw new Error('Zone A needs more pathUSD before sending.')
+ }
+
+ const { receipt } = await Actions.token.transferSync(zoneClient as never, {
+ account: rootWebAuthnAccount,
+ amount: TRANSFER_AMOUNT,
+ chain: zoneModerato(ZONE_ID) as never,
+ feeToken: pathUsd,
+ to: FAKE_RECIPIENT as Hex,
+ token: pathUsd,
+ })
+
+ return {
+ receipt,
+ startingZoneBalance: currentZoneBalance,
+ }
+ },
+ onSuccess: async () => {
+ await zoneBalanceQuery.refetch()
+ await transferConfirmationQuery.refetch()
+ },
+ })
+
+ const transferConfirmationQuery = useQuery({
+ enabled: Boolean(zoneClient && zoneAuthorization.isAuthorized && transferMutation.isSuccess),
+ queryKey: ['guide-private-zones-send-confirmation', address, ZONE_ID],
+ queryFn: async () => {
+ if (!zoneClient) throw new Error('zone client not ready')
+
+ return zoneClient.token.getBalance({
+ account: address,
+ token: pathUsd,
+ })
+ },
+ refetchInterval: (query) => {
+ if (query.state.error) return false
+
+ const expectedMaxZoneBalance = transferMutation.data?.startingZoneBalance
+ ? transferMutation.data.startingZoneBalance - TRANSFER_AMOUNT
+ : undefined
+ if (expectedMaxZoneBalance === undefined) return false
+
+ const currentZoneBalance = query.state.data as bigint | undefined
+ return currentZoneBalance !== undefined && currentZoneBalance <= expectedMaxZoneBalance
+ ? false
+ : 1_500
+ },
+ refetchOnReconnect: false,
+ refetchOnWindowFocus: false,
+ retry: false,
+ })
+
+ const hasRootBalance = Boolean(rootBalance && rootBalance > 0n)
+ const topUpReceipt = topUpMutation.data?.receipt
+ const expectedMaxZoneBalance = transferMutation.data?.startingZoneBalance
+ ? transferMutation.data.startingZoneBalance - TRANSFER_AMOUNT
+ : undefined
+ const transferConfirmed = Boolean(
+ expectedMaxZoneBalance !== undefined &&
+ transferConfirmationQuery.data !== undefined &&
+ transferConfirmationQuery.data <= expectedMaxZoneBalance,
+ )
+ const transferReceipt = transferMutation.data?.receipt
+ const authIsPreparing =
+ zoneAuthorization.isChecking || zoneAuthorization.authorizeMutation.isPending
+ const stepTwoAction = zoneAuthorization.isAuthorized ? undefined : (
+ zoneAuthorization.authorizeMutation.mutate()}
+ type="button"
+ variant={zoneClient ? 'accent' : 'default'}
+ >
+ {authIsPreparing
+ ? `Authorizing ${ZONE_LABEL} reads`
+ : zoneAuthorization.authorizeMutation.isError
+ ? 'Retry'
+ : `Authorize ${ZONE_LABEL} reads`}
+
+ )
+
+ React.useEffect(() => {
+ if (!topUpMutation.isSuccess || zoneBalanceStepComplete) return
+
+ const interval = window.setInterval(() => {
+ void zoneBalanceQuery.refetch()
+ }, 1_500)
+
+ return () => window.clearInterval(interval)
+ }, [topUpMutation.isSuccess, zoneBalanceQuery, zoneBalanceStepComplete])
+
+ let stepThreeAction: React.ReactNode
+ if (zoneBalanceStepComplete) {
+ stepThreeAction = undefined
+ } else if (zoneBalanceQuery.isPending) {
+ stepThreeAction = (
+
+ Checking balances
+
+ )
+ } else if (!hasEnoughZoneBalance && !hasRootBalance) {
+ stepThreeAction = (
+ fundMutation.mutate()}
+ type="button"
+ variant={zoneAuthorization.isAuthorized ? 'accent' : 'default'}
+ >
+ {fundMutation.isPending ? 'Getting pathUSD' : 'Get testnet pathUSD'}
+
+ )
+ } else if (!hasEnoughZoneBalance) {
+ stepThreeAction = (
+ topUpMutation.mutate()}
+ type="button"
+ variant={zoneAuthorization.isAuthorized ? 'accent' : 'default'}
+ >
+ {topUpMutation.isPending ? 'Approving + topping up Zone A' : 'Approve + top up Zone A'}
+
+ )
+ }
+
+ let stepFourAction: React.ReactNode
+ if (!zoneBalanceStepComplete) {
+ stepFourAction = undefined
+ } else {
+ stepFourAction = (
+ transferMutation.mutate()}
+ type="button"
+ variant={transferMutation.isSuccess ? 'default' : 'accent'}
+ >
+ {transferMutation.isPending
+ ? 'Sending pathUSD'
+ : transferMutation.isSuccess
+ ? 'Transfer submitted'
+ : 'Send 25 pathUSD'}
+
+ )
+ }
+
+ return (
+ <>
+
+
+
+ {topUpReceipt && (
+
+
+
+
+ )}
+
+
+
+ {transferReceipt && (
+
+
+
+
+ )}
+
+
+
+ >
+ )
+}
+
+function DisconnectedZoneFlow() {
+ return (
+ <>
+
+
+
+
+ >
+ )
+}
+
+function StepBody(props: React.PropsWithChildren) {
+ return (
+
+ )
+}
+
+function DetailLine(props: { label: string; value: string; dataTestId?: string | undefined }) {
+ const { dataTestId, label, value } = props
+
+ return (
+
+ {label}
+
+ {value}
+
+
+ )
+}
diff --git a/src/components/guides/zones/SwapAcrossZones.tsx b/src/components/guides/zones/SwapAcrossZones.tsx
new file mode 100644
index 00000000..a834e748
--- /dev/null
+++ b/src/components/guides/zones/SwapAcrossZones.tsx
@@ -0,0 +1,773 @@
+'use client'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import * as React from 'react'
+import { createClient, encodeAbiParameters, type Hex, parseAbiItem, parseUnits } from 'viem'
+import { Actions, tempoActions } from 'viem/tempo'
+import { http as zoneHttp, zoneModerato } from 'viem/tempo/zones'
+import { useConnection, useConnectorClient, usePublicClient } from 'wagmi'
+import { Hooks } from 'wagmi/tempo'
+import {
+ getZoneTransportConfig,
+ moderatoZoneFactory,
+ publicSettlementLookbackBlocks,
+ routerCallbackGasLimit,
+ stablecoinDex,
+ stripRpcBasicAuth,
+ swapAndDepositRouter,
+ ZONE_A,
+ ZONE_B,
+ zeroBytes32,
+ zoneRpcSyncTimeout,
+} from '../../../lib/private-zones.ts'
+import { useRootWebAuthnAccount } from '../../../lib/useRootWebAuthnAccount.ts'
+import { useZoneAuthorization, type ZoneAuthClientLike } from '../../../lib/useZoneAuthorization.ts'
+import { Button, ExplorerLink, Logout, ReceiptHash, Step } from '../Demo'
+import { SignInButtons } from '../EmbedPasskeys'
+import { betaUsd, pathUsd } from '../tokens'
+import { useStickyStepCompletion } from './useStickyStepCompletion.ts'
+
+const SWAP_AMOUNT = parseUnits('25', 6)
+const ZONE_GAS_BUFFER = parseUnits('1', 6)
+
+const portalAbi = [
+ {
+ name: 'calculateDepositFee',
+ type: 'function',
+ stateMutability: 'view',
+ inputs: [],
+ outputs: [{ type: 'uint128' }],
+ },
+ {
+ name: 'isTokenEnabled',
+ type: 'function',
+ stateMutability: 'view',
+ inputs: [{ name: 'token', type: 'address' }],
+ outputs: [{ type: 'bool' }],
+ },
+] as const
+
+const routerAbi = [
+ {
+ name: 'stablecoinDEX',
+ type: 'function',
+ stateMutability: 'view',
+ inputs: [],
+ outputs: [{ type: 'address' }],
+ },
+ {
+ name: 'zoneFactory',
+ type: 'function',
+ stateMutability: 'view',
+ inputs: [],
+ outputs: [{ type: 'address' }],
+ },
+] as const
+
+const targetDepositEvent = parseAbiItem(
+ 'event DepositMade(bytes32 indexed newCurrentDepositQueueHash, address indexed sender, address token, address to, uint128 netAmount, uint128 fee, bytes32 memo)',
+)
+
+type ZoneClientLike = {
+ token: {
+ getAllowance: (parameters: { account: Hex; spender: Hex; token: Hex }) => Promise
+ getBalance: (parameters: { account: Hex; token: Hex }) => Promise
+ }
+ zone: {
+ getAuthorizationTokenInfo: ZoneAuthClientLike['zone']['getAuthorizationTokenInfo']
+ requestWithdrawalSync: (parameters: {
+ account: unknown
+ amount: bigint
+ data?: Hex
+ feeToken: Hex
+ gas?: bigint
+ timeout: number
+ to: Hex
+ token: Hex
+ }) => Promise<{ receipt: { blockNumber: bigint; transactionHash: Hex } }>
+ getWithdrawalFee: (parameters?: { gasLimit?: bigint | undefined }) => Promise
+ signAuthorizationToken: ZoneAuthClientLike['zone']['signAuthorizationToken']
+ }
+}
+
+export function SwapAcrossZones() {
+ const { address } = useConnection()
+ const connected = Boolean(address)
+
+ return (
+ <>
+ : }
+ error={undefined}
+ number={1}
+ title="Create or use a passkey account on the public chain."
+ />
+
+ {address ? (
+
+ ) : (
+
+ )}
+ >
+ )
+}
+
+function ConnectedZoneFlow(props: { address: Hex }) {
+ const { address } = props
+ const queryClient = useQueryClient()
+ const publicClient = usePublicClient()
+ const { data: connectorClient } = useConnectorClient()
+ const { data: rootWebAuthnAccount } = useRootWebAuthnAccount()
+ const {
+ data: rootBalance,
+ isPending: rootBalanceIsPending,
+ refetch: refetchRootBalance,
+ } = Hooks.token.useGetBalance({
+ account: address,
+ token: pathUsd,
+ })
+
+ const sourceZoneClient = React.useMemo(
+ () =>
+ rootWebAuthnAccount
+ ? (createClient({
+ account: rootWebAuthnAccount,
+ chain: zoneModerato(ZONE_A.id),
+ transport: zoneHttp(
+ stripRpcBasicAuth(ZONE_A.rpcUrl),
+ getZoneTransportConfig(ZONE_A.rpcUrl),
+ ),
+ }).extend(tempoActions()) as unknown as ZoneClientLike)
+ : undefined,
+ [rootWebAuthnAccount],
+ )
+ const targetZoneClient = React.useMemo(
+ () =>
+ rootWebAuthnAccount
+ ? (createClient({
+ account: rootWebAuthnAccount,
+ chain: zoneModerato(ZONE_B.id),
+ transport: zoneHttp(
+ stripRpcBasicAuth(ZONE_B.rpcUrl),
+ getZoneTransportConfig(ZONE_B.rpcUrl),
+ ),
+ }).extend(tempoActions()) as unknown as ZoneClientLike)
+ : undefined,
+ [rootWebAuthnAccount],
+ )
+
+ const sourceFooterQueryKey = React.useMemo(
+ () => ['demo-zone-balance', address, ZONE_A.id, pathUsd],
+ [address],
+ )
+ const targetFooterQueryKey = React.useMemo(
+ () => ['demo-zone-balance', address, ZONE_B.id, betaUsd],
+ [address],
+ )
+
+ const sourceZoneAuthorization = useZoneAuthorization({
+ address,
+ chainId: ZONE_A.chainId,
+ queryKey: ['guide-private-zones-swap-source-auth', address, ZONE_A.id],
+ zoneClient: sourceZoneClient,
+ })
+
+ const sourceZoneBalanceQuery = useQuery({
+ enabled: Boolean(sourceZoneClient && sourceZoneAuthorization.isAuthorized),
+ queryKey: ['guide-private-zones-swap-source-balance', address, ZONE_A.id],
+ queryFn: async () => {
+ if (!sourceZoneClient) throw new Error('Zone A client not ready')
+
+ return sourceZoneClient.token.getBalance({
+ account: address,
+ token: pathUsd,
+ })
+ },
+ staleTime: 30_000,
+ })
+
+ const swapPrereqsQuery = useQuery({
+ enabled: Boolean(connectorClient && publicClient && sourceZoneAuthorization.isAuthorized),
+ queryKey: ['guide-private-zones-swap-prereqs', address, ZONE_A.id, ZONE_B.id],
+ queryFn: async () => {
+ if (!connectorClient) throw new Error('connector client not ready')
+ if (!publicClient) throw new Error('public client not ready')
+
+ const [
+ routedWithdrawalFee,
+ quotedOutput,
+ targetDepositFee,
+ targetTokenEnabled,
+ routerDex,
+ routerFactory,
+ ] = await Promise.all([
+ sourceZoneClient?.zone.getWithdrawalFee({
+ gasLimit: routerCallbackGasLimit,
+ }),
+ Actions.dex.getSellQuote(publicClient as never, {
+ amountIn: SWAP_AMOUNT,
+ tokenIn: pathUsd,
+ tokenOut: betaUsd,
+ }),
+ publicClient.readContract({
+ address: ZONE_B.portalAddress,
+ abi: portalAbi,
+ functionName: 'calculateDepositFee',
+ }),
+ publicClient.readContract({
+ address: ZONE_B.portalAddress,
+ abi: portalAbi,
+ functionName: 'isTokenEnabled',
+ args: [betaUsd],
+ }),
+ publicClient.readContract({
+ address: swapAndDepositRouter,
+ abi: routerAbi,
+ functionName: 'stablecoinDEX',
+ }),
+ publicClient.readContract({
+ address: swapAndDepositRouter,
+ abi: routerAbi,
+ functionName: 'zoneFactory',
+ }),
+ ])
+
+ if (routedWithdrawalFee === undefined) throw new Error('Zone A withdrawal fee not ready')
+ if (routerDex.toLowerCase() !== stablecoinDex.toLowerCase()) {
+ throw new Error('The routed swap router is not pointing at the expected StablecoinDEX.')
+ }
+ if (routerFactory.toLowerCase() !== moderatoZoneFactory.toLowerCase()) {
+ throw new Error(
+ 'The routed swap router is not pointing at the current public-chain ZoneFactory.',
+ )
+ }
+ if (!targetTokenEnabled) {
+ throw new Error(`${ZONE_B.label} is not ready for betaUSD deposits yet.`)
+ }
+
+ const minimumOutput = applyOnePercentSlippageBuffer(quotedOutput)
+ if (minimumOutput <= targetDepositFee) {
+ throw new Error(
+ `The current pathUSD -> betaUSD quote is too small to cover the ${ZONE_B.label} deposit fee.`,
+ )
+ }
+
+ return {
+ minimumOutput,
+ minimumTargetIncrease: minimumOutput - targetDepositFee,
+ quotedOutput,
+ routedWithdrawalFee,
+ }
+ },
+ staleTime: 30_000,
+ })
+
+ const requiredSourceZoneBalance = swapPrereqsQuery.data
+ ? SWAP_AMOUNT + swapPrereqsQuery.data.routedWithdrawalFee + ZONE_GAS_BUFFER
+ : undefined
+ const sourceZoneTopUpShortfall =
+ requiredSourceZoneBalance !== undefined &&
+ sourceZoneBalanceQuery.data !== undefined &&
+ sourceZoneBalanceQuery.data < requiredSourceZoneBalance
+ ? requiredSourceZoneBalance - sourceZoneBalanceQuery.data
+ : 0n
+ const hasEnoughSourceZoneBalance = Boolean(
+ requiredSourceZoneBalance !== undefined &&
+ sourceZoneBalanceQuery.data !== undefined &&
+ sourceZoneBalanceQuery.data >= requiredSourceZoneBalance,
+ )
+ const sourceZoneBalanceStepComplete = useStickyStepCompletion(hasEnoughSourceZoneBalance)
+
+ const fundMutation = useMutation({
+ mutationFn: async () => {
+ if (!connectorClient) throw new Error('connector client not ready')
+
+ await Actions.faucet.fundSync(connectorClient, {
+ account: address,
+ })
+ },
+ onSuccess: async () => {
+ await refetchRootBalance()
+ },
+ })
+
+ const topUpMutation = useMutation({
+ mutationFn: async () => {
+ if (!connectorClient) throw new Error('connector client not ready')
+ if (sourceZoneTopUpShortfall <= 0n) throw new Error('Zone A top-up is not required')
+
+ const { receipt } = await Actions.zone.depositSync(connectorClient as never, {
+ account: connectorClient.account,
+ amount: sourceZoneTopUpShortfall,
+ chain: connectorClient.chain as never,
+ token: pathUsd,
+ zoneId: ZONE_A.id,
+ })
+
+ return { receipt }
+ },
+ onSuccess: async () => {
+ await refetchRootBalance()
+ await sourceZoneBalanceQuery.refetch()
+ await queryClient.invalidateQueries({ queryKey: sourceFooterQueryKey })
+ },
+ })
+
+ const swapMutation = useMutation({
+ mutationFn: async () => {
+ if (!connectorClient) throw new Error('connector client not ready')
+ if (!sourceZoneClient) throw new Error('Zone A client not ready')
+ if (!publicClient) throw new Error('public client not ready')
+ if (!rootWebAuthnAccount) throw new Error('root account not ready')
+ if (!swapPrereqsQuery.data) throw new Error('Swap prerequisites are not ready')
+
+ const currentSourceBalance = await sourceZoneClient.token.getBalance({
+ account: address,
+ token: pathUsd,
+ })
+ if (
+ requiredSourceZoneBalance === undefined ||
+ currentSourceBalance < requiredSourceZoneBalance
+ ) {
+ throw new Error('Zone A needs more pathUSD before the swap can start.')
+ }
+
+ const callbackData = encodeRouterCallback({
+ minimumOutput: swapPrereqsQuery.data.minimumOutput,
+ recipient: address,
+ })
+
+ const anchorBlock = await publicClient.getBlockNumber()
+
+ const { receipt } = await sourceZoneClient.zone.requestWithdrawalSync({
+ account: rootWebAuthnAccount,
+ amount: SWAP_AMOUNT,
+ data: callbackData,
+ feeToken: pathUsd,
+ gas: routerCallbackGasLimit,
+ timeout: zoneRpcSyncTimeout,
+ to: swapAndDepositRouter,
+ token: pathUsd,
+ })
+
+ return {
+ anchorBlock,
+ minimumTargetIncrease: swapPrereqsQuery.data.minimumTargetIncrease,
+ receipt,
+ }
+ },
+ onSuccess: async () => {
+ await sourceZoneBalanceQuery.refetch()
+ await queryClient.invalidateQueries({ queryKey: sourceFooterQueryKey })
+ },
+ })
+
+ const settlementQuery = useQuery({
+ enabled: Boolean(
+ publicClient && swapMutation.isSuccess && swapMutation.data?.anchorBlock !== undefined,
+ ),
+ queryKey: [
+ 'guide-private-zones-swap-settlement',
+ address,
+ swapMutation.data?.anchorBlock?.toString(),
+ ],
+ queryFn: async () => {
+ if (!publicClient) throw new Error('public client not ready')
+ if (!swapMutation.data) throw new Error('swap submission not ready')
+
+ const fromBlock =
+ swapMutation.data.anchorBlock > publicSettlementLookbackBlocks
+ ? swapMutation.data.anchorBlock - publicSettlementLookbackBlocks
+ : 0n
+ const latest = await publicClient.getBlockNumber()
+ const logs = await publicClient.getLogs({
+ address: ZONE_B.portalAddress,
+ event: targetDepositEvent,
+ fromBlock,
+ toBlock: latest,
+ })
+
+ const match = logs.find((log) => {
+ const sender = log.args.sender
+ const token = log.args.token
+ const recipient = log.args.to
+ const netAmount = log.args.netAmount
+
+ return (
+ typeof sender === 'string' &&
+ typeof token === 'string' &&
+ typeof recipient === 'string' &&
+ typeof netAmount === 'bigint' &&
+ sender.toLowerCase() === swapAndDepositRouter.toLowerCase() &&
+ token.toLowerCase() === betaUsd.toLowerCase() &&
+ recipient.toLowerCase() === address.toLowerCase() &&
+ netAmount >= swapMutation.data.minimumTargetIncrease
+ )
+ })
+
+ return match ? { txHash: match.transactionHash } : null
+ },
+ refetchInterval: (query) => {
+ if (query.state.error || query.state.data) return false
+
+ return 2_000
+ },
+ refetchOnReconnect: false,
+ refetchOnWindowFocus: false,
+ retry: false,
+ })
+
+ const targetZoneAuthorization = useZoneAuthorization({
+ address,
+ chainId: ZONE_B.chainId,
+ queryKey: ['guide-private-zones-swap-target-auth', address, ZONE_B.id],
+ zoneClient: targetZoneClient,
+ })
+
+ const targetZoneBalanceQuery = useQuery({
+ enabled: Boolean(
+ targetZoneClient && targetZoneAuthorization.isAuthorized && settlementQuery.data,
+ ),
+ queryKey: ['guide-private-zones-swap-target-balance', address, ZONE_B.id],
+ queryFn: async () => {
+ if (!targetZoneClient) throw new Error('Zone B client not ready')
+
+ return targetZoneClient.token.getBalance({
+ account: address,
+ token: betaUsd,
+ })
+ },
+ staleTime: 30_000,
+ refetchOnReconnect: false,
+ refetchOnWindowFocus: false,
+ retry: false,
+ })
+
+ const hasRootBalance = Boolean(rootBalance && rootBalance > 0n)
+ const topUpReceipt = topUpMutation.data?.receipt
+ const routedSwapReceipt = swapMutation.data?.receipt
+ const settlementTxHash = settlementQuery.data?.txHash
+ const targetBalanceReady =
+ settlementQuery.data && targetZoneAuthorization.isAuthorized && targetZoneBalanceQuery.isSuccess
+ const sourceAuthIsPreparing =
+ sourceZoneAuthorization.isChecking || sourceZoneAuthorization.authorizeMutation.isPending
+ const stepTwoAction = sourceZoneAuthorization.isAuthorized ? undefined : (
+ sourceZoneAuthorization.authorizeMutation.mutate()}
+ type="button"
+ variant={sourceZoneClient ? 'accent' : 'default'}
+ >
+ {sourceAuthIsPreparing
+ ? `Authorizing ${ZONE_A.label} reads`
+ : sourceZoneAuthorization.authorizeMutation.isError
+ ? 'Retry'
+ : `Authorize ${ZONE_A.label} reads`}
+
+ )
+
+ React.useEffect(() => {
+ if (!sourceZoneAuthorization.isAuthorized) return
+
+ void queryClient.invalidateQueries({ queryKey: sourceFooterQueryKey })
+ }, [queryClient, sourceFooterQueryKey, sourceZoneAuthorization.isAuthorized])
+
+ React.useEffect(() => {
+ if (!targetZoneAuthorization.isAuthorized) return
+
+ void queryClient.invalidateQueries({ queryKey: targetFooterQueryKey })
+ }, [queryClient, targetFooterQueryKey, targetZoneAuthorization.isAuthorized])
+
+ React.useEffect(() => {
+ if (!topUpMutation.isSuccess || sourceZoneBalanceStepComplete) return
+
+ const interval = window.setInterval(() => {
+ void sourceZoneBalanceQuery.refetch()
+ }, 1_500)
+
+ return () => window.clearInterval(interval)
+ }, [sourceZoneBalanceQuery, sourceZoneBalanceStepComplete, topUpMutation.isSuccess])
+
+ let stepThreeAction: React.ReactNode
+ if (sourceZoneBalanceStepComplete) {
+ stepThreeAction = undefined
+ } else if (sourceZoneBalanceQuery.isPending || swapPrereqsQuery.isPending) {
+ stepThreeAction = (
+
+ Checking Zone A
+
+ )
+ } else if (!hasEnoughSourceZoneBalance && !hasRootBalance) {
+ stepThreeAction = (
+ fundMutation.mutate()}
+ type="button"
+ variant={sourceZoneAuthorization.isAuthorized ? 'accent' : 'default'}
+ >
+ {fundMutation.isPending ? 'Getting pathUSD' : 'Get testnet pathUSD'}
+
+ )
+ } else if (!hasEnoughSourceZoneBalance) {
+ stepThreeAction = (
+ topUpMutation.mutate()}
+ type="button"
+ variant={sourceZoneAuthorization.isAuthorized ? 'accent' : 'default'}
+ >
+ {topUpMutation.isPending ? 'Approving + topping up Zone A' : 'Approve + top up Zone A'}
+
+ )
+ }
+
+ let stepFourAction: React.ReactNode
+ if (!sourceZoneBalanceStepComplete || swapPrereqsQuery.isPending) {
+ stepFourAction = undefined
+ } else if (swapPrereqsQuery.isError) {
+ stepFourAction = (
+ swapPrereqsQuery.refetch()}
+ type="button"
+ variant="default"
+ >
+ Retry swap check
+
+ )
+ } else {
+ stepFourAction = (
+ swapMutation.mutate()}
+ type="button"
+ variant={swapMutation.isSuccess ? 'default' : 'accent'}
+ >
+ {swapMutation.isPending
+ ? 'Submitting routed swap'
+ : swapMutation.isSuccess
+ ? 'Swap submitted'
+ : 'Swap 25 pathUSD into Zone B betaUSD'}
+
+ )
+ }
+
+ let stepSixAction: React.ReactNode
+ if (!settlementQuery.data) {
+ stepSixAction = undefined
+ } else if (targetZoneBalanceQuery.isError) {
+ stepSixAction = (
+ targetZoneBalanceQuery.refetch()}
+ type="button"
+ variant="default"
+ >
+ Retry Zone B read
+
+ )
+ } else if (!targetZoneAuthorization.isAuthorized) {
+ stepSixAction = (
+ targetZoneAuthorization.authorizeMutation.mutate()}
+ type="button"
+ variant="accent"
+ >
+ {targetZoneAuthorization.authorizeMutation.isPending
+ ? 'Authorizing Zone B reads'
+ : 'Authorize Zone B reads'}
+
+ )
+ } else if (targetZoneBalanceQuery.isPending) {
+ stepSixAction = (
+
+ Reading Zone B betaUSD
+
+ )
+ }
+
+ return (
+ <>
+
+
+
+ {topUpReceipt && (
+
+
+
+
+ )}
+
+
+
+ {routedSwapReceipt && (
+
+
+
+
+ )}
+
+
+
+ {settlementTxHash && (
+ {settlementTxHash && }
+ )}
+
+
+
+ >
+ )
+}
+
+function DisconnectedZoneFlow() {
+ return (
+ <>
+
+
+
+
+
+ >
+ )
+}
+
+function encodeRouterCallback(parameters: { minimumOutput: bigint; recipient: Hex }) {
+ const { minimumOutput, recipient } = parameters
+
+ return encodeAbiParameters(
+ [
+ { type: 'bool' },
+ { type: 'address' },
+ { type: 'address' },
+ { type: 'address' },
+ { type: 'bytes32' },
+ { type: 'uint128' },
+ ],
+ [false, betaUsd, ZONE_B.portalAddress, recipient, zeroBytes32, minimumOutput],
+ )
+}
+
+function applyOnePercentSlippageBuffer(value: bigint) {
+ if (value <= 1n) return value
+ return value - value / 100n
+}
+
+function StepBody(props: React.PropsWithChildren) {
+ return (
+
+ )
+}
+
+function DetailLine(props: { label: string; value: string; dataTestId?: string | undefined }) {
+ const { dataTestId, label, value } = props
+
+ return (
+
+ {label}
+
+ {value}
+
+
+ )
+}
diff --git a/src/components/guides/zones/WithdrawFromZone.tsx b/src/components/guides/zones/WithdrawFromZone.tsx
new file mode 100644
index 00000000..ddade018
--- /dev/null
+++ b/src/components/guides/zones/WithdrawFromZone.tsx
@@ -0,0 +1,608 @@
+'use client'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import * as React from 'react'
+import { createClient, type Hex, parseAbiItem, parseUnits } from 'viem'
+import { Actions, tempoActions } from 'viem/tempo'
+import { http as zoneHttp, zoneModerato } from 'viem/tempo/zones'
+import { useConnection, useConnectorClient, usePublicClient } from 'wagmi'
+import { Hooks } from 'wagmi/tempo'
+import {
+ getZoneTransportConfig,
+ moderatoZoneRpcUrls,
+ publicSettlementLookbackBlocks,
+ stripRpcBasicAuth,
+ zoneRpcSyncTimeout,
+} from '../../../lib/private-zones.ts'
+import { useRootWebAuthnAccount } from '../../../lib/useRootWebAuthnAccount.ts'
+import { useZoneAuthorization, type ZoneAuthClientLike } from '../../../lib/useZoneAuthorization.ts'
+import { Button, ExplorerLink, Logout, Step } from '../Demo'
+import { SignInButtons } from '../EmbedPasskeys'
+import { pathUsd } from '../tokens'
+import { useStickyStepCompletion } from './useStickyStepCompletion.ts'
+
+const ZONE_LABEL = 'Zone A'
+const ZONE_ID = 6 as const
+const AUTHENTICATED_WITHDRAWAL_REVEAL_TO =
+ '0x031dc147467e8f106eb22850fef549dc74b8f6634aeac554ebdd4ab896b67cdf68' as const
+const WITHDRAWAL_AMOUNT = parseUnits('100', 6)
+const ZONE_GAS_BUFFER = parseUnits('1', 6)
+
+const tip20TransferEvent = parseAbiItem(
+ 'event Transfer(address indexed from, address indexed to, uint256 value)',
+)
+
+type WithdrawalMode = 'standard' | 'authenticated'
+
+type ZoneClientLike = {
+ token: {
+ getBalance: (parameters: { account: Hex; token: Hex }) => Promise
+ }
+ zone: {
+ requestVerifiableWithdrawalSync: (parameters: {
+ account: unknown
+ amount: bigint
+ feeToken: Hex
+ revealTo: Hex
+ timeout: number
+ to: Hex
+ token: Hex
+ }) => Promise<{ receipt: { blockNumber: bigint; transactionHash: Hex } }>
+ requestWithdrawalSync: (parameters: {
+ account: unknown
+ amount: bigint
+ feeToken: Hex
+ timeout: number
+ to: Hex
+ token: Hex
+ }) => Promise<{ receipt: { blockNumber: bigint; transactionHash: Hex } }>
+ getAuthorizationTokenInfo: ZoneAuthClientLike['zone']['getAuthorizationTokenInfo']
+ signAuthorizationToken: ZoneAuthClientLike['zone']['signAuthorizationToken']
+ getWithdrawalFee: () => Promise
+ }
+}
+
+export function WithdrawFromZone() {
+ const { address } = useConnection()
+ const [mode, setMode] = React.useState('standard')
+ const connected = Boolean(address)
+
+ return (
+ <>
+ : }
+ error={undefined}
+ number={1}
+ title="Create or use a passkey account on the public chain."
+ />
+
+
+
+ {address ? (
+
+ ) : (
+
+ )}
+ >
+ )
+}
+
+function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) {
+ const { address, mode } = props
+ const queryClient = useQueryClient()
+ const publicClient = usePublicClient()
+ const { data: connectorClient } = useConnectorClient()
+ const { data: rootWebAuthnAccount } = useRootWebAuthnAccount()
+ const {
+ data: rootBalance,
+ isPending: rootBalanceIsPending,
+ refetch: refetchRootBalance,
+ } = Hooks.token.useGetBalance({
+ account: address,
+ token: pathUsd,
+ })
+
+ const zoneClient = React.useMemo(
+ () =>
+ rootWebAuthnAccount
+ ? (createClient({
+ account: rootWebAuthnAccount,
+ chain: zoneModerato(ZONE_ID),
+ transport: zoneHttp(
+ stripRpcBasicAuth(moderatoZoneRpcUrls[ZONE_ID]),
+ getZoneTransportConfig(moderatoZoneRpcUrls[ZONE_ID]),
+ ),
+ }).extend(tempoActions()) as unknown as ZoneClientLike)
+ : undefined,
+ [rootWebAuthnAccount],
+ )
+
+ const zoneAuthorization = useZoneAuthorization({
+ address,
+ chainId: zoneModerato(ZONE_ID).id,
+ queryKey: ['guide-private-zones-withdraw-auth', address, ZONE_ID],
+ zoneClient,
+ })
+
+ React.useEffect(() => {
+ if (!zoneAuthorization.isAuthorized) return
+
+ void queryClient.invalidateQueries({
+ queryKey: ['demo-zone-balance', address, ZONE_ID],
+ })
+ }, [address, queryClient, zoneAuthorization.isAuthorized])
+
+ const withdrawalFeeQuery = useQuery({
+ enabled: Boolean(zoneClient && zoneAuthorization.isAuthorized),
+ queryKey: ['guide-private-zones-withdraw-fee', address, ZONE_ID],
+ queryFn: async () => {
+ if (!zoneClient) throw new Error('zone client not ready')
+
+ return zoneClient.zone.getWithdrawalFee()
+ },
+ staleTime: 30_000,
+ })
+
+ const zoneBalanceQuery = useQuery({
+ enabled: Boolean(zoneClient && zoneAuthorization.isAuthorized),
+ queryKey: ['guide-private-zones-withdraw-zone-balance', address, ZONE_ID],
+ queryFn: async () => {
+ if (!zoneClient) throw new Error('zone client not ready')
+
+ return zoneClient.token.getBalance({
+ account: address,
+ token: pathUsd,
+ })
+ },
+ staleTime: 30_000,
+ })
+
+ const zoneTopUpTarget =
+ withdrawalFeeQuery.data !== undefined
+ ? WITHDRAWAL_AMOUNT + withdrawalFeeQuery.data + ZONE_GAS_BUFFER
+ : undefined
+ const zoneTopUpShortfall =
+ zoneTopUpTarget !== undefined &&
+ zoneBalanceQuery.data !== undefined &&
+ zoneBalanceQuery.data < zoneTopUpTarget
+ ? zoneTopUpTarget - zoneBalanceQuery.data
+ : 0n
+ const hasEnoughZoneBalance = Boolean(
+ zoneTopUpTarget !== undefined &&
+ zoneBalanceQuery.data !== undefined &&
+ zoneBalanceQuery.data >= zoneTopUpTarget,
+ )
+ const zoneBalanceStepComplete = useStickyStepCompletion(hasEnoughZoneBalance)
+
+ const fundMutation = useMutation({
+ mutationFn: async () => {
+ if (!connectorClient) throw new Error('connector client not ready')
+
+ await Actions.faucet.fundSync(connectorClient, {
+ account: address,
+ })
+ },
+ onSuccess: async () => {
+ await refetchRootBalance()
+ },
+ })
+
+ const topUpMutation = useMutation({
+ mutationFn: async () => {
+ if (!connectorClient) throw new Error('connector client not ready')
+ if (zoneTopUpShortfall <= 0n) throw new Error('zone top-up is not required')
+
+ const { receipt } = await Actions.zone.depositSync(connectorClient as never, {
+ account: connectorClient.account,
+ amount: zoneTopUpShortfall,
+ chain: connectorClient.chain as never,
+ token: pathUsd,
+ zoneId: ZONE_ID,
+ })
+
+ return { receipt }
+ },
+ onSuccess: async () => {
+ await refetchRootBalance()
+ await zoneBalanceQuery.refetch()
+ },
+ })
+
+ const withdrawMutation = useMutation({
+ mutationFn: async () => {
+ if (!connectorClient) throw new Error('connector client not ready')
+ if (!publicClient) throw new Error('public client not ready')
+ if (!zoneClient) throw new Error('zone client not ready')
+ if (!rootWebAuthnAccount) throw new Error('root account not ready')
+ if (withdrawalFeeQuery.data === undefined) throw new Error('withdrawal fee not ready')
+
+ const currentRootBalance = await Actions.token.getBalance(connectorClient as never, {
+ account: address,
+ token: pathUsd,
+ })
+ const currentZoneBalance = await zoneClient.token.getBalance({
+ account: address,
+ token: pathUsd,
+ })
+ const anchorBlock = await publicClient.getBlockNumber()
+ const receipt =
+ mode === 'authenticated'
+ ? (
+ await zoneClient.zone.requestVerifiableWithdrawalSync({
+ account: rootWebAuthnAccount,
+ amount: WITHDRAWAL_AMOUNT,
+ feeToken: pathUsd,
+ revealTo: AUTHENTICATED_WITHDRAWAL_REVEAL_TO,
+ timeout: zoneRpcSyncTimeout,
+ to: address,
+ token: pathUsd,
+ })
+ ).receipt
+ : (
+ await zoneClient.zone.requestWithdrawalSync({
+ account: rootWebAuthnAccount,
+ amount: WITHDRAWAL_AMOUNT,
+ feeToken: pathUsd,
+ timeout: zoneRpcSyncTimeout,
+ to: address,
+ token: pathUsd,
+ })
+ ).receipt
+
+ return {
+ anchorBlock,
+ receipt,
+ startingRootBalance: currentRootBalance,
+ startingZoneBalance: currentZoneBalance,
+ }
+ },
+ onSuccess: async () => {
+ await refetchRootBalance()
+ await zoneBalanceQuery.refetch()
+ await withdrawalConfirmationQuery.refetch()
+ },
+ })
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: switching modes should clear the previous submission state.
+ React.useEffect(() => {
+ withdrawMutation.reset()
+ }, [mode])
+
+ const withdrawalConfirmationQuery = useQuery({
+ enabled: Boolean(
+ publicClient &&
+ zoneClient &&
+ connectorClient &&
+ zoneAuthorization.isAuthorized &&
+ withdrawMutation.isSuccess,
+ ),
+ queryKey: [
+ 'guide-private-zones-withdraw-confirmation',
+ address,
+ ZONE_ID,
+ withdrawMutation.data?.anchorBlock?.toString(),
+ ],
+ queryFn: async () => {
+ if (!publicClient) throw new Error('public client not ready')
+ if (!zoneClient) throw new Error('zone client not ready')
+ if (!connectorClient) throw new Error('connector client not ready')
+ if (!withdrawMutation.data) throw new Error('withdrawal submission not ready')
+
+ const fromBlock =
+ withdrawMutation.data.anchorBlock > publicSettlementLookbackBlocks
+ ? withdrawMutation.data.anchorBlock - publicSettlementLookbackBlocks
+ : 0n
+
+ const [currentRootBalance, currentZoneBalance, latest] = await Promise.all([
+ Actions.token.getBalance(connectorClient as never, {
+ account: address,
+ token: pathUsd,
+ }),
+ zoneClient.token.getBalance({
+ account: address,
+ token: pathUsd,
+ }),
+ publicClient.getBlockNumber(),
+ ])
+
+ const logs = await publicClient.getLogs({
+ address: pathUsd,
+ args: { to: address },
+ event: tip20TransferEvent,
+ fromBlock,
+ toBlock: latest,
+ })
+
+ const settlement = logs.find((log) => log.args.value === WITHDRAWAL_AMOUNT)
+
+ return {
+ rootBalance: currentRootBalance,
+ txHash: settlement?.transactionHash ?? null,
+ zoneBalance: currentZoneBalance,
+ }
+ },
+ refetchInterval: (query) => {
+ if (query.state.error) return false
+
+ const txHash = (query.state.data as { txHash: Hex | null } | undefined)?.txHash
+
+ return txHash ? false : 1_500
+ },
+ refetchOnReconnect: false,
+ refetchOnWindowFocus: false,
+ retry: false,
+ })
+
+ const hasRootBalance = Boolean(rootBalance && rootBalance > 0n)
+ const settlementTxHash = withdrawalConfirmationQuery.data?.txHash
+ const withdrawalConfirmed = Boolean(settlementTxHash)
+ const topUpReceipt = topUpMutation.data?.receipt
+ const authIsPreparing =
+ zoneAuthorization.isChecking || zoneAuthorization.authorizeMutation.isPending
+ const stepTwoAction = zoneAuthorization.isAuthorized ? undefined : (
+ zoneAuthorization.authorizeMutation.mutate()}
+ type="button"
+ variant={zoneClient ? 'accent' : 'default'}
+ >
+ {authIsPreparing
+ ? `Authorizing ${ZONE_LABEL} reads`
+ : zoneAuthorization.authorizeMutation.isError
+ ? 'Retry'
+ : `Authorize ${ZONE_LABEL} reads`}
+
+ )
+
+ React.useEffect(() => {
+ if (!topUpMutation.isSuccess || zoneBalanceStepComplete) return
+
+ const interval = window.setInterval(() => {
+ void zoneBalanceQuery.refetch()
+ }, 1_500)
+
+ return () => window.clearInterval(interval)
+ }, [topUpMutation.isSuccess, zoneBalanceQuery, zoneBalanceStepComplete])
+
+ let stepThreeAction: React.ReactNode
+ if (zoneBalanceStepComplete) {
+ stepThreeAction = undefined
+ } else if (withdrawalFeeQuery.isPending || zoneBalanceQuery.isPending) {
+ stepThreeAction = (
+
+ Checking balances
+
+ )
+ } else if (!hasEnoughZoneBalance && !hasRootBalance) {
+ stepThreeAction = (
+ fundMutation.mutate()}
+ type="button"
+ variant={zoneAuthorization.isAuthorized ? 'accent' : 'default'}
+ >
+ {fundMutation.isPending ? 'Getting pathUSD' : 'Get testnet pathUSD'}
+
+ )
+ } else if (!hasEnoughZoneBalance) {
+ stepThreeAction = (
+ topUpMutation.mutate()}
+ type="button"
+ variant={zoneAuthorization.isAuthorized ? 'accent' : 'default'}
+ >
+ {topUpMutation.isPending ? 'Approving + topping up Zone A' : 'Approve + top up Zone A'}
+
+ )
+ }
+
+ let stepFourAction: React.ReactNode
+ if (!zoneBalanceStepComplete) {
+ stepFourAction = undefined
+ } else {
+ stepFourAction = (
+ withdrawMutation.mutate()}
+ type="button"
+ variant={withdrawMutation.isSuccess ? 'default' : 'accent'}
+ >
+ {getWithdrawalActionLabel({
+ isPending: withdrawMutation.isPending,
+ isSuccess: withdrawMutation.isSuccess,
+ })}
+
+ )
+ }
+
+ return (
+ <>
+
+
+
+ {topUpReceipt && (
+
+
+
+
+ )}
+
+
+
+
+
+ {settlementTxHash && (
+ {settlementTxHash && }
+ )}
+
+ >
+ )
+}
+
+function DisconnectedZoneFlow(props: { mode: WithdrawalMode }) {
+ const { mode } = props
+
+ return (
+ <>
+
+
+
+
+ >
+ )
+}
+
+function WithdrawalModeSelector(props: {
+ mode: WithdrawalMode
+ onChange: (mode: WithdrawalMode) => void
+}) {
+ const { mode, onChange } = props
+
+ return (
+
+
+
+
Withdrawal mode
+
+ Standard withdrawals reveal the sender of the withdrawal, while authenticated
+ withdrawals only reveal sender details to the holder of the reveal key.
+
+
+
+ {[
+ ['standard', 'Standard'],
+ ['authenticated', 'Authenticated'],
+ ].map(([value, label]) => {
+ const selected = mode === value
+
+ return (
+ onChange(value as WithdrawalMode)}
+ >
+ {label}
+
+ )
+ })}
+
+
+
+ )
+}
+
+function StepBody(props: React.PropsWithChildren) {
+ return (
+
+ )
+}
+
+function getWithdrawalActionLabel(parameters: { isPending: boolean; isSuccess: boolean }) {
+ const { isPending, isSuccess } = parameters
+
+ if (isPending) return 'Withdrawing pathUSD'
+
+ if (isSuccess) return 'Withdrawal submitted'
+
+ return 'Withdraw 100 pathUSD'
+}
+
+function getWithdrawalSubmitStepTitle(mode: WithdrawalMode) {
+ return mode === 'authenticated'
+ ? `Submit the authenticated withdrawal back from ${ZONE_LABEL}.`
+ : `Submit the withdrawal back from ${ZONE_LABEL}.`
+}
+
+function DetailLine(props: { label: string; value: string; dataTestId?: string | undefined }) {
+ const { dataTestId, label, value } = props
+
+ return (
+
+ {label}
+
+ {value}
+
+
+ )
+}
diff --git a/src/components/guides/zones/useStickyStepCompletion.ts b/src/components/guides/zones/useStickyStepCompletion.ts
new file mode 100644
index 00000000..37cc28d8
--- /dev/null
+++ b/src/components/guides/zones/useStickyStepCompletion.ts
@@ -0,0 +1,11 @@
+import * as React from 'react'
+
+export function useStickyStepCompletion(isComplete: boolean) {
+ const [isStickyComplete, setIsStickyComplete] = React.useState(isComplete)
+
+ React.useEffect(() => {
+ if (isComplete) setIsStickyComplete(true)
+ }, [isComplete])
+
+ return isStickyComplete
+}
diff --git a/src/lib/private-zones.ts b/src/lib/private-zones.ts
new file mode 100644
index 00000000..39714e4c
--- /dev/null
+++ b/src/lib/private-zones.ts
@@ -0,0 +1,105 @@
+type ZoneTransportConfig = {
+ onFetchRequest?: (
+ request: Request,
+ init?: RequestInit,
+ ) => Promise | RequestInit | undefined
+}
+
+export const feeToken = '0x20c0000000000000000000000000000000000001' as const
+export const stablecoinDex = '0xDEc0000000000000000000000000000000000000' as const
+export const moderatoZoneFactory = '0x7Cc496Dc634b718289c192b59CF90262C5228545' as const
+export const zoneOutbox = '0x1c00000000000000000000000000000000000002' as const
+export const swapAndDepositRouter = '0xf9b794e0dca9bc12ac90067df792d7aad33436e4' as const
+// Private sequencers currently only accept the raw transaction param on eth_sendRawTransactionSync.
+export const zoneRpcSyncTimeout = 0
+export const routerCallbackGasLimit = 2_000_000n
+// Routed settlement can appear before the UI records a post-submission anchor block.
+export const publicSettlementLookbackBlocks = 100n
+export const zeroBytes32 =
+ '0x0000000000000000000000000000000000000000000000000000000000000000' as const
+
+const ZONE_A_RPC_URL = 'https://eng:bold-raman-silly-torvalds@rpc-zone-a.testnet.tempo.xyz' as const
+const ZONE_B_RPC_URL = 'https://eng:bold-raman-silly-torvalds@rpc-zone-b.testnet.tempo.xyz' as const
+
+export const ZONE_A = {
+ chainId: 4217000006,
+ id: 6,
+ label: 'Zone A',
+ portalAddress: '0x7069DeC4E64Fd07334A0933eDe836C17259c9B23',
+ rpcUrl: ZONE_A_RPC_URL,
+ rpcUrls: {
+ default: {
+ http: [stripRpcBasicAuth(ZONE_A_RPC_URL)],
+ webSocket: [],
+ },
+ },
+} as const
+
+export const ZONE_B = {
+ chainId: 4217000007,
+ id: 7,
+ label: 'Zone B',
+ portalAddress: '0x3F5296303400B56271b476F5A0B9cBF74350D6Ac',
+ rpcUrl: ZONE_B_RPC_URL,
+ rpcUrls: {
+ default: {
+ http: [stripRpcBasicAuth(ZONE_B_RPC_URL)],
+ webSocket: [],
+ },
+ },
+} as const
+
+export const moderatoZoneRpcUrls = {
+ [ZONE_A.id]: ZONE_A.rpcUrl,
+ [ZONE_B.id]: ZONE_B.rpcUrl,
+} as const
+
+export const moderatoZones = {
+ [ZONE_A.id]: {
+ chainId: ZONE_A.chainId,
+ name: ZONE_A.label,
+ portalAddress: ZONE_A.portalAddress,
+ rpcUrls: ZONE_A.rpcUrls,
+ },
+ [ZONE_B.id]: {
+ chainId: ZONE_B.chainId,
+ name: ZONE_B.label,
+ portalAddress: ZONE_B.portalAddress,
+ rpcUrls: ZONE_B.rpcUrls,
+ },
+} as const
+
+export function stripRpcBasicAuth(url: string) {
+ const parsedUrl = new URL(url)
+ parsedUrl.username = ''
+ parsedUrl.password = ''
+ return parsedUrl.toString()
+}
+
+export function getZoneTransportConfig(rpcUrl: string): ZoneTransportConfig | undefined {
+ const parsedUrl = new URL(rpcUrl)
+ const username = decodeURIComponent(parsedUrl.username)
+ const password = decodeURIComponent(parsedUrl.password)
+
+ if (!username && !password) return undefined
+
+ const authorization = `Basic ${encodeBase64(`${username}:${password}`)}`
+
+ return {
+ async onFetchRequest(_request: Request, init?: RequestInit) {
+ const headers = new Headers(init?.headers)
+ headers.set('authorization', authorization)
+
+ return {
+ ...init,
+ headers,
+ }
+ },
+ }
+}
+
+function encodeBase64(value: string) {
+ if (typeof globalThis.btoa === 'function') return globalThis.btoa(value)
+
+ return Buffer.from(value).toString('base64')
+}
diff --git a/src/lib/useRootWebAuthnAccount.ts b/src/lib/useRootWebAuthnAccount.ts
new file mode 100644
index 00000000..bdd661db
--- /dev/null
+++ b/src/lib/useRootWebAuthnAccount.ts
@@ -0,0 +1,38 @@
+'use client'
+
+import { useQuery } from '@tanstack/react-query'
+import { Account } from 'viem/tempo'
+import { useConnection } from 'wagmi'
+
+type RootWebAuthnAccount = ReturnType
+type RootWebAuthnAccountProvider = {
+ getAccount: (options: {
+ accessKey?: boolean | undefined
+ address?: `0x${string}` | undefined
+ signable?: boolean | undefined
+ }) => RootWebAuthnAccount
+}
+
+export function useRootWebAuthnAccount() {
+ const { address, connector } = useConnection()
+
+ return useQuery({
+ enabled: Boolean(address && connector?.id === 'webAuthn'),
+ queryKey: ['root-webauthn-account', address],
+ queryFn: async () => {
+ if (!address) throw new Error('account address not ready')
+ if (!connector) throw new Error('connector not ready')
+
+ const provider = (await connector.getProvider()) as RootWebAuthnAccountProvider
+ return provider.getAccount({
+ accessKey: false,
+ address: address as `0x${string}`,
+ signable: true,
+ })
+ },
+ refetchOnReconnect: false,
+ refetchOnWindowFocus: false,
+ retry: false,
+ staleTime: Number.POSITIVE_INFINITY,
+ })
+}
diff --git a/src/lib/useZoneAuthorization.ts b/src/lib/useZoneAuthorization.ts
new file mode 100644
index 00000000..e236c7d6
--- /dev/null
+++ b/src/lib/useZoneAuthorization.ts
@@ -0,0 +1,172 @@
+'use client'
+
+import { useMutation, useQuery } from '@tanstack/react-query'
+import type { Hex } from 'viem'
+import { Storage as ZoneStorage } from 'viem/tempo'
+
+const zoneAuthorizationInfoTimeoutMs = 5_000
+
+export type ZoneAuthClientLike = {
+ zone: {
+ getAuthorizationTokenInfo: () => Promise<{
+ account: Hex
+ expiresAt: bigint
+ }>
+ signAuthorizationToken: () => Promise<{
+ authentication: {
+ expiresAt: number
+ zoneId: number
+ }
+ token: Hex
+ }>
+ }
+}
+
+export function useZoneAuthorization(parameters: {
+ address: Hex | undefined
+ chainId: number
+ queryKey: readonly unknown[]
+ zoneClient: ZoneAuthClientLike | undefined
+}) {
+ const { address, chainId, queryKey, zoneClient } = parameters
+
+ const statusQuery = useQuery({
+ enabled: Boolean(address && zoneClient),
+ queryKey,
+ queryFn: async () => {
+ if (!address) throw new Error('account address not ready')
+ if (!zoneClient) throw new Error('zone client not ready')
+
+ const storage = ZoneStorage.defaultStorage()
+ const lowerAddress = address.toLowerCase()
+ const accountStorageKey = `auth:${lowerAddress}:${chainId}`
+ const chainStorageKey = `auth:token:${chainId}`
+ const accountToken = await storage.getItem(accountStorageKey)
+
+ if (accountToken) await storage.setItem(chainStorageKey, accountToken)
+
+ try {
+ const info = await withTimeout(
+ zoneClient.zone.getAuthorizationTokenInfo(),
+ zoneAuthorizationInfoTimeoutMs,
+ )
+ const expired = info.expiresAt <= BigInt(Math.floor(Date.now() / 1000))
+ const matchesAccount = info.account.toLowerCase() === lowerAddress
+
+ if (!matchesAccount || expired) {
+ await storage.removeItem(chainStorageKey)
+ if (accountToken) await storage.removeItem(accountStorageKey)
+ return null
+ }
+
+ if (!accountToken) {
+ const chainToken = await storage.getItem(chainStorageKey)
+ if (chainToken) await storage.setItem(accountStorageKey, chainToken)
+ }
+
+ return info
+ } catch (error) {
+ if (!isZoneAuthorizationError(error)) throw error
+
+ await storage.removeItem(chainStorageKey)
+ if (accountToken) await storage.removeItem(accountStorageKey)
+ return null
+ }
+ },
+ refetchOnReconnect: false,
+ refetchOnWindowFocus: false,
+ retry: false,
+ staleTime: 30_000,
+ })
+
+ const authorizeMutation = useMutation({
+ mutationFn: async () => {
+ if (!zoneClient) throw new Error('zone client not ready')
+
+ return zoneClient.zone.signAuthorizationToken()
+ },
+ onSuccess: async () => {
+ await statusQuery.refetch()
+ },
+ })
+
+ return {
+ authorizeMutation,
+ error: authorizeMutation.error ?? statusQuery.error,
+ isAuthorized: statusQuery.data !== null && statusQuery.data !== undefined,
+ isChecking: statusQuery.isPending,
+ statusQuery,
+ }
+}
+
+function withTimeout(promise: Promise, timeoutMs: number) {
+ return Promise.race([
+ promise,
+ new Promise((_, reject) => {
+ const timeout = setTimeout(() => {
+ const error = new Error('zone authorization info request timed out')
+ error.name = 'TimeoutError'
+ reject(error)
+ }, timeoutMs)
+
+ promise.finally(() => clearTimeout(timeout))
+ }),
+ ])
+}
+
+function isZoneAuthorizationError(error: unknown) {
+ const status = getErrorStatus(error)
+ if (status === 401 || status === 403) return true
+
+ const name = getErrorName(error)
+ if (name === 'HttpRequestError' || name === 'TimeoutError') return true
+
+ const message = getErrorMessage(error)
+ return /authorization token/i.test(message)
+}
+
+function getErrorMessage(error: unknown) {
+ if (typeof error === 'object' && error !== null) {
+ if ('shortMessage' in error && typeof error.shortMessage === 'string') {
+ return error.shortMessage
+ }
+
+ if ('message' in error && typeof error.message === 'string') return error.message
+ }
+
+ if (error instanceof Error) return error.message
+
+ return ''
+}
+
+function getErrorStatus(error: unknown): number | null {
+ if (typeof error !== 'object' || error === null) return null
+
+ if ('status' in error && typeof error.status === 'number') {
+ return error.status
+ }
+
+ if ('statusCode' in error && typeof error.statusCode === 'number') {
+ return error.statusCode
+ }
+
+ if ('cause' in error) {
+ return getErrorStatus(error.cause)
+ }
+
+ return null
+}
+
+function getErrorName(error: unknown): string | null {
+ if (typeof error !== 'object' || error === null) return null
+
+ if ('name' in error && typeof error.name === 'string') {
+ return error.name
+ }
+
+ if ('cause' in error) {
+ return getErrorName(error.cause)
+ }
+
+ return null
+}
diff --git a/src/pages/guide/private-zones/connect-to-a-zone.mdx b/src/pages/guide/private-zones/connect-to-a-zone.mdx
new file mode 100644
index 00000000..cd534d16
--- /dev/null
+++ b/src/pages/guide/private-zones/connect-to-a-zone.mdx
@@ -0,0 +1,59 @@
+---
+title: Connect to a Zone
+description: Connect to Tempo Zones on testnet using Zone A and Zone B RPC URLs, chain IDs, and a minimal viem client setup for private flows.
+---
+
+# Connect to a Zone
+
+:::info
+Tempo Zones is still in early development and is available for testing purposes on Tempo Testnet only. While Tempo Zones are in this stage, expect breaking changes to the design and implementation. Do not use this in production. If you're interested in working with Tempo Labs as a design partner on the development of Tempo Zones, reach out to us at [zones@tempo.xyz](mailto:zones@tempo.xyz).
+:::
+
+Use this page when you need the RPC endpoint and chain metadata for `Zone A` or `Zone B`.
+
+Account-scoped zone RPC methods require an `X-Authorization-Token` header signed by the Tempo account you are using. The interactive guides handle that for you automatically. If you are building your own integration, see the [Zone RPC specification](/protocol/zones/rpc) for the token format and the list of scoped methods.
+
+## Create a Viem client
+
+Use `zoneModerato(...)` from `viem/tempo/zones` so the client has the correct chain metadata for the zone you want to reach.
+
+```ts
+import { createPublicClient } from 'viem'
+import { http, zoneModerato } from 'viem/tempo/zones'
+
+const rpcUrl = 'https://rpc-zone-a.testnet.tempo.xyz'
+
+const zoneClient = createPublicClient({
+ chain: zoneModerato(6),
+ transport: http(rpcUrl),
+})
+
+const blockNumber = await zoneClient.getBlockNumber()
+console.log(blockNumber)
+```
+
+## Direct Connection Details
+
+### Zone A
+
+| **Property** | **Value** |
+|-------------------|-------|
+| **Network Name** | Zone A |
+| **Zone ID** | `6` |
+| **Chain ID** | `4217000006` |
+| **HTTP URL** | `https://rpc-zone-a.testnet.tempo.xyz` |
+| **Portal Address** | `0x7069DeC4E64Fd07334A0933eDe836C17259c9B23` |
+| **Outbox Address** | `0x1c00000000000000000000000000000000000002` |
+
+### Zone B
+
+| **Property** | **Value** |
+|-------------------|-------|
+| **Network Name** | Zone B |
+| **Zone ID** | `7` |
+| **Chain ID** | `4217000007` |
+| **HTTP URL** | `https://rpc-zone-b.testnet.tempo.xyz` |
+| **Portal Address** | `0x3F5296303400B56271b476F5A0B9cBF74350D6Ac` |
+| **Outbox Address** | `0x1c00000000000000000000000000000000000002` |
+
+Zones do not expose a public block explorer for private activity. Use authenticated RPC reads instead.
diff --git a/src/pages/guide/private-zones/deposit-to-a-zone.mdx b/src/pages/guide/private-zones/deposit-to-a-zone.mdx
new file mode 100644
index 00000000..95a4801c
--- /dev/null
+++ b/src/pages/guide/private-zones/deposit-to-a-zone.mdx
@@ -0,0 +1,94 @@
+---
+title: Deposit to a Zone
+description: Deposit pathUSD from your public-chain balance into Zone A and confirm the resulting zone balance.
+interactive: true
+---
+
+import * as Demo from '../../../components/guides/Demo.tsx'
+import { Tab, Tabs } from 'vocs'
+import { DepositToZone } from '../../../components/guides/zones/DepositToZone.tsx'
+
+export const depositZoneBalances = [{ label: 'Zone A', token: Demo.pathUsd, zone: 6 }]
+
+# Deposit to a Zone
+
+:::info
+Tempo Zones is still in early development and is available for testing purposes on Tempo Testnet only. While Tempo Zones are in this stage, expect breaking changes to the design and implementation. Do not use this in production. If you're interested in working with Tempo Labs as a design partner on the development of Tempo Zones, reach out to us at [zones@tempo.xyz](mailto:zones@tempo.xyz).
+:::
+
+Use this guide when you want to move `pathUSD` from your public Tempo balance into `Zone A`. You will submit a public-chain deposit first, then wait for `Zone A` to credit the net amount after fees.
+
+
+
+The deposit is accepted through `ZonePortal` on the public chain. You need private zone authorization to read the resulting zone balance, because those reads are only exposed to the authenticated account.
+
+## Depositing pathUSD to Zone A
+
+By the end of this guide you will have deposited `pathUSD` into `Zone A` and confirmed the balance update.
+
+
+
+
+
+## Code examples
+
+These snippets assume you already have a signed-in `rootClient` on the public chain and the usual token and zone constants in scope.
+Use the plaintext flow when revealing the recipient and memo is acceptable. Use the encrypted flow when only the zone sequencer should be able to read those fields.
+
+
+
+
+```ts
+import { parseUnits } from 'viem'
+import { Actions } from 'viem/tempo'
+
+const depositAmount = parseUnits('100', 6)
+
+const { receipt } = await Actions.zone.depositSync(rootClient, {
+ account: rootClient.account,
+ amount: depositAmount,
+ token: pathUsd,
+ zoneId: ZONE_A.id,
+})
+
+console.log(receipt.blockNumber)
+```
+
+
+
+
+```ts
+import { parseUnits } from 'viem'
+import { Actions } from 'viem/tempo'
+
+const depositAmount = parseUnits('100', 6)
+
+const { receipt } = await Actions.zone.encryptedDepositSync(rootClient, { // [!code focus]
+ account: rootClient.account,
+ amount: depositAmount,
+ token: pathUsd,
+ zoneId: ZONE_A.id,
+})
+
+console.log(receipt.blockNumber)
+```
+
+
+
+
+## What Happens During a Deposit
+
+A zone deposit settles in two phases.
+
+First, you submit a public Tempo transaction depositing to the `ZonePortal`. The portal escrows the token, deducts the deposit fee in the same token, and records the net deposit in the portal's deposit queue. Later, the zone sequencer processes that queue and credits the recipient inside the zone.
+
+That means your public transaction receipt and your zone balance do not update at the same time. The Tempo transaction confirms that the deposit request was accepted. The zone balance changes only after the zone has processed that deposit, and it reflects the post-fee amount rather than the full amount you passed into `deposit(...)`.
+
+:::warning
+ If you need a specific net amount inside the zone, account for the portal deposit fee first. The amount minted on the zone is `amount - depositFee`.
+:::
diff --git a/src/pages/guide/private-zones/index.mdx b/src/pages/guide/private-zones/index.mdx
new file mode 100644
index 00000000..96a30fc9
--- /dev/null
+++ b/src/pages/guide/private-zones/index.mdx
@@ -0,0 +1,74 @@
+---
+title: Connect to Tempo Zones
+description: Learn how Tempo Zones work alongside the public chain and follow guides for depositing, sending within a zone, routing pathUSD across zones, swapping into betaUSD, and withdrawing.
+---
+
+import { Card, Cards } from 'vocs'
+
+# Connect to Tempo Zones
+
+:::info
+Tempo Zones is still in early development and is available for testing purposes on Tempo Testnet only. While Tempo Zones are in this stage, expect breaking changes to the design and implementation. Do not use this in production. If you're interested in working with Tempo Labs as a design partner on the development of Tempo Zones, reach out to us at [zones@tempo.xyz](mailto:zones@tempo.xyz).
+:::
+
+Tempo Zones let you keep balances and transfers inside a private execution environment while still using the public Tempo chain when funds enter or leave. The important thing to remember is that most zone flows settle in stages: a public or zone transaction lands first, then the private balance update appears shortly after.
+
+
+
+## Before you start
+
+- Use a Tempo passkey account in the demo so the page can authorize private zone reads.
+- Keep some `pathUSD` on the public chain if you want to try deposits, source-zone top-ups, routed sends, swaps, or withdrawals.
+- Expect deposits, routed sends, routed swaps, and withdrawals to complete asynchronously rather than in a single balance update.
+
+These guides cover the current zone connection setup plus the baseline workflows used in the demos: deposits through `Actions.zone.depositSync(...)` and `Actions.zone.encryptedDepositSync(...)`, in-zone transfers, same-token routed sends through `Actions.zone.requestWithdrawalSync(...)`, routed swaps, direct withdrawals, and authenticated withdrawals through `Actions.zone.requestVerifiableWithdrawalSync(...)`.
+
+The deposit guide's demo lets you switch between plaintext and encrypted deposits, and the withdrawal guide lets you switch between standard and authenticated withdrawals, while keeping the transaction flow on the upstream `viem` zone actions.
+
+## Choose the right guide
+
+- **Connect to a zone** if you want the Zone A and Zone B RPC URLs, chain IDs, and a minimal `viem` client setup.
+- **Deposit to a zone** if you want to move `pathUSD` from your public balance into `Zone A`.
+- **Send tokens within a zone** if you want to transfer `pathUSD` between private accounts without leaving `Zone A`.
+- **Send tokens across zones** if you want to leave `Zone A` with `pathUSD` and arrive in `Zone B` with the same token.
+- **Swap across zones** if you want to leave `Zone A` with `pathUSD` and arrive in `Zone B` with `betaUSD`.
+- **Withdraw from a zone** if you want to move `pathUSD` back from `Zone A` to your public balance.
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/guide/private-zones/send-tokens-across-zones.mdx b/src/pages/guide/private-zones/send-tokens-across-zones.mdx
new file mode 100644
index 00000000..448dd7e4
--- /dev/null
+++ b/src/pages/guide/private-zones/send-tokens-across-zones.mdx
@@ -0,0 +1,91 @@
+---
+title: Send tokens across zones
+description: Send pathUSD from Zone A into Zone B by routing a same-token withdrawal through Tempo's L1 router and confirming the target deposit.
+interactive: true
+---
+
+import * as Demo from '../../../components/guides/Demo.tsx'
+import { SendTokensAcrossZones } from '../../../components/guides/zones/SendTokensAcrossZones.tsx'
+
+export const crossZoneBalances = [
+ { label: 'Zone A', token: Demo.pathUsd, zone: 6 },
+ { label: 'Zone B', token: Demo.pathUsd, zone: 7 },
+]
+
+# Send tokens across zones
+
+:::info
+Tempo Zones is still in early development and is available for testing purposes on Tempo Testnet only. While Tempo Zones are in this stage, expect breaking changes to the design and implementation. Do not use this in production. If you're interested in working with Tempo Labs as a design partner on the development of Tempo Zones, reach out to us at [zones@tempo.xyz](mailto:zones@tempo.xyz).
+:::
+
+Use this guide when you want to move `pathUSD` from `Zone A` into `Zone B` without changing the token. The route still touches the public chain, so the confirmation happens in stages rather than as a single balance update.
+
+The flow uses `swapAndDepositRouter` on the public chain in same-token mode: withdraw from `Zone A`, skip the swap because the asset stays as `pathUSD`, then deposit that `pathUSD` into `Zone B`.
+
+## Sending pathUSD from Zone A into Zone B
+
+By the end of this guide you will have sent **25 pathUSD** from **Zone A** into **Zone B** and confirmed the routed deposit.
+
+
+
+
+
+## Code example
+
+This snippet assumes you already have a signed-in `rootClient` on the public chain plus `zoneAClient`, and the shared token, router, and portal constants used throughout the zone guides.
+
+It shows the core routed send submission path; use the demo above when you want to watch the routed deposit settle into Zone B.
+
+```ts
+import { encodeAbiParameters, parseUnits } from 'viem'
+import { Actions } from 'viem/tempo'
+
+const transferAmount = parseUnits('25', 6)
+
+await zoneAClient.zone.signAuthorizationToken()
+
+const callbackData = encodeAbiParameters(
+ [
+ { type: 'bool' },
+ { type: 'address' },
+ { type: 'address' },
+ { type: 'address' },
+ { type: 'bytes32' },
+ { type: 'uint128' },
+ ],
+ [false, pathUsd, ZONE_B.portalAddress, rootClient.account.address, zeroBytes32, 0n],
+)
+
+const { receipt } = await Actions.zone.requestWithdrawalSync(zoneAClient, {
+ account: rootClient.account,
+ amount: transferAmount,
+ data: callbackData,
+ feeToken: pathUsd,
+ gas: routerCallbackGasLimit,
+ timeout: zoneRpcSyncTimeout,
+ to: swapAndDepositRouter,
+ token: pathUsd,
+})
+
+console.log(receipt.blockNumber)
+```
+
+## What this routed send does
+
+The cross-zone transfer path looks like this: the token leaves `Zone A`, briefly lands on the public chain, and is deposited back into `Zone B` as the same asset.
+
+1. Withdraws `pathUSD` from `Zone A` through `ZoneOutbox`.
+2. Routes that withdrawal to `swapAndDepositRouter` on Tempo.
+3. Skips the DEX swap because the input and output token are both `pathUSD`.
+4. Deposits the routed `pathUSD` into `Zone B` through `ZonePortal`.
+
+The target deposit still pays the normal portal deposit fee, so the amount that arrives in `Zone B` is the routed `pathUSD` minus that fee.
+
+:::warning
+ If the routed withdrawal fails on Tempo—for example because the callback reverts or the target deposit cannot be completed—the amount is bounced back to the withdrawal's `fallbackRecipient` inside `Zone A`. The fee is still paid to the sequencer.
+:::
diff --git a/src/pages/guide/private-zones/send-tokens-within-a-zone.mdx b/src/pages/guide/private-zones/send-tokens-within-a-zone.mdx
new file mode 100644
index 00000000..d5406ba5
--- /dev/null
+++ b/src/pages/guide/private-zones/send-tokens-within-a-zone.mdx
@@ -0,0 +1,57 @@
+---
+title: Send tokens within a zone
+description: Send pathUSD inside Zone A with a signed zone transfer and confirm the updated zone balance.
+interactive: true
+---
+
+import * as Demo from '../../../components/guides/Demo.tsx'
+import { SendTokensWithinZone } from '../../../components/guides/zones/SendTokensWithinZone.tsx'
+
+export const inZoneBalances = [{ label: 'Zone A', token: Demo.pathUsd, zone: 6 }]
+
+# Send tokens within a zone
+
+:::info
+Tempo Zones is still in early development and is available for testing purposes on Tempo Testnet only. While Tempo Zones are in this stage, expect breaking changes to the design and implementation. Do not use this in production. If you're interested in working with Tempo Labs as a design partner on the development of Tempo Zones, reach out to us at [zones@tempo.xyz](mailto:zones@tempo.xyz).
+:::
+
+Use this guide when you want to send `pathUSD` from one private `Zone A` balance to another without moving funds back through the public chain.
+
+Zone tokens use the `TIP20` token interface, so once you authorize private reads for the session, an in-zone transfer looks much like a normal token transfer.
+
+## Sending pathUSD within Zone A
+
+By the end of this guide you will have sent `25 pathUSD` inside `Zone A` and confirmed the updated balance.
+
+
+
+
+
+## Code example
+
+This snippet assumes you already have a signed-in `rootClient` on the public chain and a derived `zoneAClient`.
+
+It shows the core zone transfer path; use the demo above when you want to watch the updated zone balance.
+
+```ts
+import { parseUnits, type Address } from 'viem'
+import { Actions } from 'viem/tempo'
+
+const transferAmount = parseUnits('25', 6)
+const demoRecipient = '0xbeefcafe54750903ac1c8909323af7beb21ea2cb' as Address
+
+await zoneAClient.zone.signAuthorizationToken()
+
+const { receipt } = await Actions.token.transferSync(zoneAClient, {
+ account: rootClient.account,
+ amount: transferAmount,
+ feeToken: pathUsd,
+ to: demoRecipient,
+ token: pathUsd,
+})
+```
diff --git a/src/pages/guide/private-zones/swap-across-zones.mdx b/src/pages/guide/private-zones/swap-across-zones.mdx
new file mode 100644
index 00000000..f0a182fa
--- /dev/null
+++ b/src/pages/guide/private-zones/swap-across-zones.mdx
@@ -0,0 +1,103 @@
+---
+title: Swap stablecoins across zones
+description: Swap pathUSD from Zone A into betaUSD on Zone B by routing a zone withdrawal through Tempo's L1 router and confirming the target deposit.
+interactive: true
+---
+
+import * as Demo from '../../../components/guides/Demo.tsx'
+import { SwapAcrossZones } from '../../../components/guides/zones/SwapAcrossZones.tsx'
+
+export const swapZoneBalances = [
+ { label: 'Zone A', token: Demo.pathUsd, zone: 6 },
+ { label: 'Zone B', token: Demo.betaUsd, zone: 7 },
+]
+
+# Swap across zones
+
+:::info
+Tempo Zones is still in early development and is available for testing purposes on Tempo Testnet only. While Tempo Zones are in this stage, expect breaking changes to the design and implementation. Do not use this in production. If you're interested in working with Tempo Labs as a design partner on the development of Tempo Zones, reach out to us at [zones@tempo.xyz](mailto:zones@tempo.xyz).
+:::
+
+Use this guide when you want to leave `Zone A` with `pathUSD` and arrive in `Zone B` with `betaUSD` in one routed flow. The trade briefly touches the public chain, so the confirmation happens in stages rather than as a single balance update.
+
+The route uses `swapAndDepositRouter` on the public chain: withdraw from `Zone A`, swap on the Stablecoin DEX, then deposit the output token into `Zone B`.
+
+
+
+## Swapping pathUSD from Zone A into betaUSD on Zone B
+
+By the end of this guide you will have swapped **25 pathUSD** from **Zone A** into **betaUSD** on **Zone B** and confirmed the routed deposit.
+
+## What this swap does
+
+1. Withdraws `pathUSD` from `Zone A`.
+2. Routes it through the public chain and swaps it on the Stablecoin DEX.
+3. Deposits the output token into `Zone B` through `ZonePortal`.
+4. Lets you authorize private reads in `Zone B` so you can confirm the final `betaUSD` balance.
+
+
+
+
+
+## Code example
+
+This snippet assumes you already have a signed-in `rootClient` on the public chain plus `zoneAClient`, and the shared token, router, and portal constants used throughout the zone guides.
+It shows the core routed swap submission path; use the demo above when you want to watch the output deposit settle into Zone B.
+
+```ts
+import { encodeAbiParameters, parseUnits } from 'viem'
+import { Actions } from 'viem/tempo'
+
+const swapAmount = parseUnits('25', 6)
+
+await zoneAClient.zone.signAuthorizationToken()
+
+const routedWithdrawalFee = await zoneAClient.zone.getWithdrawalFee({ gas: routerCallbackGasLimit })
+const quotedBetaOut = await rootClient.dex.getSellQuote({
+ amountIn: swapAmount,
+ tokenIn: pathUsd,
+ tokenOut: betaUsd,
+})
+
+const minimumBetaOut = quotedBetaOut - quotedBetaOut / 100n
+
+const callbackData = encodeAbiParameters(
+ [
+ { type: 'bool' },
+ { type: 'address' },
+ { type: 'address' },
+ { type: 'address' },
+ { type: 'bytes32' },
+ { type: 'uint128' },
+ ],
+ [false, betaUsd, ZONE_B.portalAddress, rootClient.account.address, zeroBytes32, minimumBetaOut],
+)
+
+const { receipt } = await Actions.zone.requestWithdrawalSync(zoneAClient, {
+ account: rootClient.account,
+ amount: swapAmount,
+ data: callbackData,
+ feeToken: pathUsd,
+ gas: routerCallbackGasLimit,
+ timeout: zoneRpcSyncTimeout,
+ to: swapAndDepositRouter,
+ token: pathUsd,
+})
+
+console.log(receipt.blockNumber)
+```
+
+## How Routed Zone Swaps Settle
+
+This guide's swap flow is asynchronous because the trade temporarily leaves the zone.
+
+The source token is withdrawn through `ZoneOutbox`, transferred to `SwapAndDepositRouter` on Tempo, optionally swapped on the Stablecoin DEX, and then deposited back through a `ZonePortal` as the output token. That routed deposit pays the normal portal deposit fee, so the amount that arrives on the zone is the post-fee output.
+
+:::warning
+If the routed withdrawal fails on Tempo - for example because the swap fails, the transfer fails, the router callback reverts, or the target deposit cannot be completed—the amount is bounced back to the withdrawal's `fallbackRecipient` inside the source zone. The fee is still paid to the sequencer, so a failed routed swap still results in fees for the sender.
+:::
diff --git a/src/pages/guide/private-zones/withdraw-from-a-zone.mdx b/src/pages/guide/private-zones/withdraw-from-a-zone.mdx
new file mode 100644
index 00000000..b66f3798
--- /dev/null
+++ b/src/pages/guide/private-zones/withdraw-from-a-zone.mdx
@@ -0,0 +1,102 @@
+---
+title: Withdraw from a Zone
+description: Withdraw pathUSD from Zone A back to your public-chain balance with a direct zone outbox withdrawal.
+interactive: true
+---
+
+import * as Demo from '../../../components/guides/Demo.tsx'
+import { Tab, Tabs } from 'vocs'
+import { WithdrawFromZone } from '../../../components/guides/zones/WithdrawFromZone.tsx'
+
+export const withdrawalZoneBalances = [{ label: 'Zone A', token: Demo.pathUsd, zone: 6 }]
+
+# Withdraw from a Zone
+
+:::info
+Tempo Zones is still in early development and is available for testing purposes on Tempo Testnet only. While Tempo Zones are in this stage, expect breaking changes to the design and implementation. Do not use this in production. If you're interested in working with Tempo Labs as a design partner on the development of Tempo Zones, reach out to us at [zones@tempo.xyz](mailto:zones@tempo.xyz).
+:::
+
+Use this guide when you want to move `pathUSD` out of `Zone A` and back to your public Tempo balance.
+
+
+
+Direct withdrawals exit through `ZoneOutbox` on the zone chain. You submit the withdrawal request in the zone first, then wait for the public balance to increase after the batch settles.
+
+## Withdrawing pathUSD from Zone A
+
+By the end of this guide you will have withdrawn `pathUSD` from `Zone A` and confirmed the balance update on the public chain.
+
+
+
+
+
+## Code examples
+
+These snippets assume you already have a signed-in `rootClient` on the public chain, a derived `zoneAClient`, and the usual token constants in scope.
+Use the plaintext flow when normal withdrawal visibility is fine. Use the authenticated flow when the sender details should only be revealed to the holder of a `revealTo` public key.
+
+
+
+
+```ts
+import { parseUnits } from 'viem'
+import { Actions } from 'viem/tempo'
+
+const withdrawalAmount = parseUnits('100', 6)
+
+await zoneAClient.zone.signAuthorizationToken()
+
+const { receipt } = await Actions.zone.requestWithdrawalSync(zoneAClient, {
+ account: rootClient.account,
+ feeToken: pathUsd,
+ amount: withdrawalAmount,
+ token: pathUsd,
+ to: rootClient.account.address,
+})
+
+console.log(receipt.blockNumber)
+```
+
+
+
+
+```ts
+import { parseUnits } from 'viem'
+import { Actions } from 'viem/tempo'
+
+const withdrawalAmount = parseUnits('100', 6)
+const revealTo = '0x031dc147467e8f106eb22850fef549dc74b8f6634aeac554ebdd4ab896b67cdf68' // [!code focus]
+
+await zoneAClient.zone.signAuthorizationToken()
+
+const { receipt } = await Actions.zone.requestVerifiableWithdrawalSync(zoneAClient, { // [!code focus]
+ account: rootClient.account,
+ feeToken: pathUsd,
+ amount: withdrawalAmount,
+ revealTo, // [!code focus]
+ token: pathUsd,
+ to: rootClient.account.address,
+})
+
+console.log(receipt.blockNumber)
+```
+
+
+
+
+## What a Direct Withdrawal Does
+
+A direct withdrawal is the simplest way to exit a zone. You ask `ZoneOutbox` on the zone to burn the zone balance, include the request in the next withdrawal batch, and settle the amount back to a public Tempo address.
+
+Like deposits, withdrawals settle in phases. The request is accepted on the zone first, and the public balance changes later when the sequencer submits and processes the corresponding batch on Tempo.
+
+If Tempo-side processing fails, the withdrawal does not stay stuck in limbo. The protocol re-deposits the withdrawal amount back into the zone to the request's `fallbackRecipient`. The fee is still consumed.
+
+:::warning
+ Even with `gasLimit: 0n`, a direct withdrawal can still fail on Tempo—for example because of token transfer or policy checks. In that case, the amount bounces back to `fallbackRecipient` on the zone instead of increasing the public balance.
+:::
diff --git a/src/pages/guide/use-accounts/embed-passkeys.mdx b/src/pages/guide/use-accounts/embed-passkeys.mdx
index e5f3d1b6..e1a4c70a 100644
--- a/src/pages/guide/use-accounts/embed-passkeys.mdx
+++ b/src/pages/guide/use-accounts/embed-passkeys.mdx
@@ -140,7 +140,7 @@ export function Example() {
Sign up
-
connect.connect({ connector })}>
+ connect.connect({ connector, capabilities: { type: 'sign-in' } })}>
Sign in
@@ -216,7 +216,7 @@ export function Example() {
Sign up
-
connect.connect({ connector })}>
+ connect.connect({ connector, capabilities: { type: 'sign-in' } })}>
Sign in
diff --git a/src/pages/learn/tempo/privacy.mdx b/src/pages/learn/tempo/privacy.mdx
index 256ef729..d750f3ff 100644
--- a/src/pages/learn/tempo/privacy.mdx
+++ b/src/pages/learn/tempo/privacy.mdx
@@ -29,15 +29,25 @@ When available, private tokens will enable:
All of this is achieved while preserving the compliance features that make stablecoins viable in regulated markets. Issuers will be able to maintain the ability to monitor, report, and enforce policies as required by regulation.
-## Coming Soon
+## Zones: Private Validium Chains
-The native private token standard is currently in development and will be available in a future release. This feature is being designed in close partnership with regulated stablecoin issuers to ensure it meets both privacy needs and regulatory requirements.
+Tempo has built-in support for privacy through [zones](/protocol/zones/overview) — native validium chains anchored to Tempo. Instead of being visible to the entire world, balances and transactions on zones are only visible to the zone sequencer, the users involved, and anyone they choose to selectively disclose to.
-If you're interested in private payment flows and want to explore how privacy features can benefit your use case, reach out to our team.
-
-## Help design this feature
+Read the full technical specification:
+
+
+
Z1["Zone 1 USDX, USDY"]
+ TE --> Z2["Zone 2 pathUSD, ..."]
+ end
+`} />
+
+The sequencer runs a Tempo node with one or more zone nodes attached. Each zone node:
+
+- Synchronizes the zone's view of Tempo Mainnet each time a Tempo Mainnet block finalizes
+- Executes zone transactions using privately submitted transactions and the zone's own state
+- Produces batches proving state transitions on the zone and posts them to Tempo Mainnet
+- Watches for deposits by monitoring portal events on Tempo Mainnet, and creates corresponding transactions on the zone once the block finalizes
+- Watches for withdrawals on the zone and submits transactions to Tempo Mainnet processing them once the batch has been proven
+
+
+## Contract Architecture
+
+The system consists of contracts on both Tempo Mainnet and within each Tempo Zone.
+
+ ZI["ZoneInbox (deposits)"]
+ ZO["ZoneOutbox (withdrawals)"] -- "withdrawals" --> ZP
+
+ subgraph TEMPO["Tempo Mainnet"]
+ ZF["ZoneFactory (deploys)"]
+ ZP
+ ZM["ZoneMessenger (callbacks)"]
+ end
+ subgraph ZONE["Zone"]
+ TS["TempoState (Tempo Mainnet view)"]
+ ZI
+ ZO
+ end
+`} />
+
+### Tempo Contracts
+
+- **`ZoneFactory`** deploys new Tempo Zones. Each zone gets its own portal and messenger contracts with deterministic addresses.
+- **`ZonePortal`** is the central bridge contract. It locks all deposited tokens, verifies validity proofs, and processes withdrawals. The portal maintains the authoritative state: which deposits have been made, which batches have been proven, and which withdrawals are pending.
+- **`ZoneMessenger`** handles withdrawals that include callbacks. When a user wants to withdraw tokens and trigger a contract call atomically, the messenger executes both operations together. If the callback fails, the entire withdrawal reverts and funds bounce back to the zone.
+
+### Zone Predeploys
+
+Tempo Zones have four system contract predeploys at fixed addresses:
+
+| Contract | Address | Purpose |
+|----------|---------|---------|
+| `TempoState` | `0x1c00...0000` | Stores the zone's view of Tempo Mainnet. The sequencer updates this with Tempo Mainnet block headers, allowing zone contracts to read Tempo Mainnet state within proofs. |
+| `ZoneInbox` | `0x1c00...0001` | Processes incoming deposits. Mints tokens to recipients and validates that processed deposits match what the portal expects. |
+| `ZoneOutbox` | `0x1c00...0002` | Handles withdrawal requests. Users burn their zone tokens here and specify a Tempo Mainnet recipient. |
+| `ZoneConfig` | `0x1c00...0003` | Central configuration. Reads sequencer and token registry from Tempo Mainnet. |
+
+## Creating a Zone
+
+Tempo Zones are created through the `ZoneFactory`:
+
+1. Choose a TIP-20 token to serve as the zone's initial asset.
+2. Select a verifier contract (ZK prover or TEE attestor).
+3. Designate a sequencer address.
+4. Call `ZoneFactory.createZone()` with these parameters.
+
+The factory deploys a new `ZonePortal` and `ZoneMessenger` for the Tempo Zone. The zone itself runs as a separate chain with the system contracts deployed at genesis. The sequencer can enable additional TIP-20 tokens at any time via `ZonePortal.enableToken()`.
+
+### Chain ID
+
+Each Tempo Zone has a unique EIP-155 chain ID derived deterministically from its on-chain zone ID:
+
+```
+chain_id = 421700000 + zone_id
+```
+
+The prefix `4217` corresponds to the Tempo Mainnet chain ID. This ensures replay protection between Tempo Zones. A transaction signed for one zone cannot be replayed on another.
+
+## Sequencer Transfer
+
+The sequencer can transfer control to a new address via a two-step process on Tempo Mainnet:
+
+1. Current sequencer calls `ZonePortal.transferSequencer(newSequencer)` to nominate a new sequencer.
+2. New sequencer calls `ZonePortal.acceptSequencer()` to accept the transfer.
+
+Sequencer management happens on Tempo Mainnet. Zone-side system contracts read the sequencer from Tempo Mainnet via `ZoneConfig`, which queries `TempoState` to get the sequencer address from the finalized `ZonePortal` storage.
+
+## Trust Model
+
+Tempo Zones make explicit tradeoffs between trust and performance:
+
+| What You Trust | What Could Go Wrong |
+|---|---|
+| Sequencer for liveness | The Tempo Zone halts if the sequencer stops. |
+| Sequencer for inclusion and ordering | Transactions (including withdrawals) can be excluded or reordered. |
+| Sequencer for privacy | The sequencer can see all transactions on the Tempo Zone. |
+| Sequencer for data | Reconstructing the state of the Tempo Zone without the sequencer is impossible. |
+| Sequencer + verifier for correctness | If a critical safety bug exists in the verifier or proving system, and the sequencer is malicious, they could exploit it to steal funds. |
+
+The sequencer cannot steal funds or forge state transitions. Validity proofs prevent this. However, the sequencer can halt the zone entirely, censor specific users, or reorder transactions for MEV.
+
+Failed withdrawals always bounce back to the zone `fallbackRecipient`, ensuring users retain their funds. TIP-403 policy changes or token pauses on Tempo Mainnet will cause affected withdrawals to bounce back rather than block the queue.
diff --git a/src/pages/protocol/zones/bridging.mdx b/src/pages/protocol/zones/bridging.mdx
new file mode 100644
index 00000000..9ae73502
--- /dev/null
+++ b/src/pages/protocol/zones/bridging.mdx
@@ -0,0 +1,97 @@
+---
+title: Zone Bridging
+description: Deposit and withdraw TIP-20 tokens between Tempo Mainnet and Tempo Zones, including encrypted deposits and composable withdrawal callbacks.
+---
+
+# Zone Bridging
+
+:::info
+Tempo Zones is still in early development and is available for testing purposes on Tempo Testnet only. While Tempo Zones are in this stage, expect breaking changes to the design and implementation. Do not use this in production. If you're interested in working with Tempo Labs as a design partner on the development of Tempo Zones, reach out to us at [zones@tempo.xyz](mailto:zones@tempo.xyz).
+:::
+
+Tempo Zones use Tempo-centric bridging for cross-chain operations: deposits flow from Tempo into a zone, and withdrawals flow from a zone back to Tempo with optional callbacks for composability.
+
+
+
+Above is an example of the type of complex transaction that can remain privacy-preserving via Tempo Zones, while performing operations such as bridging, deposits & sends, and withdrawals. Learn more about [encrypted deposits](#encrypted-deposits) and [verifiable withdrawals](#verifiable-withdrawals) below.
+
+## Deposits (Tempo → Zone)
+
+1. User calls `ZonePortal.deposit(token, to, amount, memo)` on Tempo, specifying which enabled TIP-20 to deposit.
+2. The portal validates the token is enabled and deposits are active, deducts the [deposit fee](/protocol/zones/execution#deposit-fees), holds the locked funds, and appends a deposit to the queue.
+3. The sequencer observes `DepositMade` events and processes deposits in order via `ZoneInbox.advanceTempo()`, minting the corresponding zone-side TIP-20 to the recipient.
+4. A batch proof must prove the zone correctly processed deposits by validating the Tempo state read inside the proof.
+
+
+### Encrypted Deposits
+
+For privacy-sensitive use cases, users can make encrypted deposits where the recipient and memo are encrypted using the sequencer's public key. Only the sequencer can decrypt and credit the correct recipient on the zone.
+
+**What's public vs. private:**
+
+| Field | Visibility | Reason |
+|-------|------------|--------|
+| `token` | Public | Needed for locked token accounting |
+| `sender` | Public | Needed for potential refunds if decryption fails |
+| `amount` | Public | Needed for on-chain accounting |
+| `to` | Encrypted | Only sequencer knows recipient |
+| `memo` | Encrypted | Only sequencer knows payment context |
+
+The encryption uses ECIES with secp256k1:
+
+1. Sequencer publishes a secp256k1 encryption public key via `setSequencerEncryptionKey()` with a proof of possession.
+2. User generates an ephemeral keypair and derives a shared secret via ECDH.
+3. User encrypts `(to || memo)` with AES-256-GCM using the derived key.
+4. User calls `depositEncrypted(token, amount, keyIndex, encryptedPayload)` on the portal.
+
+If decryption fails (invalid ciphertext, wrong key), the zone mints tokens to the `sender`'s address on the zone. The Tempo Mainnet funds remain locked in the portal. This ensures chain progress is never blocked by invalid encrypted deposits.
+
+
+## Withdrawals (Zone → Tempo)
+
+Users withdraw by creating a withdrawal request on the zone. Withdrawals are processed in two steps:
+
+1. **Batch submission.** The sequencer calls `finalizeWithdrawalBatch()` at the end of the final block in a batch. This constructs the withdrawal hash chain and writes the `withdrawalQueueHash` and `withdrawalBatchIndex` to state. The proof validates this state and adds withdrawals to Tempo's queue.
+2. **Withdrawal processing.** The sequencer calls `processWithdrawal()` on Tempo to process withdrawals from the queue's oldest slot.
+
+### Composable Withdrawals
+
+Withdrawals support callbacks to Tempo contracts via the `ZoneMessenger`. When `gasLimit > 0`, the messenger:
+
+1. Transfers tokens from the portal to the target via `transferFrom`.
+2. Calls the target with the provided `callbackData`.
+
+Both operations are atomic. If the callback reverts, the transfer reverts too. Receiving contracts implement `IWithdrawalReceiver` and verify `msg.sender == zoneMessenger` to authenticate calls. This enables direct composition with DEX swaps, staking, or cross-zone deposits.
+
+```solidity
+interface IWithdrawalReceiver {
+ function onWithdrawalReceived(
+ bytes32 senderTag,
+ address token,
+ uint128 amount,
+ bytes calldata callbackData
+ ) external returns (bytes4);
+}
+```
+
+### Withdrawal Failure and Bounce-Back
+
+Withdrawals can fail if the token transfer or callback reverts (out of gas, TIP-403 policy, token pause, etc.). When a withdrawal fails, the portal bounces back the funds by re-depositing into the same zone to the withdrawal's `fallbackRecipient`:
+
+- The withdrawal is **popped unconditionally** from the queue, even on failure.
+- A new deposit is enqueued for the `fallbackRecipient` on the zone.
+- The sequencer keeps the processing fee regardless of success or failure.
+
+This ensures failed withdrawals never block the queue and users always retain their funds.
+
+### Verifiable Withdrawals
+
+Zone transactions are private: transaction data is not published on Tempo Mainnet. To protect sender privacy during withdrawal processing on Tempo Mainnet, the plaintext `sender` is replaced with a commitment:
+
+```
+senderTag = keccak256(abi.encodePacked(sender, txHash))
+```
+
+The `txHash` acts as a blinding factor known only to the sender and sequencer. The sender can selectively disclose their identity by revealing `txHash` to any party, who verifies it against the `senderTag`.
+
+For automated disclosure, the sender can specify a `revealTo` public key. The sequencer encrypts `(sender, txHash)` to that key using ECDH, populating the `encryptedSender` field in the Tempo Mainnet-facing withdrawal struct. This enables cross-zone transfers where the destination zone's sequencer can automatically attribute incoming deposits.
diff --git a/src/pages/protocol/zones/execution.mdx b/src/pages/protocol/zones/execution.mdx
new file mode 100644
index 00000000..febf2e1a
--- /dev/null
+++ b/src/pages/protocol/zones/execution.mdx
@@ -0,0 +1,68 @@
+---
+title: Execution & Gas
+description: Specification for gas accounting, fee tokens, fixed TIP-20 gas costs, contract creation limits, and token management on Tempo Zones.
+---
+
+# Execution & Gas
+
+:::info
+Tempo Zones is still in early development and is available for testing purposes on Tempo Testnet only. While Tempo Zones are in this stage, expect breaking changes to the design and implementation. Do not use this in production. If you're interested in working with Tempo Labs as a design partner on the development of Tempo Zones, reach out to us at [zones@tempo.xyz](mailto:zones@tempo.xyz).
+:::
+
+This page specifies how Tempo Zones handle gas accounting, fee collection, and token management. For deposit and withdrawal flows, see the [bridging specification](/protocol/zones/bridging). For balance visibility and access control rules, see the [accounts specification](/protocol/zones/accounts).
+
+## Fee Tokens
+
+Tempo Zones reuse Tempo fee units and gas accounting. Each transaction includes a `feeToken` field. Any enabled TIP-20 token with USD currency is valid for gas payment. The sequencer accepts all enabled tokens directly, so no Fee AMM is needed.
+
+## Deposit Fees
+
+Deposits charge a fixed processing fee in the deposited token:
+
+```
+fee = FIXED_DEPOSIT_GAS × zoneGasRate
+```
+
+`FIXED_DEPOSIT_GAS` is fixed at 100,000 gas. The sequencer configures `zoneGasRate` through `ZonePortal.setZoneGasRate()`. The fee is deducted from the deposit amount and paid to the sequencer on Tempo Mainnet.
+
+## Withdrawal Fees
+
+Withdrawals charge a processing fee in the withdrawn token:
+
+```
+fee = gasLimit × tempoGasRate
+```
+
+The user specifies `gasLimit` to cover processing and any callback execution. The sequencer configures `tempoGasRate` through `ZoneOutbox.setTempoGasRate()`.
+
+## Fixed Gas Costs
+
+All user-facing TIP-20 transfer and approval operations cost exactly 100,000 gas. This removes gas-based information leaks tied to storage state. On a standard EVM chain, gas varies based on whether a transfer writes to a previously empty storage slot, revealing whether the recipient has received tokens before. Fixed costs eliminate that side channel.
+
+| Function | Gas Cost |
+|----------|----------|
+| `transfer(to, amount)` | 100,000 |
+| `transferFrom(from, to, amount)` | 100,000 |
+| `transferWithMemo(to, amount, memo)` | 100,000 |
+| `transferFromWithMemo(from, to, amount, memo)` | 100,000 |
+| `approve(spender, amount)` | 100,000 |
+
+System functions (`systemTransferFrom`, `transferFeePreTx`, `transferFeePostTx`) retain standard gas costs. Only restricted system callers can invoke them, so the gas side channel does not apply.
+
+## Contract Creation Disabled
+
+Tempo Zones currently disable the `CREATE` and `CREATE2` opcodes. Each Tempo Zone runs a fixed set of system contracts and predeploys. Any transaction that attempts contract creation reverts.
+
+## Token Management
+
+
+
+The sequencer manages which TIP-20 tokens are available on a Tempo Zone:
+
+| Function | Behavior |
+|----------|----------|
+| `enableToken(token)` | Enables a TIP-20 token for bridging and gas payment. Irreversible. |
+| `pauseDeposits(token)` | Stops new deposits for the token. Withdrawals continue. |
+| `resumeDeposits(token)` | Restarts deposits for a previously paused token. |
+
+Once enabled, a token cannot be disabled. This preserves the withdrawal guarantee. Enabled tokens use the same address on the Tempo Zone as on Tempo Mainnet. `ZoneInbox` mints on deposit, `ZoneOutbox` burns on withdrawal. No mechanism exists to create new tokens on the Tempo Zone.
diff --git a/src/pages/protocol/zones/index.mdx b/src/pages/protocol/zones/index.mdx
new file mode 100644
index 00000000..0000b392
--- /dev/null
+++ b/src/pages/protocol/zones/index.mdx
@@ -0,0 +1,85 @@
+---
+title: Tempo Zones
+description: Tempo Zones are private execution environments on Tempo Mainnet where balances, transfers, and transaction history are invisible to the public chain.
+---
+
+import { Cards, Card } from 'vocs'
+
+# Tempo Zones
+
+:::info
+Tempo Zones is still in early development and is available for testing purposes on Tempo Testnet only. While Tempo Zones are in this stage, expect breaking changes to the design and implementation. Do not use this in production. If you're interested in working with Tempo Labs as a design partner on the development of Tempo Zones, reach out to us at [zones@tempo.xyz](mailto:zones@tempo.xyz).
+:::
+
+A Tempo Zone is a private execution environment attached to Tempo Mainnet. Inside a Tempo Zone, balances, transfers, and transaction history are invisible to block explorers, indexers, and other users on Tempo Mainnet. Each Tempo Zone runs its own sequencer and executes transactions independently.
+
+
+
+Funds deposited into a Tempo Zone are locked in the zone contract on Tempo Mainnet. [Validity proofs](/protocol/zones/proving) guarantee that the sequencer executed every transaction correctly. The sequencer orders and includes transactions, but cannot steal funds or forge state transitions.
+
+Each Tempo Zone operates as a separate chain, so adding more zones increases throughput without congesting Tempo Mainnet. Tempo Zones share liquidity through Tempo Mainnet. A zone can withdraw tokens, swap them on the Stablecoin DEX, and deposit the result into another zone without exposing who placed the trade. See [composable withdrawals](/protocol/zones/bridging#composable-withdrawals) for details.
+
+### Tempo Zones are private
+
+Tempo Zones make a key trade-off: Each zone has a sequencer who sees all activity on the zone. Privacy depends on the integrity of whoever is running the sequencer. Thanks to this trade-off, they achieve what few other privacy solutions do: Great privacy with good UX.
+
+Most privacy solutions offer either confidentiality (hide the amount) or anonymity (hide the sender). Tempo Zones provide both, and go further. Inside a Tempo Zone, balances, transaction history, and counterparty relationships are all invisible to outside observers. Block explorers and indexers see nothing. Other users cannot query your address.
+
+The [accounts specification](/protocol/zones/accounts) describes how balance and allowance reads are restricted at the contract level, and the [RPC specification](/protocol/zones/rpc) covers how the JSON-RPC interface is scoped per account.
+
+
+
+### Tempo Zones are compliant by design
+
+Every TIP-20 token carries its issuer's compliance policy (whitelists, blacklists, freeze controls) via the [TIP-403 registry](/protocol/tip403/overview). When deposited into a Tempo Zone, the policy is provably mirrored. The validity proof commits that every transaction in the batch followed the issuer's rules.
+
+
+
+### Tempo Zones are safe from theft
+
+Validity proofs guarantee correct state transitions. Sequencers order transactions but cannot steal deposited funds. See the [proving specification](/protocol/zones/proving) for how proofs are constructed and verified.
+
+### Tempo Zones are interoperable
+
+Tempo Zones are interoperable with Tempo Mainnet and with each other. Deposits and withdrawals settle in seconds. A Tempo Zone can withdraw tokens, swap them on the Stablecoin DEX, and deposit the result into another Tempo Zone in a single operation. The [bridging specification](/protocol/zones/bridging) covers deposits, withdrawals, encrypted deposits for private on-ramps, and composable withdrawal callbacks for cross-zone transfers.
+
+## Specifications
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/protocol/zones/proving.mdx b/src/pages/protocol/zones/proving.mdx
new file mode 100644
index 00000000..d20ac4e6
--- /dev/null
+++ b/src/pages/protocol/zones/proving.mdx
@@ -0,0 +1,136 @@
+---
+title: Zone Proving
+description: Batch submission and proof verification for Tempo zones, including the state transition function, ZK and TEE deployment modes, and ancestry proofs.
+---
+
+import { StaticMermaidDiagram } from '../../../components/StaticMermaidDiagram'
+
+# Zone Proving
+
+:::info
+Tempo Zones is still in early development and is available for testing purposes on Tempo Testnet only. While Tempo Zones are in this stage, expect breaking changes to the design and implementation. Do not use this in production. If you're interested in working with Tempo Labs as a design partner on the development of Tempo Zones, reach out to us at [zones@tempo.xyz](mailto:zones@tempo.xyz).
+:::
+
+:::warning
+The zone prover is not yet live. This page describes the planned design. The prover will be added in a future release.
+:::
+
+Zone settlement uses validity proofs to verify correct execution. The prover implements a pure state transition function in Rust with `no_std` compatibility, allowing it to run in both ZKVMs (SP1) and TEEs (SGX/TDX).
+
+## Batch Submission
+
+The sequencer posts batches to Tempo Mainnet via `submitBatch` on the portal. Each batch covers one or more zone blocks and includes:
+
+| Field | Description |
+|-------|-------------|
+| `tempoBlockNumber` | Tempo block the zone committed to (from zone's TempoState) |
+| `recentTempoBlockNumber` | Optional recent block for ancestry proof (`0` = direct lookup) |
+| `blockTransition` | Zone block hash transition (`prevBlockHash` → `nextBlockHash`) |
+| `depositQueueTransition` | Deposit queue processing progress |
+| `withdrawalQueueHash` | Hash chain of withdrawals for this batch (`0` if none) |
+| `verifierConfig` | Opaque payload for the verifier (domain separation / attestation) |
+| `proof` | Validity proof or TEE attestation |
+
+The portal verifies that `prevBlockHash` matches the stored `blockHash`, calls the verifier, and on success updates `withdrawalBatchIndex`, `blockHash`, `lastSyncedTempoBlockNumber`, and adds withdrawals to the queue.
+
+## Verifier Interface
+
+The verifier is abstracted behind a minimal interface. ZK systems and TEE attesters implement the same contract:
+
+```solidity
+interface IVerifier {
+ function verify(
+ uint64 tempoBlockNumber,
+ uint64 anchorBlockNumber,
+ bytes32 anchorBlockHash,
+ uint64 expectedWithdrawalBatchIndex,
+ address sequencer,
+ BlockTransition calldata blockTransition,
+ DepositQueueTransition calldata depositQueueTransition,
+ bytes32 withdrawalQueueHash,
+ bytes calldata verifierConfig,
+ bytes calldata proof
+ ) external view returns (bool);
+}
+```
+
+The proof verifies that:
+
+1. Valid state transition from `prevBlockHash` to `nextBlockHash`.
+2. Zone committed to `tempoBlockNumber` via TempoState.
+3. Anchor block hash matches (direct or ancestry mode).
+4. `ZoneOutbox.lastBatch()` has the correct `withdrawalBatchIndex` and `withdrawalQueueHash`.
+5. Deposit processing is correct (validated via Tempo state read inside proof).
+6. Zone block `beneficiary` matches the registered sequencer.
+
+## State Transition Function
+
+The prover takes a complete witness of zone blocks and their dependencies, executes the EVM state transitions, and outputs commitments for on-chain verification:
+
+```rust
+pub fn prove_zone_batch(witness: BatchWitness) -> Result
+```
+
+### Execution Flow
+
+ B["Verify Tempo state proofs"]
+ B --> C["Initialize zone state from previous block hash"]
+ C --> D{"Next zone block"}
+ D --> E["Check parent hash and block number"]
+ E --> F["Verify beneficiary is the sequencer"]
+ F --> G["Execute advanceTempo system transaction if present"]
+ G --> H["Execute user transactions via revm"]
+ H --> I{"Final block in batch?"}
+ I -- No --> J["Compute simplified zone block hash"]
+ J --> D
+ I -- Yes --> K["Execute finalizeWithdrawalBatch"]
+ K --> L["Compute simplified zone block hash"]
+ L --> M["Extract output commitments"]
+ M --> N["Return batch output for verification"]
+`} />
+
+1. **Verify Tempo state proofs.** Validate MPT proofs for all Tempo storage reads against Tempo state roots.
+2. **Initialize zone state.** Load the zone state from the witness, binding the initial state root to the previous block hash.
+3. **Execute zone blocks.** For each block:
+ - Validate parent hash continuity and block number sequencing.
+ - Verify beneficiary matches the registered sequencer.
+ - Execute `advanceTempo()` system transaction (if present) to process deposits.
+ - Execute user transactions via revm.
+ - Execute `finalizeWithdrawalBatch()` in the final block only.
+ - Compute the zone block hash from the simplified header.
+4. **Extract output commitments.** Block hash transition, deposit queue transition, withdrawal queue hash, and last batch parameters.
+
+### Deployment Modes
+
+**ZKVM (SP1):** The prover runs inside a ZKVM. The witness is read from the ZKVM IO, and the output is committed to the proof.
+
+**TEE (SGX/TDX):** The same function runs inside a trusted execution environment. The output is signed by the TEE attestation.
+
+## Ancestry Proofs
+
+EIP-2935 provides access to the last ~8,192 block hashes on Tempo. If a zone is inactive longer than this window, `tempoBlockNumber` rotates out of EIP-2935, which would prevent batch submission.
+
+The solution verifies ancestry inside the ZK circuit:
+
+1. The portal reads `recentTempoBlockNumber` hash from EIP-2935 (must be recent).
+2. The prover includes Tempo headers from `tempoBlockNumber + 1` to `recentTempoBlockNumber` as witness data.
+3. The proof verifies the parent hash chain: each header's parent hash must match the previous header's hash.
+4. The portal verifies the constant-size proof against the recent block hash.
+
+| Mode | Condition | Behavior |
+|------|-----------|----------|
+| Direct | `recentTempoBlockNumber = 0` | Portal reads `tempoBlockNumber` hash from EIP-2935 |
+| Ancestry | `recentTempoBlockNumber > tempoBlockNumber` | Portal reads `recentTempoBlockNumber` hash; proof verifies parent chain |
+
+Proving time increases linearly with the block gap (each gap block adds ~1 keccak operation), but on-chain verification cost remains constant. This prevents the zone from becoming stuck after an extended downtime.
+
+## Tempo State Access
+
+The zone accesses Tempo state via the TempoState predeploy (`0x1c00...0000`). During batch execution:
+
+1. `ZoneInbox` calls `TempoState.finalizeTempo(header)` to advance the zone's view of Tempo.
+2. System contracts read Tempo storage via `TempoState.readTempoStorageSlot()`, restricted to zone system contracts only.
+3. The proof includes Merkle proofs for each Tempo account and storage slot accessed during the batch.
+
+Tempo state staleness depends on how frequently the sequencer calls `advanceTempo()`. The zone client must only finalize Tempo headers after finality to avoid reorg risk.
diff --git a/src/pages/protocol/zones/rpc.mdx b/src/pages/protocol/zones/rpc.mdx
new file mode 100644
index 00000000..c18d4c31
--- /dev/null
+++ b/src/pages/protocol/zones/rpc.mdx
@@ -0,0 +1,145 @@
+---
+title: Zone RPC
+description: Authenticated JSON-RPC interface for Tempo Zones with per-account scoping, timing side channel mitigations, and event filtering.
+---
+
+# Zone RPC
+
+:::info
+Tempo Zones is still in early development and is available for testing purposes on Tempo Testnet only. While Tempo Zones are in this stage, expect breaking changes to the design and implementation. Do not use this in production. If you're interested in working with Tempo Labs as a design partner on the development of Tempo Zones, reach out to us at [zones@tempo.xyz](mailto:zones@tempo.xyz).
+:::
+
+The zone RPC starts from the standard Ethereum JSON-RPC and restricts it to enforce privacy guarantees. Every RPC request must include an authorization token that proves the caller controls a Tempo account and scopes all responses to that account.
+
+## Authorization Tokens
+
+Authorization tokens are short-lived credentials (maximum 1 month) signed by the caller's Tempo account key. Tempo accounts support multiple signature types (secp256k1, P256, WebAuthn), and accounts with Access Keys via the `AccountKeychain` precompile can use those keys to authenticate.
+
+The signed message includes:
+- `"TempoZoneRPC"` magic prefix for domain separation
+- Spec version, zone ID, and chain ID for replay protection (zone 0 can be used to allow access to all zones)
+- Issuance and expiry timestamps
+
+Tokens are sent via the `X-Authorization-Token` HTTP header on every request.
+
+## Method Access Control
+
+Each JSON-RPC method falls into one of four categories:
+
+Available to any authenticated caller:
+
+| Method | Access Type | Notes |
+|--------|-------------|-------|
+| `eth_chainId` | Allowed | Zone chain ID |
+| `eth_blockNumber` | Allowed | Latest block number |
+| `eth_gasPrice` | Allowed | Current gas price |
+| `eth_maxPriorityFeePerGas` | Allowed | Current priority fee |
+| `eth_feeHistory` | Allowed | Fee history |
+| `eth_getBlockByNumber` | Allowed | Block headers **without transaction details** |
+| `eth_getBlockByHash` | Allowed | Block headers **without transaction details** |
+| `eth_subscribe("newHeads")` | Allowed | Block headers with `logsBloom` zeroed |
+| `eth_syncing` | Allowed | Sync status |
+| `eth_coinbase` | Allowed | Sequencer address |
+| `net_version` | Allowed | Network ID |
+| `net_listening` | Allowed | Node status |
+| `web3_clientVersion` | Allowed | Client version |
+| `web3_sha3` | Allowed | Pure Keccak-256 hash |
+| `eth_getBalance` | Scoped | Returns balance for the authenticated account only. Queries for other accounts return `0x0`. |
+| `eth_getTransactionCount` | Scoped | Returns nonce for the authenticated account only. Other accounts return `0x0`. |
+| `eth_call` | Scoped | Executes with `from` set to the authenticated account. [Execution-level privacy](/protocol/zones/accounts) enforces `balanceOf` access control at the contract level. |
+| `eth_estimateGas` | Scoped | Only allowed when `from` equals the authenticated account. |
+| `eth_getTransactionByHash` | Scoped | Returns the transaction only if the authenticated account is the sender. Returns `null` otherwise. |
+| `eth_getTransactionReceipt` | Scoped | Returns the receipt only if the authenticated account is the sender. Logs are filtered (see [Event Filtering](#event-filtering)). |
+| `eth_sendRawTransaction` | Scoped | Validates that the transaction sender matches the authenticated account. |
+| `eth_getLogs` | Scoped | Filtered to TIP-20 events where the authenticated account is a relevant party (see [Event Filtering](#event-filtering)). |
+| `eth_getFilterLogs` | Scoped | Same filtering as `eth_getLogs`. |
+| `eth_getFilterChanges` | Scoped | Same filtering. Only returns new events since last poll. |
+| `eth_newFilter` | Scoped | Creates a filter implicitly scoped to the authenticated account. |
+| `eth_subscribe("logs")` | Scoped | Subscription scoped to the authenticated account. |
+| `eth_newBlockFilter` | Scoped | Returns new block hashes. |
+| `eth_uninstallFilter` | Scoped | Removes a previously created filter. |
+
+**Error vs. silent response**: Methods where the user explicitly provides a mismatched parameter (`eth_sendRawTransaction` with wrong sender, `eth_call` with wrong `from`) return explicit errors, since the user already knows the address they supplied and the error leaks nothing. Methods that query *about* other accounts return silent dummy values (`0x0`, `null`, empty results) instead of errors; an error would reveal "this data exists but you can't see it."
+
+### Restricted (sequencer-only)
+
+| Method | Reason |
+|--------|--------|
+| `eth_getStorageAt` | Raw storage reads bypass all access control |
+| `eth_getCode` | No legitimate non-sequencer use case |
+| `eth_createAccessList` | Reveals storage layout |
+| `eth_getBlockByNumber` (with `true`) | Full block with all transactions |
+| `eth_getBlockByHash` (with `true`) | Full block with all transactions |
+| `eth_getBlockTransactionCountByNumber` | Transaction counts reveal activity levels |
+| `eth_getBlockTransactionCountByHash` | Same as above |
+| `eth_getTransactionByBlockNumberAndIndex` | Arbitrary transaction access |
+| `eth_getTransactionByBlockHashAndIndex` | Same as above |
+| `debug_*`, `admin_*`, `txpool_*` | All debug, admin, and txpool namespaces |
+
+### Disabled
+
+| Method | Reason |
+|--------|--------|
+| `eth_getProof` | Merkle proofs leak state trie structure |
+| `eth_newPendingTransactionFilter` | Mempool observation |
+| `eth_subscribe("newPendingTransactions")` | Mempool observation |
+| Mining-related methods | Tempo Zones have no mining |
+
+Any method not explicitly listed returns error code `-32601` (method not found), ensuring new methods are not accidentally exposed.
+
+## Timing Side Channels
+
+Scoped methods that fetch data before checking authorization have a mandatory **100 ms minimum response time**. This ensures that `eth_getTransactionByHash` for a non-existent transaction hash and for another user's transaction have indistinguishable response times, preventing existence probing.
+
+Methods that need the speed bump:
+
+| Method | Reason |
+|--------|--------|
+| `eth_getTransactionByHash` | Must fetch the transaction to check if sender matches |
+| `eth_getTransactionReceipt` | Must fetch the receipt to check the sender |
+| `eth_getLogs` | Response time correlates with total log volume, not just the caller's logs |
+| `eth_getFilterLogs` | Same as `eth_getLogs` |
+| `eth_getFilterChanges` | Same as `eth_getLogs` |
+
+Methods that do **not** need the speed bump include `eth_getBalance` and `eth_getTransactionCount` (address checked before any data fetch), `eth_call` and `eth_estimateGas` (`from` validated before execution), and `eth_sendRawTransaction` (sender verified during decoding).
+
+## Block Responses
+
+Block headers returned to non-sequencer callers are sanitized:
+
+- `transactions` is always an empty array.
+- `logsBloom` is replaced with a zero Bloom. The real Bloom filter would allow probing whether a specific address had activity in a block.
+- All other header fields (`number`, `hash`, `gasUsed`, `stateRoot`, etc.) are returned normally.
+
+## Event Filtering
+
+Log queries are restricted to TIP-20 events where the authenticated account is a relevant party:
+
+| Event | Visible if |
+|-------|-----------|
+| `Transfer` | `from == caller` OR `to == caller` |
+| `Approval` | `owner == caller` OR `spender == caller` |
+| `TransferWithMemo` | `from == caller` OR `to == caller` |
+| `Mint` | `to == caller` |
+| `Burn` | `from == caller` |
+
+All other event topics (system events, role events, configuration events) are filtered out.
+
+## Zone-Specific RPC Methods
+
+| Method | Access | Description |
+|--------|--------|-------------|
+| `zone_getAuthorizationTokenInfo` | Any authenticated | Returns the authenticated account address and token expiry |
+| `zone_getZoneInfo` | Any authenticated | Returns zone metadata: `zoneId`, `zoneTokens`, `sequencer`, `chainId` |
+| `zone_getDepositStatus` | Scoped | Returns whether deposits from a given Tempo block have been processed, filtered to the caller's deposits |
+
+## Error Codes
+
+| Code | Message | Meaning |
+|------|---------|---------|
+| `-32001` | Authorization token required | No authorization token provided |
+| `-32002` | Authorization token expired | The authorization token has expired |
+| `-32003` | Transaction rejected | Transaction sender does not match authenticated account |
+| `-32004` | Account mismatch | The `from` field does not match the authenticated account |
+| `-32005` | Sequencer only | Method requires sequencer access |
+| `-32006` | Method disabled | Method is not available on zones |
diff --git a/src/wagmi.config.ts b/src/wagmi.config.ts
index 89007557..d9dffbfd 100644
--- a/src/wagmi.config.ts
+++ b/src/wagmi.config.ts
@@ -16,23 +16,32 @@ import {
} from 'wagmi'
import { KeyManager, webAuthn } from 'wagmi/tempo'
import { alphaUsd, betaUsd, pathUsd, thetaUsd } from './components/guides/tokens'
-
-const feeToken = '0x20c0000000000000000000000000000000000001'
+import { feeToken, moderatoZones } from './lib/private-zones.ts'
const chain =
import.meta.env.VITE_TEMPO_ENV === 'localnet'
? tempoLocalnet.extend({ feeToken })
: import.meta.env.VITE_TEMPO_ENV === 'devnet'
? tempoDevnet.extend({ feeToken })
- : tempoModerato.extend({ feeToken })
+ : tempoModerato.extend({ feeToken, zones: moderatoZones })
const rpId = (() => {
const hostname = globalThis.location?.hostname
if (!hostname) return undefined
+
+ // IP hosts and localhost must use the exact hostname as the RP ID.
+ if (hostname === 'localhost' || isIpAddress(hostname)) return hostname
+
+ // Vercel preview hosts live under the public suffix `vercel.app`, so the
+ // RP ID must stay scoped to the exact preview hostname.
+ if (hostname.endsWith('.vercel.app')) return hostname
+
const parts = hostname.split('.')
return parts.length > 2 ? parts.slice(-2).join('.') : hostname
})()
+export const webAuthnRpId = rpId
+
export function getConfig(options: getConfig.Options = {}) {
const { multiInjectedProviderDiscovery = false } = options
return createConfig({
@@ -64,10 +73,7 @@ export function getConfig(options: getConfig.Options = {}) {
},
}),
webAuthn({
- grantAccessKey: {
- // @ts-expect-error - TODO: migrate to webAuthn on Accounts SDK
- chainId: BigInt(chain.id),
- },
+ grantAccessKey: true,
keyManager: KeyManager.http('https://keys.tempo.xyz'),
rpId,
}),
@@ -110,6 +116,8 @@ export namespace getConfig {
export type Config = ReturnType
+export const config = getConfig()
+
export const queryClient = new QueryClient()
export function useTempoWalletConnector() {
@@ -130,6 +138,10 @@ export function useWebAuthnConnector() {
)
}
+function isIpAddress(hostname: string) {
+ return /^(?:\d{1,3}\.){3}\d{1,3}$/.test(hostname) || hostname.includes(':')
+}
+
declare module 'wagmi' {
interface Register {
config: Config
diff --git a/vocs.config.ts b/vocs.config.ts
index 73ce4857..6e4cb9f0 100644
--- a/vocs.config.ts
+++ b/vocs.config.ts
@@ -154,6 +154,40 @@ export default defineConfig({
// },
],
},
+ {
+ text: 'Connect to Zones',
+ collapsed: true,
+ items: [
+ {
+ text: 'Overview',
+ link: '/guide/private-zones',
+ },
+ {
+ text: 'Connect to a zone',
+ link: '/guide/private-zones/connect-to-a-zone',
+ },
+ {
+ text: 'Deposit to a zone',
+ link: '/guide/private-zones/deposit-to-a-zone',
+ },
+ {
+ text: 'Send tokens within a zone',
+ link: '/guide/private-zones/send-tokens-within-a-zone',
+ },
+ {
+ text: 'Send tokens across zones',
+ link: '/guide/private-zones/send-tokens-across-zones',
+ },
+ {
+ text: 'Swap across zones',
+ link: '/guide/private-zones/swap-across-zones',
+ },
+ {
+ text: 'Withdraw from a zone',
+ link: '/guide/private-zones/withdraw-from-a-zone',
+ },
+ ],
+ },
{
text: 'Issue Stablecoins',
collapsed: true,
@@ -588,6 +622,45 @@ export default defineConfig({
},
],
},
+ {
+ text: 'Tempo Zones',
+ collapsed: true,
+ items: [
+ {
+ text: 'Overview',
+ link: '/protocol/zones',
+ },
+ {
+ text: 'Reference',
+ items: [
+ {
+ text: 'Architecture',
+ link: '/protocol/zones/architecture',
+ },
+ {
+ text: 'Accounts',
+ link: '/protocol/zones/accounts',
+ },
+ {
+ text: 'Bridging',
+ link: '/protocol/zones/bridging',
+ },
+ {
+ text: 'RPC',
+ link: '/protocol/zones/rpc',
+ },
+ {
+ text: 'Execution & Gas',
+ link: '/protocol/zones/execution',
+ },
+ {
+ text: 'Proving',
+ link: '/protocol/zones/proving',
+ },
+ ],
+ },
+ ],
+ },
{
text: 'Network Upgrades',
collapsed: true,
@@ -1151,6 +1224,16 @@ export default defineConfig({
source: '/quickstart',
destination: '/quickstart/integrate-tempo',
},
+ {
+ source: '/protocol/zones/overview',
+ destination: '/protocol/zones',
+ status: 301,
+ },
+ {
+ source: '/protocol/zones/privacy',
+ destination: '/protocol/zones/accounts',
+ status: 301,
+ },
{
source: '/protocol/blockspace',
destination: '/protocol/blockspace/overview',