From 3ce5a91c2cae3837c6d6e31e56e84113362ec020 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:37:00 +0300 Subject: [PATCH 01/25] docs: add Zones protocol documentation (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add Zones protocol documentation Add comprehensive documentation for the Tempo Zones feature: - Overview: architecture, zone creation, token management, fees - Bridging: deposits, withdrawals, encrypted deposits, composable callbacks - Privacy Zones: execution-level and RPC-level privacy protections - Proving: batch submission, verifier interface, ancestry proofs - Upgrades: hard fork activation, verifier rotation, failure modes Also updates the privacy learn page to reference Zones and adds a Zones card to the protocol index. Prompted by: dankrad Amp-Thread-ID: https://ampcode.com/threads/T-019d2a83-5a87-708e-b2cd-8aca731e0ae6 * docs(zones): rework overview with intro from design doc Incorporate the high-level intro, system architecture diagrams, contract architecture, trust model table, and creating a zone walkthrough from the zones design document. Prompted by: dankrad Amp-Thread-ID: https://ampcode.com/threads/T-019d2a83-5a87-708e-b2cd-8aca731e0ae6 * fix: escape angle bracket in MDX to fix build Replace '<10 seconds' with 'under 10 seconds' — the bare '<' was parsed as a JSX tag by the MDX compiler. Prompted by: dankrad Amp-Thread-ID: https://ampcode.com/threads/T-019d2a83-5a87-708e-b2cd-8aca731e0ae6 * docs(zones): convert ASCII art to mermaid diagrams Replace ASCII art illustrations with native mermaid code blocks, matching the pattern used in validator-config-v1.mdx and validator-config-v2.mdx. Prompted by: dankrad Amp-Thread-ID: https://ampcode.com/threads/T-019d2a83-5a87-708e-b2cd-8aca731e0ae6 * docs(zones): add note that prover is not yet live Prompted by: dankrad Amp-Thread-ID: https://ampcode.com/threads/T-019d2a83-5a87-708e-b2cd-8aca731e0ae6 * docs(zones): clarify withdrawal queue slots contain multiple withdrawals Prompted by: dankrad Amp-Thread-ID: https://ampcode.com/threads/T-019d2a83-5a87-708e-b2cd-8aca731e0ae6 * fix(zones): fix mermaid diagram rendering - Replace \n with
for multi-line node labels (mermaid requires HTML) - Reorder edge definitions before subgraphs to fix withdrawal arrow routing through ZoneFactory instead of ZonePortal - Remove redundant edge labels for cleaner system architecture diagram Prompted by: dankrad Amp-Thread-ID: https://ampcode.com/threads/T-019d2a83-5a87-708e-b2cd-8aca731e0ae6 * docs(zones): remove queue design rationale paragraph Prompted by: dankrad Amp-Thread-ID: https://ampcode.com/threads/T-019d2a83-5a87-708e-b2cd-8aca731e0ae6 * docs(zones): rewrite privacy page, split RPC into own page All zones are private by design — rewrite the privacy page to reflect this rather than treating privacy as a special variant. Move the RPC specification (auth tokens, method access control, timing side channels, event filtering, error codes) into a dedicated /protocol/zones/rpc page. Prompted by: dankrad Amp-Thread-ID: https://ampcode.com/threads/T-019d2a83-5a87-708e-b2cd-8aca731e0ae6 * docs(zones): remove incorrect contract creation rationale Prompted by: dankrad Amp-Thread-ID: https://ampcode.com/threads/T-019d2a83-5a87-708e-b2cd-8aca731e0ae6 * docs(zones): wording fix in proving page Prompted by: dankrad Amp-Thread-ID: https://ampcode.com/threads/T-019d2a83-5a87-708e-b2cd-8aca731e0ae6 * docs(zones): wording fix for ancestry proofs Prompted by: dankrad Amp-Thread-ID: https://ampcode.com/threads/T-019d2a83-5a87-708e-b2cd-8aca731e0ae6 * docs(zones): note encrypted deposits exception for quantum security Prompted by: dankrad Amp-Thread-ID: https://ampcode.com/threads/T-019d2a83-5a87-708e-b2cd-8aca731e0ae6 * Refine overview.mdx (#52) * Refine overview.mdx - consistent terminology (zone vs tempo L1, as opposed to chain, main chain etc). - minor nits/ rewording * Apply suggestion from @dankrad * Apply suggestion from @dankrad --------- Co-authored-by: dankrad * Update privacy.md for clarity and terminology (#53) * Update privacy.md for clarity and terminology * Apply suggestion from @dankrad --------- Co-authored-by: dankrad * minor nits in proving.mdx (#54) * Update src/pages/learn/tempo/privacy.mdx Co-authored-by: Daniel Robinson * Apply suggestion from @dankrad * Apply suggestion from @dankrad * Apply suggestion from @dankrad * Apply suggestion from @malleshpai Co-authored-by: malleshpai * Apply suggestion from @dankrad * Apply suggestion from @danrobinson Co-authored-by: Daniel Robinson * Apply suggestion from @danrobinson Co-authored-by: Daniel Robinson * Apply suggestion from @danrobinson Co-authored-by: Daniel Robinson * Apply suggestion from @dankrad * Apply suggestion from @danrobinson Co-authored-by: Daniel Robinson * Apply suggestion from @dankrad * Apply suggestion from @dankrad * Apply suggestion from @dankrad * Restructure Tempo Zones docs: new IA, terminology fixes (#56) * Restructure Tempo Zones documentation Split the zones overview into a proper information architecture: - New overview page with key properties and spec navigation cards - Architecture page (extracted from old overview, structural content) - Accounts page (extracted from old privacy, EVM-level access control) - Execution & Gas page (consolidated from overview + privacy) - Remove standalone privacy page (content split into accounts + overview) Terminology changes across all zone pages: - 'L1'/'L2' replaced with 'Tempo Mainnet' throughout - 'Zones' replaced with 'Tempo Zones' in prose - Em dashes replaced with commas and periods - Sidebar label changed from 'Zones' to 'Tempo Zones' - Specification pages grouped under a 'Specification' sidebar header Adds 301 redirects for /protocol/zones/overview and /protocol/zones/privacy. Amp-Thread-ID: https://ampcode.com/threads/T-019d4538-30c7-74ad-b74c-efcb7249e5fe Co-authored-by: Amp * Apply review suggestions from base branch - non-custodial → safe from theft - EVM level → contract level - Remove 'Both layers are required' (redundant with next sentence) - Add 'for TIP-20s' to balanceOf restriction - Remove sentence about synchronous L1 state reads from architecture intro - Sequencer management 'is centralized' → 'happens' - 'opt-in privacy' → 'privacy' - Authorization token max 30 minutes → 1 month - Update replay protection fields (remove portal address, add zone 0 wildcard) - 'Zones disable' → 'Zones currently disable' CREATE opcodes Amp-Thread-ID: https://ampcode.com/threads/T-019d492e-f36b-721a-99bd-57b64f8a6e03 Co-authored-by: Amp --------- Co-authored-by: Amp * docs: remove spec-only sections from zones docs Remove Deposit Queue, Withdrawal Queue, and Queue Design sections from bridging.mdx. Remove entire upgrades.mdx page and its sidebar entry. These belong in the formal specs, not user-facing docs. Co-authored-by: dankrad <6130607+dankrad@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d4a8c-806c-7245-9d32-9ee4a96b2ae3 * docs: rename zones sidebar section from Specification to Reference Co-authored-by: dankrad <6130607+dankrad@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d4a8c-806c-7245-9d32-9ee4a96b2ae3 * Apply suggestions from code review Co-authored-by: dankrad * Apply suggestion from @dankrad * Apply suggestions from code review Co-authored-by: dankrad * Apply suggestions from code review Co-authored-by: dankrad * Apply suggestions from code review Co-authored-by: dankrad * Apply suggestions from code review Co-authored-by: dankrad * Apply suggestions from code review Co-authored-by: dankrad * Apply suggestion from @dankrad * docs: remove dead link to upgrades page Prompted by: dankrad Amp-Thread-ID: https://ampcode.com/threads/T-019d63e4-5fa8-766b-bdd9-05ad92044c04 * docs: remove 'withdraw at any time' claims Prompted by: dankrad Amp-Thread-ID: https://ampcode.com/threads/T-019d63e4-5fa8-766b-bdd9-05ad92044c04 * Apply suggestions from code review Co-authored-by: dankrad --------- Co-authored-by: malleshpai Co-authored-by: dankrad Co-authored-by: Daniel Robinson Co-authored-by: Liam Horne Co-authored-by: Amp Co-authored-by: dankrad <6130607+dankrad@users.noreply.github.com> --- src/pages/learn/tempo/privacy.mdx | 20 ++- src/pages/protocol/index.mdx | 6 + src/pages/protocol/zones/accounts.mdx | 41 ++++++ src/pages/protocol/zones/architecture.mdx | 120 +++++++++++++++++ src/pages/protocol/zones/bridging.mdx | 90 +++++++++++++ src/pages/protocol/zones/execution.mdx | 62 +++++++++ src/pages/protocol/zones/index.mdx | 75 +++++++++++ src/pages/protocol/zones/proving.mdx | 114 ++++++++++++++++ src/pages/protocol/zones/rpc.mdx | 150 ++++++++++++++++++++++ vocs.config.ts | 50 ++++++++ 10 files changed, 723 insertions(+), 5 deletions(-) create mode 100644 src/pages/protocol/zones/accounts.mdx create mode 100644 src/pages/protocol/zones/architecture.mdx create mode 100644 src/pages/protocol/zones/bridging.mdx create mode 100644 src/pages/protocol/zones/execution.mdx create mode 100644 src/pages/protocol/zones/index.mdx create mode 100644 src/pages/protocol/zones/proving.mdx create mode 100644 src/pages/protocol/zones/rpc.mdx 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. + +```mermaid +flowchart TD + ZP["ZonePortal
"] -- "deposits" --> 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..a929fc8a --- /dev/null +++ b/src/pages/protocol/zones/bridging.mdx @@ -0,0 +1,90 @@ +--- +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 + +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. + +## 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..10b7114f --- /dev/null +++ b/src/pages/protocol/zones/execution.mdx @@ -0,0 +1,62 @@ +--- +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 + +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..a0dce6d3 --- /dev/null +++ b/src/pages/protocol/zones/index.mdx @@ -0,0 +1,75 @@ +--- +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 + +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 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 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 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..5ef38064 --- /dev/null +++ b/src/pages/protocol/zones/proving.mdx @@ -0,0 +1,114 @@ +--- +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. +--- + +# Zone Proving + +:::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 + +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..c7771221 --- /dev/null +++ b/src/pages/protocol/zones/rpc.mdx @@ -0,0 +1,150 @@ +--- +title: Zone RPC +description: Authenticated JSON-RPC interface for Tempo Zones with per-account scoping, timing side channel mitigations, and event filtering. +--- + +# Zone RPC + +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: + +### Allowed + +Public zone information available to any authenticated caller: + +| Method | Notes | +|--------|-------| +| `eth_chainId` | Zone chain ID | +| `eth_blockNumber` | Latest block number | +| `eth_gasPrice` | Current gas price | +| `eth_maxPriorityFeePerGas` | Current priority fee | +| `eth_feeHistory` | Fee history | +| `eth_getBlockByNumber` | Block headers **without transaction details** | +| `eth_getBlockByHash` | Block headers **without transaction details** | +| `eth_subscribe("newHeads")` | Block headers with `logsBloom` zeroed | +| `eth_syncing` | Sync status | +| `eth_coinbase` | Sequencer address | +| `net_version` | Network ID | +| `net_listening` | Node status | +| `web3_clientVersion` | Client version | +| `web3_sha3` | Pure Keccak-256 hash | + +### Scoped + +Available to any authenticated caller, but filtered to the authenticated account: + +| Method | Scoping Rule | +|--------|-------------| +| `eth_getBalance` | Returns balance for the authenticated account only. Queries for other accounts return `0x0`. | +| `eth_getTransactionCount` | Returns nonce for the authenticated account only. Other accounts return `0x0`. | +| `eth_call` | Executes with `from` set to the authenticated account. [Execution-level privacy](/protocol/zones/accounts) enforces `balanceOf` access control at the contract level. | +| `eth_estimateGas` | Only allowed when `from` equals the authenticated account. | +| `eth_getTransactionByHash` | Returns the transaction only if the authenticated account is the sender. Returns `null` otherwise. | +| `eth_getTransactionReceipt` | Returns the receipt only if the authenticated account is the sender. Logs are filtered (see [Event Filtering](#event-filtering)). | +| `eth_sendRawTransaction` | Validates that the transaction sender matches the authenticated account. | +| `eth_getLogs` | Filtered to TIP-20 events where the authenticated account is a relevant party (see [Event Filtering](#event-filtering)). | +| `eth_getFilterLogs` | Same filtering as `eth_getLogs`. | +| `eth_getFilterChanges` | Same filtering. Only returns new events since last poll. | +| `eth_newFilter` | Creates a filter implicitly scoped to the authenticated account. | +| `eth_subscribe("logs")` | Subscription scoped to the authenticated account. | +| `eth_newBlockFilter` | Allowed. Returns new block hashes. | +| `eth_uninstallFilter` | Allowed. 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/vocs.config.ts b/vocs.config.ts index 73ce4857..217157ba 100644 --- a/vocs.config.ts +++ b/vocs.config.ts @@ -588,6 +588,46 @@ 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 +1191,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', From 894e8167dbfd9d4f8325afefe515a631f0075b3d Mon Sep 17 00:00:00 2001 From: zhygis <5236121+Zygimantass@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:40:39 +0200 Subject: [PATCH 02/25] feat: add zone demos (#59) * feat: add zone demos * feat: add private zone verification flows Amp-Thread-ID: https://ampcode.com/threads/T-019d4ec2-4960-75dd-81a1-67ebed584a50 Co-authored-by: Amp * fix: polish * feat: encryption and other fixes * fix: polish * fix: make steps sticky until restart * fix: build * fix: passkeys * fix: remove stage info * fix: polish * fix: json --------- Co-authored-by: Amp --- e2e/deposit-to-a-zone.test.ts | 49 + e2e/send-tokens-within-a-zone.test.ts | 65 ++ e2e/swap-across-zones.test.ts | 79 ++ e2e/withdraw-from-a-zone.test.ts | 58 ++ src/components/guides/Demo.tsx | 156 ++- src/components/guides/EmbedPasskeys.tsx | 72 +- src/components/guides/zones/DepositToZone.tsx | 547 +++++++++++ .../guides/zones/SendTokensWithinZone.tsx | 537 +++++++++++ .../guides/zones/SwapAcrossZones.tsx | 887 ++++++++++++++++++ .../guides/zones/WithdrawFromZone.tsx | 743 +++++++++++++++ .../guides/zones/useStickyStepCompletion.ts | 11 + src/lib/private-zones-encryption.ts | 141 +++ src/lib/private-zones-withdrawal.ts | 44 + src/lib/private-zones.ts | 49 + src/lib/viem-zone.ts | 299 ++++++ .../guide/private-zones/deposit-to-a-zone.mdx | 72 ++ src/pages/guide/private-zones/index.mdx | 54 ++ .../send-tokens-within-a-zone.mdx | 50 + .../guide/private-zones/swap-across-zones.mdx | 115 +++ .../private-zones/withdraw-from-a-zone.mdx | 85 ++ .../guide/use-accounts/embed-passkeys.mdx | 4 +- src/wagmi.config.ts | 10 +- vocs.config.ts | 26 + 23 files changed, 4117 insertions(+), 36 deletions(-) create mode 100644 e2e/deposit-to-a-zone.test.ts create mode 100644 e2e/send-tokens-within-a-zone.test.ts create mode 100644 e2e/swap-across-zones.test.ts create mode 100644 e2e/withdraw-from-a-zone.test.ts create mode 100644 src/components/guides/zones/DepositToZone.tsx create mode 100644 src/components/guides/zones/SendTokensWithinZone.tsx create mode 100644 src/components/guides/zones/SwapAcrossZones.tsx create mode 100644 src/components/guides/zones/WithdrawFromZone.tsx create mode 100644 src/components/guides/zones/useStickyStepCompletion.ts create mode 100644 src/lib/private-zones-encryption.ts create mode 100644 src/lib/private-zones-withdrawal.ts create mode 100644 src/lib/private-zones.ts create mode 100644 src/lib/viem-zone.ts create mode 100644 src/pages/guide/private-zones/deposit-to-a-zone.mdx create mode 100644 src/pages/guide/private-zones/index.mdx create mode 100644 src/pages/guide/private-zones/send-tokens-within-a-zone.mdx create mode 100644 src/pages/guide/private-zones/swap-across-zones.mdx create mode 100644 src/pages/guide/private-zones/withdraw-from-a-zone.mdx diff --git a/e2e/deposit-to-a-zone.test.ts b/e2e/deposit-to-a-zone.test.ts new file mode 100644 index 00000000..dd5538eb --- /dev/null +++ b/e2e/deposit-to-a-zone.test.ts @@ -0,0 +1,49 @@ +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 getFundsButton = page.getByRole('button', { name: 'Get testnet PathUSD' }).first() + const depositButton = page + .getByRole('button', { name: /^(Approve \+ deposit|Deposit) 100 PathUSD$/ }) + .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('Poll Zone A until the batched deposit is reflected.'), + }) + .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..12e6f579 --- /dev/null +++ b/e2e/send-tokens-within-a-zone.test.ts @@ -0,0 +1,65 @@ +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 getFundsButton = page.getByRole('button', { name: 'Get testnet PathUSD' }).first() + const topUpButton = page + .getByRole('button', { + name: /^(Approve \+ top up|Top up) Zone A$/, + }) + .first() + const sendButton = page.getByRole('button', { name: 'Send 25 PathUSD' }).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 the Zone A balance to reflect the transfer.'), + }) + .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..29d062a9 --- /dev/null +++ b/e2e/swap-across-zones.test.ts @@ -0,0 +1,79 @@ +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 getFundsButton = page.getByRole('button', { name: 'Get testnet PathUSD' }).first() + const topUpButton = page + .getByRole('button', { + name: /^(Approve \+ top up|Top up) Zone A$/, + }) + .first() + const swapButton = page + .getByRole('button', { name: 'Swap 25 PathUSD into Zone B BetaUSD' }) + .first() + const prepareTargetAuthButton = page.getByRole('button', { name: 'Prepare Zone B auth' }).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(prepareTargetAuthButton).toBeVisible({ timeout: 120000 }) + await prepareTargetAuthButton.click() + + await expect( + page + .locator('div[data-completed="true"]', { + has: page.getByText( + 'Prepare authenticated access for Zone B and read 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..358314bf --- /dev/null +++ b/e2e/withdraw-from-a-zone.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from '@playwright/test' + +test.describe.configure({ retries: 0, timeout: 120000 }) + +test('prepare zone balance and withdraw from Zone A', async ({ page }) => { + 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 getFundsButton = page.getByRole('button', { name: 'Get testnet PathUSD' }).first() + const topUpButton = page + .getByRole('button', { + name: /^(Approve \+ top up|Top up) Zone A$/, + }) + .first() + const withdrawButton = page + .getByRole('button', { name: /^(Approve \+ withdraw|Withdraw) 100 PathUSD$/ }) + .first() + + await expect(getFundsButton).toBeVisible({ timeout: 20000 }) + await getFundsButton.click() + await expect(topUpButton).toBeVisible({ timeout: 45000 }) + + await topUpButton.click() + await expect(withdrawButton).toBeVisible({ timeout: 45000 }) + + await withdrawButton.click() + + await expect( + page + .locator('div[data-completed="true"]', { + has: page.getByText('Wait for PathUSD to arrive on Moderato.'), + }) + .first(), + ).toBeVisible({ + timeout: 45000, + }) + + await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }) +}) diff --git a/src/components/guides/Demo.tsx b/src/components/guides/Demo.tsx index 544d8fe6..42246f31 100644 --- a/src/components/guides/Demo.tsx +++ b/src/components/guides/Demo.tsx @@ -1,11 +1,11 @@ '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 { tempoModerato } from 'viem/chains' -import { useAccount, useConnect, useConnections, useDisconnect } from 'wagmi' +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 +15,7 @@ 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 { getZoneClient } from '../../lib/viem-zone.ts' import { useTempoWalletConnector, useWebAuthnConnector } from '../../wagmi.config' import { Container as ParentContainer } from '../Container' import { alphaUsd } from './tokens' @@ -24,6 +25,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 @@ -86,6 +104,7 @@ export function Container( footerVariant: 'balances' tokens: Address[] balanceSource?: 'webAuthn' | 'wallet' | undefined + zoneBalances?: ZoneBalance[] | undefined } | { footerVariant: 'source' @@ -128,7 +147,11 @@ export function Container( const footerElement = React.useMemo(() => { if (props.footerVariant === 'balances') return ( - + ) if (props.footerVariant === 'source') return return null @@ -170,6 +193,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 +253,101 @@ export namespace Container { ) } - export function BalancesFooter(props: { address?: string | undefined; tokens: Address[] }) { - const { address, tokens } = props + function ZoneBalancesFooterItem(props: ZoneBalance & { address: Address; showLabel: boolean }) { + const { address, feeToken, label, showLabel, token, zone } = props + const { data: connectorClient } = useConnectorClient() + const zoneClient = React.useMemo( + () => + connectorClient + ? (getZoneClient(connectorClient as never, { + ...(feeToken ? { feeToken } : {}), + zone, + }) as unknown as ZoneClientLike) + : undefined, + [connectorClient, feeToken, zone], + ) + 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 showLabel ? ( + + {label} + {formatUnits(balance, metadata.decimals)} + {metadata.symbol} + + ) : ( + + {formatUnits(balance, metadata.decimals)} + {metadata.symbol} + + ) + } + + export function BalancesFooter(props: { + address?: string | undefined + tokens: Address[] + zoneBalances?: ZoneBalance[] | undefined + }) { + const { address, tokens, zoneBalances } = props + const showZoneBalanceLabels = Boolean(zoneBalances && zoneBalances.length > 1) + return ( -
- Balances -
-
- {address ? ( - tokens.map((token) => ( - - )) - ) : ( - No account detected - )} +
+
+ Balances +
+
+ {address ? ( + tokens.map((token) => ( + + )) + ) : ( + No account detected + )} +
+ {address && zoneBalances && zoneBalances.length > 0 && ( +
+ Zone balances +
+
+ {zoneBalances.map((zoneBalance) => ( + + ))} +
+
+ )}
) } @@ -359,13 +468,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 ( + + ) + return ( -
+
{connect.isPending ? ( )} + {connect.error && ( +
+ {'shortMessage' in connect.error ? connect.error.shortMessage : connect.error.message} +
+ )}
) } 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 ( -
- +
+
) - if (connect.isPending) + + if (busy) return (
) - if (!connector) return null + + if (account.address) + return ( +
+ +
+ ) + 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 ( +
+ +
+ ) + + if (busy) + return ( +
+ +
+ ) + return (
+ ) + + let stepThreeAction: React.ReactNode + if (!hasRootBalance) { + stepThreeAction = ( + + ) + } else if (depositSetupQuery.isError) { + stepThreeAction = ( + + ) + } else if (depositSetupQuery.isPending || depositSetupQuery.data === undefined) { + stepThreeAction = ( + + ) + } else { + stepThreeAction = ( + + ) + } + + 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 ( + + ) + })} +
+
+
+ ) +} + +function StepBody(props: React.PropsWithChildren) { + return ( +
+
+
{props.children}
+
+
+ ) +} + +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.` +} diff --git a/src/components/guides/zones/SendTokensWithinZone.tsx b/src/components/guides/zones/SendTokensWithinZone.tsx new file mode 100644 index 00000000..4717d983 --- /dev/null +++ b/src/components/guides/zones/SendTokensWithinZone.tsx @@ -0,0 +1,537 @@ +'use client' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import * as React from 'react' +import { type Hex, parseUnits } from 'viem' +import { sendTransactionSync } from 'viem/actions' +import { Actions } from 'viem/tempo' +import { useConnection, useConnectorClient } from 'wagmi' +import { Hooks } from 'wagmi/tempo' +import { getZoneClient, zoneRpcSyncTimeout } from '../../../lib/viem-zone.ts' +import { Button, ExplorerLink, FAKE_RECIPIENT, 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 TRANSFER_AMOUNT = parseUnits('25', 6) +const ZONE_GAS_BUFFER = parseUnits('1', 6) + +type ZoneClientLike = { + token: { + getBalance: (parameters: { account: Hex; token: Hex }) => Promise + } + zone: { + prepareAuthorizationToken: () => Promise<{ + account: Hex + expiresAt: bigint + }> + } +} + +type RootChainWithZones = { + zones?: Record +} + +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 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( + () => + connectorClient + ? (getZoneClient(connectorClient as never, { zone: ZONE_ID }) as unknown as ZoneClientLike) + : undefined, + [connectorClient], + ) + + const authQuery = useQuery({ + enabled: false, + queryKey: ['guide-private-zones-send-auth', address, ZONE_ID], + queryFn: async () => { + if (!zoneClient) throw new Error('zone client not ready') + + const auth = await zoneClient.zone.prepareAuthorizationToken() + + return { auth } + }, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + retry: false, + staleTime: 30_000, + }) + + React.useEffect(() => { + if (!authQuery.isSuccess) return + + void queryClient.invalidateQueries({ + queryKey: ['demo-zone-balance', address, ZONE_ID], + }) + }, [address, authQuery.isSuccess, queryClient]) + + const zoneBalanceQuery = useQuery({ + enabled: Boolean(zoneClient && authQuery.isSuccess), + 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 portalAllowanceQuery = useQuery({ + enabled: Boolean( + connectorClient && + zonePortalAddress && + authQuery.isSuccess && + !zoneBalanceStepComplete && + zoneTopUpShortfall > 0n, + ), + queryKey: ['guide-private-zones-send-portal-allowance', address, ZONE_ID, zonePortalAddress], + queryFn: async () => { + if (!connectorClient) throw new Error('connector client not ready') + if (!zonePortalAddress) throw new Error('zone portal address not configured') + + return Actions.token.getAllowance(connectorClient as never, { + account: address, + spender: zonePortalAddress, + token: pathUsd, + }) + }, + 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 topUpMutation = useMutation({ + mutationFn: async () => { + if (!connectorClient) throw new Error('connector client not ready') + if (!zonePortalAddress) throw new Error('zone portal address not configured') + if (zoneTopUpShortfall <= 0n) throw new Error('zone top-up is not required') + + const includesApproval = !hasPortalAllowance + const receipt = await sendTransactionSync( + connectorClient as never, + { + account: connectorClient.account, + calls: [ + ...(includesApproval + ? [ + Actions.token.approve.call({ + amount: zoneTopUpShortfall, + spender: zonePortalAddress, + token: pathUsd, + }), + ] + : []), + Actions.zone.deposit.call({ + account: connectorClient.account, + amount: zoneTopUpShortfall, + chain: connectorClient.chain as never, + token: pathUsd, + zone: ZONE_ID, + } as never), + ], + throwOnReceiptRevert: true, + timeout: 60_000, + } as never, + ) + + return { + includesApproval, + receipt, + } + }, + onSuccess: async () => { + await refetchRootBalance() + await portalAllowanceQuery.refetch() + 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') + + 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: connectorClient.account, + amount: TRANSFER_AMOUNT, + chain: connectorClient.chain as never, + feeToken: pathUsd, + timeout: zoneRpcSyncTimeout, + 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 && authQuery.isSuccess && 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 hasPortalAllowance = Boolean( + portalAllowanceQuery.data !== undefined && portalAllowanceQuery.data >= zoneTopUpShortfall, + ) + const topUpReceipt = topUpMutation.data?.receipt + const topUpIncludesApproval = topUpMutation.data?.includesApproval ?? !hasPortalAllowance + 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 = authQuery.fetchStatus === 'fetching' + const stepTwoAction = authQuery.isSuccess ? undefined : ( + + ) + + 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 = ( + + ) + } else if (!hasEnoughZoneBalance && !hasRootBalance) { + stepThreeAction = ( + + ) + } else if (!hasEnoughZoneBalance && portalAllowanceQuery.isError) { + stepThreeAction = ( + + ) + } else if ( + !hasEnoughZoneBalance && + (portalAllowanceQuery.isPending || portalAllowanceQuery.data === undefined) + ) { + stepThreeAction = ( + + ) + } else if (!hasEnoughZoneBalance) { + stepThreeAction = ( + + ) + } + + let stepFourAction: React.ReactNode + if (!zoneBalanceStepComplete) { + stepFourAction = undefined + } else { + stepFourAction = ( + + ) + } + + return ( + <> + + + + {topUpReceipt && ( + + + + + )} + + + + {transferReceipt && ( + + + + + )} + + + + +

+ The transfer is already accepted in {ZONE_LABEL}. This final step polls your private + balance every 1.5 seconds until the sent amount and fee are reflected. +

+
+
+ + ) +} + +function DisconnectedZoneFlow() { + return ( + <> + + + + + + ) +} + +function StepBody(props: React.PropsWithChildren) { + return ( +
+
+
{props.children}
+
+
+ ) +} + +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..05041542 --- /dev/null +++ b/src/components/guides/zones/SwapAcrossZones.tsx @@ -0,0 +1,887 @@ +'use client' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import * as React from 'react' +import { encodeAbiParameters, type Hex, parseAbiItem, parseUnits } from 'viem' +import { sendTransactionSync } from 'viem/actions' +import { Actions } from 'viem/tempo' +import { useConnection, useConnectorClient, usePublicClient } from 'wagmi' +import { Hooks } from 'wagmi/tempo' +import { + moderatoZoneFactory, + routerCallbackGasLimit, + stablecoinDex, + swapAndDepositRouter, + ZONE_A, + ZONE_B, + zeroBytes32, + zoneOutbox, +} from '../../../lib/private-zones.ts' +import { getZoneClient, zoneRpcSyncTimeout } from '../../../lib/viem-zone.ts' +import { Button, ExplorerLink, Logout, 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: { + getWithdrawalFee: (parameters?: { gasLimit?: bigint | undefined }) => Promise + prepareAuthorizationToken: () => Promise<{ + account: Hex + expiresAt: bigint + }> + } +} + +type RootChainWithZones = { + zones?: Record +} + +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 sourceZonePortalAddress = (connectorClient?.chain as RootChainWithZones | undefined) + ?.zones?.[ZONE_A.id]?.portalAddress + const { + data: rootBalance, + isPending: rootBalanceIsPending, + refetch: refetchRootBalance, + } = Hooks.token.useGetBalance({ + account: address, + token: pathUsd, + }) + + const sourceZoneClient = React.useMemo( + () => + connectorClient + ? (getZoneClient(connectorClient as never, { + feeToken: pathUsd, + zone: ZONE_A.id, + }) as unknown as ZoneClientLike) + : undefined, + [connectorClient], + ) + const targetZoneClient = React.useMemo( + () => + connectorClient + ? (getZoneClient(connectorClient as never, { + zone: ZONE_B.id, + }) as unknown as ZoneClientLike) + : undefined, + [connectorClient], + ) + + 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 sourceAuthQuery = useQuery({ + enabled: false, + queryKey: ['guide-private-zones-swap-source-auth', address, ZONE_A.id], + queryFn: async () => { + if (!sourceZoneClient) throw new Error('Zone A client not ready') + + const auth = await sourceZoneClient.zone.prepareAuthorizationToken() + + return { auth } + }, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + retry: false, + staleTime: 30_000, + }) + + const sourceZoneBalanceQuery = useQuery({ + enabled: Boolean(sourceZoneClient && sourceAuthQuery.isSuccess), + 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 && sourceAuthQuery.isSuccess), + 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 portalAllowanceQuery = useQuery({ + enabled: Boolean( + connectorClient && + sourceZonePortalAddress && + sourceAuthQuery.isSuccess && + !sourceZoneBalanceStepComplete && + sourceZoneTopUpShortfall > 0n, + ), + queryKey: [ + 'guide-private-zones-swap-portal-allowance', + address, + ZONE_A.id, + sourceZonePortalAddress, + ], + queryFn: async () => { + if (!connectorClient) throw new Error('connector client not ready') + if (!sourceZonePortalAddress) throw new Error('Zone A portal address not configured') + + return Actions.token.getAllowance(connectorClient as never, { + account: address, + spender: sourceZonePortalAddress, + token: pathUsd, + }) + }, + 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 topUpMutation = useMutation({ + mutationFn: async () => { + if (!connectorClient) throw new Error('connector client not ready') + if (!sourceZonePortalAddress) throw new Error('Zone A portal address not configured') + if (sourceZoneTopUpShortfall <= 0n) throw new Error('Zone A top-up is not required') + + const includesApproval = !hasPortalAllowance + const receipt = await sendTransactionSync( + connectorClient as never, + { + account: connectorClient.account, + calls: [ + ...(includesApproval + ? [ + Actions.token.approve.call({ + amount: sourceZoneTopUpShortfall, + spender: sourceZonePortalAddress, + token: pathUsd, + }), + ] + : []), + Actions.zone.deposit.call({ + account: connectorClient.account, + amount: sourceZoneTopUpShortfall, + chain: connectorClient.chain as never, + token: pathUsd, + zone: ZONE_A.id, + } as never), + ], + throwOnReceiptRevert: true, + timeout: 60_000, + } as never, + ) + + return { + includesApproval, + receipt, + } + }, + onSuccess: async () => { + await refetchRootBalance() + await portalAllowanceQuery.refetch() + 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 (!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 currentAllowance = await sourceZoneClient.token.getAllowance({ + account: address, + spender: zoneOutbox, + token: pathUsd, + }) + const requiredSourceAllowance = SWAP_AMOUNT + swapPrereqsQuery.data.routedWithdrawalFee + const includesApproval = currentAllowance < requiredSourceAllowance + const callbackData = encodeRouterCallback({ + minimumOutput: swapPrereqsQuery.data.minimumOutput, + recipient: address, + }) + + const receipt = await sendTransactionSync( + sourceZoneClient as never, + { + account: connectorClient.account, + feeToken: pathUsd, + calls: [ + ...(includesApproval + ? [ + Actions.token.approve.call({ + amount: requiredSourceAllowance, + spender: zoneOutbox, + token: pathUsd, + }), + ] + : []), + Actions.zone.requestWithdrawal.call({ + account: connectorClient.account, + amount: SWAP_AMOUNT, + data: callbackData, + gasLimit: routerCallbackGasLimit, + to: swapAndDepositRouter, + token: pathUsd, + }), + ], + throwOnReceiptRevert: true, + timeout: zoneRpcSyncTimeout, + } as never, + ) + + const anchorBlock = await publicClient.getBlockNumber() + + 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 > 5n ? swapMutation.data.anchorBlock - 5n : 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 targetAuthMutation = useMutation({ + mutationFn: async () => { + if (!targetZoneClient) throw new Error('Zone B client not ready') + + return targetZoneClient.zone.prepareAuthorizationToken() + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: targetFooterQueryKey }) + await targetZoneBalanceQuery.refetch() + }, + }) + + const targetZoneBalanceQuery = useQuery({ + enabled: Boolean(targetZoneClient && targetAuthMutation.isSuccess && 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 hasPortalAllowance = Boolean( + portalAllowanceQuery.data !== undefined && + portalAllowanceQuery.data >= sourceZoneTopUpShortfall, + ) + const topUpReceipt = topUpMutation.data?.receipt + const topUpIncludesApproval = topUpMutation.data?.includesApproval ?? !hasPortalAllowance + const routedSwapReceipt = swapMutation.data?.receipt + const settlementTxHash = settlementQuery.data?.txHash + const targetBalanceReady = + settlementQuery.data && targetAuthMutation.isSuccess && targetZoneBalanceQuery.isSuccess + const sourceAuthIsPreparing = sourceAuthQuery.fetchStatus === 'fetching' + const stepTwoAction = sourceAuthQuery.isSuccess ? undefined : ( + + ) + + React.useEffect(() => { + if (!sourceAuthQuery.isSuccess) return + + void queryClient.invalidateQueries({ queryKey: sourceFooterQueryKey }) + }, [queryClient, sourceAuthQuery.isSuccess, sourceFooterQueryKey]) + + React.useEffect(() => { + if (!targetAuthMutation.isSuccess) return + + void queryClient.invalidateQueries({ queryKey: targetFooterQueryKey }) + }, [queryClient, targetAuthMutation.isSuccess, targetFooterQueryKey]) + + 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 = ( + + ) + } else if (!hasEnoughSourceZoneBalance && !hasRootBalance) { + stepThreeAction = ( + + ) + } else if (!hasEnoughSourceZoneBalance && portalAllowanceQuery.isError) { + stepThreeAction = ( + + ) + } else if ( + !hasEnoughSourceZoneBalance && + (portalAllowanceQuery.isPending || portalAllowanceQuery.data === undefined) + ) { + stepThreeAction = ( + + ) + } else if (!hasEnoughSourceZoneBalance) { + stepThreeAction = ( + + ) + } + + let stepFourAction: React.ReactNode + if (!sourceZoneBalanceStepComplete || swapPrereqsQuery.isPending) { + stepFourAction = undefined + } else if (swapPrereqsQuery.isError) { + stepFourAction = ( + + ) + } else { + stepFourAction = ( + + ) + } + + let stepSixAction: React.ReactNode + if (!settlementQuery.data) { + stepSixAction = undefined + } else if (targetZoneBalanceQuery.isError) { + stepSixAction = ( + + ) + } else if (!targetAuthMutation.isSuccess) { + stepSixAction = ( + + ) + } else if (targetZoneBalanceQuery.isPending) { + stepSixAction = ( + + ) + } + + return ( + <> + + + + {topUpReceipt && ( + + + + + )} + + + + {routedSwapReceipt && ( + + + + + )} + + + + +

+ The funds have already left {ZONE_A.label}. This step polls the public-chain deposit + into {ZONE_B.label} every 2 seconds while the withdrawal, swap, and deposit finish. +

+

+ The final betaUSD amount will be the swap output minus {ZONE_B.label}'s portal deposit + fee. +

+ {settlementTxHash && } +
+
+ + + +

+ The routed deposit can settle before this page is allowed to read {ZONE_B.label}. Once + you authorize private reads for this session, the demo fetches your betaUSD balance. +

+
+
+ + ) +} + +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 ( +
+
+
{props.children}
+
+
+ ) +} + +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..88660216 --- /dev/null +++ b/src/components/guides/zones/WithdrawFromZone.tsx @@ -0,0 +1,743 @@ +'use client' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import * as React from 'react' +import { type Hex, parseAbiItem, parseUnits } from 'viem' +import { sendTransactionSync } from 'viem/actions' +import { Actions } from 'viem/tempo' +import { useConnection, useConnectorClient, usePublicClient } from 'wagmi' +import { Hooks } from 'wagmi/tempo' +import { encodeAuthenticatedWithdrawalCall } from '../../../lib/private-zones-withdrawal.ts' +import { getZoneClient, zoneRpcSyncTimeout } from '../../../lib/viem-zone.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 ZONE_OUTBOX = '0x1c00000000000000000000000000000000000002' 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: { + getAllowance: (parameters: { account: Hex; spender: Hex; token: Hex }) => Promise + getBalance: (parameters: { account: Hex; token: Hex }) => Promise + } + zone: { + getWithdrawalFee: () => Promise + prepareAuthorizationToken: () => Promise<{ + account: Hex + expiresAt: bigint + }> + } +} + +type RootChainWithZones = { + zones?: Record +} + +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 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( + () => + connectorClient + ? (getZoneClient(connectorClient as never, { + feeToken: pathUsd, + zone: ZONE_ID, + }) as unknown as ZoneClientLike) + : undefined, + [connectorClient], + ) + + const authQuery = useQuery({ + enabled: false, + queryKey: ['guide-private-zones-withdraw-auth', address, ZONE_ID], + queryFn: async () => { + if (!zoneClient) throw new Error('zone client not ready') + + const auth = await zoneClient.zone.prepareAuthorizationToken() + + return { auth } + }, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + retry: false, + staleTime: 30_000, + }) + + React.useEffect(() => { + if (!authQuery.isSuccess) return + + void queryClient.invalidateQueries({ + queryKey: ['demo-zone-balance', address, ZONE_ID], + }) + }, [address, authQuery.isSuccess, queryClient]) + + const withdrawalFeeQuery = useQuery({ + enabled: Boolean(zoneClient && authQuery.isSuccess), + 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 && authQuery.isSuccess), + 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 zoneApprovalTarget = + withdrawalFeeQuery.data !== undefined ? WITHDRAWAL_AMOUNT + withdrawalFeeQuery.data : undefined + + const zoneOutboxAllowanceQuery = useQuery({ + enabled: Boolean(zoneClient && authQuery.isSuccess && zoneApprovalTarget !== undefined), + queryKey: ['guide-private-zones-withdraw-zone-outbox-allowance', address, ZONE_ID], + queryFn: async () => { + if (!zoneClient) throw new Error('zone client not ready') + + return zoneClient.token.getAllowance({ + account: address, + spender: ZONE_OUTBOX, + token: pathUsd, + }) + }, + staleTime: 30_000, + }) + + const hasZoneOutboxAllowance = Boolean( + zoneApprovalTarget !== undefined && + zoneOutboxAllowanceQuery.data !== undefined && + zoneOutboxAllowanceQuery.data >= zoneApprovalTarget, + ) + const zoneBalanceRequirement = + hasZoneOutboxAllowance && zoneApprovalTarget !== undefined + ? zoneApprovalTarget + : zoneTopUpTarget + const zoneTopUpShortfall = + zoneBalanceRequirement !== undefined && + zoneBalanceQuery.data !== undefined && + zoneBalanceQuery.data < zoneBalanceRequirement + ? zoneBalanceRequirement - zoneBalanceQuery.data + : 0n + const hasEnoughZoneBalance = Boolean( + zoneBalanceRequirement !== undefined && + zoneBalanceQuery.data !== undefined && + zoneBalanceQuery.data >= zoneBalanceRequirement, + ) + const zoneBalanceStepComplete = useStickyStepCompletion(hasEnoughZoneBalance) + + const portalAllowanceQuery = useQuery({ + enabled: Boolean( + connectorClient && + zonePortalAddress && + authQuery.isSuccess && + !zoneBalanceStepComplete && + zoneTopUpShortfall > 0n, + ), + queryKey: [ + 'guide-private-zones-withdraw-portal-allowance', + address, + ZONE_ID, + zonePortalAddress, + ], + queryFn: async () => { + if (!connectorClient) throw new Error('connector client not ready') + if (!zonePortalAddress) throw new Error('zone portal address not configured') + + return Actions.token.getAllowance(connectorClient as never, { + account: address, + spender: zonePortalAddress, + token: pathUsd, + }) + }, + 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 topUpMutation = useMutation({ + mutationFn: async () => { + if (!connectorClient) throw new Error('connector client not ready') + if (!zonePortalAddress) throw new Error('zone portal address not configured') + if (zoneTopUpShortfall <= 0n) throw new Error('zone top-up is not required') + + const includesApproval = !hasPortalAllowance + const receipt = await sendTransactionSync( + connectorClient as never, + { + account: connectorClient.account, + calls: [ + ...(includesApproval + ? [ + Actions.token.approve.call({ + amount: zoneTopUpShortfall, + spender: zonePortalAddress, + token: pathUsd, + }), + ] + : []), + Actions.zone.deposit.call({ + account: connectorClient.account, + amount: zoneTopUpShortfall, + chain: connectorClient.chain as never, + token: pathUsd, + zone: ZONE_ID, + } as never), + ], + throwOnReceiptRevert: true, + timeout: 60_000, + } as never, + ) + + return { + includesApproval, + receipt, + } + }, + onSuccess: async () => { + await refetchRootBalance() + await portalAllowanceQuery.refetch() + 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 (zoneApprovalTarget === 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 includesApproval = !hasZoneOutboxAllowance + const withdrawalCall = + mode === 'authenticated' + ? encodeAuthenticatedWithdrawalCall({ + amount: WITHDRAWAL_AMOUNT, + fallbackRecipient: address, + outbox: ZONE_OUTBOX, + to: address, + token: pathUsd, + }) + : Actions.zone.requestWithdrawal.call({ + account: connectorClient.account, + amount: WITHDRAWAL_AMOUNT, + token: pathUsd, + to: address, + }) + const receipt = await sendTransactionSync( + zoneClient as never, + { + account: connectorClient.account, + feeToken: pathUsd, + calls: [ + ...(includesApproval + ? [ + Actions.token.approve.call({ + amount: zoneApprovalTarget, + spender: ZONE_OUTBOX, + token: pathUsd, + }), + ] + : []), + withdrawalCall, + ], + throwOnReceiptRevert: true, + timeout: zoneRpcSyncTimeout, + } as never, + ) + const anchorBlock = await publicClient.getBlockNumber() + + return { + anchorBlock, + includesApproval, + receipt, + startingRootBalance: currentRootBalance, + startingZoneBalance: currentZoneBalance, + } + }, + onSuccess: async () => { + await refetchRootBalance() + await zoneBalanceQuery.refetch() + await zoneOutboxAllowanceQuery.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 && + authQuery.isSuccess && + 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 > 5n ? withdrawMutation.data.anchorBlock - 5n : 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 hasPortalAllowance = Boolean( + portalAllowanceQuery.data !== undefined && portalAllowanceQuery.data >= zoneTopUpShortfall, + ) + const settlementTxHash = withdrawalConfirmationQuery.data?.txHash + const withdrawalConfirmed = Boolean(settlementTxHash) + const topUpReceipt = topUpMutation.data?.receipt + const topUpIncludesApproval = topUpMutation.data?.includesApproval ?? !hasPortalAllowance + const authIsPreparing = authQuery.fetchStatus === 'fetching' + const stepTwoAction = authQuery.isSuccess ? undefined : ( + + ) + + 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 = ( + + ) + } else if (!hasEnoughZoneBalance && !hasRootBalance) { + stepThreeAction = ( + + ) + } else if (!hasEnoughZoneBalance && portalAllowanceQuery.isError) { + stepThreeAction = ( + + ) + } else if ( + !hasEnoughZoneBalance && + (portalAllowanceQuery.isPending || portalAllowanceQuery.data === undefined) + ) { + stepThreeAction = ( + + ) + } else if (!hasEnoughZoneBalance) { + stepThreeAction = ( + + ) + } + + let stepFourAction: React.ReactNode + if (!zoneBalanceStepComplete) { + stepFourAction = undefined + } else if (zoneOutboxAllowanceQuery.isPending || zoneOutboxAllowanceQuery.data === undefined) { + stepFourAction = ( + + ) + } else { + stepFourAction = ( + + ) + } + + return ( + <> + + + + {topUpReceipt && ( + + + + + )} + + + + + + +

+ The withdrawal request is already accepted in {ZONE_LABEL}. This final step polls your + public-chain pathUSD balance every 1.5 seconds until the batch settles. +

+

+ If Tempo-side settlement fails, the amount returns to the fallback recipient in the zone + and the fee is still consumed. +

+ {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 selectively reveal the sender using the revealTo field. +

+
+
+ {[ + ['standard', 'Standard'], + ['authenticated', 'Authenticated'], + ].map(([value, label]) => { + const selected = mode === value + + return ( + + ) + })} +
+
+
+ ) +} + +function StepBody(props: React.PropsWithChildren) { + return ( +
+
+
{props.children}
+
+
+ ) +} + +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-encryption.ts b/src/lib/private-zones-encryption.ts new file mode 100644 index 00000000..4293be17 --- /dev/null +++ b/src/lib/private-zones-encryption.ts @@ -0,0 +1,141 @@ +import { Bytes, Hash, Hex as OxHex, PublicKey, Secp256k1 } from 'ox' +import { type Address, encodeFunctionData, type Hex, numberToHex, parseAbi } from 'viem' + +export const zeroBytes32 = + '0x0000000000000000000000000000000000000000000000000000000000000000' as const + +export const zonePortalDepositAbi = parseAbi([ + 'function calculateDepositFee() view returns (uint128)', + 'function sequencerEncryptionKey() view returns (bytes32 x, uint8 yParity)', + 'function encryptionKeyCount() view returns (uint256)', + 'function depositEncrypted(address token, uint128 amount, uint256 keyIndex, (bytes32 ephemeralPubkeyX, uint8 ephemeralPubkeyYParity, bytes ciphertext, bytes12 nonce, bytes16 tag) encrypted) returns (bytes32)', +]) + +export type SequencerEncryptionKey = + | { + x: Hex + yParity: number + } + | readonly [Hex, number] + +export type EncryptedDepositPayload = { + ciphertext: Hex + ephemeralPubkeyX: Hex + ephemeralPubkeyYParity: number + nonce: Hex + tag: Hex +} + +function normalizeSec1Parity(yParity: number) { + if (yParity === 0 || yParity === 1) return yParity + 2 + if (yParity === 2 || yParity === 3) return yParity + + throw new Error(`Unexpected yParity: ${yParity}`) +} + +function hkdf256(ikm: Uint8Array, salt: Uint8Array, info: Uint8Array) { + const prk = Hash.hmac256(salt, ikm, { as: 'Bytes' }) + return Hash.hmac256(prk, Bytes.concat(info, Uint8Array.from([1])), { as: 'Bytes' }) +} + +function normalizeSequencerEncryptionKey(key: SequencerEncryptionKey): { x: Hex; yParity: number } { + if ('x' in key) { + return { + x: key.x, + yParity: key.yParity, + } + } + + return { + x: key[0], + yParity: key[1], + } +} + +export async function encryptZoneDepositPayload(parameters: { + keyIndex: bigint + memo?: Hex | undefined + portalAddress: Address + recipient: Address + sequencerKey: SequencerEncryptionKey +}) { + const { privateKey: ephemeralPrivateKey, publicKey: ephemeralPublicKey } = + Secp256k1.createKeyPair() + const sequencerKey = normalizeSequencerEncryptionKey(parameters.sequencerKey) + + const sequencerPublicKey = PublicKey.from({ + prefix: normalizeSec1Parity(sequencerKey.yParity), + x: BigInt(sequencerKey.x), + }) + + const sharedSecret = Secp256k1.getSharedSecret({ + as: 'Bytes', + privateKey: ephemeralPrivateKey, + publicKey: sequencerPublicKey, + }) + + const compressedEphemeralKey = PublicKey.compress(ephemeralPublicKey) + const ephemeralPubkeyX = numberToHex(compressedEphemeralKey.x, { size: 32 }) + const sharedSecretX = sharedSecret.slice(1, 33) + + const hkdfInfo = Bytes.concat( + Bytes.from(parameters.portalAddress), + Bytes.from(numberToHex(parameters.keyIndex, { size: 32 })), + Bytes.from(ephemeralPubkeyX), + ) + const aesKeyBytes = hkdf256(sharedSecretX, new TextEncoder().encode('ecies-aes-key'), hkdfInfo) + const aesKey = await crypto.subtle.importKey( + 'raw', + Uint8Array.from(aesKeyBytes), + { name: 'AES-GCM' }, + false, + ['encrypt'], + ) + + const plaintext = Uint8Array.from( + Bytes.concat( + Bytes.from(parameters.recipient), + Bytes.from(parameters.memo ?? zeroBytes32), + new Uint8Array(12), + ), + ) + const nonce = crypto.getRandomValues(new Uint8Array(12)) + const sealed = new Uint8Array( + await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, aesKey, plaintext), + ) + + return { + ciphertext: OxHex.from(sealed.slice(0, -16)), + ephemeralPubkeyX, + ephemeralPubkeyYParity: compressedEphemeralKey.prefix, + nonce: OxHex.from(nonce), + tag: OxHex.from(sealed.slice(-16)), + } satisfies EncryptedDepositPayload +} + +export function encodeEncryptedDepositCall(parameters: { + amount: bigint + encrypted: EncryptedDepositPayload + keyIndex: bigint + portalAddress: Address + token: Address +}) { + return { + data: encodeFunctionData({ + abi: zonePortalDepositAbi, + functionName: 'depositEncrypted', + args: [parameters.token, parameters.amount, parameters.keyIndex, parameters.encrypted], + }), + to: parameters.portalAddress, + } +} + +export 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/lib/private-zones-withdrawal.ts b/src/lib/private-zones-withdrawal.ts new file mode 100644 index 00000000..a2f3dbea --- /dev/null +++ b/src/lib/private-zones-withdrawal.ts @@ -0,0 +1,44 @@ +import { PublicKey } from 'ox' +import { type Address, encodeFunctionData, type Hex, parseAbi } from 'viem' +import { zeroBytes32 } from './private-zones-encryption.ts' + +export const AUTHENTICATED_WITHDRAWAL_REVEAL_TO = + '0x031dc147467e8f106eb22850fef549dc74b8f6634aeac554ebdd4ab896b67cdf68' as const + +export const zoneOutboxAuthenticatedWithdrawalAbi = parseAbi([ + 'function requestWithdrawal(address token, address to, uint128 amount, bytes32 memo, uint64 gasLimit, address fallbackRecipient, bytes data, bytes revealTo)', +]) + +export function encodeAuthenticatedWithdrawalCall(parameters: { + amount: bigint + data?: Hex | undefined + fallbackRecipient: Address + gasLimit?: bigint | undefined + memo?: Hex | undefined + outbox: Address + revealTo?: Hex | undefined + to: Address + token: Address +}) { + const revealTo = PublicKey.toHex( + PublicKey.fromHex(parameters.revealTo ?? AUTHENTICATED_WITHDRAWAL_REVEAL_TO), + ) + + return { + data: encodeFunctionData({ + abi: zoneOutboxAuthenticatedWithdrawalAbi, + functionName: 'requestWithdrawal', + args: [ + parameters.token, + parameters.to, + parameters.amount, + parameters.memo ?? zeroBytes32, + parameters.gasLimit ?? 0n, + parameters.fallbackRecipient, + parameters.data ?? '0x', + revealTo, + ], + }), + to: parameters.outbox, + } +} diff --git a/src/lib/private-zones.ts b/src/lib/private-zones.ts new file mode 100644 index 00000000..80440797 --- /dev/null +++ b/src/lib/private-zones.ts @@ -0,0 +1,49 @@ +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 +export const routerCallbackGasLimit = 2_000_000n +export const zeroBytes32 = + '0x0000000000000000000000000000000000000000000000000000000000000000' as const + +export const ZONE_A = { + chainId: 4217000006, + id: 6, + label: 'Zone A', + portalAddress: '0x7069DeC4E64Fd07334A0933eDe836C17259c9B23', + rpcUrls: { + default: { + http: ['https://eng:bold-raman-silly-torvalds@rpc-zone-005-private.tempoxyz.dev'], + webSocket: [], + }, + }, +} as const + +export const ZONE_B = { + chainId: 4217000007, + id: 7, + label: 'Zone B', + portalAddress: '0x3F5296303400B56271b476F5A0B9cBF74350D6Ac', + rpcUrls: { + default: { + http: ['https://eng:bold-raman-silly-torvalds@rpc-zone-006-private.tempoxyz.dev'], + webSocket: [], + }, + }, +} 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 diff --git a/src/lib/viem-zone.ts b/src/lib/viem-zone.ts new file mode 100644 index 00000000..0f8099e2 --- /dev/null +++ b/src/lib/viem-zone.ts @@ -0,0 +1,299 @@ +import { SignatureEnvelope, TokenId, ZoneRpcAuthentication } from 'ox/tempo' +import { + type Account, + type Chain, + createClient, + defineChain, + erc20Abi, + type Hex, + http, + publicActions, + walletActions, +} from 'viem' +import { Actions } from 'viem/tempo' + +const authorizationTokenTtl = 1_800 +const authorizationTokenRefreshBuffer = 30 +// Private sequencers currently only accept the raw transaction param on eth_sendRawTransactionSync. +export const zoneRpcSyncTimeout = 0 +type AuthorizationToken = { expiresAt: number; token: string } +type AuthorizationTokenCacheState = { + cachedToken?: AuthorizationToken | undefined + inflight?: Promise | undefined +} + +const authorizationTokenCache = new Map() + +const p256Order = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n +const p256HalfOrder = 0x7fffffff800000007fffffffffffffffde737d56d38bcf4279dce5617e3192a8n + +type ZoneParameters = { + feeToken?: `0x${string}` | undefined + zone: number +} + +type ZoneConfigLike = { + blockExplorers?: Chain['blockExplorers'] + chainId: number + name?: string | undefined + portalAddress: `0x${string}` + rpcUrls?: { + default?: { + http?: string[] + } + } +} + +type ZoneClientRoot = { + account: Account & { + address: `0x${string}` + sign: (parameters: { hash: Hex }) => Promise + } + chain: Chain & { + id: number + name: string + zones?: Record + [key: string]: unknown + } +} + +function normalizeSignatureEnvelope(envelope: T): T { + const candidate = envelope as { + inner?: T + signature?: { s?: bigint } + type?: string + } + + if (candidate.type === 'keychain') { + return { + ...candidate, + inner: normalizeSignatureEnvelope(candidate.inner), + } as T + } + + if ( + (candidate.type === 'p256' || candidate.type === 'webAuthn') && + candidate.signature?.s !== undefined && + candidate.signature.s > p256HalfOrder + ) { + return { + ...candidate, + signature: { + ...candidate.signature, + s: p256Order - candidate.signature.s, + }, + } as T + } + + return candidate as T +} + +function normalizeSignature(signature: `0x${string}`) { + const envelope = SignatureEnvelope.deserialize(signature) + return SignatureEnvelope.serialize(normalizeSignatureEnvelope(envelope) as never) +} + +function encodeBase64(value: string) { + if (typeof globalThis.btoa === 'function') return globalThis.btoa(value) + + return Buffer.from(value).toString('base64') +} + +function getAuthorizationTokenCacheKey( + account: ZoneClientRoot['account'], + zoneId: number, + zone: ZoneConfigLike, +) { + return [ + account.address.toLowerCase(), + zone.chainId, + zoneId, + zone.portalAddress.toLowerCase(), + ].join(':') +} + +function getAuthorizationTokenCacheState(cacheKey: string) { + const cached = authorizationTokenCache.get(cacheKey) + if (cached) return cached + + const state: AuthorizationTokenCacheState = {} + authorizationTokenCache.set(cacheKey, state) + return state +} + +function getFreshAuthorizationToken( + cachedToken: AuthorizationToken | undefined, + now = Math.floor(Date.now() / 1_000), +) { + if (!cachedToken) return undefined + if (cachedToken.expiresAt - now <= authorizationTokenRefreshBuffer) return undefined + return cachedToken +} + +function createAuthorizationTokenGetter( + account: ZoneClientRoot['account'], + zoneId: number, + zone: ZoneConfigLike, +) { + const state = getAuthorizationTokenCacheState( + getAuthorizationTokenCacheKey(account, zoneId, zone), + ) + + return async ({ allowPrompt = true }: { allowPrompt?: boolean } = {}) => { + const cachedToken = getFreshAuthorizationToken(state.cachedToken) + + if (cachedToken) return cachedToken + + if (state.inflight) return state.inflight + + if (!allowPrompt) { + throw new Error('Prepare authenticated access before reading zone data.') + } + + state.inflight = (async () => { + try { + const issuedAt = Math.floor(Date.now() / 1_000) + const expiresAt = issuedAt + authorizationTokenTtl + const authentication = ZoneRpcAuthentication.from({ + chainId: zone.chainId, + expiresAt, + issuedAt, + zoneId, + zonePortal: zone.portalAddress, + }) + + const signature = normalizeSignature( + await account.sign({ + hash: ZoneRpcAuthentication.getSignPayload(authentication), + }), + ) + + const token = ZoneRpcAuthentication.serialize(authentication, { signature }).slice(2) + const nextToken = { expiresAt, token } + + state.cachedToken = nextToken + return nextToken + } finally { + state.inflight = undefined + } + })() + + return state.inflight + } +} + +export function getZoneClient(client: ZoneClientRoot, parameters: ZoneParameters) { + const zone = client.chain.zones?.[parameters.zone] + if (!zone) throw new Error(`Zone ${parameters.zone} is not configured on the current chain.`) + + const rpcUrl = zone.rpcUrls?.default?.http?.[0] + if (!rpcUrl) throw new Error(`Zone ${parameters.zone} is missing an HTTP RPC URL.`) + + const parsedUrl = new URL(rpcUrl) + const username = decodeURIComponent(parsedUrl.username) + const password = decodeURIComponent(parsedUrl.password) + const basicAuthHeader = + username || password ? `Basic ${encodeBase64(`${username}:${password}`)}` : undefined + + parsedUrl.username = '' + parsedUrl.password = '' + + const { extend: _extend, ...baseChain } = client.chain + const zoneRpcUrls = zone.rpcUrls ?? { default: {} } + const zoneChain = defineChain({ + ...baseChain, + blockExplorers: zone.blockExplorers, + feeToken: + parameters.feeToken ?? (baseChain as { feeToken?: `0x${string}` }).feeToken ?? undefined, + id: zone.chainId, + name: zone.name ?? `${client.chain.name} Zone ${parameters.zone}`, + rpcUrls: { + ...zoneRpcUrls, + default: { + ...(zoneRpcUrls.default ?? {}), + http: [parsedUrl.toString()], + }, + }, + sourceId: client.chain.id, + zones: undefined, + }) + + const getAuthorizationToken = createAuthorizationTokenGetter( + client.account, + parameters.zone, + zone, + ) + const zoneClient = createClient({ + account: client.account as never, + chain: zoneChain, + transport: http(parsedUrl.toString(), { + batch: false, + async onFetchRequest(_request, init) { + const headers = new Headers(init?.headers) + + if (basicAuthHeader) headers.set('authorization', basicAuthHeader) + headers.set( + ZoneRpcAuthentication.headerName, + (await getAuthorizationToken({ allowPrompt: false })).token, + ) + + return { + ...init, + headers, + } + }, + }), + }) + .extend(publicActions) + .extend(walletActions) + + return { + ...zoneClient, + token: { + approveSync: (parameters: Parameters[1]) => + Actions.token.approveSync( + zoneClient as never, + { + ...parameters, + timeout: parameters.timeout ?? zoneRpcSyncTimeout, + } as never, + ), + getAllowance: (parameters: Parameters[1]) => + Actions.token.getAllowance(zoneClient as never, parameters as never), + getBalance: ({ account, token }: { account: `0x${string}`; token: `0x${string}` }) => + zoneClient.readContract({ + account, + address: TokenId.toAddress(token), + abi: erc20Abi, + functionName: 'balanceOf', + args: [account], + }), + }, + zone: { + getAuthorizationTokenInfo: () => Actions.zone.getAuthorizationTokenInfo(zoneClient as never), + getDepositStatus: (parameters: Parameters[1]) => + Actions.zone.getDepositStatus(zoneClient as never, parameters as never), + getWithdrawalFee: (parameters?: Parameters[1]) => + Actions.zone.getWithdrawalFee(zoneClient as never, parameters as never), + getZoneInfo: () => Actions.zone.getZoneInfo(zoneClient as never), + prepareAuthorizationToken: async () => { + const { expiresAt } = await getAuthorizationToken({ allowPrompt: true }) + + return { + account: client.account.address, + expiresAt: BigInt(expiresAt), + } + }, + requestWithdrawalSync: ( + parameters: Parameters[1], + ) => + Actions.zone.requestWithdrawalSync( + zoneClient as never, + { + ...parameters, + timeout: parameters.timeout ?? zoneRpcSyncTimeout, + } as never, + ), + }, + } +} 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..51e8111d --- /dev/null +++ b/src/pages/guide/private-zones/deposit-to-a-zone.mdx @@ -0,0 +1,72 @@ +--- +title: Deposit to a Zone +description: Deposit pathUSD from your public-chain balance into Zone A and confirm the resulting zone balance. +--- + +import * as Demo from '../../../components/guides/Demo.tsx' +import { DepositToZone } from '../../../components/guides/zones/DepositToZone.tsx' + +# Deposit to a Zone + +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 example + +This snippet assumes you already have a signed-in `rootClient` on the public chain and the usual token and zone constants in scope. +It shows the core deposit transaction path; use the demo above when you want to watch the resulting zone balance update. + +```ts +import { parseUnits } from 'viem' +import { sendTransactionSync } from 'viem/actions' +import { Actions } from 'viem/tempo' + +const depositAmount = parseUnits('100', 6) + +const receipt = await sendTransactionSync(rootClient, { + account: rootClient.account, + calls: [ + Actions.token.approve.call({ + amount: depositAmount, + spender: ZONE_A.portalAddress, + token: pathUsd, + }), + Actions.zone.deposit.call({ + account: rootClient.account, + amount: depositAmount, + chain: rootClient.chain, + token: pathUsd, + zone: ZONE_A.id, + }), + ], + throwOnReceiptRevert: true, +}) + +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..d19a628e --- /dev/null +++ b/src/pages/guide/private-zones/index.mdx @@ -0,0 +1,54 @@ +--- +title: Connect to Tempo Zones +description: Learn how Tempo Zones work alongside the public chain and follow guides for depositing, sending, swapping, and withdrawing pathUSD across Zone A and Zone B. +--- + +import { Card, Cards } from 'vocs' + +# Connect to Tempo Zones + +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, zone top-ups, swaps, or withdrawals. +- Expect deposits, routed swaps, and withdrawals to complete asynchronously rather than in a single balance update. + +These guides cover the baseline zone workflows used in the current demos: regular deposits through `ZonePortal.deposit(...)`, in-zone transfers, routed swaps, and direct withdrawals through `ZoneOutbox.requestWithdrawal(...)`. + +When you need protocol-level privacy features before high-level SDK helpers land, the deposit guide's demo lets you switch between plaintext and encrypted deposits, and the withdrawal guide still includes a hand-rolled `requestWithdrawal(..., revealTo)` example. + +## Choose the right guide + +- **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`. +- **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-within-a-zone.mdx b/src/pages/guide/private-zones/send-tokens-within-a-zone.mdx new file mode 100644 index 00000000..ca5c6310 --- /dev/null +++ b/src/pages/guide/private-zones/send-tokens-within-a-zone.mdx @@ -0,0 +1,50 @@ +--- +title: Send tokens within a zone +description: Send pathUSD inside Zone A with a signed zone transfer and confirm the updated zone balance. +--- + +import * as Demo from '../../../components/guides/Demo.tsx' +import { SendTokensWithinZone } from '../../../components/guides/zones/SendTokensWithinZone.tsx' + +# Send tokens within a zone + +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.prepareAuthorizationToken() + +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..0dc396c7 --- /dev/null +++ b/src/pages/guide/private-zones/swap-across-zones.mdx @@ -0,0 +1,115 @@ +--- +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. +--- + +import * as Demo from '../../../components/guides/Demo.tsx' +import { SwapAcrossZones } from '../../../components/guides/zones/SwapAcrossZones.tsx' + +# Swap across zones + +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 { sendTransactionSync } from 'viem/actions' +import { Actions } from 'viem/tempo' + +const swapAmount = parseUnits('25', 6) + +await zoneAClient.zone.prepareAuthorizationToken() + +const routedWithdrawalFee = await zoneAClient.zone.getWithdrawalFee({ gasLimit: routerCallbackGasLimit }) +const quotedBetaOut = await rootClient.dex.getSellQuote({ + amountIn: swapAmount, + tokenIn: pathUsd, + tokenOut: betaUsd, +}) + +const minimumBetaOut = quotedBetaOut - quotedBetaOut / 100n +const requiredZoneAllowance = swapAmount + routedWithdrawalFee + +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 sendTransactionSync(zoneAClient, { + account: rootClient.account, + feeToken: pathUsd, + calls: [ + Actions.token.approve.call({ + amount: requiredZoneAllowance, + spender: zoneOutbox, + token: pathUsd, + }), + Actions.zone.requestWithdrawal.call({ + account: rootClient.account, + amount: swapAmount, + data: callbackData, + gasLimit: routerCallbackGasLimit, + to: swapAndDepositRouter, + token: pathUsd, + }), + ], + throwOnReceiptRevert: true, +}) + +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. + +That creates multiple checkpoints your app should reflect in the UI: + +- The withdrawal request is accepted on the zone. +- The withdrawal is processed and the funds are released on the L1. +- The StablecoinDEX completes the swap on the L1 and the output token is deposited back into the `ZonePortal`. +- Deposit is processed back into the zone and becomes visible through the zone client as a post-fee amount. + +A user may see the input asset leave the zone balance before the output asset appears. The swap should only be treated as fully settled once the zone balance for the output token increases. That balance increase reflects the router output minus the portal deposit fee. + +:::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..88d86652 --- /dev/null +++ b/src/pages/guide/private-zones/withdraw-from-a-zone.mdx @@ -0,0 +1,85 @@ +--- +title: Withdraw from a Zone +description: Withdraw pathUSD from Zone A back to your public-chain balance with a direct zone outbox withdrawal. +--- + +import * as Demo from '../../../components/guides/Demo.tsx' +import { WithdrawFromZone } from '../../../components/guides/zones/WithdrawFromZone.tsx' + +# Withdraw from a Zone + +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 approve the withdrawal amount plus fee, submit the request, then wait for the public balance to increase after the batch settles. + +This page covers the standard `ZoneOutbox.requestWithdrawal(...)` flow. The `prepareAuthorizationToken()` call in the example makes authenticated zone RPC access explicit for the session; it is not a separate withdrawal mode. + +## 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 example + +This snippet assumes you already have a signed-in `rootClient` on the public chain, a derived `zoneAClient`, and the usual token and outbox constants in scope. +It shows the core withdrawal transaction path; use the demo above when you want to watch the public balance settle. + +```ts +import { parseUnits } from 'viem' +import { sendTransactionSync } from 'viem/actions' +import { Actions } from 'viem/tempo' + +const withdrawalAmount = parseUnits('100', 6) + +await zoneAClient.zone.prepareAuthorizationToken() + +const withdrawalFee = await zoneAClient.zone.getWithdrawalFee() +const requiredZoneAllowance = withdrawalAmount + withdrawalFee + +const receipt = await sendTransactionSync(zoneAClient, { + account: rootClient.account, + feeToken: pathUsd, + calls: [ + Actions.token.approve.call({ + amount: requiredZoneAllowance, + spender: zoneOutbox, + token: pathUsd, + }), + Actions.zone.requestWithdrawal.call({ + account: rootClient.account, + amount: withdrawalAmount, + token: pathUsd, + to: rootClient.account.address, + }), + ], + throwOnReceiptRevert: true, +}) + +console.log(receipt.blockNumber) +``` + +In other words, the withdrawal transaction is still `requestWithdrawal(...)`. The explicit authorization step is there so the app can use the zone RPC without triggering an unexpected wallet prompt later in the flow. + +## 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. + +:::tip + If a user is trying to withdraw most or all of a zone balance, subtract the withdrawal fee first. `ZoneOutbox` burns `amount + fee`, and the fee is paid in the same token being withdrawn. +::: + +:::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 -
@@ -216,7 +216,7 @@ export function Example() { Sign up -
diff --git a/src/wagmi.config.ts b/src/wagmi.config.ts index 89007557..61cd62f7 100644 --- a/src/wagmi.config.ts +++ b/src/wagmi.config.ts @@ -16,19 +16,23 @@ 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 + + // 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 })() diff --git a/vocs.config.ts b/vocs.config.ts index 217157ba..5f0a3254 100644 --- a/vocs.config.ts +++ b/vocs.config.ts @@ -154,6 +154,32 @@ export default defineConfig({ // }, ], }, + { + text: 'Connect to Zones', + collapsed: true, + items: [ + { + text: 'Overview', + link: '/guide/private-zones', + }, + { + 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: '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, From cbc7b52a72d53e54e20567b90858c0434342dcda Mon Sep 17 00:00:00 2001 From: Zygimantas <5236121+Zygimantass@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:19:55 +0200 Subject: [PATCH 03/25] fix: types --- .../guides/zones/SendTokensWithinZone.tsx | 3 +-- src/lib/viem-zone.ts | 16 ++-------------- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/src/components/guides/zones/SendTokensWithinZone.tsx b/src/components/guides/zones/SendTokensWithinZone.tsx index 4717d983..d84dc291 100644 --- a/src/components/guides/zones/SendTokensWithinZone.tsx +++ b/src/components/guides/zones/SendTokensWithinZone.tsx @@ -6,7 +6,7 @@ import { sendTransactionSync } from 'viem/actions' import { Actions } from 'viem/tempo' import { useConnection, useConnectorClient } from 'wagmi' import { Hooks } from 'wagmi/tempo' -import { getZoneClient, zoneRpcSyncTimeout } from '../../../lib/viem-zone.ts' +import { getZoneClient } from '../../../lib/viem-zone.ts' import { Button, ExplorerLink, FAKE_RECIPIENT, Logout, Step } from '../Demo' import { SignInButtons } from '../EmbedPasskeys' import { pathUsd } from '../tokens' @@ -228,7 +228,6 @@ function ConnectedZoneFlow(props: { address: Hex }) { amount: TRANSFER_AMOUNT, chain: connectorClient.chain as never, feeToken: pathUsd, - timeout: zoneRpcSyncTimeout, to: FAKE_RECIPIENT as Hex, token: pathUsd, }) diff --git a/src/lib/viem-zone.ts b/src/lib/viem-zone.ts index 0f8099e2..ea443190 100644 --- a/src/lib/viem-zone.ts +++ b/src/lib/viem-zone.ts @@ -251,13 +251,7 @@ export function getZoneClient(client: ZoneClientRoot, parameters: ZoneParameters ...zoneClient, token: { approveSync: (parameters: Parameters[1]) => - Actions.token.approveSync( - zoneClient as never, - { - ...parameters, - timeout: parameters.timeout ?? zoneRpcSyncTimeout, - } as never, - ), + Actions.token.approveSync(zoneClient as never, parameters as never), getAllowance: (parameters: Parameters[1]) => Actions.token.getAllowance(zoneClient as never, parameters as never), getBalance: ({ account, token }: { account: `0x${string}`; token: `0x${string}` }) => @@ -287,13 +281,7 @@ export function getZoneClient(client: ZoneClientRoot, parameters: ZoneParameters requestWithdrawalSync: ( parameters: Parameters[1], ) => - Actions.zone.requestWithdrawalSync( - zoneClient as never, - { - ...parameters, - timeout: parameters.timeout ?? zoneRpcSyncTimeout, - } as never, - ), + Actions.zone.requestWithdrawalSync(zoneClient as never, parameters as never), }, } } From bef22c44c3549fdab9e7499d00b5e040558b0043 Mon Sep 17 00:00:00 2001 From: Zygimantas <5236121+Zygimantass@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:48:45 +0200 Subject: [PATCH 04/25] chore: add banner --- src/components/guides/Demo.tsx | 27 +- src/components/guides/zones/DepositToZone.tsx | 13 +- .../guides/zones/SendTokensWithinZone.tsx | 11 +- .../guides/zones/SwapAcrossZones.tsx | 19 +- .../guides/zones/WithdrawFromZone.tsx | 15 +- src/lib/private-zones.ts | 108 ++++++- src/lib/viem-zone.ts | 287 ------------------ .../guide/private-zones/deposit-to-a-zone.mdx | 4 + src/pages/guide/private-zones/index.mdx | 4 + .../send-tokens-within-a-zone.mdx | 4 + .../guide/private-zones/swap-across-zones.mdx | 4 + .../private-zones/withdraw-from-a-zone.mdx | 4 + src/pages/protocol/zones/accounts.mdx | 4 + src/pages/protocol/zones/architecture.mdx | 4 + src/pages/protocol/zones/bridging.mdx | 5 +- src/pages/protocol/zones/execution.mdx | 4 + src/pages/protocol/zones/index.mdx | 4 + src/pages/protocol/zones/proving.mdx | 5 +- src/pages/protocol/zones/rpc.mdx | 4 + vocs.config.ts | 1 - 20 files changed, 212 insertions(+), 319 deletions(-) delete mode 100644 src/lib/viem-zone.ts diff --git a/src/components/guides/Demo.tsx b/src/components/guides/Demo.tsx index 42246f31..c2b7e736 100644 --- a/src/components/guides/Demo.tsx +++ b/src/components/guides/Demo.tsx @@ -15,7 +15,11 @@ 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 { getZoneClient } from '../../lib/viem-zone.ts' +import { + getTempoZoneClient, + getZoneClientParameters, + moderatoZoneRpcUrls, +} from '../../lib/private-zones.ts' import { useTempoWalletConnector, useWebAuthnConnector } from '../../wagmi.config' import { Container as ParentContainer } from '../Container' import { alphaUsd } from './tokens' @@ -254,17 +258,24 @@ export namespace Container { } function ZoneBalancesFooterItem(props: ZoneBalance & { address: Address; showLabel: boolean }) { - const { address, feeToken, label, showLabel, token, zone } = props + const { address, label, showLabel, token, zone } = props const { data: connectorClient } = useConnectorClient() + const zoneRpcUrl = + moderatoZoneRpcUrls[zone as keyof typeof moderatoZoneRpcUrls] ?? + ( + connectorClient?.chain as + | { zones?: Record } + | undefined + )?.zones?.[zone]?.rpcUrls.default.http[0] const zoneClient = React.useMemo( () => - connectorClient - ? (getZoneClient(connectorClient as never, { - ...(feeToken ? { feeToken } : {}), - zone, - }) as unknown as ZoneClientLike) + connectorClient && zoneRpcUrl + ? (getTempoZoneClient( + connectorClient as never, + getZoneClientParameters(zone, zoneRpcUrl) as never, + ) as unknown as ZoneClientLike) : undefined, - [connectorClient, feeToken, zone], + [connectorClient, zone, zoneRpcUrl], ) const { data: metadata, isPending: metadataIsPending } = Hooks.token.useGetMetadata({ token, diff --git a/src/components/guides/zones/DepositToZone.tsx b/src/components/guides/zones/DepositToZone.tsx index 80bbaf20..d2b0e0b6 100644 --- a/src/components/guides/zones/DepositToZone.tsx +++ b/src/components/guides/zones/DepositToZone.tsx @@ -6,6 +6,11 @@ import { sendTransactionSync } from 'viem/actions' import { Actions } from 'viem/tempo' import { useConnection, useConnectorClient, usePublicClient } from 'wagmi' import { Hooks } from 'wagmi/tempo' +import { + getTempoZoneClient, + getZoneClientParameters, + moderatoZoneRpcUrls, +} from '../../../lib/private-zones.ts' import { encodeEncryptedDepositCall, encryptZoneDepositPayload, @@ -13,7 +18,6 @@ import { type SequencerEncryptionKey, zonePortalDepositAbi, } from '../../../lib/private-zones-encryption.ts' -import { getZoneClient } from '../../../lib/viem-zone.ts' import { Button, ExplorerLink, Logout, Step } from '../Demo' import { SignInButtons } from '../EmbedPasskeys' import { pathUsd } from '../tokens' @@ -97,9 +101,10 @@ function ConnectedZoneFlow(props: { address: Hex; mode: DepositMode }) { const zoneClient = React.useMemo( () => connectorClient - ? (getZoneClient(connectorClient as never, { - zone: ZONE_ID, - }) as unknown as ZoneClientLike) + ? (getTempoZoneClient( + connectorClient as never, + getZoneClientParameters(ZONE_ID, moderatoZoneRpcUrls[ZONE_ID]) as never, + ) as unknown as ZoneClientLike) : undefined, [connectorClient], ) diff --git a/src/components/guides/zones/SendTokensWithinZone.tsx b/src/components/guides/zones/SendTokensWithinZone.tsx index d84dc291..cfc30837 100644 --- a/src/components/guides/zones/SendTokensWithinZone.tsx +++ b/src/components/guides/zones/SendTokensWithinZone.tsx @@ -6,7 +6,11 @@ import { sendTransactionSync } from 'viem/actions' import { Actions } from 'viem/tempo' import { useConnection, useConnectorClient } from 'wagmi' import { Hooks } from 'wagmi/tempo' -import { getZoneClient } from '../../../lib/viem-zone.ts' +import { + getTempoZoneClient, + getZoneClientParameters, + moderatoZoneRpcUrls, +} from '../../../lib/private-zones.ts' import { Button, ExplorerLink, FAKE_RECIPIENT, Logout, Step } from '../Demo' import { SignInButtons } from '../EmbedPasskeys' import { pathUsd } from '../tokens' @@ -76,7 +80,10 @@ function ConnectedZoneFlow(props: { address: Hex }) { const zoneClient = React.useMemo( () => connectorClient - ? (getZoneClient(connectorClient as never, { zone: ZONE_ID }) as unknown as ZoneClientLike) + ? (getTempoZoneClient( + connectorClient as never, + getZoneClientParameters(ZONE_ID, moderatoZoneRpcUrls[ZONE_ID]) as never, + ) as unknown as ZoneClientLike) : undefined, [connectorClient], ) diff --git a/src/components/guides/zones/SwapAcrossZones.tsx b/src/components/guides/zones/SwapAcrossZones.tsx index 05041542..3a80f2da 100644 --- a/src/components/guides/zones/SwapAcrossZones.tsx +++ b/src/components/guides/zones/SwapAcrossZones.tsx @@ -7,6 +7,8 @@ import { Actions } from 'viem/tempo' import { useConnection, useConnectorClient, usePublicClient } from 'wagmi' import { Hooks } from 'wagmi/tempo' import { + getTempoZoneClient, + getZoneClientParameters, moderatoZoneFactory, routerCallbackGasLimit, stablecoinDex, @@ -15,8 +17,8 @@ import { ZONE_B, zeroBytes32, zoneOutbox, + zoneRpcSyncTimeout, } from '../../../lib/private-zones.ts' -import { getZoneClient, zoneRpcSyncTimeout } from '../../../lib/viem-zone.ts' import { Button, ExplorerLink, Logout, Step } from '../Demo' import { SignInButtons } from '../EmbedPasskeys' import { betaUsd, pathUsd } from '../tokens' @@ -124,19 +126,20 @@ function ConnectedZoneFlow(props: { address: Hex }) { const sourceZoneClient = React.useMemo( () => connectorClient - ? (getZoneClient(connectorClient as never, { - feeToken: pathUsd, - zone: ZONE_A.id, - }) as unknown as ZoneClientLike) + ? (getTempoZoneClient( + connectorClient as never, + getZoneClientParameters(ZONE_A.id, ZONE_A.rpcUrl) as never, + ) as unknown as ZoneClientLike) : undefined, [connectorClient], ) const targetZoneClient = React.useMemo( () => connectorClient - ? (getZoneClient(connectorClient as never, { - zone: ZONE_B.id, - }) as unknown as ZoneClientLike) + ? (getTempoZoneClient( + connectorClient as never, + getZoneClientParameters(ZONE_B.id, ZONE_B.rpcUrl) as never, + ) as unknown as ZoneClientLike) : undefined, [connectorClient], ) diff --git a/src/components/guides/zones/WithdrawFromZone.tsx b/src/components/guides/zones/WithdrawFromZone.tsx index 88660216..e2e71f36 100644 --- a/src/components/guides/zones/WithdrawFromZone.tsx +++ b/src/components/guides/zones/WithdrawFromZone.tsx @@ -6,8 +6,13 @@ import { sendTransactionSync } from 'viem/actions' import { Actions } from 'viem/tempo' import { useConnection, useConnectorClient, usePublicClient } from 'wagmi' import { Hooks } from 'wagmi/tempo' +import { + getTempoZoneClient, + getZoneClientParameters, + moderatoZoneRpcUrls, + zoneRpcSyncTimeout, +} from '../../../lib/private-zones.ts' import { encodeAuthenticatedWithdrawalCall } from '../../../lib/private-zones-withdrawal.ts' -import { getZoneClient, zoneRpcSyncTimeout } from '../../../lib/viem-zone.ts' import { Button, ExplorerLink, Logout, Step } from '../Demo' import { SignInButtons } from '../EmbedPasskeys' import { pathUsd } from '../tokens' @@ -90,10 +95,10 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { const zoneClient = React.useMemo( () => connectorClient - ? (getZoneClient(connectorClient as never, { - feeToken: pathUsd, - zone: ZONE_ID, - }) as unknown as ZoneClientLike) + ? (getTempoZoneClient( + connectorClient as never, + getZoneClientParameters(ZONE_ID, moderatoZoneRpcUrls[ZONE_ID]) as never, + ) as unknown as ZoneClientLike) : undefined, [connectorClient], ) diff --git a/src/lib/private-zones.ts b/src/lib/private-zones.ts index 80440797..fa4d311d 100644 --- a/src/lib/private-zones.ts +++ b/src/lib/private-zones.ts @@ -1,20 +1,31 @@ +import { type Hex, walletActions } from 'viem' +import { type GetZoneClientParameters, tempoActions, type ZoneTransportConfig } from 'viem/tempo' + 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 export const zeroBytes32 = '0x0000000000000000000000000000000000000000000000000000000000000000' as const +const ZONE_A_RPC_URL = + 'https://eng:bold-raman-silly-torvalds@rpc-zone-005-private.tempoxyz.dev' as const +const ZONE_B_RPC_URL = + 'https://eng:bold-raman-silly-torvalds@rpc-zone-006-private.tempoxyz.dev' as const + export const ZONE_A = { chainId: 4217000006, id: 6, label: 'Zone A', portalAddress: '0x7069DeC4E64Fd07334A0933eDe836C17259c9B23', + rpcUrl: ZONE_A_RPC_URL, rpcUrls: { default: { - http: ['https://eng:bold-raman-silly-torvalds@rpc-zone-005-private.tempoxyz.dev'], + http: [stripRpcBasicAuth(ZONE_A_RPC_URL)], webSocket: [], }, }, @@ -25,14 +36,20 @@ export const ZONE_B = { id: 7, label: 'Zone B', portalAddress: '0x3F5296303400B56271b476F5A0B9cBF74350D6Ac', + rpcUrl: ZONE_B_RPC_URL, rpcUrls: { default: { - http: ['https://eng:bold-raman-silly-torvalds@rpc-zone-006-private.tempoxyz.dev'], + 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, @@ -47,3 +64,90 @@ export const moderatoZones = { 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, init) { + const headers = new Headers(init?.headers) + headers.set('authorization', authorization) + + return { + ...init, + headers, + } + }, + } +} + +export function getZoneClientParameters(zone: number, rpcUrl: string) { + const transport = getZoneTransportConfig(rpcUrl) + + return transport ? { transport, zone } : { zone } +} + +type ClientWithExtend = { + extend: (decorator: unknown) => ClientWithExtend +} + +type AuthorizationTokenInfo = { + account: Hex + expiresAt: bigint +} + +type DepositStatus = { + deposits: readonly unknown[] + processed: boolean +} + +type ZoneInfo = { + chainId: number + zoneId: number + zoneTokens: readonly unknown[] +} + +export type TempoZoneClient = { + getBlockNumber: () => Promise + token: { + getAllowance: (parameters: { account: Hex; spender: Hex; token: Hex }) => Promise + getBalance: (parameters: { account: Hex; token: Hex }) => Promise + } + zone: { + getAuthorizationTokenInfo: () => Promise + getDepositStatus: (parameters: { tempoBlockNumber: bigint }) => Promise + getWithdrawalFee: (parameters?: { gasLimit?: bigint | undefined }) => Promise + getZoneInfo: () => Promise + prepareAuthorizationToken: () => Promise + } +} + +// `viem/tempo` exposes zone client creation through the public tempo decorator. +export function getTempoZoneClient(client: ClientWithExtend, parameters: GetZoneClientParameters) { + const zoneCapableClient = client + .extend(walletActions) + .extend(tempoActions()) as ClientWithExtend & { + getZoneClient: (parameters: GetZoneClientParameters) => TempoZoneClient + } + + return zoneCapableClient.getZoneClient(parameters) +} + +function encodeBase64(value: string) { + if (typeof globalThis.btoa === 'function') return globalThis.btoa(value) + + return Buffer.from(value).toString('base64') +} diff --git a/src/lib/viem-zone.ts b/src/lib/viem-zone.ts deleted file mode 100644 index ea443190..00000000 --- a/src/lib/viem-zone.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { SignatureEnvelope, TokenId, ZoneRpcAuthentication } from 'ox/tempo' -import { - type Account, - type Chain, - createClient, - defineChain, - erc20Abi, - type Hex, - http, - publicActions, - walletActions, -} from 'viem' -import { Actions } from 'viem/tempo' - -const authorizationTokenTtl = 1_800 -const authorizationTokenRefreshBuffer = 30 -// Private sequencers currently only accept the raw transaction param on eth_sendRawTransactionSync. -export const zoneRpcSyncTimeout = 0 -type AuthorizationToken = { expiresAt: number; token: string } -type AuthorizationTokenCacheState = { - cachedToken?: AuthorizationToken | undefined - inflight?: Promise | undefined -} - -const authorizationTokenCache = new Map() - -const p256Order = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n -const p256HalfOrder = 0x7fffffff800000007fffffffffffffffde737d56d38bcf4279dce5617e3192a8n - -type ZoneParameters = { - feeToken?: `0x${string}` | undefined - zone: number -} - -type ZoneConfigLike = { - blockExplorers?: Chain['blockExplorers'] - chainId: number - name?: string | undefined - portalAddress: `0x${string}` - rpcUrls?: { - default?: { - http?: string[] - } - } -} - -type ZoneClientRoot = { - account: Account & { - address: `0x${string}` - sign: (parameters: { hash: Hex }) => Promise - } - chain: Chain & { - id: number - name: string - zones?: Record - [key: string]: unknown - } -} - -function normalizeSignatureEnvelope(envelope: T): T { - const candidate = envelope as { - inner?: T - signature?: { s?: bigint } - type?: string - } - - if (candidate.type === 'keychain') { - return { - ...candidate, - inner: normalizeSignatureEnvelope(candidate.inner), - } as T - } - - if ( - (candidate.type === 'p256' || candidate.type === 'webAuthn') && - candidate.signature?.s !== undefined && - candidate.signature.s > p256HalfOrder - ) { - return { - ...candidate, - signature: { - ...candidate.signature, - s: p256Order - candidate.signature.s, - }, - } as T - } - - return candidate as T -} - -function normalizeSignature(signature: `0x${string}`) { - const envelope = SignatureEnvelope.deserialize(signature) - return SignatureEnvelope.serialize(normalizeSignatureEnvelope(envelope) as never) -} - -function encodeBase64(value: string) { - if (typeof globalThis.btoa === 'function') return globalThis.btoa(value) - - return Buffer.from(value).toString('base64') -} - -function getAuthorizationTokenCacheKey( - account: ZoneClientRoot['account'], - zoneId: number, - zone: ZoneConfigLike, -) { - return [ - account.address.toLowerCase(), - zone.chainId, - zoneId, - zone.portalAddress.toLowerCase(), - ].join(':') -} - -function getAuthorizationTokenCacheState(cacheKey: string) { - const cached = authorizationTokenCache.get(cacheKey) - if (cached) return cached - - const state: AuthorizationTokenCacheState = {} - authorizationTokenCache.set(cacheKey, state) - return state -} - -function getFreshAuthorizationToken( - cachedToken: AuthorizationToken | undefined, - now = Math.floor(Date.now() / 1_000), -) { - if (!cachedToken) return undefined - if (cachedToken.expiresAt - now <= authorizationTokenRefreshBuffer) return undefined - return cachedToken -} - -function createAuthorizationTokenGetter( - account: ZoneClientRoot['account'], - zoneId: number, - zone: ZoneConfigLike, -) { - const state = getAuthorizationTokenCacheState( - getAuthorizationTokenCacheKey(account, zoneId, zone), - ) - - return async ({ allowPrompt = true }: { allowPrompt?: boolean } = {}) => { - const cachedToken = getFreshAuthorizationToken(state.cachedToken) - - if (cachedToken) return cachedToken - - if (state.inflight) return state.inflight - - if (!allowPrompt) { - throw new Error('Prepare authenticated access before reading zone data.') - } - - state.inflight = (async () => { - try { - const issuedAt = Math.floor(Date.now() / 1_000) - const expiresAt = issuedAt + authorizationTokenTtl - const authentication = ZoneRpcAuthentication.from({ - chainId: zone.chainId, - expiresAt, - issuedAt, - zoneId, - zonePortal: zone.portalAddress, - }) - - const signature = normalizeSignature( - await account.sign({ - hash: ZoneRpcAuthentication.getSignPayload(authentication), - }), - ) - - const token = ZoneRpcAuthentication.serialize(authentication, { signature }).slice(2) - const nextToken = { expiresAt, token } - - state.cachedToken = nextToken - return nextToken - } finally { - state.inflight = undefined - } - })() - - return state.inflight - } -} - -export function getZoneClient(client: ZoneClientRoot, parameters: ZoneParameters) { - const zone = client.chain.zones?.[parameters.zone] - if (!zone) throw new Error(`Zone ${parameters.zone} is not configured on the current chain.`) - - const rpcUrl = zone.rpcUrls?.default?.http?.[0] - if (!rpcUrl) throw new Error(`Zone ${parameters.zone} is missing an HTTP RPC URL.`) - - const parsedUrl = new URL(rpcUrl) - const username = decodeURIComponent(parsedUrl.username) - const password = decodeURIComponent(parsedUrl.password) - const basicAuthHeader = - username || password ? `Basic ${encodeBase64(`${username}:${password}`)}` : undefined - - parsedUrl.username = '' - parsedUrl.password = '' - - const { extend: _extend, ...baseChain } = client.chain - const zoneRpcUrls = zone.rpcUrls ?? { default: {} } - const zoneChain = defineChain({ - ...baseChain, - blockExplorers: zone.blockExplorers, - feeToken: - parameters.feeToken ?? (baseChain as { feeToken?: `0x${string}` }).feeToken ?? undefined, - id: zone.chainId, - name: zone.name ?? `${client.chain.name} Zone ${parameters.zone}`, - rpcUrls: { - ...zoneRpcUrls, - default: { - ...(zoneRpcUrls.default ?? {}), - http: [parsedUrl.toString()], - }, - }, - sourceId: client.chain.id, - zones: undefined, - }) - - const getAuthorizationToken = createAuthorizationTokenGetter( - client.account, - parameters.zone, - zone, - ) - const zoneClient = createClient({ - account: client.account as never, - chain: zoneChain, - transport: http(parsedUrl.toString(), { - batch: false, - async onFetchRequest(_request, init) { - const headers = new Headers(init?.headers) - - if (basicAuthHeader) headers.set('authorization', basicAuthHeader) - headers.set( - ZoneRpcAuthentication.headerName, - (await getAuthorizationToken({ allowPrompt: false })).token, - ) - - return { - ...init, - headers, - } - }, - }), - }) - .extend(publicActions) - .extend(walletActions) - - return { - ...zoneClient, - token: { - approveSync: (parameters: Parameters[1]) => - Actions.token.approveSync(zoneClient as never, parameters as never), - getAllowance: (parameters: Parameters[1]) => - Actions.token.getAllowance(zoneClient as never, parameters as never), - getBalance: ({ account, token }: { account: `0x${string}`; token: `0x${string}` }) => - zoneClient.readContract({ - account, - address: TokenId.toAddress(token), - abi: erc20Abi, - functionName: 'balanceOf', - args: [account], - }), - }, - zone: { - getAuthorizationTokenInfo: () => Actions.zone.getAuthorizationTokenInfo(zoneClient as never), - getDepositStatus: (parameters: Parameters[1]) => - Actions.zone.getDepositStatus(zoneClient as never, parameters as never), - getWithdrawalFee: (parameters?: Parameters[1]) => - Actions.zone.getWithdrawalFee(zoneClient as never, parameters as never), - getZoneInfo: () => Actions.zone.getZoneInfo(zoneClient as never), - prepareAuthorizationToken: async () => { - const { expiresAt } = await getAuthorizationToken({ allowPrompt: true }) - - return { - account: client.account.address, - expiresAt: BigInt(expiresAt), - } - }, - requestWithdrawalSync: ( - parameters: Parameters[1], - ) => - Actions.zone.requestWithdrawalSync(zoneClient as never, parameters as never), - }, - } -} diff --git a/src/pages/guide/private-zones/deposit-to-a-zone.mdx b/src/pages/guide/private-zones/deposit-to-a-zone.mdx index 51e8111d..fb39bd4e 100644 --- a/src/pages/guide/private-zones/deposit-to-a-zone.mdx +++ b/src/pages/guide/private-zones/deposit-to-a-zone.mdx @@ -8,6 +8,10 @@ import { DepositToZone } from '../../../components/guides/zones/DepositToZone.ts # 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. diff --git a/src/pages/guide/private-zones/index.mdx b/src/pages/guide/private-zones/index.mdx index d19a628e..54980162 100644 --- a/src/pages/guide/private-zones/index.mdx +++ b/src/pages/guide/private-zones/index.mdx @@ -7,6 +7,10 @@ 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 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 index ca5c6310..2e6fca68 100644 --- a/src/pages/guide/private-zones/send-tokens-within-a-zone.mdx +++ b/src/pages/guide/private-zones/send-tokens-within-a-zone.mdx @@ -8,6 +8,10 @@ import { SendTokensWithinZone } from '../../../components/guides/zones/SendToken # 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. diff --git a/src/pages/guide/private-zones/swap-across-zones.mdx b/src/pages/guide/private-zones/swap-across-zones.mdx index 0dc396c7..09c8a097 100644 --- a/src/pages/guide/private-zones/swap-across-zones.mdx +++ b/src/pages/guide/private-zones/swap-across-zones.mdx @@ -8,6 +8,10 @@ import { SwapAcrossZones } from '../../../components/guides/zones/SwapAcrossZone # 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`. diff --git a/src/pages/guide/private-zones/withdraw-from-a-zone.mdx b/src/pages/guide/private-zones/withdraw-from-a-zone.mdx index 88d86652..d95e63a4 100644 --- a/src/pages/guide/private-zones/withdraw-from-a-zone.mdx +++ b/src/pages/guide/private-zones/withdraw-from-a-zone.mdx @@ -8,6 +8,10 @@ import { WithdrawFromZone } from '../../../components/guides/zones/WithdrawFromZ # 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 approve the withdrawal amount plus fee, submit the request, then wait for the public balance to increase after the batch settles. diff --git a/src/pages/protocol/zones/accounts.mdx b/src/pages/protocol/zones/accounts.mdx index cabb2bff..6e239a5c 100644 --- a/src/pages/protocol/zones/accounts.mdx +++ b/src/pages/protocol/zones/accounts.mdx @@ -5,6 +5,10 @@ description: Account-scoped access control on Tempo Zones, including private bal # Accounts +:::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 enforce account privacy at two complementary layers: the EVM execution level and the [RPC access control](/protocol/zones/rpc) level. Neither is sufficient alone. - **Execution alone is insufficient.** Without RPC restrictions, a caller could use `eth_getStorageAt` to read TIP-20 balance mapping slots directly, bypassing `balanceOf` access control. diff --git a/src/pages/protocol/zones/architecture.mdx b/src/pages/protocol/zones/architecture.mdx index 12d2594a..1a7a0824 100644 --- a/src/pages/protocol/zones/architecture.mdx +++ b/src/pages/protocol/zones/architecture.mdx @@ -5,6 +5,10 @@ description: Architecture of Tempo Zones, including contract layout, sequencer m # Tempo Zone Architecture +:::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 dedicated blockchain rooted to Tempo Mainnet where one sequencer controls block production and visibility. No zone data is published on Tempo Mainnet. Instead, the sequencer publishes commitments to the current zone state along with proofs of correct execution. These proofs allow funds to move in and out of the Tempo Zone. This scaling approach is known as a validium. The sequencer can enable any TIP-20 token on the Tempo Zone. Any enabled TIP-20 with USD currency can pay for zone gas. TIP-20 tokens bridge into the zone nearly instantly and bridge out as soon as a validity proof is posted (targeting under 10 seconds). diff --git a/src/pages/protocol/zones/bridging.mdx b/src/pages/protocol/zones/bridging.mdx index a929fc8a..e49a0f4c 100644 --- a/src/pages/protocol/zones/bridging.mdx +++ b/src/pages/protocol/zones/bridging.mdx @@ -5,6 +5,10 @@ description: Deposit and withdraw TIP-20 tokens between Tempo Mainnet and Tempo # 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. ## Deposits (Tempo → Zone) @@ -87,4 +91,3 @@ 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 index 10b7114f..fa3dacbb 100644 --- a/src/pages/protocol/zones/execution.mdx +++ b/src/pages/protocol/zones/execution.mdx @@ -5,6 +5,10 @@ description: Specification for gas accounting, fee tokens, fixed TIP-20 gas cost # 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 diff --git a/src/pages/protocol/zones/index.mdx b/src/pages/protocol/zones/index.mdx index a0dce6d3..145b70e6 100644 --- a/src/pages/protocol/zones/index.mdx +++ b/src/pages/protocol/zones/index.mdx @@ -7,6 +7,10 @@ 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. diff --git a/src/pages/protocol/zones/proving.mdx b/src/pages/protocol/zones/proving.mdx index 5ef38064..26df29dd 100644 --- a/src/pages/protocol/zones/proving.mdx +++ b/src/pages/protocol/zones/proving.mdx @@ -5,6 +5,10 @@ description: Batch submission and proof verification for Tempo zones, including # 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. ::: @@ -111,4 +115,3 @@ The zone accesses Tempo state via the TempoState predeploy (`0x1c00...0000`). Du 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 index c7771221..8ca1ce02 100644 --- a/src/pages/protocol/zones/rpc.mdx +++ b/src/pages/protocol/zones/rpc.mdx @@ -5,6 +5,10 @@ description: Authenticated JSON-RPC interface for Tempo Zones with per-account s # 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 diff --git a/vocs.config.ts b/vocs.config.ts index 5f0a3254..5c4a541a 100644 --- a/vocs.config.ts +++ b/vocs.config.ts @@ -649,7 +649,6 @@ export default defineConfig({ text: 'Proving', link: '/protocol/zones/proving', }, - ], }, ], From 5042a817c376ffddc8b958f6d100092e6ad11457 Mon Sep 17 00:00:00 2001 From: Zygimantas <5236121+Zygimantass@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:01:28 +0200 Subject: [PATCH 05/25] fix: refactor with latest viem --- package.json | 2 +- src/components/guides/Demo.tsx | 23 +- src/components/guides/zones/DepositToZone.tsx | 181 ++++------- .../guides/zones/SendTokensWithinZone.tsx | 149 ++------- .../guides/zones/SwapAcrossZones.tsx | 220 ++++---------- .../guides/zones/WithdrawFromZone.tsx | 286 +++++------------- src/lib/private-zones-encryption.ts | 141 --------- src/lib/private-zones-withdrawal.ts | 44 --- src/lib/private-zones.ts | 62 +--- .../guide/private-zones/deposit-to-a-zone.mdx | 55 ++-- src/pages/guide/private-zones/index.mdx | 4 +- .../send-tokens-within-a-zone.mdx | 2 +- .../guide/private-zones/swap-across-zones.mdx | 30 +- .../private-zones/withdraw-from-a-zone.mdx | 64 ++-- 14 files changed, 342 insertions(+), 921 deletions(-) delete mode 100644 src/lib/private-zones-encryption.ts delete mode 100644 src/lib/private-zones-withdrawal.ts diff --git a/package.json b/package.json index 6941fce4..662ce82f 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": "https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@b6babdcf", "vocs": "https://pkg.pr.new/wevm/vocs@2fb25c2", "wagmi": "^3.6.1", "waku": "1.0.0-alpha.4", diff --git a/src/components/guides/Demo.tsx b/src/components/guides/Demo.tsx index c2b7e736..3bee9ade 100644 --- a/src/components/guides/Demo.tsx +++ b/src/components/guides/Demo.tsx @@ -2,9 +2,10 @@ 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 { 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' @@ -16,9 +17,9 @@ import LucideWalletCards from '~icons/lucide/wallet-cards' import { cva, cx } from '../../../cva.config' import { usePostHogTracking } from '../../lib/posthog' import { - getTempoZoneClient, - getZoneClientParameters, + getZoneTransportConfig, moderatoZoneRpcUrls, + stripRpcBasicAuth, } from '../../lib/private-zones.ts' import { useTempoWalletConnector, useWebAuthnConnector } from '../../wagmi.config' import { Container as ParentContainer } from '../Container' @@ -269,11 +270,15 @@ export namespace Container { )?.zones?.[zone]?.rpcUrls.default.http[0] const zoneClient = React.useMemo( () => - connectorClient && zoneRpcUrl - ? (getTempoZoneClient( - connectorClient as never, - getZoneClientParameters(zone, zoneRpcUrl) as never, - ) as unknown as ZoneClientLike) + connectorClient?.account && zoneRpcUrl + ? (createClient({ + account: connectorClient.account, + chain: zoneModerato(zone), + transport: zoneHttp( + stripRpcBasicAuth(zoneRpcUrl), + getZoneTransportConfig(zoneRpcUrl), + ), + }).extend(tempoActions()) as unknown as ZoneClientLike) : undefined, [connectorClient, zone, zoneRpcUrl], ) diff --git a/src/components/guides/zones/DepositToZone.tsx b/src/components/guides/zones/DepositToZone.tsx index d2b0e0b6..1c924494 100644 --- a/src/components/guides/zones/DepositToZone.tsx +++ b/src/components/guides/zones/DepositToZone.tsx @@ -1,23 +1,16 @@ 'use client' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import * as React from 'react' -import { type Hex, parseUnits } from 'viem' -import { sendTransactionSync } from 'viem/actions' -import { Actions } from 'viem/tempo' +import { createClient, 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 { - getTempoZoneClient, - getZoneClientParameters, + getZoneTransportConfig, moderatoZoneRpcUrls, + stripRpcBasicAuth, } from '../../../lib/private-zones.ts' -import { - encodeEncryptedDepositCall, - encryptZoneDepositPayload, - getNetZoneDepositAmount, - type SequencerEncryptionKey, - zonePortalDepositAbi, -} from '../../../lib/private-zones-encryption.ts' import { Button, ExplorerLink, Logout, Step } from '../Demo' import { SignInButtons } from '../EmbedPasskeys' import { pathUsd } from '../tokens' @@ -25,18 +18,21 @@ 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 = { - getBlockNumber: () => Promise token: { getBalance: (parameters: { account: Hex; token: Hex }) => Promise } zone: { - prepareAuthorizationToken: () => Promise<{ - account: Hex - expiresAt: bigint + signAuthorizationToken: () => Promise<{ + authentication: { + expiresAt: number + zoneId: number + } + token: Hex }> } } @@ -46,12 +42,7 @@ type RootChainWithZones = { } type DepositSetup = { - allowance: bigint depositFee: bigint - encrypted?: { - keyIndex: bigint - sequencerKey: SequencerEncryptionKey - } } export function DepositToZone() { @@ -100,11 +91,15 @@ function ConnectedZoneFlow(props: { address: Hex; mode: DepositMode }) { const zoneClient = React.useMemo( () => - connectorClient - ? (getTempoZoneClient( - connectorClient as never, - getZoneClientParameters(ZONE_ID, moderatoZoneRpcUrls[ZONE_ID]) as never, - ) as unknown as ZoneClientLike) + connectorClient?.account + ? (createClient({ + account: connectorClient.account, + chain: zoneModerato(ZONE_ID), + transport: zoneHttp( + stripRpcBasicAuth(moderatoZoneRpcUrls[ZONE_ID]), + getZoneTransportConfig(moderatoZoneRpcUrls[ZONE_ID]), + ), + }).extend(tempoActions()) as unknown as ZoneClientLike) : undefined, [connectorClient], ) @@ -115,7 +110,7 @@ function ConnectedZoneFlow(props: { address: Hex; mode: DepositMode }) { queryFn: async () => { if (!zoneClient) throw new Error('zone client not ready') - const auth = await zoneClient.zone.prepareAuthorizationToken() + const auth = await zoneClient.zone.signAuthorizationToken() return { auth } }, @@ -135,52 +130,17 @@ function ConnectedZoneFlow(props: { address: Hex; mode: DepositMode }) { const depositSetupQuery = useQuery({ enabled: Boolean(connectorClient && publicClient && zonePortalAddress && authQuery.isSuccess), - queryKey: ['guide-private-zones-deposit-setup', address, ZONE_ID, zonePortalAddress, mode], + queryKey: ['guide-private-zones-deposit-setup', address, ZONE_ID, zonePortalAddress], queryFn: async (): Promise => { - if (!connectorClient) throw new Error('connector client not ready') if (!publicClient) throw new Error('public client not ready') if (!zonePortalAddress) throw new Error('zone portal address not configured') - const [allowance, depositFee] = await Promise.all([ - Actions.token.getAllowance(connectorClient as never, { - account: address, - spender: zonePortalAddress, - token: pathUsd, - }), - publicClient.readContract({ + return { + depositFee: await publicClient.readContract({ address: zonePortalAddress, - abi: zonePortalDepositAbi, + abi: zonePortalFeeAbi, functionName: 'calculateDepositFee', }), - ]) - - if (mode === 'encrypted') { - const [sequencerKey, encryptionKeyCount] = await Promise.all([ - publicClient.readContract({ - address: zonePortalAddress, - abi: zonePortalDepositAbi, - functionName: 'sequencerEncryptionKey', - }), - publicClient.readContract({ - address: zonePortalAddress, - abi: zonePortalDepositAbi, - functionName: 'encryptionKeyCount', - }), - ]) - - return { - allowance, - depositFee, - encrypted: { - keyIndex: encryptionKeyCount - 1n, - sequencerKey, - }, - } - } - - return { - allowance, - depositFee, } }, staleTime: 30_000, @@ -203,7 +163,6 @@ function ConnectedZoneFlow(props: { address: Hex; mode: DepositMode }) { mutationFn: async () => { if (!connectorClient) throw new Error('connector client not ready') if (!zoneClient) throw new Error('zone client not ready') - if (!zonePortalAddress) throw new Error('zone portal address not configured') if (!depositSetupQuery.data) throw new Error('deposit setup not ready') const startingZoneBalance = await zoneClient.token @@ -213,65 +172,41 @@ function ConnectedZoneFlow(props: { address: Hex; mode: DepositMode }) { }) .catch(() => 0n) - const includesApproval = depositSetupQuery.data.allowance < DEPOSIT_AMOUNT const creditedAmount = getNetZoneDepositAmount( DEPOSIT_AMOUNT, depositSetupQuery.data.depositFee, ) - const depositCall = + const receipt = mode === 'encrypted' - ? await (async () => { - const encryptedSetup = depositSetupQuery.data?.encrypted - if (!encryptedSetup) throw new Error('encrypted deposit setup not ready') - - const encrypted = await encryptZoneDepositPayload({ - keyIndex: encryptedSetup.keyIndex, - portalAddress: zonePortalAddress, - recipient: address, - sequencerKey: encryptedSetup.sequencerKey, - }) - - return encodeEncryptedDepositCall({ - amount: DEPOSIT_AMOUNT, - encrypted, - keyIndex: encryptedSetup.keyIndex, - portalAddress: zonePortalAddress, - token: pathUsd, - }) - })() - : Actions.zone.deposit.call({ - account: connectorClient.account, - amount: DEPOSIT_AMOUNT, - chain: connectorClient.chain as never, - token: pathUsd, - zone: ZONE_ID, - } as never) - - const receipt = await sendTransactionSync( - connectorClient as never, - { - account: connectorClient.account, - calls: [ - ...(includesApproval - ? [ - Actions.token.approve.call({ - amount: DEPOSIT_AMOUNT, - spender: zonePortalAddress, - token: pathUsd, - }), - ] - : []), - depositCall, - ], - throwOnReceiptRevert: true, - timeout: 60_000, - } as never, - ) + ? ( + await Actions.zone.encryptedDepositSync( + connectorClient as never, + { + account: connectorClient.account, + amount: DEPOSIT_AMOUNT, + chain: 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, - includesApproval, receipt, startingZoneBalance, } @@ -375,7 +310,7 @@ function ConnectedZoneFlow(props: { address: Hex; mode: DepositMode }) { type="button" variant="default" > - {mode === 'encrypted' ? 'Checking encrypted deposit setup' : 'Checking deposit setup'} + Checking deposit setup ) } else { @@ -550,3 +485,13 @@ function getConfirmationStepTitle(mode: DepositMode) { ? `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/SendTokensWithinZone.tsx b/src/components/guides/zones/SendTokensWithinZone.tsx index cfc30837..d25ae21a 100644 --- a/src/components/guides/zones/SendTokensWithinZone.tsx +++ b/src/components/guides/zones/SendTokensWithinZone.tsx @@ -1,15 +1,15 @@ 'use client' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import * as React from 'react' -import { type Hex, parseUnits } from 'viem' -import { sendTransactionSync } from 'viem/actions' -import { Actions } from 'viem/tempo' +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 { - getTempoZoneClient, - getZoneClientParameters, + getZoneTransportConfig, moderatoZoneRpcUrls, + stripRpcBasicAuth, } from '../../../lib/private-zones.ts' import { Button, ExplorerLink, FAKE_RECIPIENT, Logout, Step } from '../Demo' import { SignInButtons } from '../EmbedPasskeys' @@ -26,17 +26,16 @@ type ZoneClientLike = { getBalance: (parameters: { account: Hex; token: Hex }) => Promise } zone: { - prepareAuthorizationToken: () => Promise<{ - account: Hex - expiresAt: bigint + signAuthorizationToken: () => Promise<{ + authentication: { + expiresAt: number + zoneId: number + } + token: Hex }> } } -type RootChainWithZones = { - zones?: Record -} - export function SendTokensWithinZone() { const { address } = useConnection() const connected = Boolean(address) @@ -65,9 +64,6 @@ function ConnectedZoneFlow(props: { address: Hex }) { const { address } = props const queryClient = useQueryClient() const { data: connectorClient } = useConnectorClient() - const zonePortalAddress = (connectorClient?.chain as RootChainWithZones | undefined)?.zones?.[ - ZONE_ID - ]?.portalAddress const { data: rootBalance, isPending: rootBalanceIsPending, @@ -79,11 +75,15 @@ function ConnectedZoneFlow(props: { address: Hex }) { const zoneClient = React.useMemo( () => - connectorClient - ? (getTempoZoneClient( - connectorClient as never, - getZoneClientParameters(ZONE_ID, moderatoZoneRpcUrls[ZONE_ID]) as never, - ) as unknown as ZoneClientLike) + connectorClient?.account + ? (createClient({ + account: connectorClient.account, + chain: zoneModerato(ZONE_ID), + transport: zoneHttp( + stripRpcBasicAuth(moderatoZoneRpcUrls[ZONE_ID]), + getZoneTransportConfig(moderatoZoneRpcUrls[ZONE_ID]), + ), + }).extend(tempoActions()) as unknown as ZoneClientLike) : undefined, [connectorClient], ) @@ -94,7 +94,7 @@ function ConnectedZoneFlow(props: { address: Hex }) { queryFn: async () => { if (!zoneClient) throw new Error('zone client not ready') - const auth = await zoneClient.zone.prepareAuthorizationToken() + const auth = await zoneClient.zone.signAuthorizationToken() return { auth } }, @@ -136,28 +136,6 @@ function ConnectedZoneFlow(props: { address: Hex }) { ) const zoneBalanceStepComplete = useStickyStepCompletion(hasEnoughZoneBalance) - const portalAllowanceQuery = useQuery({ - enabled: Boolean( - connectorClient && - zonePortalAddress && - authQuery.isSuccess && - !zoneBalanceStepComplete && - zoneTopUpShortfall > 0n, - ), - queryKey: ['guide-private-zones-send-portal-allowance', address, ZONE_ID, zonePortalAddress], - queryFn: async () => { - if (!connectorClient) throw new Error('connector client not ready') - if (!zonePortalAddress) throw new Error('zone portal address not configured') - - return Actions.token.getAllowance(connectorClient as never, { - account: address, - spender: zonePortalAddress, - token: pathUsd, - }) - }, - staleTime: 30_000, - }) - const fundMutation = useMutation({ mutationFn: async () => { if (!connectorClient) throw new Error('connector client not ready') @@ -174,45 +152,20 @@ function ConnectedZoneFlow(props: { address: Hex }) { const topUpMutation = useMutation({ mutationFn: async () => { if (!connectorClient) throw new Error('connector client not ready') - if (!zonePortalAddress) throw new Error('zone portal address not configured') if (zoneTopUpShortfall <= 0n) throw new Error('zone top-up is not required') - const includesApproval = !hasPortalAllowance - const receipt = await sendTransactionSync( - connectorClient as never, - { - account: connectorClient.account, - calls: [ - ...(includesApproval - ? [ - Actions.token.approve.call({ - amount: zoneTopUpShortfall, - spender: zonePortalAddress, - token: pathUsd, - }), - ] - : []), - Actions.zone.deposit.call({ - account: connectorClient.account, - amount: zoneTopUpShortfall, - chain: connectorClient.chain as never, - token: pathUsd, - zone: ZONE_ID, - } as never), - ], - throwOnReceiptRevert: true, - timeout: 60_000, - } as never, - ) + 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 { - includesApproval, - receipt, - } + return { receipt } }, onSuccess: async () => { await refetchRootBalance() - await portalAllowanceQuery.refetch() await zoneBalanceQuery.refetch() }, }) @@ -280,11 +233,7 @@ function ConnectedZoneFlow(props: { address: Hex }) { }) const hasRootBalance = Boolean(rootBalance && rootBalance > 0n) - const hasPortalAllowance = Boolean( - portalAllowanceQuery.data !== undefined && portalAllowanceQuery.data >= zoneTopUpShortfall, - ) const topUpReceipt = topUpMutation.data?.receipt - const topUpIncludesApproval = topUpMutation.data?.includesApproval ?? !hasPortalAllowance const expectedMaxZoneBalance = transferMutation.data?.startingZoneBalance ? transferMutation.data.startingZoneBalance - TRANSFER_AMOUNT : undefined @@ -347,31 +296,6 @@ function ConnectedZoneFlow(props: { address: Hex }) { {fundMutation.isPending ? 'Getting pathUSD' : 'Get testnet pathUSD'} ) - } else if (!hasEnoughZoneBalance && portalAllowanceQuery.isError) { - stepThreeAction = ( - - ) - } else if ( - !hasEnoughZoneBalance && - (portalAllowanceQuery.isPending || portalAllowanceQuery.data === undefined) - ) { - stepThreeAction = ( - - ) } else if (!hasEnoughZoneBalance) { stepThreeAction = ( ) } @@ -428,12 +346,7 @@ function ConnectedZoneFlow(props: { address: Hex }) { active={authQuery.isSuccess && !zoneBalanceStepComplete} completed={zoneBalanceStepComplete} actions={stepThreeAction} - error={ - topUpMutation.error ?? - portalAllowanceQuery.error ?? - zoneBalanceQuery.error ?? - fundMutation.error - } + error={topUpMutation.error ?? zoneBalanceQuery.error ?? fundMutation.error} number={3} title={`Make sure ${ZONE_LABEL} has enough pathUSD to cover the transfer and fee.`} > diff --git a/src/components/guides/zones/SwapAcrossZones.tsx b/src/components/guides/zones/SwapAcrossZones.tsx index 3a80f2da..c39d0cc0 100644 --- a/src/components/guides/zones/SwapAcrossZones.tsx +++ b/src/components/guides/zones/SwapAcrossZones.tsx @@ -1,22 +1,21 @@ 'use client' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import * as React from 'react' -import { encodeAbiParameters, type Hex, parseAbiItem, parseUnits } from 'viem' -import { sendTransactionSync } from 'viem/actions' -import { Actions } from 'viem/tempo' +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 { - getTempoZoneClient, - getZoneClientParameters, + getZoneTransportConfig, moderatoZoneFactory, routerCallbackGasLimit, stablecoinDex, + stripRpcBasicAuth, swapAndDepositRouter, ZONE_A, ZONE_B, zeroBytes32, - zoneOutbox, zoneRpcSyncTimeout, } from '../../../lib/private-zones.ts' import { Button, ExplorerLink, Logout, Step } from '../Demo' @@ -71,18 +70,27 @@ type ZoneClientLike = { getBalance: (parameters: { account: Hex; token: Hex }) => Promise } zone: { + 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 - prepareAuthorizationToken: () => Promise<{ - account: Hex - expiresAt: bigint + signAuthorizationToken: () => Promise<{ + authentication: { + expiresAt: number + zoneId: number + } + token: Hex }> } } -type RootChainWithZones = { - zones?: Record -} - export function SwapAcrossZones() { const { address } = useConnection() const connected = Boolean(address) @@ -112,8 +120,6 @@ function ConnectedZoneFlow(props: { address: Hex }) { const queryClient = useQueryClient() const publicClient = usePublicClient() const { data: connectorClient } = useConnectorClient() - const sourceZonePortalAddress = (connectorClient?.chain as RootChainWithZones | undefined) - ?.zones?.[ZONE_A.id]?.portalAddress const { data: rootBalance, isPending: rootBalanceIsPending, @@ -125,21 +131,29 @@ function ConnectedZoneFlow(props: { address: Hex }) { const sourceZoneClient = React.useMemo( () => - connectorClient - ? (getTempoZoneClient( - connectorClient as never, - getZoneClientParameters(ZONE_A.id, ZONE_A.rpcUrl) as never, - ) as unknown as ZoneClientLike) + connectorClient?.account + ? (createClient({ + account: connectorClient.account, + chain: zoneModerato(ZONE_A.id), + transport: zoneHttp( + stripRpcBasicAuth(ZONE_A.rpcUrl), + getZoneTransportConfig(ZONE_A.rpcUrl), + ), + }).extend(tempoActions()) as unknown as ZoneClientLike) : undefined, [connectorClient], ) const targetZoneClient = React.useMemo( () => - connectorClient - ? (getTempoZoneClient( - connectorClient as never, - getZoneClientParameters(ZONE_B.id, ZONE_B.rpcUrl) as never, - ) as unknown as ZoneClientLike) + connectorClient?.account + ? (createClient({ + account: connectorClient.account, + chain: zoneModerato(ZONE_B.id), + transport: zoneHttp( + stripRpcBasicAuth(ZONE_B.rpcUrl), + getZoneTransportConfig(ZONE_B.rpcUrl), + ), + }).extend(tempoActions()) as unknown as ZoneClientLike) : undefined, [connectorClient], ) @@ -159,7 +173,7 @@ function ConnectedZoneFlow(props: { address: Hex }) { queryFn: async () => { if (!sourceZoneClient) throw new Error('Zone A client not ready') - const auth = await sourceZoneClient.zone.prepareAuthorizationToken() + const auth = await sourceZoneClient.zone.signAuthorizationToken() return { auth } }, @@ -273,33 +287,6 @@ function ConnectedZoneFlow(props: { address: Hex }) { ) const sourceZoneBalanceStepComplete = useStickyStepCompletion(hasEnoughSourceZoneBalance) - const portalAllowanceQuery = useQuery({ - enabled: Boolean( - connectorClient && - sourceZonePortalAddress && - sourceAuthQuery.isSuccess && - !sourceZoneBalanceStepComplete && - sourceZoneTopUpShortfall > 0n, - ), - queryKey: [ - 'guide-private-zones-swap-portal-allowance', - address, - ZONE_A.id, - sourceZonePortalAddress, - ], - queryFn: async () => { - if (!connectorClient) throw new Error('connector client not ready') - if (!sourceZonePortalAddress) throw new Error('Zone A portal address not configured') - - return Actions.token.getAllowance(connectorClient as never, { - account: address, - spender: sourceZonePortalAddress, - token: pathUsd, - }) - }, - staleTime: 30_000, - }) - const fundMutation = useMutation({ mutationFn: async () => { if (!connectorClient) throw new Error('connector client not ready') @@ -316,45 +303,20 @@ function ConnectedZoneFlow(props: { address: Hex }) { const topUpMutation = useMutation({ mutationFn: async () => { if (!connectorClient) throw new Error('connector client not ready') - if (!sourceZonePortalAddress) throw new Error('Zone A portal address not configured') if (sourceZoneTopUpShortfall <= 0n) throw new Error('Zone A top-up is not required') - const includesApproval = !hasPortalAllowance - const receipt = await sendTransactionSync( - connectorClient as never, - { - account: connectorClient.account, - calls: [ - ...(includesApproval - ? [ - Actions.token.approve.call({ - amount: sourceZoneTopUpShortfall, - spender: sourceZonePortalAddress, - token: pathUsd, - }), - ] - : []), - Actions.zone.deposit.call({ - account: connectorClient.account, - amount: sourceZoneTopUpShortfall, - chain: connectorClient.chain as never, - token: pathUsd, - zone: ZONE_A.id, - } as never), - ], - throwOnReceiptRevert: true, - timeout: 60_000, - } as never, - ) + 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 { - includesApproval, - receipt, - } + return { receipt } }, onSuccess: async () => { await refetchRootBalance() - await portalAllowanceQuery.refetch() await sourceZoneBalanceQuery.refetch() await queryClient.invalidateQueries({ queryKey: sourceFooterQueryKey }) }, @@ -378,46 +340,21 @@ function ConnectedZoneFlow(props: { address: Hex }) { throw new Error('Zone A needs more pathUSD before the swap can start.') } - const currentAllowance = await sourceZoneClient.token.getAllowance({ - account: address, - spender: zoneOutbox, - token: pathUsd, - }) - const requiredSourceAllowance = SWAP_AMOUNT + swapPrereqsQuery.data.routedWithdrawalFee - const includesApproval = currentAllowance < requiredSourceAllowance const callbackData = encodeRouterCallback({ minimumOutput: swapPrereqsQuery.data.minimumOutput, recipient: address, }) - const receipt = await sendTransactionSync( - sourceZoneClient as never, - { - account: connectorClient.account, - feeToken: pathUsd, - calls: [ - ...(includesApproval - ? [ - Actions.token.approve.call({ - amount: requiredSourceAllowance, - spender: zoneOutbox, - token: pathUsd, - }), - ] - : []), - Actions.zone.requestWithdrawal.call({ - account: connectorClient.account, - amount: SWAP_AMOUNT, - data: callbackData, - gasLimit: routerCallbackGasLimit, - to: swapAndDepositRouter, - token: pathUsd, - }), - ], - throwOnReceiptRevert: true, - timeout: zoneRpcSyncTimeout, - } as never, - ) + const { receipt } = await sourceZoneClient.zone.requestWithdrawalSync({ + account: connectorClient.account, + amount: SWAP_AMOUNT, + data: callbackData, + feeToken: pathUsd, + gas: routerCallbackGasLimit, + timeout: zoneRpcSyncTimeout, + to: swapAndDepositRouter, + token: pathUsd, + }) const anchorBlock = await publicClient.getBlockNumber() @@ -489,7 +426,7 @@ function ConnectedZoneFlow(props: { address: Hex }) { mutationFn: async () => { if (!targetZoneClient) throw new Error('Zone B client not ready') - return targetZoneClient.zone.prepareAuthorizationToken() + return targetZoneClient.zone.signAuthorizationToken() }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: targetFooterQueryKey }) @@ -515,12 +452,7 @@ function ConnectedZoneFlow(props: { address: Hex }) { }) const hasRootBalance = Boolean(rootBalance && rootBalance > 0n) - const hasPortalAllowance = Boolean( - portalAllowanceQuery.data !== undefined && - portalAllowanceQuery.data >= sourceZoneTopUpShortfall, - ) const topUpReceipt = topUpMutation.data?.receipt - const topUpIncludesApproval = topUpMutation.data?.includesApproval ?? !hasPortalAllowance const routedSwapReceipt = swapMutation.data?.receipt const settlementTxHash = settlementQuery.data?.txHash const targetBalanceReady = @@ -590,31 +522,6 @@ function ConnectedZoneFlow(props: { address: Hex }) { {fundMutation.isPending ? 'Getting pathUSD' : 'Get testnet pathUSD'} ) - } else if (!hasEnoughSourceZoneBalance && portalAllowanceQuery.isError) { - stepThreeAction = ( - - ) - } else if ( - !hasEnoughSourceZoneBalance && - (portalAllowanceQuery.isPending || portalAllowanceQuery.data === undefined) - ) { - stepThreeAction = ( - - ) } else if (!hasEnoughSourceZoneBalance) { stepThreeAction = ( ) } @@ -723,7 +624,6 @@ function ConnectedZoneFlow(props: { address: Hex }) { actions={stepThreeAction} error={ topUpMutation.error ?? - portalAllowanceQuery.error ?? sourceZoneBalanceQuery.error ?? swapPrereqsQuery.error ?? fundMutation.error diff --git a/src/components/guides/zones/WithdrawFromZone.tsx b/src/components/guides/zones/WithdrawFromZone.tsx index e2e71f36..0e817220 100644 --- a/src/components/guides/zones/WithdrawFromZone.tsx +++ b/src/components/guides/zones/WithdrawFromZone.tsx @@ -1,18 +1,17 @@ 'use client' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import * as React from 'react' -import { type Hex, parseAbiItem, parseUnits } from 'viem' -import { sendTransactionSync } from 'viem/actions' -import { Actions } from 'viem/tempo' +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 { - getTempoZoneClient, - getZoneClientParameters, + getZoneTransportConfig, moderatoZoneRpcUrls, + stripRpcBasicAuth, zoneRpcSyncTimeout, } from '../../../lib/private-zones.ts' -import { encodeAuthenticatedWithdrawalCall } from '../../../lib/private-zones-withdrawal.ts' import { Button, ExplorerLink, Logout, Step } from '../Demo' import { SignInButtons } from '../EmbedPasskeys' import { pathUsd } from '../tokens' @@ -20,7 +19,8 @@ import { useStickyStepCompletion } from './useStickyStepCompletion.ts' const ZONE_LABEL = 'Zone A' const ZONE_ID = 6 as const -const ZONE_OUTBOX = '0x1c00000000000000000000000000000000000002' as const +const AUTHENTICATED_WITHDRAWAL_REVEAL_TO = + '0x031dc147467e8f106eb22850fef549dc74b8f6634aeac554ebdd4ab896b67cdf68' as const const WITHDRAWAL_AMOUNT = parseUnits('100', 6) const ZONE_GAS_BUFFER = parseUnits('1', 6) @@ -32,22 +32,37 @@ type WithdrawalMode = 'standard' | 'authenticated' type ZoneClientLike = { token: { - getAllowance: (parameters: { account: Hex; spender: Hex; token: Hex }) => Promise getBalance: (parameters: { account: Hex; token: Hex }) => Promise } zone: { - getWithdrawalFee: () => Promise - prepareAuthorizationToken: () => Promise<{ - account: Hex - expiresAt: bigint + requestEncryptedWithdrawalSync: (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 } }> + signAuthorizationToken: () => Promise<{ + authentication: { + expiresAt: number + zoneId: number + } + token: Hex }> + getWithdrawalFee: () => Promise } } -type RootChainWithZones = { - zones?: Record -} - export function WithdrawFromZone() { const { address } = useConnection() const [mode, setMode] = React.useState('standard') @@ -80,9 +95,6 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { const queryClient = useQueryClient() const publicClient = usePublicClient() const { data: connectorClient } = useConnectorClient() - const zonePortalAddress = (connectorClient?.chain as RootChainWithZones | undefined)?.zones?.[ - ZONE_ID - ]?.portalAddress const { data: rootBalance, isPending: rootBalanceIsPending, @@ -94,11 +106,15 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { const zoneClient = React.useMemo( () => - connectorClient - ? (getTempoZoneClient( - connectorClient as never, - getZoneClientParameters(ZONE_ID, moderatoZoneRpcUrls[ZONE_ID]) as never, - ) as unknown as ZoneClientLike) + connectorClient?.account + ? (createClient({ + account: connectorClient.account, + chain: zoneModerato(ZONE_ID), + transport: zoneHttp( + stripRpcBasicAuth(moderatoZoneRpcUrls[ZONE_ID]), + getZoneTransportConfig(moderatoZoneRpcUrls[ZONE_ID]), + ), + }).extend(tempoActions()) as unknown as ZoneClientLike) : undefined, [connectorClient], ) @@ -109,7 +125,7 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { queryFn: async () => { if (!zoneClient) throw new Error('zone client not ready') - const auth = await zoneClient.zone.prepareAuthorizationToken() + const auth = await zoneClient.zone.signAuthorizationToken() return { auth } }, @@ -156,73 +172,19 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { withdrawalFeeQuery.data !== undefined ? WITHDRAWAL_AMOUNT + withdrawalFeeQuery.data + ZONE_GAS_BUFFER : undefined - const zoneApprovalTarget = - withdrawalFeeQuery.data !== undefined ? WITHDRAWAL_AMOUNT + withdrawalFeeQuery.data : undefined - - const zoneOutboxAllowanceQuery = useQuery({ - enabled: Boolean(zoneClient && authQuery.isSuccess && zoneApprovalTarget !== undefined), - queryKey: ['guide-private-zones-withdraw-zone-outbox-allowance', address, ZONE_ID], - queryFn: async () => { - if (!zoneClient) throw new Error('zone client not ready') - - return zoneClient.token.getAllowance({ - account: address, - spender: ZONE_OUTBOX, - token: pathUsd, - }) - }, - staleTime: 30_000, - }) - - const hasZoneOutboxAllowance = Boolean( - zoneApprovalTarget !== undefined && - zoneOutboxAllowanceQuery.data !== undefined && - zoneOutboxAllowanceQuery.data >= zoneApprovalTarget, - ) - const zoneBalanceRequirement = - hasZoneOutboxAllowance && zoneApprovalTarget !== undefined - ? zoneApprovalTarget - : zoneTopUpTarget const zoneTopUpShortfall = - zoneBalanceRequirement !== undefined && + zoneTopUpTarget !== undefined && zoneBalanceQuery.data !== undefined && - zoneBalanceQuery.data < zoneBalanceRequirement - ? zoneBalanceRequirement - zoneBalanceQuery.data + zoneBalanceQuery.data < zoneTopUpTarget + ? zoneTopUpTarget - zoneBalanceQuery.data : 0n const hasEnoughZoneBalance = Boolean( - zoneBalanceRequirement !== undefined && + zoneTopUpTarget !== undefined && zoneBalanceQuery.data !== undefined && - zoneBalanceQuery.data >= zoneBalanceRequirement, + zoneBalanceQuery.data >= zoneTopUpTarget, ) const zoneBalanceStepComplete = useStickyStepCompletion(hasEnoughZoneBalance) - const portalAllowanceQuery = useQuery({ - enabled: Boolean( - connectorClient && - zonePortalAddress && - authQuery.isSuccess && - !zoneBalanceStepComplete && - zoneTopUpShortfall > 0n, - ), - queryKey: [ - 'guide-private-zones-withdraw-portal-allowance', - address, - ZONE_ID, - zonePortalAddress, - ], - queryFn: async () => { - if (!connectorClient) throw new Error('connector client not ready') - if (!zonePortalAddress) throw new Error('zone portal address not configured') - - return Actions.token.getAllowance(connectorClient as never, { - account: address, - spender: zonePortalAddress, - token: pathUsd, - }) - }, - staleTime: 30_000, - }) - const fundMutation = useMutation({ mutationFn: async () => { if (!connectorClient) throw new Error('connector client not ready') @@ -239,45 +201,20 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { const topUpMutation = useMutation({ mutationFn: async () => { if (!connectorClient) throw new Error('connector client not ready') - if (!zonePortalAddress) throw new Error('zone portal address not configured') if (zoneTopUpShortfall <= 0n) throw new Error('zone top-up is not required') - const includesApproval = !hasPortalAllowance - const receipt = await sendTransactionSync( - connectorClient as never, - { - account: connectorClient.account, - calls: [ - ...(includesApproval - ? [ - Actions.token.approve.call({ - amount: zoneTopUpShortfall, - spender: zonePortalAddress, - token: pathUsd, - }), - ] - : []), - Actions.zone.deposit.call({ - account: connectorClient.account, - amount: zoneTopUpShortfall, - chain: connectorClient.chain as never, - token: pathUsd, - zone: ZONE_ID, - } as never), - ], - throwOnReceiptRevert: true, - timeout: 60_000, - } as never, - ) + 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 { - includesApproval, - receipt, - } + return { receipt } }, onSuccess: async () => { await refetchRootBalance() - await portalAllowanceQuery.refetch() await zoneBalanceQuery.refetch() }, }) @@ -287,7 +224,7 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { 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 (zoneApprovalTarget === undefined) throw new Error('withdrawal fee not ready') + if (withdrawalFeeQuery.data === undefined) throw new Error('withdrawal fee not ready') const currentRootBalance = await Actions.token.getBalance(connectorClient as never, { account: address, @@ -297,48 +234,33 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { account: address, token: pathUsd, }) - const includesApproval = !hasZoneOutboxAllowance - const withdrawalCall = + const receipt = mode === 'authenticated' - ? encodeAuthenticatedWithdrawalCall({ - amount: WITHDRAWAL_AMOUNT, - fallbackRecipient: address, - outbox: ZONE_OUTBOX, - to: address, - token: pathUsd, - }) - : Actions.zone.requestWithdrawal.call({ - account: connectorClient.account, - amount: WITHDRAWAL_AMOUNT, - token: pathUsd, - to: address, - }) - const receipt = await sendTransactionSync( - zoneClient as never, - { - account: connectorClient.account, - feeToken: pathUsd, - calls: [ - ...(includesApproval - ? [ - Actions.token.approve.call({ - amount: zoneApprovalTarget, - spender: ZONE_OUTBOX, - token: pathUsd, - }), - ] - : []), - withdrawalCall, - ], - throwOnReceiptRevert: true, - timeout: zoneRpcSyncTimeout, - } as never, - ) + ? ( + await zoneClient.zone.requestEncryptedWithdrawalSync({ + account: connectorClient.account, + amount: WITHDRAWAL_AMOUNT, + feeToken: pathUsd, + revealTo: AUTHENTICATED_WITHDRAWAL_REVEAL_TO, + timeout: zoneRpcSyncTimeout, + to: address, + token: pathUsd, + }) + ).receipt + : ( + await zoneClient.zone.requestWithdrawalSync({ + account: connectorClient.account, + amount: WITHDRAWAL_AMOUNT, + feeToken: pathUsd, + timeout: zoneRpcSyncTimeout, + to: address, + token: pathUsd, + }) + ).receipt const anchorBlock = await publicClient.getBlockNumber() return { anchorBlock, - includesApproval, receipt, startingRootBalance: currentRootBalance, startingZoneBalance: currentZoneBalance, @@ -347,7 +269,6 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { onSuccess: async () => { await refetchRootBalance() await zoneBalanceQuery.refetch() - await zoneOutboxAllowanceQuery.refetch() await withdrawalConfirmationQuery.refetch() }, }) @@ -421,13 +342,9 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { }) const hasRootBalance = Boolean(rootBalance && rootBalance > 0n) - const hasPortalAllowance = Boolean( - portalAllowanceQuery.data !== undefined && portalAllowanceQuery.data >= zoneTopUpShortfall, - ) const settlementTxHash = withdrawalConfirmationQuery.data?.txHash const withdrawalConfirmed = Boolean(settlementTxHash) const topUpReceipt = topUpMutation.data?.receipt - const topUpIncludesApproval = topUpMutation.data?.includesApproval ?? !hasPortalAllowance const authIsPreparing = authQuery.fetchStatus === 'fetching' const stepTwoAction = authQuery.isSuccess ? undefined : ( - ) - } else if ( - !hasEnoughZoneBalance && - (portalAllowanceQuery.isPending || portalAllowanceQuery.data === undefined) - ) { - stepThreeAction = ( - - ) } else if (!hasEnoughZoneBalance) { stepThreeAction = ( ) } @@ -529,17 +415,6 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { let stepFourAction: React.ReactNode if (!zoneBalanceStepComplete) { stepFourAction = undefined - } else if (zoneOutboxAllowanceQuery.isPending || zoneOutboxAllowanceQuery.data === undefined) { - stepFourAction = ( - - ) } else { stepFourAction = (
diff --git a/src/lib/private-zones-encryption.ts b/src/lib/private-zones-encryption.ts deleted file mode 100644 index 4293be17..00000000 --- a/src/lib/private-zones-encryption.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { Bytes, Hash, Hex as OxHex, PublicKey, Secp256k1 } from 'ox' -import { type Address, encodeFunctionData, type Hex, numberToHex, parseAbi } from 'viem' - -export const zeroBytes32 = - '0x0000000000000000000000000000000000000000000000000000000000000000' as const - -export const zonePortalDepositAbi = parseAbi([ - 'function calculateDepositFee() view returns (uint128)', - 'function sequencerEncryptionKey() view returns (bytes32 x, uint8 yParity)', - 'function encryptionKeyCount() view returns (uint256)', - 'function depositEncrypted(address token, uint128 amount, uint256 keyIndex, (bytes32 ephemeralPubkeyX, uint8 ephemeralPubkeyYParity, bytes ciphertext, bytes12 nonce, bytes16 tag) encrypted) returns (bytes32)', -]) - -export type SequencerEncryptionKey = - | { - x: Hex - yParity: number - } - | readonly [Hex, number] - -export type EncryptedDepositPayload = { - ciphertext: Hex - ephemeralPubkeyX: Hex - ephemeralPubkeyYParity: number - nonce: Hex - tag: Hex -} - -function normalizeSec1Parity(yParity: number) { - if (yParity === 0 || yParity === 1) return yParity + 2 - if (yParity === 2 || yParity === 3) return yParity - - throw new Error(`Unexpected yParity: ${yParity}`) -} - -function hkdf256(ikm: Uint8Array, salt: Uint8Array, info: Uint8Array) { - const prk = Hash.hmac256(salt, ikm, { as: 'Bytes' }) - return Hash.hmac256(prk, Bytes.concat(info, Uint8Array.from([1])), { as: 'Bytes' }) -} - -function normalizeSequencerEncryptionKey(key: SequencerEncryptionKey): { x: Hex; yParity: number } { - if ('x' in key) { - return { - x: key.x, - yParity: key.yParity, - } - } - - return { - x: key[0], - yParity: key[1], - } -} - -export async function encryptZoneDepositPayload(parameters: { - keyIndex: bigint - memo?: Hex | undefined - portalAddress: Address - recipient: Address - sequencerKey: SequencerEncryptionKey -}) { - const { privateKey: ephemeralPrivateKey, publicKey: ephemeralPublicKey } = - Secp256k1.createKeyPair() - const sequencerKey = normalizeSequencerEncryptionKey(parameters.sequencerKey) - - const sequencerPublicKey = PublicKey.from({ - prefix: normalizeSec1Parity(sequencerKey.yParity), - x: BigInt(sequencerKey.x), - }) - - const sharedSecret = Secp256k1.getSharedSecret({ - as: 'Bytes', - privateKey: ephemeralPrivateKey, - publicKey: sequencerPublicKey, - }) - - const compressedEphemeralKey = PublicKey.compress(ephemeralPublicKey) - const ephemeralPubkeyX = numberToHex(compressedEphemeralKey.x, { size: 32 }) - const sharedSecretX = sharedSecret.slice(1, 33) - - const hkdfInfo = Bytes.concat( - Bytes.from(parameters.portalAddress), - Bytes.from(numberToHex(parameters.keyIndex, { size: 32 })), - Bytes.from(ephemeralPubkeyX), - ) - const aesKeyBytes = hkdf256(sharedSecretX, new TextEncoder().encode('ecies-aes-key'), hkdfInfo) - const aesKey = await crypto.subtle.importKey( - 'raw', - Uint8Array.from(aesKeyBytes), - { name: 'AES-GCM' }, - false, - ['encrypt'], - ) - - const plaintext = Uint8Array.from( - Bytes.concat( - Bytes.from(parameters.recipient), - Bytes.from(parameters.memo ?? zeroBytes32), - new Uint8Array(12), - ), - ) - const nonce = crypto.getRandomValues(new Uint8Array(12)) - const sealed = new Uint8Array( - await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, aesKey, plaintext), - ) - - return { - ciphertext: OxHex.from(sealed.slice(0, -16)), - ephemeralPubkeyX, - ephemeralPubkeyYParity: compressedEphemeralKey.prefix, - nonce: OxHex.from(nonce), - tag: OxHex.from(sealed.slice(-16)), - } satisfies EncryptedDepositPayload -} - -export function encodeEncryptedDepositCall(parameters: { - amount: bigint - encrypted: EncryptedDepositPayload - keyIndex: bigint - portalAddress: Address - token: Address -}) { - return { - data: encodeFunctionData({ - abi: zonePortalDepositAbi, - functionName: 'depositEncrypted', - args: [parameters.token, parameters.amount, parameters.keyIndex, parameters.encrypted], - }), - to: parameters.portalAddress, - } -} - -export 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/lib/private-zones-withdrawal.ts b/src/lib/private-zones-withdrawal.ts deleted file mode 100644 index a2f3dbea..00000000 --- a/src/lib/private-zones-withdrawal.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { PublicKey } from 'ox' -import { type Address, encodeFunctionData, type Hex, parseAbi } from 'viem' -import { zeroBytes32 } from './private-zones-encryption.ts' - -export const AUTHENTICATED_WITHDRAWAL_REVEAL_TO = - '0x031dc147467e8f106eb22850fef549dc74b8f6634aeac554ebdd4ab896b67cdf68' as const - -export const zoneOutboxAuthenticatedWithdrawalAbi = parseAbi([ - 'function requestWithdrawal(address token, address to, uint128 amount, bytes32 memo, uint64 gasLimit, address fallbackRecipient, bytes data, bytes revealTo)', -]) - -export function encodeAuthenticatedWithdrawalCall(parameters: { - amount: bigint - data?: Hex | undefined - fallbackRecipient: Address - gasLimit?: bigint | undefined - memo?: Hex | undefined - outbox: Address - revealTo?: Hex | undefined - to: Address - token: Address -}) { - const revealTo = PublicKey.toHex( - PublicKey.fromHex(parameters.revealTo ?? AUTHENTICATED_WITHDRAWAL_REVEAL_TO), - ) - - return { - data: encodeFunctionData({ - abi: zoneOutboxAuthenticatedWithdrawalAbi, - functionName: 'requestWithdrawal', - args: [ - parameters.token, - parameters.to, - parameters.amount, - parameters.memo ?? zeroBytes32, - parameters.gasLimit ?? 0n, - parameters.fallbackRecipient, - parameters.data ?? '0x', - revealTo, - ], - }), - to: parameters.outbox, - } -} diff --git a/src/lib/private-zones.ts b/src/lib/private-zones.ts index fa4d311d..ca061138 100644 --- a/src/lib/private-zones.ts +++ b/src/lib/private-zones.ts @@ -1,5 +1,9 @@ -import { type Hex, walletActions } from 'viem' -import { type GetZoneClientParameters, tempoActions, type ZoneTransportConfig } from 'viem/tempo' +type ZoneTransportConfig = { + onFetchRequest?: ( + request: Request, + init?: RequestInit, + ) => Promise | RequestInit | undefined +} export const feeToken = '0x20c0000000000000000000000000000000000001' as const export const stablecoinDex = '0xDEc0000000000000000000000000000000000000' as const @@ -82,7 +86,7 @@ export function getZoneTransportConfig(rpcUrl: string): ZoneTransportConfig | un const authorization = `Basic ${encodeBase64(`${username}:${password}`)}` return { - async onFetchRequest(_request, init) { + async onFetchRequest(_request: Request, init?: RequestInit) { const headers = new Headers(init?.headers) headers.set('authorization', authorization) @@ -94,58 +98,6 @@ export function getZoneTransportConfig(rpcUrl: string): ZoneTransportConfig | un } } -export function getZoneClientParameters(zone: number, rpcUrl: string) { - const transport = getZoneTransportConfig(rpcUrl) - - return transport ? { transport, zone } : { zone } -} - -type ClientWithExtend = { - extend: (decorator: unknown) => ClientWithExtend -} - -type AuthorizationTokenInfo = { - account: Hex - expiresAt: bigint -} - -type DepositStatus = { - deposits: readonly unknown[] - processed: boolean -} - -type ZoneInfo = { - chainId: number - zoneId: number - zoneTokens: readonly unknown[] -} - -export type TempoZoneClient = { - getBlockNumber: () => Promise - token: { - getAllowance: (parameters: { account: Hex; spender: Hex; token: Hex }) => Promise - getBalance: (parameters: { account: Hex; token: Hex }) => Promise - } - zone: { - getAuthorizationTokenInfo: () => Promise - getDepositStatus: (parameters: { tempoBlockNumber: bigint }) => Promise - getWithdrawalFee: (parameters?: { gasLimit?: bigint | undefined }) => Promise - getZoneInfo: () => Promise - prepareAuthorizationToken: () => Promise - } -} - -// `viem/tempo` exposes zone client creation through the public tempo decorator. -export function getTempoZoneClient(client: ClientWithExtend, parameters: GetZoneClientParameters) { - const zoneCapableClient = client - .extend(walletActions) - .extend(tempoActions()) as ClientWithExtend & { - getZoneClient: (parameters: GetZoneClientParameters) => TempoZoneClient - } - - return zoneCapableClient.getZoneClient(parameters) -} - function encodeBase64(value: string) { if (typeof globalThis.btoa === 'function') return globalThis.btoa(value) diff --git a/src/pages/guide/private-zones/deposit-to-a-zone.mdx b/src/pages/guide/private-zones/deposit-to-a-zone.mdx index fb39bd4e..a082e101 100644 --- a/src/pages/guide/private-zones/deposit-to-a-zone.mdx +++ b/src/pages/guide/private-zones/deposit-to-a-zone.mdx @@ -4,6 +4,7 @@ description: Deposit pathUSD from your public-chain balance into Zone A and conf --- import * as Demo from '../../../components/guides/Demo.tsx' +import { Tab, Tabs } from 'vocs' import { DepositToZone } from '../../../components/guides/zones/DepositToZone.tsx' # Deposit to a Zone @@ -16,6 +17,8 @@ Use this guide when you want to move `pathUSD` from your public Tempo balance in 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. +The demo below supports both the standard deposit flow and the upstream `Actions.zone.encryptedDepositSync(...)` flow when you want the zone recipient and memo to stay encrypted from everyone except the sequencer. + ## Depositing pathUSD to Zone A By the end of this guide you will have deposited `pathUSD` into `Zone A` and confirmed the balance update. @@ -29,40 +32,52 @@ By the end of this guide you will have deposited `pathUSD` into `Zone A` and con -## Code example +## 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, +}) -This snippet assumes you already have a signed-in `rootClient` on the public chain and the usual token and zone constants in scope. -It shows the core deposit transaction path; use the demo above when you want to watch the resulting zone balance update. +console.log(receipt.blockNumber) +``` + + + ```ts import { parseUnits } from 'viem' -import { sendTransactionSync } from 'viem/actions' import { Actions } from 'viem/tempo' const depositAmount = parseUnits('100', 6) -const receipt = await sendTransactionSync(rootClient, { +const { receipt } = await Actions.zone.encryptedDepositSync(rootClient, { account: rootClient.account, - calls: [ - Actions.token.approve.call({ - amount: depositAmount, - spender: ZONE_A.portalAddress, - token: pathUsd, - }), - Actions.zone.deposit.call({ - account: rootClient.account, - amount: depositAmount, - chain: rootClient.chain, - token: pathUsd, - zone: ZONE_A.id, - }), - ], - throwOnReceiptRevert: true, + amount: depositAmount, + token: pathUsd, + zoneId: ZONE_A.id, }) console.log(receipt.blockNumber) ``` + + + ## What Happens During a Deposit A zone deposit settles in two phases. diff --git a/src/pages/guide/private-zones/index.mdx b/src/pages/guide/private-zones/index.mdx index 54980162..554785e6 100644 --- a/src/pages/guide/private-zones/index.mdx +++ b/src/pages/guide/private-zones/index.mdx @@ -19,9 +19,9 @@ Tempo Zones let you keep balances and transfers inside a private execution envir - Keep some `pathUSD` on the public chain if you want to try deposits, zone top-ups, swaps, or withdrawals. - Expect deposits, routed swaps, and withdrawals to complete asynchronously rather than in a single balance update. -These guides cover the baseline zone workflows used in the current demos: regular deposits through `ZonePortal.deposit(...)`, in-zone transfers, routed swaps, and direct withdrawals through `ZoneOutbox.requestWithdrawal(...)`. +These guides cover the baseline zone workflows used in the current demos: deposits through `Actions.zone.depositSync(...)` and `Actions.zone.encryptedDepositSync(...)`, in-zone transfers, routed swaps, direct withdrawals through `Actions.zone.requestWithdrawalSync(...)`, and authenticated withdrawals through `Actions.zone.requestEncryptedWithdrawalSync(...)`. -When you need protocol-level privacy features before high-level SDK helpers land, the deposit guide's demo lets you switch between plaintext and encrypted deposits, and the withdrawal guide still includes a hand-rolled `requestWithdrawal(..., revealTo)` example. +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 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 index 2e6fca68..18626380 100644 --- a/src/pages/guide/private-zones/send-tokens-within-a-zone.mdx +++ b/src/pages/guide/private-zones/send-tokens-within-a-zone.mdx @@ -42,7 +42,7 @@ import { Actions } from 'viem/tempo' const transferAmount = parseUnits('25', 6) const demoRecipient = '0xbeefcafe54750903ac1c8909323af7beb21ea2cb' as Address -await zoneAClient.zone.prepareAuthorizationToken() +await zoneAClient.zone.signAuthorizationToken() const { receipt } = await Actions.token.transferSync(zoneAClient, { account: rootClient.account, diff --git a/src/pages/guide/private-zones/swap-across-zones.mdx b/src/pages/guide/private-zones/swap-across-zones.mdx index 09c8a097..ffade43b 100644 --- a/src/pages/guide/private-zones/swap-across-zones.mdx +++ b/src/pages/guide/private-zones/swap-across-zones.mdx @@ -46,14 +46,13 @@ It shows the core routed swap submission path; use the demo above when you want ```ts import { encodeAbiParameters, parseUnits } from 'viem' -import { sendTransactionSync } from 'viem/actions' import { Actions } from 'viem/tempo' const swapAmount = parseUnits('25', 6) -await zoneAClient.zone.prepareAuthorizationToken() +await zoneAClient.zone.signAuthorizationToken() -const routedWithdrawalFee = await zoneAClient.zone.getWithdrawalFee({ gasLimit: routerCallbackGasLimit }) +const routedWithdrawalFee = await zoneAClient.zone.getWithdrawalFee({ gas: routerCallbackGasLimit }) const quotedBetaOut = await rootClient.dex.getSellQuote({ amountIn: swapAmount, tokenIn: pathUsd, @@ -61,7 +60,6 @@ const quotedBetaOut = await rootClient.dex.getSellQuote({ }) const minimumBetaOut = quotedBetaOut - quotedBetaOut / 100n -const requiredZoneAllowance = swapAmount + routedWithdrawalFee const callbackData = encodeAbiParameters( [ @@ -75,25 +73,15 @@ const callbackData = encodeAbiParameters( [false, betaUsd, ZONE_B.portalAddress, rootClient.account.address, zeroBytes32, minimumBetaOut], ) -const receipt = await sendTransactionSync(zoneAClient, { +const { receipt } = await Actions.zone.requestWithdrawalSync(zoneAClient, { account: rootClient.account, + amount: swapAmount, + data: callbackData, feeToken: pathUsd, - calls: [ - Actions.token.approve.call({ - amount: requiredZoneAllowance, - spender: zoneOutbox, - token: pathUsd, - }), - Actions.zone.requestWithdrawal.call({ - account: rootClient.account, - amount: swapAmount, - data: callbackData, - gasLimit: routerCallbackGasLimit, - to: swapAndDepositRouter, - token: pathUsd, - }), - ], - throwOnReceiptRevert: true, + gas: routerCallbackGasLimit, + timeout: zoneRpcSyncTimeout, + to: swapAndDepositRouter, + token: pathUsd, }) console.log(receipt.blockNumber) diff --git a/src/pages/guide/private-zones/withdraw-from-a-zone.mdx b/src/pages/guide/private-zones/withdraw-from-a-zone.mdx index d95e63a4..8d4a542f 100644 --- a/src/pages/guide/private-zones/withdraw-from-a-zone.mdx +++ b/src/pages/guide/private-zones/withdraw-from-a-zone.mdx @@ -4,6 +4,7 @@ description: Withdraw pathUSD from Zone A back to your public-chain balance with --- import * as Demo from '../../../components/guides/Demo.tsx' +import { Tab, Tabs } from 'vocs' import { WithdrawFromZone } from '../../../components/guides/zones/WithdrawFromZone.tsx' # Withdraw from a Zone @@ -14,9 +15,9 @@ Tempo Zones is still in early development and is available for testing purposes 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 approve the withdrawal amount plus fee, submit the request, then wait for the public balance to increase after the batch settles. +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. -This page covers the standard `ZoneOutbox.requestWithdrawal(...)` flow. The `prepareAuthorizationToken()` call in the example makes authenticated zone RPC access explicit for the session; it is not a separate withdrawal mode. +The demo below supports both the standard `Actions.zone.requestWithdrawalSync(...)` flow and the authenticated `Actions.zone.requestEncryptedWithdrawalSync(...)` flow. In both cases, `signAuthorizationToken()` makes authenticated zone RPC access explicit for the session. ## Withdrawing pathUSD from Zone A @@ -31,46 +32,59 @@ By the end of this guide you will have withdrawn `pathUSD` from `Zone A` and con -## Code example +## Code examples -This snippet assumes you already have a signed-in `rootClient` on the public chain, a derived `zoneAClient`, and the usual token and outbox constants in scope. -It shows the core withdrawal transaction path; use the demo above when you want to watch the public balance settle. +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 { sendTransactionSync } from 'viem/actions' import { Actions } from 'viem/tempo' const withdrawalAmount = parseUnits('100', 6) -await zoneAClient.zone.prepareAuthorizationToken() +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' -const withdrawalFee = await zoneAClient.zone.getWithdrawalFee() -const requiredZoneAllowance = withdrawalAmount + withdrawalFee +await zoneAClient.zone.signAuthorizationToken() -const receipt = await sendTransactionSync(zoneAClient, { +const { receipt } = await Actions.zone.requestEncryptedWithdrawalSync(zoneAClient, { account: rootClient.account, feeToken: pathUsd, - calls: [ - Actions.token.approve.call({ - amount: requiredZoneAllowance, - spender: zoneOutbox, - token: pathUsd, - }), - Actions.zone.requestWithdrawal.call({ - account: rootClient.account, - amount: withdrawalAmount, - token: pathUsd, - to: rootClient.account.address, - }), - ], - throwOnReceiptRevert: true, + amount: withdrawalAmount, + revealTo, + token: pathUsd, + to: rootClient.account.address, }) console.log(receipt.blockNumber) ``` -In other words, the withdrawal transaction is still `requestWithdrawal(...)`. The explicit authorization step is there so the app can use the zone RPC without triggering an unexpected wallet prompt later in the flow. + + ## What a Direct Withdrawal Does From b76061a67f72b17df6d5b62665a666787f02388f Mon Sep 17 00:00:00 2001 From: Zygimantas <5236121+Zygimantass@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:21:27 +0200 Subject: [PATCH 06/25] feat: add send across zones --- .../guides/zones/SendTokensAcrossZones.tsx | 740 ++++++++++++++++++ .../guide/private-zones/deposit-to-a-zone.mdx | 2 - src/pages/guide/private-zones/index.mdx | 15 +- .../send-tokens-across-zones.mdx | 88 +++ .../guide/private-zones/swap-across-zones.mdx | 11 +- vocs.config.ts | 4 + 6 files changed, 844 insertions(+), 16 deletions(-) create mode 100644 src/components/guides/zones/SendTokensAcrossZones.tsx create mode 100644 src/pages/guide/private-zones/send-tokens-across-zones.mdx diff --git a/src/components/guides/zones/SendTokensAcrossZones.tsx b/src/components/guides/zones/SendTokensAcrossZones.tsx new file mode 100644 index 00000000..d4a8541e --- /dev/null +++ b/src/components/guides/zones/SendTokensAcrossZones.tsx @@ -0,0 +1,740 @@ +'use client' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import * as React from 'react' +import { + createClient, + encodeAbiParameters, + formatUnits, + 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, + routerCallbackGasLimit, + stripRpcBasicAuth, + swapAndDepositRouter, + ZONE_A, + ZONE_B, + zeroBytes32, + zoneRpcSyncTimeout, +} from '../../../lib/private-zones.ts' +import { Button, ExplorerLink, Login, Logout, Step } from '../Demo' +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: { + 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: () => Promise<{ + authentication: { + expiresAt: number + zoneId: number + } + token: Hex + }> + } +} + +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: rootBalance, + isPending: rootBalanceIsPending, + refetch: refetchRootBalance, + } = Hooks.token.useGetBalance({ + account: address, + token: pathUsd, + }) + + const sourceZoneClient = React.useMemo( + () => + connectorClient?.account + ? (createClient({ + account: connectorClient.account, + chain: zoneModerato(ZONE_A.id), + transport: zoneHttp( + stripRpcBasicAuth(ZONE_A.rpcUrl), + getZoneTransportConfig(ZONE_A.rpcUrl), + ), + }).extend(tempoActions()) as unknown as ZoneClientLike) + : undefined, + [connectorClient], + ) + const targetZoneClient = React.useMemo( + () => + connectorClient?.account + ? (createClient({ + account: connectorClient.account, + chain: zoneModerato(ZONE_B.id), + transport: zoneHttp( + stripRpcBasicAuth(ZONE_B.rpcUrl), + getZoneTransportConfig(ZONE_B.rpcUrl), + ), + }).extend(tempoActions()) as unknown as ZoneClientLike) + : undefined, + [connectorClient], + ) + + 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 sourceAuthQuery = useQuery({ + enabled: false, + queryKey: ['guide-private-zones-cross-zone-send-source-auth', address, ZONE_A.id], + queryFn: async () => { + if (!sourceZoneClient) throw new Error('Zone A client not ready') + + const auth = await sourceZoneClient.zone.signAuthorizationToken() + + return { auth } + }, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + retry: false, + staleTime: 30_000, + }) + + const sourceZoneBalanceQuery = useQuery({ + enabled: Boolean(sourceZoneClient && sourceAuthQuery.isSuccess), + 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 && sourceAuthQuery.isSuccess), + 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 (!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 { receipt } = await sourceZoneClient.zone.requestWithdrawalSync({ + account: connectorClient.account, + amount: TRANSFER_AMOUNT, + data: encodeRouterCallback(address), + feeToken: pathUsd, + gas: routerCallbackGasLimit, + timeout: zoneRpcSyncTimeout, + to: swapAndDepositRouter, + token: pathUsd, + }) + + const anchorBlock = await publicClient.getBlockNumber() + + 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 > 5n ? sendMutation.data.anchorBlock - 5n : 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 targetAuthMutation = useMutation({ + mutationFn: async () => { + if (!targetZoneClient) throw new Error('Zone B client not ready') + + return targetZoneClient.zone.signAuthorizationToken() + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: targetFooterQueryKey }) + await targetZoneBalanceQuery.refetch() + }, + }) + + const targetZoneBalanceQuery = useQuery({ + enabled: Boolean(targetZoneClient && targetAuthMutation.isSuccess && 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 && targetAuthMutation.isSuccess && targetZoneBalanceQuery.isSuccess, + ) + const sourceAuthIsPreparing = sourceAuthQuery.fetchStatus === 'fetching' + const stepTwoAction = sourceAuthQuery.isSuccess ? undefined : ( + + ) + + React.useEffect(() => { + if (!sourceAuthQuery.isSuccess) return + + void queryClient.invalidateQueries({ queryKey: sourceFooterQueryKey }) + }, [queryClient, sourceAuthQuery.isSuccess, sourceFooterQueryKey]) + + React.useEffect(() => { + if (!targetAuthMutation.isSuccess) return + + void queryClient.invalidateQueries({ queryKey: targetFooterQueryKey }) + }, [queryClient, targetAuthMutation.isSuccess, targetFooterQueryKey]) + + 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 = ( + + ) + } else if (!hasEnoughSourceZoneBalance && !hasRootBalance) { + stepThreeAction = ( + + ) + } else if (!hasEnoughSourceZoneBalance) { + stepThreeAction = ( + + ) + } + + let stepFourAction: React.ReactNode + if (!sourceZoneBalanceStepComplete || transferPrereqsQuery.isPending) { + stepFourAction = undefined + } else if (transferPrereqsQuery.isError) { + stepFourAction = ( + + ) + } else { + stepFourAction = ( + + ) + } + + let stepSixAction: React.ReactNode + if (!settlementQuery.data) { + stepSixAction = undefined + } else if (targetZoneBalanceQuery.isError) { + stepSixAction = ( + + ) + } else if (!targetAuthMutation.isSuccess) { + stepSixAction = ( + + ) + } else if (targetZoneBalanceQuery.isPending) { + stepSixAction = ( + + ) + } + + return ( + <> + + + + {topUpReceipt && ( + + + + + )} + + + + {routedSendReceipt && sendMutation.data && ( + + + + + + + )} + + + + +

+ The funds have already left {ZONE_A.label}. This step polls the public-chain deposit + into {ZONE_B.label} every 2 seconds while the routed withdrawal is processed. +

+

+ Because the token stays as pathUSD, the router skips the swap and deposits the same + asset into {ZONE_B.label}. {ZONE_B.label}'s portal deposit fee is still deducted from + the arriving amount. +

+ {settlementTxHash && } +
+
+ + + +

+ The routed deposit can settle before this page is allowed to read {ZONE_B.label}. Once + you authorize private reads for this session, the demo fetches your pathUSD balance. +

+
+
+ + ) +} + +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 ( +
+
+
{props.children}
+
+
+ ) +} + +function DetailLine(props: { label: string; value: string; dataTestId?: string | undefined }) { + const { dataTestId, label, value } = props + + return ( +
+ {label} + + {value} + +
+ ) +} diff --git a/src/pages/guide/private-zones/deposit-to-a-zone.mdx b/src/pages/guide/private-zones/deposit-to-a-zone.mdx index a082e101..04607a2a 100644 --- a/src/pages/guide/private-zones/deposit-to-a-zone.mdx +++ b/src/pages/guide/private-zones/deposit-to-a-zone.mdx @@ -17,8 +17,6 @@ Use this guide when you want to move `pathUSD` from your public Tempo balance in 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. -The demo below supports both the standard deposit flow and the upstream `Actions.zone.encryptedDepositSync(...)` flow when you want the zone recipient and memo to stay encrypted from everyone except the sequencer. - ## Depositing pathUSD to Zone A By the end of this guide you will have deposited `pathUSD` into `Zone A` and confirmed the balance update. diff --git a/src/pages/guide/private-zones/index.mdx b/src/pages/guide/private-zones/index.mdx index 554785e6..54736f02 100644 --- a/src/pages/guide/private-zones/index.mdx +++ b/src/pages/guide/private-zones/index.mdx @@ -1,6 +1,6 @@ --- title: Connect to Tempo Zones -description: Learn how Tempo Zones work alongside the public chain and follow guides for depositing, sending, swapping, and withdrawing pathUSD across Zone A and Zone B. +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' @@ -16,10 +16,10 @@ Tempo Zones let you keep balances and transfers inside a private execution envir ## 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, zone top-ups, swaps, or withdrawals. -- Expect deposits, routed swaps, and withdrawals to complete asynchronously rather than in a single balance update. +- 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 baseline zone workflows used in the current demos: deposits through `Actions.zone.depositSync(...)` and `Actions.zone.encryptedDepositSync(...)`, in-zone transfers, routed swaps, direct withdrawals through `Actions.zone.requestWithdrawalSync(...)`, and authenticated withdrawals through `Actions.zone.requestEncryptedWithdrawalSync(...)`. +These guides cover the baseline zone workflows used in the current 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.requestEncryptedWithdrawalSync(...)`. 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. @@ -27,6 +27,7 @@ The deposit guide's demo lets you switch between plaintext and encrypted deposit - **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. @@ -43,6 +44,12 @@ The deposit guide's demo lets you switch between plaintext and encrypted deposit title="Send tokens within a zone" to="/guide/private-zones/send-tokens-within-a-zone" /> + + + + +## 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/swap-across-zones.mdx b/src/pages/guide/private-zones/swap-across-zones.mdx index ffade43b..9c3c3a54 100644 --- a/src/pages/guide/private-zones/swap-across-zones.mdx +++ b/src/pages/guide/private-zones/swap-across-zones.mdx @@ -93,15 +93,6 @@ This guide's swap flow is asynchronous because the trade temporarily leaves the 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. -That creates multiple checkpoints your app should reflect in the UI: - -- The withdrawal request is accepted on the zone. -- The withdrawal is processed and the funds are released on the L1. -- The StablecoinDEX completes the swap on the L1 and the output token is deposited back into the `ZonePortal`. -- Deposit is processed back into the zone and becomes visible through the zone client as a post-fee amount. - -A user may see the input asset leave the zone balance before the output asset appears. The swap should only be treated as fully settled once the zone balance for the output token increases. That balance increase reflects the router output minus the portal deposit fee. - :::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. +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/vocs.config.ts b/vocs.config.ts index 5c4a541a..a13375ef 100644 --- a/vocs.config.ts +++ b/vocs.config.ts @@ -170,6 +170,10 @@ export default defineConfig({ 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', From 78615b79b876b69e3f86eb01fed0e97d1ab27554 Mon Sep 17 00:00:00 2001 From: Zygimantas <5236121+Zygimantass@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:20:00 +0200 Subject: [PATCH 07/25] feat: reuse auth token --- src/components/guides/Demo.tsx | 8 +- src/components/guides/zones/DepositToZone.tsx | 73 +-- .../guides/zones/SendTokensAcrossZones.tsx | 606 ++++++++++-------- .../guides/zones/SendTokensWithinZone.tsx | 83 +-- .../guides/zones/SwapAcrossZones.tsx | 143 ++--- .../guides/zones/WithdrawFromZone.tsx | 89 ++- src/lib/useRootWebAuthnAccount.ts | 31 + src/lib/useZoneAuthorization.ts | 114 ++++ .../private-zones/withdraw-from-a-zone.mdx | 2 - src/wagmi.config.ts | 16 +- 10 files changed, 650 insertions(+), 515 deletions(-) create mode 100644 src/lib/useRootWebAuthnAccount.ts create mode 100644 src/lib/useZoneAuthorization.ts diff --git a/src/components/guides/Demo.tsx b/src/components/guides/Demo.tsx index 3bee9ade..770045e5 100644 --- a/src/components/guides/Demo.tsx +++ b/src/components/guides/Demo.tsx @@ -21,6 +21,7 @@ import { 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' @@ -261,6 +262,7 @@ export namespace Container { function ZoneBalancesFooterItem(props: ZoneBalance & { address: Address; showLabel: boolean }) { const { address, label, showLabel, token, zone } = props const { data: connectorClient } = useConnectorClient() + const { data: rootWebAuthnAccount } = useRootWebAuthnAccount() const zoneRpcUrl = moderatoZoneRpcUrls[zone as keyof typeof moderatoZoneRpcUrls] ?? ( @@ -270,9 +272,9 @@ export namespace Container { )?.zones?.[zone]?.rpcUrls.default.http[0] const zoneClient = React.useMemo( () => - connectorClient?.account && zoneRpcUrl + rootWebAuthnAccount && zoneRpcUrl ? (createClient({ - account: connectorClient.account, + account: rootWebAuthnAccount, chain: zoneModerato(zone), transport: zoneHttp( stripRpcBasicAuth(zoneRpcUrl), @@ -280,7 +282,7 @@ export namespace Container { ), }).extend(tempoActions()) as unknown as ZoneClientLike) : undefined, - [connectorClient, zone, zoneRpcUrl], + [rootWebAuthnAccount, zone, zoneRpcUrl], ) const { data: metadata, isPending: metadataIsPending } = Hooks.token.useGetMetadata({ token, diff --git a/src/components/guides/zones/DepositToZone.tsx b/src/components/guides/zones/DepositToZone.tsx index 1c924494..3281df23 100644 --- a/src/components/guides/zones/DepositToZone.tsx +++ b/src/components/guides/zones/DepositToZone.tsx @@ -11,6 +11,8 @@ import { 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' @@ -26,15 +28,7 @@ type ZoneClientLike = { token: { getBalance: (parameters: { account: Hex; token: Hex }) => Promise } - zone: { - signAuthorizationToken: () => Promise<{ - authentication: { - expiresAt: number - zoneId: number - } - token: Hex - }> - } + zone: ZoneAuthClientLike['zone'] } type RootChainWithZones = { @@ -76,6 +70,7 @@ function ConnectedZoneFlow(props: { address: Hex; mode: DepositMode }) { const { address, mode } = props const queryClient = useQueryClient() const { data: connectorClient } = useConnectorClient() + const { data: rootWebAuthnAccount } = useRootWebAuthnAccount() const publicClient = usePublicClient() const zonePortalAddress = (connectorClient?.chain as RootChainWithZones | undefined)?.zones?.[ ZONE_ID @@ -91,9 +86,9 @@ function ConnectedZoneFlow(props: { address: Hex; mode: DepositMode }) { const zoneClient = React.useMemo( () => - connectorClient?.account + rootWebAuthnAccount ? (createClient({ - account: connectorClient.account, + account: rootWebAuthnAccount, chain: zoneModerato(ZONE_ID), transport: zoneHttp( stripRpcBasicAuth(moderatoZoneRpcUrls[ZONE_ID]), @@ -101,35 +96,28 @@ function ConnectedZoneFlow(props: { address: Hex; mode: DepositMode }) { ), }).extend(tempoActions()) as unknown as ZoneClientLike) : undefined, - [connectorClient], + [rootWebAuthnAccount], ) - const authQuery = useQuery({ - enabled: false, + const zoneAuthorization = useZoneAuthorization({ + address, + chainId: zoneModerato(ZONE_ID).id, queryKey: ['guide-private-zones-auth', address, ZONE_ID], - queryFn: async () => { - if (!zoneClient) throw new Error('zone client not ready') - - const auth = await zoneClient.zone.signAuthorizationToken() - - return { auth } - }, - refetchOnReconnect: false, - refetchOnWindowFocus: false, - retry: false, - staleTime: 30_000, + zoneClient, }) React.useEffect(() => { - if (!authQuery.isSuccess) return + if (!zoneAuthorization.isAuthorized) return void queryClient.invalidateQueries({ queryKey: ['demo-zone-balance', address, ZONE_ID], }) - }, [address, authQuery.isSuccess, queryClient]) + }, [address, queryClient, zoneAuthorization.isAuthorized]) const depositSetupQuery = useQuery({ - enabled: Boolean(connectorClient && publicClient && zonePortalAddress && authQuery.isSuccess), + 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') @@ -229,7 +217,9 @@ function ConnectedZoneFlow(props: { address: Hex; mode: DepositMode }) { : undefined const zoneBalanceQuery = useQuery({ - enabled: Boolean(zoneClient && authQuery.isSuccess && targetZoneBalance !== undefined), + 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') @@ -261,18 +251,19 @@ function ConnectedZoneFlow(props: { address: Hex; mode: DepositMode }) { typeof zoneBalanceQuery.data === 'bigint' && zoneBalanceQuery.data >= targetZoneBalance, ) - const authIsPreparing = authQuery.fetchStatus === 'fetching' - const stepTwoAction = authQuery.isSuccess ? undefined : ( + const authIsPreparing = + zoneAuthorization.isChecking || zoneAuthorization.authorizeMutation.isPending + const stepTwoAction = zoneAuthorization.isAuthorized ? undefined : ( @@ -283,10 +274,10 @@ function ConnectedZoneFlow(props: { address: Hex; mode: DepositMode }) { stepThreeAction = ( @@ -317,10 +308,10 @@ function ConnectedZoneFlow(props: { address: Hex; mode: DepositMode }) { stepThreeAction = ( @@ -330,16 +321,16 @@ function ConnectedZoneFlow(props: { address: Hex; mode: DepositMode }) { return ( <> 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: () => Promise<{ - authentication: { - expiresAt: number - zoneId: number - } - token: Hex - }> - } -} + 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) + const { address } = useConnection(); + const connected = Boolean(address); return ( <> @@ -98,14 +100,15 @@ export function SendTokensAcrossZones() { )} - ) + ); } function ConnectedZoneFlow(props: { address: Hex }) { - const { address } = props - const queryClient = useQueryClient() - const publicClient = usePublicClient() - const { data: connectorClient } = useConnectorClient() + const { address } = props; + const queryClient = useQueryClient(); + const publicClient = usePublicClient(); + const { data: connectorClient } = useConnectorClient(); + const { data: rootWebAuthnAccount } = useRootWebAuthnAccount(); const { data: rootBalance, isPending: rootBalanceIsPending, @@ -113,13 +116,13 @@ function ConnectedZoneFlow(props: { address: Hex }) { } = Hooks.token.useGetBalance({ account: address, token: pathUsd, - }) + }); const sourceZoneClient = React.useMemo( () => - connectorClient?.account + rootWebAuthnAccount ? (createClient({ - account: connectorClient.account, + account: rootWebAuthnAccount, chain: zoneModerato(ZONE_A.id), transport: zoneHttp( stripRpcBasicAuth(ZONE_A.rpcUrl), @@ -127,13 +130,13 @@ function ConnectedZoneFlow(props: { address: Hex }) { ), }).extend(tempoActions()) as unknown as ZoneClientLike) : undefined, - [connectorClient], - ) + [rootWebAuthnAccount], + ); const targetZoneClient = React.useMemo( () => - connectorClient?.account + rootWebAuthnAccount ? (createClient({ - account: connectorClient.account, + account: rootWebAuthnAccount, chain: zoneModerato(ZONE_B.id), transport: zoneHttp( stripRpcBasicAuth(ZONE_B.rpcUrl), @@ -141,158 +144,179 @@ function ConnectedZoneFlow(props: { address: Hex }) { ), }).extend(tempoActions()) as unknown as ZoneClientLike) : undefined, - [connectorClient], - ) + [rootWebAuthnAccount], + ); const sourceFooterQueryKey = React.useMemo( - () => ['demo-zone-balance', address, ZONE_A.id, pathUsd], + () => ["demo-zone-balance", address, ZONE_A.id, pathUsd], [address], - ) + ); const targetFooterQueryKey = React.useMemo( - () => ['demo-zone-balance', address, ZONE_B.id, pathUsd], + () => ["demo-zone-balance", address, ZONE_B.id, pathUsd], [address], - ) - - const sourceAuthQuery = useQuery({ - enabled: false, - queryKey: ['guide-private-zones-cross-zone-send-source-auth', address, ZONE_A.id], - queryFn: async () => { - if (!sourceZoneClient) throw new Error('Zone A client not ready') + ); - const auth = await sourceZoneClient.zone.signAuthorizationToken() - - return { auth } - }, - refetchOnReconnect: false, - refetchOnWindowFocus: false, - retry: false, - staleTime: 30_000, - }) + 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 && sourceAuthQuery.isSuccess), - queryKey: ['guide-private-zones-cross-zone-send-source-balance', address, ZONE_A.id], + 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') + 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 && sourceAuthQuery.isSuccess), - queryKey: ['guide-private-zones-cross-zone-send-prereqs', address, ZONE_A.id, ZONE_B.id], + 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 (!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.`) + 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 + ? TRANSFER_AMOUNT + + transferPrereqsQuery.data.routedWithdrawalFee + + ZONE_GAS_BUFFER + : undefined; const sourceZoneTopUpShortfall = requiredSourceZoneBalance !== undefined && sourceZoneBalanceQuery.data !== undefined && sourceZoneBalanceQuery.data < requiredSourceZoneBalance ? requiredSourceZoneBalance - sourceZoneBalanceQuery.data - : 0n + : 0n; const hasEnoughSourceZoneBalance = Boolean( requiredSourceZoneBalance !== undefined && sourceZoneBalanceQuery.data !== undefined && sourceZoneBalanceQuery.data >= requiredSourceZoneBalance, - ) - const sourceZoneBalanceStepComplete = useStickyStepCompletion(hasEnoughSourceZoneBalance) + ); + const sourceZoneBalanceStepComplete = useStickyStepCompletion( + hasEnoughSourceZoneBalance, + ); const fundMutation = useMutation({ mutationFn: async () => { - if (!connectorClient) throw new Error('connector client not ready') + if (!connectorClient) throw new Error("connector client not ready"); await Actions.faucet.fundSync(connectorClient, { account: address, - }) + }); }, onSuccess: async () => { - await refetchRootBalance() + 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 } + 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() + 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 (!transferPrereqsQuery.data) throw new Error('Send prerequisites are not ready') + 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.') + throw new Error("Zone A needs more pathUSD before the send can start."); } const { receipt } = await sourceZoneClient.zone.requestWithdrawalSync({ - account: connectorClient.account, + account: rootWebAuthnAccount, amount: TRANSFER_AMOUNT, data: encodeRouterCallback(address), feeToken: pathUsd, @@ -300,154 +324,171 @@ function ConnectedZoneFlow(props: { address: Hex }) { timeout: zoneRpcSyncTimeout, to: swapAndDepositRouter, token: pathUsd, - }) + }); - const anchorBlock = await publicClient.getBlockNumber() + const anchorBlock = await publicClient.getBlockNumber(); return { anchorBlock, minimumTargetIncrease: transferPrereqsQuery.data.minimumTargetIncrease, receipt, targetDepositFee: transferPrereqsQuery.data.targetDepositFee, - } + }; }, onSuccess: async () => { - await sourceZoneBalanceQuery.refetch() - await queryClient.invalidateQueries({ queryKey: sourceFooterQueryKey }) + await sourceZoneBalanceQuery.refetch(); + await queryClient.invalidateQueries({ queryKey: sourceFooterQueryKey }); }, - }) + }); const settlementQuery = useQuery({ enabled: Boolean( - publicClient && sendMutation.isSuccess && sendMutation.data?.anchorBlock !== undefined, + publicClient && + sendMutation.isSuccess && + sendMutation.data?.anchorBlock !== undefined, ), queryKey: [ - 'guide-private-zones-cross-zone-send-settlement', + "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 > 5n ? sendMutation.data.anchorBlock - 5n : 0n - const latest = await publicClient.getBlockNumber() + if (!publicClient) throw new Error("public client not ready"); + if (!sendMutation.data) throw new Error("send submission not ready"); + + const fromBlock = + sendMutation.data.anchorBlock > 5n + ? sendMutation.data.anchorBlock - 5n + : 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 + 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' && + 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 + return match ? { txHash: match.transactionHash } : null; }, refetchInterval: (query) => { - if (query.state.error || query.state.data) return false + if (query.state.error || query.state.data) return false; - return 2_000 + return 2_000; }, refetchOnReconnect: false, refetchOnWindowFocus: false, retry: false, - }) + }); - const targetAuthMutation = useMutation({ - mutationFn: async () => { - if (!targetZoneClient) throw new Error('Zone B client not ready') - - return targetZoneClient.zone.signAuthorizationToken() - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: targetFooterQueryKey }) - await targetZoneBalanceQuery.refetch() - }, - }) + 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 && targetAuthMutation.isSuccess && settlementQuery.data), - queryKey: ['guide-private-zones-cross-zone-send-target-balance', address, ZONE_B.id], + 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') + 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 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 && targetAuthMutation.isSuccess && targetZoneBalanceQuery.isSuccess, - ) - const sourceAuthIsPreparing = sourceAuthQuery.fetchStatus === 'fetching' - const stepTwoAction = sourceAuthQuery.isSuccess ? undefined : ( + settlementQuery.data && + targetZoneAuthorization.isAuthorized && + targetZoneBalanceQuery.isSuccess, + ); + const sourceAuthIsPreparing = + sourceZoneAuthorization.isChecking || + sourceZoneAuthorization.authorizeMutation.isPending; + const stepTwoAction = sourceZoneAuthorization.isAuthorized ? undefined : ( - ) + ); React.useEffect(() => { - if (!sourceAuthQuery.isSuccess) return + if (!sourceZoneAuthorization.isAuthorized) return; - void queryClient.invalidateQueries({ queryKey: sourceFooterQueryKey }) - }, [queryClient, sourceAuthQuery.isSuccess, sourceFooterQueryKey]) + void queryClient.invalidateQueries({ queryKey: sourceFooterQueryKey }); + }, [queryClient, sourceFooterQueryKey, sourceZoneAuthorization.isAuthorized]); React.useEffect(() => { - if (!targetAuthMutation.isSuccess) return + if (!targetZoneAuthorization.isAuthorized) return; - void queryClient.invalidateQueries({ queryKey: targetFooterQueryKey }) - }, [queryClient, targetAuthMutation.isSuccess, targetFooterQueryKey]) + void queryClient.invalidateQueries({ queryKey: targetFooterQueryKey }); + }, [queryClient, targetFooterQueryKey, targetZoneAuthorization.isAuthorized]); React.useEffect(() => { - if (!topUpMutation.isSuccess || sourceZoneBalanceStepComplete) return + if (!topUpMutation.isSuccess || sourceZoneBalanceStepComplete) return; const interval = window.setInterval(() => { - void sourceZoneBalanceQuery.refetch() - }, 1_500) + void sourceZoneBalanceQuery.refetch(); + }, 1_500); - return () => window.clearInterval(interval) - }, [sourceZoneBalanceQuery, sourceZoneBalanceStepComplete, topUpMutation.isSuccess]) + return () => window.clearInterval(interval); + }, [ + sourceZoneBalanceQuery, + sourceZoneBalanceStepComplete, + topUpMutation.isSuccess, + ]); - let stepThreeAction: React.ReactNode + let stepThreeAction: React.ReactNode; if (sourceZoneBalanceStepComplete) { - stepThreeAction = undefined - } else if (sourceZoneBalanceQuery.isPending || transferPrereqsQuery.isPending) { + stepThreeAction = undefined; + } else if ( + sourceZoneBalanceQuery.isPending || + transferPrereqsQuery.isPending + ) { stepThreeAction = ( - ) + ); } else if (!hasEnoughSourceZoneBalance && !hasRootBalance) { stepThreeAction = ( - ) + ); } else if (!hasEnoughSourceZoneBalance) { stepThreeAction = ( - ) + ); } - let stepFourAction: React.ReactNode + let stepFourAction: React.ReactNode; if (!sourceZoneBalanceStepComplete || transferPrereqsQuery.isPending) { - stepFourAction = undefined + stepFourAction = undefined; } else if (transferPrereqsQuery.isError) { stepFourAction = ( - ) + ); } else { stepFourAction = ( - ) + ); } - let stepSixAction: React.ReactNode + let stepSixAction: React.ReactNode; if (!settlementQuery.data) { - stepSixAction = undefined + stepSixAction = undefined; } else if (targetZoneBalanceQuery.isError) { stepSixAction = ( - ) - } else if (!targetAuthMutation.isSuccess) { + ); + } else if (!targetZoneAuthorization.isAuthorized) { stepSixAction = ( - ) + ); } else if (targetZoneBalanceQuery.isPending) { stepSixAction = ( - ) + ); } return ( <> {topUpReceipt && ( - + )} @@ -597,7 +649,10 @@ function ConnectedZoneFlow(props: { address: Hex }) { > {routedSendReceipt && sendMutation.data && ( - + - -

- The funds have already left {ZONE_A.label}. This step polls the public-chain deposit - into {ZONE_B.label} every 2 seconds while the routed withdrawal is processed. -

-

- Because the token stays as pathUSD, the router skips the swap and deposits the same - asset into {ZONE_B.label}. {ZONE_B.label}'s portal deposit fee is still deducted from - the arriving amount. -

- {settlementTxHash && } -
+ {settlementTxHash && ( + + {settlementTxHash && } + + )}
- -

- The routed deposit can settle before this page is allowed to read {ZONE_B.label}. Once - you authorize private reads for this session, the demo fetches your pathUSD balance. -

-
-
+ /> - ) + ); } function DisconnectedZoneFlow() { @@ -699,21 +740,21 @@ function DisconnectedZoneFlow() { title={`Authorize private reads in ${ZONE_B.label} and confirm the pathUSD balance.`} /> - ) + ); } function encodeRouterCallback(recipient: Hex) { return encodeAbiParameters( [ - { type: 'bool' }, - { type: 'address' }, - { type: 'address' }, - { type: 'address' }, - { type: 'bytes32' }, - { type: 'uint128' }, + { 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) { @@ -723,18 +764,25 @@ function StepBody(props: React.PropsWithChildren) {
{props.children}
- ) + ); } -function DetailLine(props: { label: string; value: string; dataTestId?: string | undefined }) { - const { dataTestId, label, value } = props +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 index d25ae21a..7e46ac09 100644 --- a/src/components/guides/zones/SendTokensWithinZone.tsx +++ b/src/components/guides/zones/SendTokensWithinZone.tsx @@ -11,6 +11,8 @@ import { 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, Step } from '../Demo' import { SignInButtons } from '../EmbedPasskeys' import { pathUsd } from '../tokens' @@ -25,15 +27,7 @@ type ZoneClientLike = { token: { getBalance: (parameters: { account: Hex; token: Hex }) => Promise } - zone: { - signAuthorizationToken: () => Promise<{ - authentication: { - expiresAt: number - zoneId: number - } - token: Hex - }> - } + zone: ZoneAuthClientLike['zone'] } export function SendTokensWithinZone() { @@ -64,6 +58,7 @@ function ConnectedZoneFlow(props: { address: Hex }) { const { address } = props const queryClient = useQueryClient() const { data: connectorClient } = useConnectorClient() + const { data: rootWebAuthnAccount } = useRootWebAuthnAccount() const { data: rootBalance, isPending: rootBalanceIsPending, @@ -75,9 +70,9 @@ function ConnectedZoneFlow(props: { address: Hex }) { const zoneClient = React.useMemo( () => - connectorClient?.account + rootWebAuthnAccount ? (createClient({ - account: connectorClient.account, + account: rootWebAuthnAccount, chain: zoneModerato(ZONE_ID), transport: zoneHttp( stripRpcBasicAuth(moderatoZoneRpcUrls[ZONE_ID]), @@ -85,35 +80,26 @@ function ConnectedZoneFlow(props: { address: Hex }) { ), }).extend(tempoActions()) as unknown as ZoneClientLike) : undefined, - [connectorClient], + [rootWebAuthnAccount], ) - const authQuery = useQuery({ - enabled: false, + const zoneAuthorization = useZoneAuthorization({ + address, + chainId: zoneModerato(ZONE_ID).id, queryKey: ['guide-private-zones-send-auth', address, ZONE_ID], - queryFn: async () => { - if (!zoneClient) throw new Error('zone client not ready') - - const auth = await zoneClient.zone.signAuthorizationToken() - - return { auth } - }, - refetchOnReconnect: false, - refetchOnWindowFocus: false, - retry: false, - staleTime: 30_000, + zoneClient, }) React.useEffect(() => { - if (!authQuery.isSuccess) return + if (!zoneAuthorization.isAuthorized) return void queryClient.invalidateQueries({ queryKey: ['demo-zone-balance', address, ZONE_ID], }) - }, [address, authQuery.isSuccess, queryClient]) + }, [address, queryClient, zoneAuthorization.isAuthorized]) const zoneBalanceQuery = useQuery({ - enabled: Boolean(zoneClient && authQuery.isSuccess), + 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') @@ -174,6 +160,7 @@ function ConnectedZoneFlow(props: { address: Hex }) { 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, @@ -184,9 +171,9 @@ function ConnectedZoneFlow(props: { address: Hex }) { } const { receipt } = await Actions.token.transferSync(zoneClient as never, { - account: connectorClient.account, + account: rootWebAuthnAccount, amount: TRANSFER_AMOUNT, - chain: connectorClient.chain as never, + chain: zoneModerato(ZONE_ID) as never, feeToken: pathUsd, to: FAKE_RECIPIENT as Hex, token: pathUsd, @@ -204,7 +191,7 @@ function ConnectedZoneFlow(props: { address: Hex }) { }) const transferConfirmationQuery = useQuery({ - enabled: Boolean(zoneClient && authQuery.isSuccess && transferMutation.isSuccess), + 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') @@ -243,18 +230,19 @@ function ConnectedZoneFlow(props: { address: Hex }) { transferConfirmationQuery.data <= expectedMaxZoneBalance, ) const transferReceipt = transferMutation.data?.receipt - const authIsPreparing = authQuery.fetchStatus === 'fetching' - const stepTwoAction = authQuery.isSuccess ? undefined : ( + const authIsPreparing = + zoneAuthorization.isChecking || zoneAuthorization.authorizeMutation.isPending + const stepTwoAction = zoneAuthorization.isAuthorized ? undefined : ( @@ -288,10 +276,10 @@ function ConnectedZoneFlow(props: { address: Hex }) { stepThreeAction = ( @@ -300,10 +288,10 @@ function ConnectedZoneFlow(props: { address: Hex }) { stepThreeAction = ( @@ -334,16 +322,16 @@ function ConnectedZoneFlow(props: { address: Hex }) { return ( <> - -

- The transfer is already accepted in {ZONE_LABEL}. This final step polls your private - balance every 1.5 seconds until the sent amount and fee are reflected. -

-
-
+ /> ) } diff --git a/src/components/guides/zones/SwapAcrossZones.tsx b/src/components/guides/zones/SwapAcrossZones.tsx index c39d0cc0..3ece49f1 100644 --- a/src/components/guides/zones/SwapAcrossZones.tsx +++ b/src/components/guides/zones/SwapAcrossZones.tsx @@ -18,6 +18,8 @@ import { 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, Step } from '../Demo' import { SignInButtons } from '../EmbedPasskeys' import { betaUsd, pathUsd } from '../tokens' @@ -70,6 +72,7 @@ type ZoneClientLike = { getBalance: (parameters: { account: Hex; token: Hex }) => Promise } zone: { + getAuthorizationTokenInfo: ZoneAuthClientLike['zone']['getAuthorizationTokenInfo'] requestWithdrawalSync: (parameters: { account: unknown amount: bigint @@ -81,13 +84,7 @@ type ZoneClientLike = { token: Hex }) => Promise<{ receipt: { blockNumber: bigint; transactionHash: Hex } }> getWithdrawalFee: (parameters?: { gasLimit?: bigint | undefined }) => Promise - signAuthorizationToken: () => Promise<{ - authentication: { - expiresAt: number - zoneId: number - } - token: Hex - }> + signAuthorizationToken: ZoneAuthClientLike['zone']['signAuthorizationToken'] } } @@ -120,6 +117,7 @@ function ConnectedZoneFlow(props: { address: Hex }) { const queryClient = useQueryClient() const publicClient = usePublicClient() const { data: connectorClient } = useConnectorClient() + const { data: rootWebAuthnAccount } = useRootWebAuthnAccount() const { data: rootBalance, isPending: rootBalanceIsPending, @@ -131,9 +129,9 @@ function ConnectedZoneFlow(props: { address: Hex }) { const sourceZoneClient = React.useMemo( () => - connectorClient?.account + rootWebAuthnAccount ? (createClient({ - account: connectorClient.account, + account: rootWebAuthnAccount, chain: zoneModerato(ZONE_A.id), transport: zoneHttp( stripRpcBasicAuth(ZONE_A.rpcUrl), @@ -141,13 +139,13 @@ function ConnectedZoneFlow(props: { address: Hex }) { ), }).extend(tempoActions()) as unknown as ZoneClientLike) : undefined, - [connectorClient], + [rootWebAuthnAccount], ) const targetZoneClient = React.useMemo( () => - connectorClient?.account + rootWebAuthnAccount ? (createClient({ - account: connectorClient.account, + account: rootWebAuthnAccount, chain: zoneModerato(ZONE_B.id), transport: zoneHttp( stripRpcBasicAuth(ZONE_B.rpcUrl), @@ -155,7 +153,7 @@ function ConnectedZoneFlow(props: { address: Hex }) { ), }).extend(tempoActions()) as unknown as ZoneClientLike) : undefined, - [connectorClient], + [rootWebAuthnAccount], ) const sourceFooterQueryKey = React.useMemo( @@ -167,24 +165,15 @@ function ConnectedZoneFlow(props: { address: Hex }) { [address], ) - const sourceAuthQuery = useQuery({ - enabled: false, + const sourceZoneAuthorization = useZoneAuthorization({ + address, + chainId: ZONE_A.chainId, queryKey: ['guide-private-zones-swap-source-auth', address, ZONE_A.id], - queryFn: async () => { - if (!sourceZoneClient) throw new Error('Zone A client not ready') - - const auth = await sourceZoneClient.zone.signAuthorizationToken() - - return { auth } - }, - refetchOnReconnect: false, - refetchOnWindowFocus: false, - retry: false, - staleTime: 30_000, + zoneClient: sourceZoneClient, }) const sourceZoneBalanceQuery = useQuery({ - enabled: Boolean(sourceZoneClient && sourceAuthQuery.isSuccess), + 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') @@ -198,7 +187,7 @@ function ConnectedZoneFlow(props: { address: Hex }) { }) const swapPrereqsQuery = useQuery({ - enabled: Boolean(connectorClient && publicClient && sourceAuthQuery.isSuccess), + 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') @@ -212,7 +201,9 @@ function ConnectedZoneFlow(props: { address: Hex }) { routerDex, routerFactory, ] = await Promise.all([ - sourceZoneClient?.zone.getWithdrawalFee({ gasLimit: routerCallbackGasLimit }), + sourceZoneClient?.zone.getWithdrawalFee({ + gasLimit: routerCallbackGasLimit, + }), Actions.dex.getSellQuote(publicClient as never, { amountIn: SWAP_AMOUNT, tokenIn: pathUsd, @@ -327,6 +318,7 @@ function ConnectedZoneFlow(props: { address: Hex }) { 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({ @@ -346,7 +338,7 @@ function ConnectedZoneFlow(props: { address: Hex }) { }) const { receipt } = await sourceZoneClient.zone.requestWithdrawalSync({ - account: connectorClient.account, + account: rootWebAuthnAccount, amount: SWAP_AMOUNT, data: callbackData, feeToken: pathUsd, @@ -422,20 +414,17 @@ function ConnectedZoneFlow(props: { address: Hex }) { retry: false, }) - const targetAuthMutation = useMutation({ - mutationFn: async () => { - if (!targetZoneClient) throw new Error('Zone B client not ready') - - return targetZoneClient.zone.signAuthorizationToken() - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: targetFooterQueryKey }) - await targetZoneBalanceQuery.refetch() - }, + 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 && targetAuthMutation.isSuccess && settlementQuery.data), + 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') @@ -456,35 +445,36 @@ function ConnectedZoneFlow(props: { address: Hex }) { const routedSwapReceipt = swapMutation.data?.receipt const settlementTxHash = settlementQuery.data?.txHash const targetBalanceReady = - settlementQuery.data && targetAuthMutation.isSuccess && targetZoneBalanceQuery.isSuccess - const sourceAuthIsPreparing = sourceAuthQuery.fetchStatus === 'fetching' - const stepTwoAction = sourceAuthQuery.isSuccess ? undefined : ( + settlementQuery.data && targetZoneAuthorization.isAuthorized && targetZoneBalanceQuery.isSuccess + const sourceAuthIsPreparing = + sourceZoneAuthorization.isChecking || sourceZoneAuthorization.authorizeMutation.isPending + const stepTwoAction = sourceZoneAuthorization.isAuthorized ? undefined : ( ) React.useEffect(() => { - if (!sourceAuthQuery.isSuccess) return + if (!sourceZoneAuthorization.isAuthorized) return void queryClient.invalidateQueries({ queryKey: sourceFooterQueryKey }) - }, [queryClient, sourceAuthQuery.isSuccess, sourceFooterQueryKey]) + }, [queryClient, sourceFooterQueryKey, sourceZoneAuthorization.isAuthorized]) React.useEffect(() => { - if (!targetAuthMutation.isSuccess) return + if (!targetZoneAuthorization.isAuthorized) return void queryClient.invalidateQueries({ queryKey: targetFooterQueryKey }) - }, [queryClient, targetAuthMutation.isSuccess, targetFooterQueryKey]) + }, [queryClient, targetFooterQueryKey, targetZoneAuthorization.isAuthorized]) React.useEffect(() => { if (!topUpMutation.isSuccess || sourceZoneBalanceStepComplete) return @@ -514,10 +504,12 @@ function ConnectedZoneFlow(props: { address: Hex }) { stepThreeAction = ( @@ -526,10 +518,10 @@ function ConnectedZoneFlow(props: { address: Hex }) { stepThreeAction = ( @@ -582,16 +574,18 @@ function ConnectedZoneFlow(props: { address: Hex }) { Retry Zone B read ) - } else if (!targetAuthMutation.isSuccess) { + } else if (!targetZoneAuthorization.isAuthorized) { stepSixAction = ( ) } else if (targetZoneBalanceQuery.isPending) { @@ -610,16 +604,16 @@ function ConnectedZoneFlow(props: { address: Hex }) { return ( <> - -

- The funds have already left {ZONE_A.label}. This step polls the public-chain deposit - into {ZONE_B.label} every 2 seconds while the withdrawal, swap, and deposit finish. -

-

- The final betaUSD amount will be the swap output minus {ZONE_B.label}'s portal deposit - fee. -

- {settlementTxHash && } -
+ {settlementTxHash && ( + {settlementTxHash && } + )}
- -

- The routed deposit can settle before this page is allowed to read {ZONE_B.label}. Once - you authorize private reads for this session, the demo fetches your betaUSD balance. -

-
-
+ /> ) } diff --git a/src/components/guides/zones/WithdrawFromZone.tsx b/src/components/guides/zones/WithdrawFromZone.tsx index 0e817220..1787c801 100644 --- a/src/components/guides/zones/WithdrawFromZone.tsx +++ b/src/components/guides/zones/WithdrawFromZone.tsx @@ -12,6 +12,8 @@ import { 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' @@ -52,13 +54,8 @@ type ZoneClientLike = { to: Hex token: Hex }) => Promise<{ receipt: { blockNumber: bigint; transactionHash: Hex } }> - signAuthorizationToken: () => Promise<{ - authentication: { - expiresAt: number - zoneId: number - } - token: Hex - }> + getAuthorizationTokenInfo: ZoneAuthClientLike['zone']['getAuthorizationTokenInfo'] + signAuthorizationToken: ZoneAuthClientLike['zone']['signAuthorizationToken'] getWithdrawalFee: () => Promise } } @@ -95,6 +92,7 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { const queryClient = useQueryClient() const publicClient = usePublicClient() const { data: connectorClient } = useConnectorClient() + const { data: rootWebAuthnAccount } = useRootWebAuthnAccount() const { data: rootBalance, isPending: rootBalanceIsPending, @@ -106,9 +104,9 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { const zoneClient = React.useMemo( () => - connectorClient?.account + rootWebAuthnAccount ? (createClient({ - account: connectorClient.account, + account: rootWebAuthnAccount, chain: zoneModerato(ZONE_ID), transport: zoneHttp( stripRpcBasicAuth(moderatoZoneRpcUrls[ZONE_ID]), @@ -116,35 +114,26 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { ), }).extend(tempoActions()) as unknown as ZoneClientLike) : undefined, - [connectorClient], + [rootWebAuthnAccount], ) - const authQuery = useQuery({ - enabled: false, + const zoneAuthorization = useZoneAuthorization({ + address, + chainId: zoneModerato(ZONE_ID).id, queryKey: ['guide-private-zones-withdraw-auth', address, ZONE_ID], - queryFn: async () => { - if (!zoneClient) throw new Error('zone client not ready') - - const auth = await zoneClient.zone.signAuthorizationToken() - - return { auth } - }, - refetchOnReconnect: false, - refetchOnWindowFocus: false, - retry: false, - staleTime: 30_000, + zoneClient, }) React.useEffect(() => { - if (!authQuery.isSuccess) return + if (!zoneAuthorization.isAuthorized) return void queryClient.invalidateQueries({ queryKey: ['demo-zone-balance', address, ZONE_ID], }) - }, [address, authQuery.isSuccess, queryClient]) + }, [address, queryClient, zoneAuthorization.isAuthorized]) const withdrawalFeeQuery = useQuery({ - enabled: Boolean(zoneClient && authQuery.isSuccess), + 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') @@ -155,7 +144,7 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { }) const zoneBalanceQuery = useQuery({ - enabled: Boolean(zoneClient && authQuery.isSuccess), + 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') @@ -224,6 +213,7 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { 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, { @@ -238,7 +228,7 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { mode === 'authenticated' ? ( await zoneClient.zone.requestEncryptedWithdrawalSync({ - account: connectorClient.account, + account: rootWebAuthnAccount, amount: WITHDRAWAL_AMOUNT, feeToken: pathUsd, revealTo: AUTHENTICATED_WITHDRAWAL_REVEAL_TO, @@ -249,7 +239,7 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { ).receipt : ( await zoneClient.zone.requestWithdrawalSync({ - account: connectorClient.account, + account: rootWebAuthnAccount, amount: WITHDRAWAL_AMOUNT, feeToken: pathUsd, timeout: zoneRpcSyncTimeout, @@ -283,7 +273,7 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { publicClient && zoneClient && connectorClient && - authQuery.isSuccess && + zoneAuthorization.isAuthorized && withdrawMutation.isSuccess, ), queryKey: [ @@ -345,18 +335,19 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { const settlementTxHash = withdrawalConfirmationQuery.data?.txHash const withdrawalConfirmed = Boolean(settlementTxHash) const topUpReceipt = topUpMutation.data?.receipt - const authIsPreparing = authQuery.fetchStatus === 'fetching' - const stepTwoAction = authQuery.isSuccess ? undefined : ( + const authIsPreparing = + zoneAuthorization.isChecking || zoneAuthorization.authorizeMutation.isPending + const stepTwoAction = zoneAuthorization.isAuthorized ? undefined : ( @@ -390,10 +381,10 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { stepThreeAction = ( @@ -402,10 +393,10 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { stepThreeAction = ( @@ -435,16 +426,16 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { return ( <> - -

- The withdrawal request is already accepted in {ZONE_LABEL}. This final step polls your - public-chain pathUSD balance every 1.5 seconds until the batch settles. -

-

- If Tempo-side settlement fails, the amount returns to the fallback recipient in the zone - and the fee is still consumed. -

- {settlementTxHash && } -
+ {settlementTxHash && ( + {settlementTxHash && } + )}
) diff --git a/src/lib/useRootWebAuthnAccount.ts b/src/lib/useRootWebAuthnAccount.ts new file mode 100644 index 00000000..2ca157d4 --- /dev/null +++ b/src/lib/useRootWebAuthnAccount.ts @@ -0,0 +1,31 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' +import { Account } from 'viem/tempo' +import { useConnection } from 'wagmi' +import { config, webAuthnRpId } from '../wagmi.config.ts' + +type RootWebAuthnCredential = Parameters[0] + +export function useRootWebAuthnAccount() { + const { address, connector } = useConnection() + + return useQuery({ + enabled: Boolean(address && connector?.id === 'webAuthn' && webAuthnRpId), + queryKey: ['root-webauthn-account', address, webAuthnRpId], + queryFn: async () => { + if (!webAuthnRpId) throw new Error('webauthn RP ID is not configured') + + const credential = await config.storage?.getItem('webAuthn.activeCredential') + if (!credential) throw new Error('webauthn credential not available') + + return Account.fromWebAuthnP256(credential as RootWebAuthnCredential, { + rpId: webAuthnRpId, + }) + }, + 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..9273d900 --- /dev/null +++ b/src/lib/useZoneAuthorization.ts @@ -0,0 +1,114 @@ +'use client' + +import { useMutation, useQuery } from '@tanstack/react-query' +import type { Hex } from 'viem' +import { Storage as ZoneStorage } from 'viem/tempo' + +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 zoneClient.zone.getAuthorizationTokenInfo() + 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 isZoneAuthorizationError(error: unknown) { + 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 '' +} diff --git a/src/pages/guide/private-zones/withdraw-from-a-zone.mdx b/src/pages/guide/private-zones/withdraw-from-a-zone.mdx index 8d4a542f..fef88038 100644 --- a/src/pages/guide/private-zones/withdraw-from-a-zone.mdx +++ b/src/pages/guide/private-zones/withdraw-from-a-zone.mdx @@ -17,8 +17,6 @@ Use this guide when you want to move `pathUSD` out of `Zone A` and back to your 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. -The demo below supports both the standard `Actions.zone.requestWithdrawalSync(...)` flow and the authenticated `Actions.zone.requestEncryptedWithdrawalSync(...)` flow. In both cases, `signAuthorizationToken()` makes authenticated zone RPC access explicit for the session. - ## 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. diff --git a/src/wagmi.config.ts b/src/wagmi.config.ts index 61cd62f7..d9dffbfd 100644 --- a/src/wagmi.config.ts +++ b/src/wagmi.config.ts @@ -29,6 +29,9 @@ 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 @@ -37,6 +40,8 @@ const rpId = (() => { 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({ @@ -68,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, }), @@ -114,6 +116,8 @@ export namespace getConfig { export type Config = ReturnType +export const config = getConfig() + export const queryClient = new QueryClient() export function useTempoWalletConnector() { @@ -134,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 From b8e3b0b3dd21332d432b2fc8638c8d063ed99a97 Mon Sep 17 00:00:00 2001 From: Liam Horne Date: Wed, 8 Apr 2026 23:05:09 -0700 Subject: [PATCH 08/25] fix: suppress spurious zone auth error Amp-Thread-ID: https://ampcode.com/threads/T-019d70b3-ae75-70de-bfc1-cdcdc45deafb Co-authored-by: Amp --- e2e/send-tokens-across-zones.test.ts | 96 ++++++++++++++++++++++++++++ src/lib/useZoneAuthorization.ts | 38 +++++++++++ 2 files changed, 134 insertions(+) create mode 100644 e2e/send-tokens-across-zones.test.ts diff --git a/e2e/send-tokens-across-zones.test.ts b/e2e/send-tokens-across-zones.test.ts new file mode 100644 index 00000000..d9715a89 --- /dev/null +++ b/e2e/send-tokens-across-zones.test.ts @@ -0,0 +1,96 @@ +import { expect, test } from '@playwright/test' + +test('send PathUSD from Zone A into Zone B without a spurious Zone B auth error', 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, + }, + }) + + try { + 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 authorizeZoneAButton = page + .getByRole('button', { name: 'Authorize Zone A reads' }) + .first() + const getFundsButton = page.getByRole('button', { name: 'Get testnet PathUSD' }).first() + const topUpButton = page + .getByRole('button', { + name: /^(Approve \+ top up|Top up) Zone A$/, + }) + .first() + const sendButton = page.getByRole('button', { name: 'Send 25 PathUSD into Zone B' }).first() + const waitingForZoneBDeposit = page.getByText( + 'Wait for the routed pathUSD deposit to land in Zone B.', + { exact: true }, + ) + const httpError = page.getByText('HTTP request failed.', { exact: true }) + + await expect + .poll( + async () => + (await authorizeZoneAButton.isVisible()) || + (await getFundsButton.isVisible()) || + (await topUpButton.isVisible()) || + (await sendButton.isVisible()), + { timeout: 90000 }, + ) + .toBe(true) + + if (await authorizeZoneAButton.isVisible()) { + await authorizeZoneAButton.click() + 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(waitingForZoneBDeposit).toBeVisible({ timeout: 120000 }) + + for (let index = 0; index < 30; index++) { + await expect(httpError).toHaveCount(0) + await page.waitForTimeout(1000) + } + } finally { + await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }) + } +}) diff --git a/src/lib/useZoneAuthorization.ts b/src/lib/useZoneAuthorization.ts index 9273d900..b41d1e7f 100644 --- a/src/lib/useZoneAuthorization.ts +++ b/src/lib/useZoneAuthorization.ts @@ -95,6 +95,12 @@ export function useZoneAuthorization(parameters: { } function isZoneAuthorizationError(error: unknown) { + const status = getErrorStatus(error) + if (status === 401 || status === 403) return true + + const name = getErrorName(error) + if (name === 'HttpRequestError') return true + const message = getErrorMessage(error) return /authorization token/i.test(message) } @@ -112,3 +118,35 @@ function getErrorMessage(error: unknown) { 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 +} From 8ad3f4356d11b8e08fe3314ace0533794215ec97 Mon Sep 17 00:00:00 2001 From: Liam Horne Date: Wed, 8 Apr 2026 23:16:29 -0700 Subject: [PATCH 09/25] fix: widen zone settlement detection Amp-Thread-ID: https://ampcode.com/threads/T-019d70b3-ae75-70de-bfc1-cdcdc45deafb Co-authored-by: Amp --- src/components/guides/zones/SendTokensAcrossZones.tsx | 9 +++++---- src/components/guides/zones/SwapAcrossZones.tsx | 10 +++++++--- src/components/guides/zones/WithdrawFromZone.tsx | 7 +++++-- src/lib/private-zones.ts | 2 ++ 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/components/guides/zones/SendTokensAcrossZones.tsx b/src/components/guides/zones/SendTokensAcrossZones.tsx index c09505f3..921b0d60 100644 --- a/src/components/guides/zones/SendTokensAcrossZones.tsx +++ b/src/components/guides/zones/SendTokensAcrossZones.tsx @@ -15,6 +15,7 @@ import { useConnection, useConnectorClient, usePublicClient } from "wagmi"; import { Hooks } from "wagmi/tempo"; import { getZoneTransportConfig, + publicSettlementLookbackBlocks, routerCallbackGasLimit, stripRpcBasicAuth, swapAndDepositRouter, @@ -315,6 +316,8 @@ function ConnectedZoneFlow(props: { address: Hex }) { 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, @@ -326,8 +329,6 @@ function ConnectedZoneFlow(props: { address: Hex }) { token: pathUsd, }); - const anchorBlock = await publicClient.getBlockNumber(); - return { anchorBlock, minimumTargetIncrease: transferPrereqsQuery.data.minimumTargetIncrease, @@ -357,8 +358,8 @@ function ConnectedZoneFlow(props: { address: Hex }) { if (!sendMutation.data) throw new Error("send submission not ready"); const fromBlock = - sendMutation.data.anchorBlock > 5n - ? sendMutation.data.anchorBlock - 5n + sendMutation.data.anchorBlock > publicSettlementLookbackBlocks + ? sendMutation.data.anchorBlock - publicSettlementLookbackBlocks : 0n; const latest = await publicClient.getBlockNumber(); const logs = await publicClient.getLogs({ diff --git a/src/components/guides/zones/SwapAcrossZones.tsx b/src/components/guides/zones/SwapAcrossZones.tsx index 3ece49f1..c58d4ef5 100644 --- a/src/components/guides/zones/SwapAcrossZones.tsx +++ b/src/components/guides/zones/SwapAcrossZones.tsx @@ -9,6 +9,7 @@ import { Hooks } from 'wagmi/tempo' import { getZoneTransportConfig, moderatoZoneFactory, + publicSettlementLookbackBlocks, routerCallbackGasLimit, stablecoinDex, stripRpcBasicAuth, @@ -337,6 +338,8 @@ function ConnectedZoneFlow(props: { address: Hex }) { recipient: address, }) + const anchorBlock = await publicClient.getBlockNumber() + const { receipt } = await sourceZoneClient.zone.requestWithdrawalSync({ account: rootWebAuthnAccount, amount: SWAP_AMOUNT, @@ -348,8 +351,6 @@ function ConnectedZoneFlow(props: { address: Hex }) { token: pathUsd, }) - const anchorBlock = await publicClient.getBlockNumber() - return { anchorBlock, minimumTargetIncrease: swapPrereqsQuery.data.minimumTargetIncrease, @@ -375,7 +376,10 @@ function ConnectedZoneFlow(props: { address: Hex }) { if (!publicClient) throw new Error('public client not ready') if (!swapMutation.data) throw new Error('swap submission not ready') - const fromBlock = swapMutation.data.anchorBlock > 5n ? swapMutation.data.anchorBlock - 5n : 0n + 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, diff --git a/src/components/guides/zones/WithdrawFromZone.tsx b/src/components/guides/zones/WithdrawFromZone.tsx index 1787c801..f4f9f601 100644 --- a/src/components/guides/zones/WithdrawFromZone.tsx +++ b/src/components/guides/zones/WithdrawFromZone.tsx @@ -9,6 +9,7 @@ import { Hooks } from 'wagmi/tempo' import { getZoneTransportConfig, moderatoZoneRpcUrls, + publicSettlementLookbackBlocks, stripRpcBasicAuth, zoneRpcSyncTimeout, } from '../../../lib/private-zones.ts' @@ -224,6 +225,7 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { account: address, token: pathUsd, }) + const anchorBlock = await publicClient.getBlockNumber() const receipt = mode === 'authenticated' ? ( @@ -247,7 +249,6 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { token: pathUsd, }) ).receipt - const anchorBlock = await publicClient.getBlockNumber() return { anchorBlock, @@ -289,7 +290,9 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { if (!withdrawMutation.data) throw new Error('withdrawal submission not ready') const fromBlock = - withdrawMutation.data.anchorBlock > 5n ? withdrawMutation.data.anchorBlock - 5n : 0n + withdrawMutation.data.anchorBlock > publicSettlementLookbackBlocks + ? withdrawMutation.data.anchorBlock - publicSettlementLookbackBlocks + : 0n const [currentRootBalance, currentZoneBalance, latest] = await Promise.all([ Actions.token.getBalance(connectorClient as never, { diff --git a/src/lib/private-zones.ts b/src/lib/private-zones.ts index ca061138..d4bf353c 100644 --- a/src/lib/private-zones.ts +++ b/src/lib/private-zones.ts @@ -13,6 +13,8 @@ export const swapAndDepositRouter = '0xf9b794e0dca9bc12ac90067df792d7aad33436e4' // 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 From 12f5c6492f013e744267cb2362778523c1e5688f Mon Sep 17 00:00:00 2001 From: Liam Horne Date: Wed, 8 Apr 2026 23:27:16 -0700 Subject: [PATCH 10/25] fix: stop linking zone receipts to explorer Amp-Thread-ID: https://ampcode.com/threads/T-019d70b3-ae75-70de-bfc1-cdcdc45deafb Co-authored-by: Amp --- src/components/guides/Demo.tsx | 24 +++++++++++++++++++ .../guides/zones/SendTokensAcrossZones.tsx | 4 ++-- .../guides/zones/SendTokensWithinZone.tsx | 4 ++-- .../guides/zones/SwapAcrossZones.tsx | 4 ++-- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/components/guides/Demo.tsx b/src/components/guides/Demo.tsx index 770045e5..fbbd8f56 100644 --- a/src/components/guides/Demo.tsx +++ b/src/components/guides/Demo.tsx @@ -77,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} + +
+ ) +} + export function ExplorerAccountLink({ address }: { address: string }) { const { trackExternalLinkClick } = usePostHogTracking() const url = `${getExplorerHost()}/account/${address}` diff --git a/src/components/guides/zones/SendTokensAcrossZones.tsx b/src/components/guides/zones/SendTokensAcrossZones.tsx index 921b0d60..0057da36 100644 --- a/src/components/guides/zones/SendTokensAcrossZones.tsx +++ b/src/components/guides/zones/SendTokensAcrossZones.tsx @@ -29,7 +29,7 @@ import { type ZoneAuthClientLike, useZoneAuthorization, } from "../../../lib/useZoneAuthorization.ts"; -import { Button, ExplorerLink, Login, Logout, Step } from "../Demo"; +import { Button, ExplorerLink, Login, Logout, ReceiptHash, Step } from "../Demo"; import { pathUsd } from "../tokens"; import { useStickyStepCompletion } from "./useStickyStepCompletion.ts"; @@ -662,7 +662,7 @@ function ConnectedZoneFlow(props: { address: Hex }) { label={`Expected ${ZONE_B.label} net amount`} value={`${formatUnits(sendMutation.data.minimumTargetIncrease, 6)} pathUSD`} /> - + )}
diff --git a/src/components/guides/zones/SendTokensWithinZone.tsx b/src/components/guides/zones/SendTokensWithinZone.tsx index 7e46ac09..15f5d27b 100644 --- a/src/components/guides/zones/SendTokensWithinZone.tsx +++ b/src/components/guides/zones/SendTokensWithinZone.tsx @@ -13,7 +13,7 @@ import { } 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, Step } from '../Demo' +import { Button, ExplorerLink, FAKE_RECIPIENT, Logout, ReceiptHash, Step } from '../Demo' import { SignInButtons } from '../EmbedPasskeys' import { pathUsd } from '../tokens' import { useStickyStepCompletion } from './useStickyStepCompletion.ts' @@ -357,7 +357,7 @@ function ConnectedZoneFlow(props: { address: Hex }) { {transferReceipt && ( - + )}
diff --git a/src/components/guides/zones/SwapAcrossZones.tsx b/src/components/guides/zones/SwapAcrossZones.tsx index c58d4ef5..a834e748 100644 --- a/src/components/guides/zones/SwapAcrossZones.tsx +++ b/src/components/guides/zones/SwapAcrossZones.tsx @@ -21,7 +21,7 @@ import { } 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 { Button, ExplorerLink, Logout, ReceiptHash, Step } from '../Demo' import { SignInButtons } from '../EmbedPasskeys' import { betaUsd, pathUsd } from '../tokens' import { useStickyStepCompletion } from './useStickyStepCompletion.ts' @@ -648,7 +648,7 @@ function ConnectedZoneFlow(props: { address: Hex }) { {routedSwapReceipt && ( - + )}
From caca40cbfff0575e30ed9e2ce7d01ae05a87825c Mon Sep 17 00:00:00 2001 From: Dankrad Feist Date: Thu, 9 Apr 2026 23:34:28 +0300 Subject: [PATCH 11/25] docs: reorder zones security sections Improve the zones overview flow by grouping the theft-safety explanation after the compliance guarantees. Made-with: Cursor --- src/pages/protocol/zones/index.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/protocol/zones/index.mdx b/src/pages/protocol/zones/index.mdx index 145b70e6..d02e966b 100644 --- a/src/pages/protocol/zones/index.mdx +++ b/src/pages/protocol/zones/index.mdx @@ -17,10 +17,6 @@ Funds deposited into a Tempo Zone are locked in the zone contract on Tempo Mainn 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 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 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. @@ -33,6 +29,10 @@ The [accounts specification](/protocol/zones/accounts) describes how balance and 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. From a23fe3a8e1985f8d55f4f7b558aba8764fe552b0 Mon Sep 17 00:00:00 2001 From: Dankrad Feist Date: Thu, 9 Apr 2026 23:39:49 +0300 Subject: [PATCH 12/25] docs: merge zone RPC method tables Simplify the zone RPC access control docs by combining allowed and scoped methods into a single table with explicit access types. Made-with: Cursor --- src/pages/protocol/zones/rpc.mdx | 73 ++++++++++++++------------------ 1 file changed, 32 insertions(+), 41 deletions(-) diff --git a/src/pages/protocol/zones/rpc.mdx b/src/pages/protocol/zones/rpc.mdx index 8ca1ce02..c18d4c31 100644 --- a/src/pages/protocol/zones/rpc.mdx +++ b/src/pages/protocol/zones/rpc.mdx @@ -26,47 +26,38 @@ Tokens are sent via the `X-Authorization-Token` HTTP header on every request. Each JSON-RPC method falls into one of four categories: -### Allowed - -Public zone information available to any authenticated caller: - -| Method | Notes | -|--------|-------| -| `eth_chainId` | Zone chain ID | -| `eth_blockNumber` | Latest block number | -| `eth_gasPrice` | Current gas price | -| `eth_maxPriorityFeePerGas` | Current priority fee | -| `eth_feeHistory` | Fee history | -| `eth_getBlockByNumber` | Block headers **without transaction details** | -| `eth_getBlockByHash` | Block headers **without transaction details** | -| `eth_subscribe("newHeads")` | Block headers with `logsBloom` zeroed | -| `eth_syncing` | Sync status | -| `eth_coinbase` | Sequencer address | -| `net_version` | Network ID | -| `net_listening` | Node status | -| `web3_clientVersion` | Client version | -| `web3_sha3` | Pure Keccak-256 hash | - -### Scoped - -Available to any authenticated caller, but filtered to the authenticated account: - -| Method | Scoping Rule | -|--------|-------------| -| `eth_getBalance` | Returns balance for the authenticated account only. Queries for other accounts return `0x0`. | -| `eth_getTransactionCount` | Returns nonce for the authenticated account only. Other accounts return `0x0`. | -| `eth_call` | Executes with `from` set to the authenticated account. [Execution-level privacy](/protocol/zones/accounts) enforces `balanceOf` access control at the contract level. | -| `eth_estimateGas` | Only allowed when `from` equals the authenticated account. | -| `eth_getTransactionByHash` | Returns the transaction only if the authenticated account is the sender. Returns `null` otherwise. | -| `eth_getTransactionReceipt` | Returns the receipt only if the authenticated account is the sender. Logs are filtered (see [Event Filtering](#event-filtering)). | -| `eth_sendRawTransaction` | Validates that the transaction sender matches the authenticated account. | -| `eth_getLogs` | Filtered to TIP-20 events where the authenticated account is a relevant party (see [Event Filtering](#event-filtering)). | -| `eth_getFilterLogs` | Same filtering as `eth_getLogs`. | -| `eth_getFilterChanges` | Same filtering. Only returns new events since last poll. | -| `eth_newFilter` | Creates a filter implicitly scoped to the authenticated account. | -| `eth_subscribe("logs")` | Subscription scoped to the authenticated account. | -| `eth_newBlockFilter` | Allowed. Returns new block hashes. | -| `eth_uninstallFilter` | Allowed. Removes a previously created filter. | +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." From c62f3bee019cb8122f661d2e0e2d7a98e9fdd137 Mon Sep 17 00:00:00 2001 From: Dankrad Feist Date: Thu, 9 Apr 2026 23:42:27 +0300 Subject: [PATCH 13/25] docs: add zone execution flow diagram Clarify the execution and gas model by adding a Mermaid diagram that shows validation, gas selection, execution, and fee settlement. Made-with: Cursor --- src/pages/protocol/zones/execution.mdx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/pages/protocol/zones/execution.mdx b/src/pages/protocol/zones/execution.mdx index fa3dacbb..8b798a8c 100644 --- a/src/pages/protocol/zones/execution.mdx +++ b/src/pages/protocol/zones/execution.mdx @@ -11,6 +11,26 @@ Tempo Zones is still in early development and is available for testing purposes 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). +## Execution Flow + +The execution pipeline for a zone transaction is: + +```mermaid +flowchart TD + A[User submits zone transaction] --> B{Fee token enabled
and USD-denominated?} + B -- No --> X[Reject transaction] + B -- Yes --> C{TIP-20 user action?} + C -- Yes --> D[Apply fixed 100,000 gas cost] + C -- No --> E[Use standard gas accounting] + D --> F{Uses CREATE or CREATE2?} + E --> F + F -- Yes --> Y[Revert transaction] + F -- No --> G[Execute against zone state] + G --> H[Charge fees in selected fee token] + H --> I[Pay sequencer] + I --> J[Commit updated zone state] +``` + ## 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. From 037a25b9dc29aa8947ff31632ce98ec1aa5ddfda Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Thu, 9 Apr 2026 15:10:39 -0700 Subject: [PATCH 14/25] feat: add StaticMermaidDiagram component for consistent diagram styling (#62) * feat: add StaticMermaidDiagram component for consistent diagram styling - New StaticMermaidDiagram component that uses the mermaid library with theme colors from the existing MermaidDiagram component - Uses site's system sans-serif font instead of monospace - Compact node spacing and padding matching the docs design - Supports dark/light mode via THEMES from MermaidDiagram - Replaces raw mermaid code blocks in architecture.mdx and execution.mdx * fix: improve dark mode contrast for StaticMermaidDiagram - Lighter node backgrounds (#2e2e33) so they stand out from page bg - Stronger borders (#52525b) for both nodes and clusters - Darker cluster background (#1e1e22) for depth separation --- src/pages/protocol/zones/architecture.mdx | 14 +++++++------- src/pages/protocol/zones/execution.mdx | 7 ++++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/pages/protocol/zones/architecture.mdx b/src/pages/protocol/zones/architecture.mdx index 1a7a0824..b15f91a3 100644 --- a/src/pages/protocol/zones/architecture.mdx +++ b/src/pages/protocol/zones/architecture.mdx @@ -3,6 +3,8 @@ title: Tempo Zone Architecture description: Architecture of Tempo Zones, including contract layout, sequencer management, chain IDs, and the trust model. --- +import { StaticMermaidDiagram } from '../../../components/StaticMermaidDiagram' + # Tempo Zone Architecture :::info @@ -21,14 +23,13 @@ Tempo Zones are designed for applications that want safe operation guaranteed by Each Tempo Zone runs as a separate Tempo chain with its own Tempo node(s). Tempo Zones are tightly coupled with Tempo Mainnet and have direct, synchronous access to Tempo Mainnet state. Zone contracts can read certain Tempo Mainnet state without any message passing delay, such as deposit queues and TIP-403 policy information. -```mermaid -flowchart TD + 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: @@ -43,9 +44,8 @@ The sequencer runs a Tempo node with one or more zone nodes attached. Each zone The system consists of contracts on both Tempo Mainnet and within each Tempo Zone. -```mermaid -flowchart TD - ZP["ZonePortal
"] -- "deposits" --> ZI["ZoneInbox
(deposits)"] + ZI["ZoneInbox
(deposits)"] ZO["ZoneOutbox
(withdrawals)"] -- "withdrawals" --> ZP subgraph TEMPO["Tempo Mainnet"] @@ -58,7 +58,7 @@ flowchart TD ZI ZO end -``` +`} /> ### Tempo Contracts diff --git a/src/pages/protocol/zones/execution.mdx b/src/pages/protocol/zones/execution.mdx index 8b798a8c..056d560b 100644 --- a/src/pages/protocol/zones/execution.mdx +++ b/src/pages/protocol/zones/execution.mdx @@ -3,6 +3,8 @@ title: Execution & Gas description: Specification for gas accounting, fee tokens, fixed TIP-20 gas costs, contract creation limits, and token management on Tempo Zones. --- +import { StaticMermaidDiagram } from '../../../components/StaticMermaidDiagram' + # Execution & Gas :::info @@ -15,8 +17,7 @@ This page specifies how Tempo Zones handle gas accounting, fee collection, and t The execution pipeline for a zone transaction is: -```mermaid -flowchart TD + B{Fee token enabled
and USD-denominated?} B -- No --> X[Reject transaction] B -- Yes --> C{TIP-20 user action?} @@ -29,7 +30,7 @@ flowchart TD G --> H[Charge fees in selected fee token] H --> I[Pay sequencer] I --> J[Commit updated zone state] -``` +`} /> ## Fee Tokens From a6dab224020d38d2581cebf2a672289e25de7d35 Mon Sep 17 00:00:00 2001 From: Dankrad Feist Date: Fri, 10 Apr 2026 01:58:55 -0400 Subject: [PATCH 15/25] docs: move execution flow diagram to proving Correct the zone docs by placing the execution flow diagram on the proving page and removing it from the execution page. Made-with: Cursor --- src/pages/protocol/zones/execution.mdx | 21 --------------------- src/pages/protocol/zones/proving.mdx | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/src/pages/protocol/zones/execution.mdx b/src/pages/protocol/zones/execution.mdx index 056d560b..fa3dacbb 100644 --- a/src/pages/protocol/zones/execution.mdx +++ b/src/pages/protocol/zones/execution.mdx @@ -3,8 +3,6 @@ title: Execution & Gas description: Specification for gas accounting, fee tokens, fixed TIP-20 gas costs, contract creation limits, and token management on Tempo Zones. --- -import { StaticMermaidDiagram } from '../../../components/StaticMermaidDiagram' - # Execution & Gas :::info @@ -13,25 +11,6 @@ Tempo Zones is still in early development and is available for testing purposes 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). -## Execution Flow - -The execution pipeline for a zone transaction is: - - B{Fee token enabled
and USD-denominated?} - B -- No --> X[Reject transaction] - B -- Yes --> C{TIP-20 user action?} - C -- Yes --> D[Apply fixed 100,000 gas cost] - C -- No --> E[Use standard gas accounting] - D --> F{Uses CREATE or CREATE2?} - E --> F - F -- Yes --> Y[Revert transaction] - F -- No --> G[Execute against zone state] - G --> H[Charge fees in selected fee token] - H --> I[Pay sequencer] - I --> J[Commit updated zone state] -`} /> - ## 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. diff --git a/src/pages/protocol/zones/proving.mdx b/src/pages/protocol/zones/proving.mdx index 26df29dd..a63e39b4 100644 --- a/src/pages/protocol/zones/proving.mdx +++ b/src/pages/protocol/zones/proving.mdx @@ -71,6 +71,24 @@ pub fn prove_zone_batch(witness: BatchWitness) -> Result ### Execution Flow +```mermaid +flowchart TD + A[Batch witness] --> 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: From 70e96916435b91ae6f5839a1c293afa5db420d8c Mon Sep 17 00:00:00 2001 From: Zygimantas <5236121+Zygimantass@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:44:41 +0200 Subject: [PATCH 16/25] feat: add connect-to-a-zone page --- .../guide/private-zones/connect-to-a-zone.mdx | 59 +++++++++++++++++++ src/pages/guide/private-zones/index.mdx | 9 ++- vocs.config.ts | 4 ++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 src/pages/guide/private-zones/connect-to-a-zone.mdx 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..5211f7a1 --- /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 5 and Zone 6 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 5` or `Zone 6`. + +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-005-private.tempoxyz.dev' + +const zoneClient = createPublicClient({ + chain: zoneModerato(6), + transport: http(rpcUrl), +}) + +const blockNumber = await zoneClient.getBlockNumber() +console.log(blockNumber) +``` + +## Direct Connection Details + +### Zone 5 + +| **Property** | **Value** | +|-------------------|-------| +| **Network Name** | Zone 5 | +| **Zone ID** | `6` | +| **Chain ID** | `4217000006` | +| **HTTP URL** | `https://rpc-zone-005-private.tempoxyz.dev` | +| **Portal Address** | `0x7069DeC4E64Fd07334A0933eDe836C17259c9B23` | +| **Outbox Address** | `0x1c00000000000000000000000000000000000002` | + +### Zone 6 + +| **Property** | **Value** | +|-------------------|-------| +| **Network Name** | Zone 6 | +| **Zone ID** | `7` | +| **Chain ID** | `4217000007` | +| **HTTP URL** | `https://rpc-zone-006-private.tempoxyz.dev` | +| **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/index.mdx b/src/pages/guide/private-zones/index.mdx index 54736f02..a74ffc73 100644 --- a/src/pages/guide/private-zones/index.mdx +++ b/src/pages/guide/private-zones/index.mdx @@ -19,12 +19,13 @@ Tempo Zones let you keep balances and transfers inside a private execution envir - 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 baseline zone workflows used in the current 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.requestEncryptedWithdrawalSync(...)`. +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.requestEncryptedWithdrawalSync(...)`. 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. @@ -32,6 +33,12 @@ The deposit guide's demo lets you switch between plaintext and encrypted deposit - **Withdraw from a zone** if you want to move `pathUSD` back from `Zone A` to your public balance. + Date: Mon, 13 Apr 2026 13:43:18 +0200 Subject: [PATCH 17/25] fix: bump viem --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 662ce82f..cd8757d9 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": "https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@b6babdcf", + "viem": "https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02", "vocs": "https://pkg.pr.new/wevm/vocs@2fb25c2", "wagmi": "^3.6.1", "waku": "1.0.0-alpha.4", From 23e55a98dc841498a2215d7da86a72587d4ba6c0 Mon Sep 17 00:00:00 2001 From: Zygimantas <5236121+Zygimantass@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:50:14 +0200 Subject: [PATCH 18/25] fix: withdraws --- src/components/guides/zones/WithdrawFromZone.tsx | 4 ++-- src/pages/guide/private-zones/index.mdx | 2 +- src/pages/guide/private-zones/withdraw-from-a-zone.mdx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/guides/zones/WithdrawFromZone.tsx b/src/components/guides/zones/WithdrawFromZone.tsx index f4f9f601..ddade018 100644 --- a/src/components/guides/zones/WithdrawFromZone.tsx +++ b/src/components/guides/zones/WithdrawFromZone.tsx @@ -38,7 +38,7 @@ type ZoneClientLike = { getBalance: (parameters: { account: Hex; token: Hex }) => Promise } zone: { - requestEncryptedWithdrawalSync: (parameters: { + requestVerifiableWithdrawalSync: (parameters: { account: unknown amount: bigint feeToken: Hex @@ -229,7 +229,7 @@ function ConnectedZoneFlow(props: { address: Hex; mode: WithdrawalMode }) { const receipt = mode === 'authenticated' ? ( - await zoneClient.zone.requestEncryptedWithdrawalSync({ + await zoneClient.zone.requestVerifiableWithdrawalSync({ account: rootWebAuthnAccount, amount: WITHDRAWAL_AMOUNT, feeToken: pathUsd, diff --git a/src/pages/guide/private-zones/index.mdx b/src/pages/guide/private-zones/index.mdx index a74ffc73..875006a9 100644 --- a/src/pages/guide/private-zones/index.mdx +++ b/src/pages/guide/private-zones/index.mdx @@ -19,7 +19,7 @@ Tempo Zones let you keep balances and transfers inside a private execution envir - 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.requestEncryptedWithdrawalSync(...)`. +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. diff --git a/src/pages/guide/private-zones/withdraw-from-a-zone.mdx b/src/pages/guide/private-zones/withdraw-from-a-zone.mdx index fef88038..6b1bff11 100644 --- a/src/pages/guide/private-zones/withdraw-from-a-zone.mdx +++ b/src/pages/guide/private-zones/withdraw-from-a-zone.mdx @@ -69,7 +69,7 @@ const revealTo = '0x031dc147467e8f106eb22850fef549dc74b8f6634aeac554ebdd4ab896b6 await zoneAClient.zone.signAuthorizationToken() -const { receipt } = await Actions.zone.requestEncryptedWithdrawalSync(zoneAClient, { +const { receipt } = await Actions.zone.requestVerifiableWithdrawalSync(zoneAClient, { account: rootClient.account, feeToken: pathUsd, amount: withdrawalAmount, From fe846dc514d3a92a4935139e68ed6b5992a9d685 Mon Sep 17 00:00:00 2001 From: achalvs Date: Mon, 13 Apr 2026 08:27:40 -0700 Subject: [PATCH 19/25] docs: add zone architecture diagrams with descriptive names (#61) * docs: add static zone architecture diagrams Place 7 SVG diagrams across the zones guide and protocol pages. * fix: add HB Set + Pilat fonts for SVG rendering, fix diagram placement - Add woff2 font files (HB Set v0.96 Light/Medium, Pilat Regular/Demi/Bold) to public/fonts/ with @font-face declarations in _root.css so SVG text renders correctly on Vercel (not just macOS) - Move diagram-f (Privacy Flow) from guide/private-zones/index to protocol/zones/index under "Tempo Zones are private" where it fits the surrounding copy better * docs: add diagram-h to zones overview after intro paragraph * docs: add diagram-b under deposit two-phase settlement * docs: move diagram-b above ZonePortal intro paragraph * docs: add diagram-b to withdrawals page * fix: embed fonts directly in SVGs so they render in img tags on Vercel * docs: add diagram-e to token management, diagram-f to bridging overview * Update index.mdx * refactor: replace lettered diagrams with named SVGs, remove font deps - Replace diagram-a..h with descriptive names (diagram-node, diagram-deposit, diagram-overview, diagram-privacy, diagram-swap, diagram-tip20, diagram-withdraw) - New SVGs use outlined text (no font embedding needed) - Remove @font-face imports from _root.css and woff2 files from public/fonts/ - Remove diagram-d from send-across-zones (no named equivalent) - Use diagram-withdraw on withdraw page instead of diagram-deposit * docs: add diagram-overview to Connect to Tempo Zones guide * docs: use diagram-contracts under Contract Architecture * fix: update wording --------- Co-authored-by: Zygimantas <5236121+Zygimantass@users.noreply.github.com> Co-authored-by: Derek Cofausper <256792747+decofe@users.noreply.github.com> --- public/learn/zones/diagram-deposit.svg | 143 ++++++ public/learn/zones/diagram-node.svg | 110 +++++ public/learn/zones/diagram-overview.svg | 458 ++++++++++++++++++ public/learn/zones/diagram-privacy.svg | 179 +++++++ public/learn/zones/diagram-swap.svg | 118 +++++ public/learn/zones/diagram-tip20.svg | 152 ++++++ public/learn/zones/diagram-withdraw.svg | 138 ++++++ .../guide/private-zones/deposit-to-a-zone.mdx | 2 + src/pages/guide/private-zones/index.mdx | 2 + .../guide/private-zones/swap-across-zones.mdx | 2 + .../private-zones/withdraw-from-a-zone.mdx | 2 + src/pages/protocol/zones/architecture.mdx | 8 + src/pages/protocol/zones/bridging.mdx | 4 + src/pages/protocol/zones/execution.mdx | 2 + src/pages/protocol/zones/index.mdx | 6 + 15 files changed, 1326 insertions(+) create mode 100644 public/learn/zones/diagram-deposit.svg create mode 100644 public/learn/zones/diagram-node.svg create mode 100644 public/learn/zones/diagram-overview.svg create mode 100644 public/learn/zones/diagram-privacy.svg create mode 100644 public/learn/zones/diagram-swap.svg create mode 100644 public/learn/zones/diagram-tip20.svg create mode 100644 public/learn/zones/diagram-withdraw.svg 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/pages/guide/private-zones/deposit-to-a-zone.mdx b/src/pages/guide/private-zones/deposit-to-a-zone.mdx index 04607a2a..9279cbf7 100644 --- a/src/pages/guide/private-zones/deposit-to-a-zone.mdx +++ b/src/pages/guide/private-zones/deposit-to-a-zone.mdx @@ -15,6 +15,8 @@ Tempo Zones is still in early development and is available for testing purposes 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. +![Zone contract architecture](/learn/zones/diagram-deposit.svg) + 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 diff --git a/src/pages/guide/private-zones/index.mdx b/src/pages/guide/private-zones/index.mdx index 875006a9..96a30fc9 100644 --- a/src/pages/guide/private-zones/index.mdx +++ b/src/pages/guide/private-zones/index.mdx @@ -13,6 +13,8 @@ Tempo Zones is still in early development and is available for testing purposes 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. +![Tempo Zones overview](/learn/zones/diagram-overview.svg) + ## Before you start - Use a Tempo passkey account in the demo so the page can authorize private zone reads. diff --git a/src/pages/guide/private-zones/swap-across-zones.mdx b/src/pages/guide/private-zones/swap-across-zones.mdx index 9c3c3a54..0f8cc95d 100644 --- a/src/pages/guide/private-zones/swap-across-zones.mdx +++ b/src/pages/guide/private-zones/swap-across-zones.mdx @@ -16,6 +16,8 @@ Use this guide when you want to leave `Zone A` with `pathUSD` and arrive in `Zon 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`. +![Cross-zone DEX swap flow](/learn/zones/diagram-swap.svg) + ## 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. diff --git a/src/pages/guide/private-zones/withdraw-from-a-zone.mdx b/src/pages/guide/private-zones/withdraw-from-a-zone.mdx index 6b1bff11..42bfe0c8 100644 --- a/src/pages/guide/private-zones/withdraw-from-a-zone.mdx +++ b/src/pages/guide/private-zones/withdraw-from-a-zone.mdx @@ -15,6 +15,8 @@ Tempo Zones is still in early development and is available for testing purposes Use this guide when you want to move `pathUSD` out of `Zone A` and back to your public Tempo balance. +![Zone contract architecture](/learn/zones/diagram-withdraw.svg) + 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 diff --git a/src/pages/protocol/zones/architecture.mdx b/src/pages/protocol/zones/architecture.mdx index b15f91a3..58fdb959 100644 --- a/src/pages/protocol/zones/architecture.mdx +++ b/src/pages/protocol/zones/architecture.mdx @@ -23,6 +23,9 @@ Tempo Zones are designed for applications that want safe operation guaranteed by Each Tempo Zone runs as a separate Tempo chain with its own Tempo node(s). Tempo Zones are tightly coupled with Tempo Mainnet and have direct, synchronous access to Tempo Mainnet state. Zone contracts can read certain Tempo Mainnet state without any message passing delay, such as deposit queues and TIP-403 policy information. +<<<<<<< feat/zones-static-diagrams +![Tempo Node system architecture](/learn/zones/diagram-node.svg) +======= Z2["Zone 2
pathUSD, ..."] end `} /> +>>>>>>> feat/zones The sequencer runs a Tempo node with one or more zone nodes attached. Each zone node: @@ -44,6 +48,9 @@ The sequencer runs a Tempo node with one or more zone nodes attached. Each zone The system consists of contracts on both Tempo Mainnet and within each Tempo Zone. +<<<<<<< feat/zones-static-diagrams +![Zone contract architecture](/learn/zones/diagram-contracts.svg) +======= ZI["ZoneInbox
(deposits)"] ZO["ZoneOutbox
(withdrawals)"] -- "withdrawals" --> ZP @@ -59,6 +66,7 @@ The system consists of contracts on both Tempo Mainnet and within each Tempo Zon ZO end `} /> +>>>>>>> feat/zones ### Tempo Contracts diff --git a/src/pages/protocol/zones/bridging.mdx b/src/pages/protocol/zones/bridging.mdx index e49a0f4c..9ae73502 100644 --- a/src/pages/protocol/zones/bridging.mdx +++ b/src/pages/protocol/zones/bridging.mdx @@ -11,6 +11,10 @@ Tempo Zones is still in early development and is available for testing purposes 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. +![End-to-end privacy flow through a zone](/learn/zones/diagram-privacy.svg) + +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. diff --git a/src/pages/protocol/zones/execution.mdx b/src/pages/protocol/zones/execution.mdx index fa3dacbb..febf2e1a 100644 --- a/src/pages/protocol/zones/execution.mdx +++ b/src/pages/protocol/zones/execution.mdx @@ -55,6 +55,8 @@ Tempo Zones currently disable the `CREATE` and `CREATE2` opcodes. Each Tempo Zon ## Token Management +![Policy inheritance from mainnet to zone](/learn/zones/diagram-tip20.svg) + The sequencer manages which TIP-20 tokens are available on a Tempo Zone: | Function | Behavior | diff --git a/src/pages/protocol/zones/index.mdx b/src/pages/protocol/zones/index.mdx index d02e966b..0000b392 100644 --- a/src/pages/protocol/zones/index.mdx +++ b/src/pages/protocol/zones/index.mdx @@ -13,6 +13,8 @@ Tempo Zones is still in early development and is available for testing purposes 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. +![Tempo Zones overview](/learn/zones/diagram-overview.svg) + 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. @@ -25,10 +27,14 @@ Most privacy solutions offer either confidentiality (hide the amount) or anonymi 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. +![End-to-end privacy flow through a zone](/learn/zones/diagram-privacy.svg) + ### 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. +![Policy inheritance from mainnet to zone](/learn/zones/diagram-tip20.svg) + ### 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. From 47a57e1ee72dc16d1972156308fcd525a8095ce3 Mon Sep 17 00:00:00 2001 From: Zygimantas <5236121+Zygimantass@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:54:56 +0200 Subject: [PATCH 20/25] fix: zone renamings --- .../guides/zones/SendTokensAcrossZones.tsx | 499 ++++++++---------- src/lib/private-zones.ts | 6 +- .../guide/private-zones/connect-to-a-zone.mdx | 18 +- 3 files changed, 228 insertions(+), 295 deletions(-) diff --git a/src/components/guides/zones/SendTokensAcrossZones.tsx b/src/components/guides/zones/SendTokensAcrossZones.tsx index 0057da36..ff20e533 100644 --- a/src/components/guides/zones/SendTokensAcrossZones.tsx +++ b/src/components/guides/zones/SendTokensAcrossZones.tsx @@ -1,6 +1,6 @@ -"use client"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import * as React from "react"; +'use client' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import * as React from 'react' import { createClient, encodeAbiParameters, @@ -8,11 +8,11 @@ import { 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"; +} 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, @@ -23,66 +23,61 @@ import { ZONE_B, zeroBytes32, zoneRpcSyncTimeout, -} from "../../../lib/private-zones.ts"; -import { useRootWebAuthnAccount } from "../../../lib/useRootWebAuthnAccount.ts"; -import { - type ZoneAuthClientLike, - useZoneAuthorization, -} from "../../../lib/useZoneAuthorization.ts"; -import { Button, ExplorerLink, Login, Logout, ReceiptHash, Step } from "../Demo"; -import { pathUsd } from "../tokens"; -import { useStickyStepCompletion } from "./useStickyStepCompletion.ts"; +} from '../../../lib/private-zones.ts' +import { useRootWebAuthnAccount } from '../../../lib/useRootWebAuthnAccount.ts' +import { useZoneAuthorization, type ZoneAuthClientLike } from '../../../lib/useZoneAuthorization.ts' +import { Button, ExplorerLink, Login, Logout, ReceiptHash, Step } from '../Demo' +import { pathUsd } from '../tokens' +import { useStickyStepCompletion } from './useStickyStepCompletion.ts' -const TRANSFER_AMOUNT = parseUnits("25", 6); -const ZONE_GAS_BUFFER = parseUnits("1", 6); +const TRANSFER_AMOUNT = parseUnits('25', 6) +const ZONE_GAS_BUFFER = parseUnits('1', 6) const portalAbi = [ { - name: "calculateDepositFee", - type: "function", - stateMutability: "view", + name: 'calculateDepositFee', + type: 'function', + stateMutability: 'view', inputs: [], - outputs: [{ type: "uint128" }], + outputs: [{ type: 'uint128' }], }, { - name: "isTokenEnabled", - type: "function", - stateMutability: "view", - inputs: [{ name: "token", type: "address" }], - outputs: [{ type: "bool" }], + name: 'isTokenEnabled', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'token', type: 'address' }], + outputs: [{ type: 'bool' }], }, -] as const; +] as const const targetDepositEvent = parseAbiItem( - "event DepositMade(bytes32 indexed newCurrentDepositQueueHash, address indexed sender, address token, address to, uint128 netAmount, uint128 fee, bytes32 memo)", -); + '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; - }; + getBalance: (parameters: { account: Hex; token: Hex }) => Promise + } zone: { - getAuthorizationTokenInfo: ZoneAuthClientLike["zone"]["getAuthorizationTokenInfo"]; + 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"]; - }; -}; + 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); + const { address } = useConnection() + const connected = Boolean(address) return ( <> @@ -101,15 +96,15 @@ export function SendTokensAcrossZones() { )} - ); + ) } function ConnectedZoneFlow(props: { address: Hex }) { - const { address } = props; - const queryClient = useQueryClient(); - const publicClient = usePublicClient(); - const { data: connectorClient } = useConnectorClient(); - const { data: rootWebAuthnAccount } = useRootWebAuthnAccount(); + const { address } = props + const queryClient = useQueryClient() + const publicClient = usePublicClient() + const { data: connectorClient } = useConnectorClient() + const { data: rootWebAuthnAccount } = useRootWebAuthnAccount() const { data: rootBalance, isPending: rootBalanceIsPending, @@ -117,7 +112,7 @@ function ConnectedZoneFlow(props: { address: Hex }) { } = Hooks.token.useGetBalance({ account: address, token: pathUsd, - }); + }) const sourceZoneClient = React.useMemo( () => @@ -132,7 +127,7 @@ function ConnectedZoneFlow(props: { address: Hex }) { }).extend(tempoActions()) as unknown as ZoneClientLike) : undefined, [rootWebAuthnAccount], - ); + ) const targetZoneClient = React.useMemo( () => rootWebAuthnAccount @@ -146,177 +141,150 @@ function ConnectedZoneFlow(props: { address: Hex }) { }).extend(tempoActions()) as unknown as ZoneClientLike) : undefined, [rootWebAuthnAccount], - ); + ) const sourceFooterQueryKey = React.useMemo( - () => ["demo-zone-balance", address, ZONE_A.id, pathUsd], + () => ['demo-zone-balance', address, ZONE_A.id, pathUsd], [address], - ); + ) const targetFooterQueryKey = React.useMemo( - () => ["demo-zone-balance", address, ZONE_B.id, pathUsd], + () => ['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, - ], + 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, - ], + 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"); + 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, - ], + 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 (!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.`, - ); + 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; + ? TRANSFER_AMOUNT + transferPrereqsQuery.data.routedWithdrawalFee + ZONE_GAS_BUFFER + : undefined const sourceZoneTopUpShortfall = requiredSourceZoneBalance !== undefined && sourceZoneBalanceQuery.data !== undefined && sourceZoneBalanceQuery.data < requiredSourceZoneBalance ? requiredSourceZoneBalance - sourceZoneBalanceQuery.data - : 0n; + : 0n const hasEnoughSourceZoneBalance = Boolean( requiredSourceZoneBalance !== undefined && sourceZoneBalanceQuery.data !== undefined && sourceZoneBalanceQuery.data >= requiredSourceZoneBalance, - ); - const sourceZoneBalanceStepComplete = useStickyStepCompletion( - hasEnoughSourceZoneBalance, - ); + ) + const sourceZoneBalanceStepComplete = useStickyStepCompletion(hasEnoughSourceZoneBalance) const fundMutation = useMutation({ mutationFn: async () => { - if (!connectorClient) throw new Error("connector client not ready"); + if (!connectorClient) throw new Error('connector client not ready') await Actions.faucet.fundSync(connectorClient, { account: address, - }); + }) }, onSuccess: async () => { - await refetchRootBalance(); + 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 }; + 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(); + 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"); + 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."); + throw new Error('Zone A needs more pathUSD before the send can start.') } - const anchorBlock = await publicClient.getBlockNumber(); + const anchorBlock = await publicClient.getBlockNumber() const { receipt } = await sourceZoneClient.zone.requestWithdrawalSync({ account: rootWebAuthnAccount, @@ -327,169 +295,155 @@ function ConnectedZoneFlow(props: { address: Hex }) { 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 }); + await sourceZoneBalanceQuery.refetch() + await queryClient.invalidateQueries({ queryKey: sourceFooterQueryKey }) }, - }); + }) const settlementQuery = useQuery({ enabled: Boolean( - publicClient && - sendMutation.isSuccess && - sendMutation.data?.anchorBlock !== undefined, + publicClient && sendMutation.isSuccess && sendMutation.data?.anchorBlock !== undefined, ), queryKey: [ - "guide-private-zones-cross-zone-send-settlement", + '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"); + 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(); + : 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; + 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" && + 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; + return match ? { txHash: match.transactionHash } : null }, refetchInterval: (query) => { - if (query.state.error || query.state.data) return false; + if (query.state.error || query.state.data) return false - return 2_000; + 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], + 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, - ], + 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"); + 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 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; + sourceZoneAuthorization.isChecking || sourceZoneAuthorization.authorizeMutation.isPending const stepTwoAction = sourceZoneAuthorization.isAuthorized ? undefined : ( - ); + ) React.useEffect(() => { - if (!sourceZoneAuthorization.isAuthorized) return; + if (!sourceZoneAuthorization.isAuthorized) return - void queryClient.invalidateQueries({ queryKey: sourceFooterQueryKey }); - }, [queryClient, sourceFooterQueryKey, sourceZoneAuthorization.isAuthorized]); + void queryClient.invalidateQueries({ queryKey: sourceFooterQueryKey }) + }, [queryClient, sourceFooterQueryKey, sourceZoneAuthorization.isAuthorized]) React.useEffect(() => { - if (!targetZoneAuthorization.isAuthorized) return; + if (!targetZoneAuthorization.isAuthorized) return - void queryClient.invalidateQueries({ queryKey: targetFooterQueryKey }); - }, [queryClient, targetFooterQueryKey, targetZoneAuthorization.isAuthorized]); + void queryClient.invalidateQueries({ queryKey: targetFooterQueryKey }) + }, [queryClient, targetFooterQueryKey, targetZoneAuthorization.isAuthorized]) React.useEffect(() => { - if (!topUpMutation.isSuccess || sourceZoneBalanceStepComplete) return; + if (!topUpMutation.isSuccess || sourceZoneBalanceStepComplete) return const interval = window.setInterval(() => { - void sourceZoneBalanceQuery.refetch(); - }, 1_500); + void sourceZoneBalanceQuery.refetch() + }, 1_500) - return () => window.clearInterval(interval); - }, [ - sourceZoneBalanceQuery, - sourceZoneBalanceStepComplete, - topUpMutation.isSuccess, - ]); + return () => window.clearInterval(interval) + }, [sourceZoneBalanceQuery, sourceZoneBalanceStepComplete, topUpMutation.isSuccess]) - let stepThreeAction: React.ReactNode; + let stepThreeAction: React.ReactNode if (sourceZoneBalanceStepComplete) { - stepThreeAction = undefined; - } else if ( - sourceZoneBalanceQuery.isPending || - transferPrereqsQuery.isPending - ) { + stepThreeAction = undefined + } else if (sourceZoneBalanceQuery.isPending || transferPrereqsQuery.isPending) { stepThreeAction = ( - ); + ) } else if (!hasEnoughSourceZoneBalance && !hasRootBalance) { stepThreeAction = ( - ); + ) } else if (!hasEnoughSourceZoneBalance) { stepThreeAction = ( - ); + ) } - let stepFourAction: React.ReactNode; + let stepFourAction: React.ReactNode if (!sourceZoneBalanceStepComplete || transferPrereqsQuery.isPending) { - stepFourAction = undefined; + stepFourAction = undefined } else if (transferPrereqsQuery.isError) { stepFourAction = ( - ); + ) } else { stepFourAction = ( - ); + ) } - let stepSixAction: React.ReactNode; + let stepSixAction: React.ReactNode if (!settlementQuery.data) { - stepSixAction = undefined; + stepSixAction = undefined } else if (targetZoneBalanceQuery.isError) { stepSixAction = ( - ); + ) } else if (!targetZoneAuthorization.isAuthorized) { stepSixAction = ( - ); + ) } else if (targetZoneBalanceQuery.isPending) { stepSixAction = ( - ); + ) } return ( @@ -631,10 +581,7 @@ function ConnectedZoneFlow(props: { address: Hex }) { > {topUpReceipt && ( - + )} @@ -650,10 +597,7 @@ function ConnectedZoneFlow(props: { address: Hex }) { > {routedSendReceipt && sendMutation.data && ( - + {settlementTxHash && ( - - {settlementTxHash && } - + {settlementTxHash && } )} @@ -694,7 +636,7 @@ function ConnectedZoneFlow(props: { address: Hex }) { title={`Authorize private reads in ${ZONE_B.label} and confirm the pathUSD balance.`} /> - ); + ) } function DisconnectedZoneFlow() { @@ -741,21 +683,21 @@ function DisconnectedZoneFlow() { title={`Authorize private reads in ${ZONE_B.label} and confirm the pathUSD balance.`} /> - ); + ) } function encodeRouterCallback(recipient: Hex) { return encodeAbiParameters( [ - { type: "bool" }, - { type: "address" }, - { type: "address" }, - { type: "address" }, - { type: "bytes32" }, - { type: "uint128" }, + { 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) { @@ -765,25 +707,18 @@ function StepBody(props: React.PropsWithChildren) {
{props.children}
- ); + ) } -function DetailLine(props: { - label: string; - value: string; - dataTestId?: string | undefined; -}) { - const { dataTestId, label, value } = props; +function DetailLine(props: { label: string; value: string; dataTestId?: string | undefined }) { + const { dataTestId, label, value } = props return (
{label} - + {value}
- ); + ) } diff --git a/src/lib/private-zones.ts b/src/lib/private-zones.ts index d4bf353c..39714e4c 100644 --- a/src/lib/private-zones.ts +++ b/src/lib/private-zones.ts @@ -18,10 +18,8 @@ export const publicSettlementLookbackBlocks = 100n export const zeroBytes32 = '0x0000000000000000000000000000000000000000000000000000000000000000' as const -const ZONE_A_RPC_URL = - 'https://eng:bold-raman-silly-torvalds@rpc-zone-005-private.tempoxyz.dev' as const -const ZONE_B_RPC_URL = - 'https://eng:bold-raman-silly-torvalds@rpc-zone-006-private.tempoxyz.dev' 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, diff --git a/src/pages/guide/private-zones/connect-to-a-zone.mdx b/src/pages/guide/private-zones/connect-to-a-zone.mdx index 5211f7a1..cd534d16 100644 --- a/src/pages/guide/private-zones/connect-to-a-zone.mdx +++ b/src/pages/guide/private-zones/connect-to-a-zone.mdx @@ -1,6 +1,6 @@ --- title: Connect to a Zone -description: Connect to Tempo Zones on testnet using Zone 5 and Zone 6 RPC URLs, chain IDs, and a minimal viem client setup for private flows. +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 @@ -9,7 +9,7 @@ description: Connect to Tempo Zones on testnet using Zone 5 and Zone 6 RPC URLs, 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 5` or `Zone 6`. +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. @@ -21,7 +21,7 @@ Use `zoneModerato(...)` from `viem/tempo/zones` so the client has the correct ch import { createPublicClient } from 'viem' import { http, zoneModerato } from 'viem/tempo/zones' -const rpcUrl = 'https://rpc-zone-005-private.tempoxyz.dev' +const rpcUrl = 'https://rpc-zone-a.testnet.tempo.xyz' const zoneClient = createPublicClient({ chain: zoneModerato(6), @@ -34,25 +34,25 @@ console.log(blockNumber) ## Direct Connection Details -### Zone 5 +### Zone A | **Property** | **Value** | |-------------------|-------| -| **Network Name** | Zone 5 | +| **Network Name** | Zone A | | **Zone ID** | `6` | | **Chain ID** | `4217000006` | -| **HTTP URL** | `https://rpc-zone-005-private.tempoxyz.dev` | +| **HTTP URL** | `https://rpc-zone-a.testnet.tempo.xyz` | | **Portal Address** | `0x7069DeC4E64Fd07334A0933eDe836C17259c9B23` | | **Outbox Address** | `0x1c00000000000000000000000000000000000002` | -### Zone 6 +### Zone B | **Property** | **Value** | |-------------------|-------| -| **Network Name** | Zone 6 | +| **Network Name** | Zone B | | **Zone ID** | `7` | | **Chain ID** | `4217000007` | -| **HTTP URL** | `https://rpc-zone-006-private.tempoxyz.dev` | +| **HTTP URL** | `https://rpc-zone-b.testnet.tempo.xyz` | | **Portal Address** | `0x3F5296303400B56271b476F5A0B9cBF74350D6Ac` | | **Outbox Address** | `0x1c00000000000000000000000000000000000002` | From d0620608d13a1a98d010cc7204685fa071938dfb Mon Sep 17 00:00:00 2001 From: Zygimantas <5236121+Zygimantass@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:26:59 +0200 Subject: [PATCH 21/25] feat: cleanup --- src/components/guides/Demo.tsx | 45 ++++++++----------- .../guides/zones/SendTokensAcrossZones.tsx | 17 +------ .../guide/private-zones/deposit-to-a-zone.mdx | 6 ++- .../send-tokens-across-zones.mdx | 10 +++-- .../send-tokens-within-a-zone.mdx | 4 +- .../guide/private-zones/swap-across-zones.mdx | 10 +++-- .../private-zones/withdraw-from-a-zone.mdx | 14 +++--- src/pages/protocol/zones/architecture.mdx | 8 ---- 8 files changed, 45 insertions(+), 69 deletions(-) diff --git a/src/components/guides/Demo.tsx b/src/components/guides/Demo.tsx index fbbd8f56..ceb60a3c 100644 --- a/src/components/guides/Demo.tsx +++ b/src/components/guides/Demo.tsx @@ -283,8 +283,8 @@ export namespace Container { ) } - function ZoneBalancesFooterItem(props: ZoneBalance & { address: Address; showLabel: boolean }) { - const { address, label, showLabel, token, zone } = props + function ZoneBalancesFooterItem(props: ZoneBalance & { address: Address }) { + const { address, token, zone } = props const { data: connectorClient } = useConnectorClient() const { data: rootWebAuthnAccount } = useRootWebAuthnAccount() const zoneRpcUrl = @@ -337,13 +337,7 @@ export namespace Container { return } - return showLabel ? ( - - {label} - {formatUnits(balance, metadata.decimals)} - {metadata.symbol} - - ) : ( + return ( {formatUnits(balance, metadata.decimals)} {metadata.symbol} @@ -357,12 +351,12 @@ export namespace Container { zoneBalances?: ZoneBalance[] | undefined }) { const { address, tokens, zoneBalances } = props - const showZoneBalanceLabels = Boolean(zoneBalances && zoneBalances.length > 1) + const personalBalanceLabel = tokens.length > 1 ? 'Personal balances' : 'Personal balance' return (
- Balances + {personalBalanceLabel}
{address ? ( @@ -374,22 +368,21 @@ export namespace Container { )}
- {address && zoneBalances && zoneBalances.length > 0 && ( -
- Zone balances -
-
- {zoneBalances.map((zoneBalance) => ( - - ))} + {address && + zoneBalances && + zoneBalances.length > 0 && + zoneBalances.map((zoneBalance) => ( +
+ {zoneBalance.label} balance +
+
+ +
-
- )} + ))}
) } diff --git a/src/components/guides/zones/SendTokensAcrossZones.tsx b/src/components/guides/zones/SendTokensAcrossZones.tsx index ff20e533..89f13bf7 100644 --- a/src/components/guides/zones/SendTokensAcrossZones.tsx +++ b/src/components/guides/zones/SendTokensAcrossZones.tsx @@ -1,14 +1,7 @@ 'use client' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import * as React from 'react' -import { - createClient, - encodeAbiParameters, - formatUnits, - type Hex, - parseAbiItem, - parseUnits, -} from 'viem' +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' @@ -598,14 +591,6 @@ function ConnectedZoneFlow(props: { address: Hex }) { {routedSendReceipt && sendMutation.data && ( - - )} diff --git a/src/pages/guide/private-zones/deposit-to-a-zone.mdx b/src/pages/guide/private-zones/deposit-to-a-zone.mdx index 9279cbf7..63353622 100644 --- a/src/pages/guide/private-zones/deposit-to-a-zone.mdx +++ b/src/pages/guide/private-zones/deposit-to-a-zone.mdx @@ -7,6 +7,8 @@ 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 @@ -27,7 +29,7 @@ By the end of this guide you will have deposited `pathUSD` into `Zone A` and con name="Deposit to Zone A" footerVariant="balances" tokens={[Demo.pathUsd]} - zoneBalances={[{ label: 'Zone A', token: Demo.pathUsd, zone: 6 }]} + zoneBalances={depositZoneBalances} > @@ -65,7 +67,7 @@ import { Actions } from 'viem/tempo' const depositAmount = parseUnits('100', 6) -const { receipt } = await Actions.zone.encryptedDepositSync(rootClient, { +const { receipt } = await Actions.zone.encryptedDepositSync(rootClient, { // [!code focus] account: rootClient.account, amount: depositAmount, token: pathUsd, diff --git a/src/pages/guide/private-zones/send-tokens-across-zones.mdx b/src/pages/guide/private-zones/send-tokens-across-zones.mdx index 65bf9227..5c405f3f 100644 --- a/src/pages/guide/private-zones/send-tokens-across-zones.mdx +++ b/src/pages/guide/private-zones/send-tokens-across-zones.mdx @@ -6,6 +6,11 @@ description: Send pathUSD from Zone A into Zone B by routing a same-token withdr 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 @@ -24,10 +29,7 @@ By the end of this guide you will have sent **25 pathUSD** from **Zone A** into name="Send tokens across zones" footerVariant="balances" tokens={[Demo.pathUsd]} - zoneBalances={[ - { label: 'Zone A', token: Demo.pathUsd, zone: 6 }, - { label: 'Zone B', token: Demo.pathUsd, zone: 7 }, - ]} + zoneBalances={crossZoneBalances} > 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 index 18626380..e1161b8b 100644 --- a/src/pages/guide/private-zones/send-tokens-within-a-zone.mdx +++ b/src/pages/guide/private-zones/send-tokens-within-a-zone.mdx @@ -6,6 +6,8 @@ description: Send pathUSD inside Zone A with a signed zone transfer and confirm 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 @@ -24,7 +26,7 @@ By the end of this guide you will have sent `25 pathUSD` inside `Zone A` and con name="Send tokens within Zone A" footerVariant="balances" tokens={[Demo.pathUsd]} - zoneBalances={[{ label: 'Zone A', token: Demo.pathUsd, zone: 6 }]} + zoneBalances={inZoneBalances} > diff --git a/src/pages/guide/private-zones/swap-across-zones.mdx b/src/pages/guide/private-zones/swap-across-zones.mdx index 0f8cc95d..c7af1b4a 100644 --- a/src/pages/guide/private-zones/swap-across-zones.mdx +++ b/src/pages/guide/private-zones/swap-across-zones.mdx @@ -6,6 +6,11 @@ description: Swap pathUSD from Zone A into betaUSD on Zone B by routing a zone w 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 @@ -33,10 +38,7 @@ By the end of this guide you will have swapped **25 pathUSD** from **Zone A** in name="Swap across zones" footerVariant="balances" tokens={[Demo.pathUsd]} - zoneBalances={[ - { label: 'Zone A', token: Demo.pathUsd, zone: 6 }, - { label: 'Zone B', token: Demo.betaUsd, zone: 7 }, - ]} + zoneBalances={swapZoneBalances} > diff --git a/src/pages/guide/private-zones/withdraw-from-a-zone.mdx b/src/pages/guide/private-zones/withdraw-from-a-zone.mdx index 42bfe0c8..4e9139ad 100644 --- a/src/pages/guide/private-zones/withdraw-from-a-zone.mdx +++ b/src/pages/guide/private-zones/withdraw-from-a-zone.mdx @@ -7,6 +7,8 @@ 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 @@ -27,7 +29,7 @@ By the end of this guide you will have withdrawn `pathUSD` from `Zone A` and con name="Withdraw from Zone A" footerVariant="balances" tokens={[Demo.pathUsd]} - zoneBalances={[{ label: 'Zone A', token: Demo.pathUsd, zone: 6 }]} + zoneBalances={withdrawalZoneBalances} > @@ -67,15 +69,15 @@ import { parseUnits } from 'viem' import { Actions } from 'viem/tempo' const withdrawalAmount = parseUnits('100', 6) -const revealTo = '0x031dc147467e8f106eb22850fef549dc74b8f6634aeac554ebdd4ab896b67cdf68' +const revealTo = '0x031dc147467e8f106eb22850fef549dc74b8f6634aeac554ebdd4ab896b67cdf68' // [!code focus] await zoneAClient.zone.signAuthorizationToken() -const { receipt } = await Actions.zone.requestVerifiableWithdrawalSync(zoneAClient, { +const { receipt } = await Actions.zone.requestVerifiableWithdrawalSync(zoneAClient, { // [!code focus] account: rootClient.account, feeToken: pathUsd, amount: withdrawalAmount, - revealTo, + revealTo, // [!code focus] token: pathUsd, to: rootClient.account.address, }) @@ -94,10 +96,6 @@ Like deposits, withdrawals settle in phases. The request is accepted on the zone 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. -:::tip - If a user is trying to withdraw most or all of a zone balance, subtract the withdrawal fee first. `ZoneOutbox` burns `amount + fee`, and the fee is paid in the same token being withdrawn. -::: - :::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/protocol/zones/architecture.mdx b/src/pages/protocol/zones/architecture.mdx index 58fdb959..b15f91a3 100644 --- a/src/pages/protocol/zones/architecture.mdx +++ b/src/pages/protocol/zones/architecture.mdx @@ -23,9 +23,6 @@ Tempo Zones are designed for applications that want safe operation guaranteed by Each Tempo Zone runs as a separate Tempo chain with its own Tempo node(s). Tempo Zones are tightly coupled with Tempo Mainnet and have direct, synchronous access to Tempo Mainnet state. Zone contracts can read certain Tempo Mainnet state without any message passing delay, such as deposit queues and TIP-403 policy information. -<<<<<<< feat/zones-static-diagrams -![Tempo Node system architecture](/learn/zones/diagram-node.svg) -======= Z2["Zone 2
pathUSD, ..."] end `} /> ->>>>>>> feat/zones The sequencer runs a Tempo node with one or more zone nodes attached. Each zone node: @@ -48,9 +44,6 @@ The sequencer runs a Tempo node with one or more zone nodes attached. Each zone The system consists of contracts on both Tempo Mainnet and within each Tempo Zone. -<<<<<<< feat/zones-static-diagrams -![Zone contract architecture](/learn/zones/diagram-contracts.svg) -======= ZI["ZoneInbox
(deposits)"] ZO["ZoneOutbox
(withdrawals)"] -- "withdrawals" --> ZP @@ -66,7 +59,6 @@ The system consists of contracts on both Tempo Mainnet and within each Tempo Zon ZO end `} /> ->>>>>>> feat/zones ### Tempo Contracts From cfdfc8fb026431f56c4fe3bac905f691720d4f66 Mon Sep 17 00:00:00 2001 From: Zygimantas <5236121+Zygimantass@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:17:47 +0200 Subject: [PATCH 22/25] fix: rebase --- e2e/deposit-to-a-zone.test.ts | 12 +- e2e/send-tokens-across-zones.test.ts | 113 +++++++----------- e2e/send-tokens-within-a-zone.test.ts | 16 +-- e2e/swap-across-zones.test.ts | 26 ++-- e2e/withdraw-from-a-zone.test.ts | 50 +++++--- src/components/guides/zones/DepositToZone.tsx | 36 +++++- .../guide/private-zones/deposit-to-a-zone.mdx | 1 + .../send-tokens-across-zones.mdx | 1 + .../send-tokens-within-a-zone.mdx | 1 + .../guide/private-zones/swap-across-zones.mdx | 1 + .../private-zones/withdraw-from-a-zone.mdx | 1 + 11 files changed, 142 insertions(+), 116 deletions(-) diff --git a/e2e/deposit-to-a-zone.test.ts b/e2e/deposit-to-a-zone.test.ts index dd5538eb..a57f5847 100644 --- a/e2e/deposit-to-a-zone.test.ts +++ b/e2e/deposit-to-a-zone.test.ts @@ -25,10 +25,12 @@ test('prepare zone access and deposit to Zone A', async ({ page }) => { timeout: 30000, }) - const getFundsButton = page.getByRole('button', { name: 'Get testnet PathUSD' }).first() - const depositButton = page - .getByRole('button', { name: /^(Approve \+ deposit|Deposit) 100 PathUSD$/ }) - .first() + const authorizeButton = page.getByRole('button', { name: 'Authorize Zone A reads' }).first() + await expect(authorizeButton).toBeVisible({ timeout: 30000 }) + 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() @@ -40,7 +42,7 @@ test('prepare zone access and deposit to Zone A', async ({ page }) => { await expect( page .locator('div[data-completed="true"]', { - has: page.getByText('Poll Zone A until the batched deposit is reflected.'), + has: page.getByText('Wait for Zone A to credit the deposit.'), }) .first(), ).toBeVisible({ timeout: 120000 }) diff --git a/e2e/send-tokens-across-zones.test.ts b/e2e/send-tokens-across-zones.test.ts index d9715a89..7e1ea954 100644 --- a/e2e/send-tokens-across-zones.test.ts +++ b/e2e/send-tokens-across-zones.test.ts @@ -1,8 +1,6 @@ import { expect, test } from '@playwright/test' -test('send PathUSD from Zone A into Zone B without a spurious Zone B auth error', async ({ - page, -}) => { +test('send pathUSD from Zone A into Zone B', async ({ page }) => { test.setTimeout(240000) const client = await page.context().newCDPSession(page) @@ -17,80 +15,61 @@ test('send PathUSD from Zone A into Zone B without a spurious Zone B auth error' }, }) - try { - await page.goto('/guide/private-zones/send-tokens-across-zones') + 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() + 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, - }) + await expect(page.getByRole('button', { name: 'Sign out' }).first()).toBeVisible({ + timeout: 30000, + }) - const authorizeZoneAButton = page - .getByRole('button', { name: 'Authorize Zone A reads' }) - .first() - const getFundsButton = page.getByRole('button', { name: 'Get testnet PathUSD' }).first() - const topUpButton = page - .getByRole('button', { - name: /^(Approve \+ top up|Top up) Zone A$/, - }) - .first() - const sendButton = page.getByRole('button', { name: 'Send 25 PathUSD into Zone B' }).first() - const waitingForZoneBDeposit = page.getByText( - 'Wait for the routed pathUSD deposit to land in Zone B.', - { exact: true }, + const authorizeSourceButton = page.getByRole('button', { name: 'Authorize Zone A reads' }).first() + await expect(authorizeSourceButton).toBeVisible({ timeout: 30000 }) + 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: 'Authorize Zone B reads' }).first() + + await expect + .poll( + async () => + (await getFundsButton.isVisible()) || + (await topUpButton.isVisible()) || + (await sendButton.isVisible()), + { timeout: 90000 }, ) - const httpError = page.getByText('HTTP request failed.', { exact: true }) + .toBe(true) + if (await getFundsButton.isVisible()) { + await getFundsButton.click() await expect - .poll( - async () => - (await authorizeZoneAButton.isVisible()) || - (await getFundsButton.isVisible()) || - (await topUpButton.isVisible()) || - (await sendButton.isVisible()), - { timeout: 90000 }, - ) + .poll(async () => (await topUpButton.isVisible()) || (await sendButton.isVisible()), { + timeout: 90000, + }) .toBe(true) + } - if (await authorizeZoneAButton.isVisible()) { - await authorizeZoneAButton.click() - 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() + } - if (await topUpButton.isVisible()) { - await topUpButton.click() - } + await expect(sendButton).toBeVisible({ timeout: 120000 }) + await sendButton.click() - await expect(sendButton).toBeVisible({ timeout: 120000 }) - await sendButton.click() + await expect(authorizeTargetButton).toBeVisible({ timeout: 120000 }) + await authorizeTargetButton.click() - await expect(waitingForZoneBDeposit).toBeVisible({ timeout: 120000 }) + 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 }) - for (let index = 0; index < 30; index++) { - await expect(httpError).toHaveCount(0) - await page.waitForTimeout(1000) - } - } finally { - await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }) - } + 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 index 12e6f579..f656dabe 100644 --- a/e2e/send-tokens-within-a-zone.test.ts +++ b/e2e/send-tokens-within-a-zone.test.ts @@ -25,13 +25,13 @@ test('prepare zone balance and send tokens within Zone A', async ({ page }) => { timeout: 30000, }) - const getFundsButton = page.getByRole('button', { name: 'Get testnet PathUSD' }).first() - const topUpButton = page - .getByRole('button', { - name: /^(Approve \+ top up|Top up) Zone A$/, - }) - .first() - const sendButton = page.getByRole('button', { name: 'Send 25 PathUSD' }).first() + const authorizeButton = page.getByRole('button', { name: 'Authorize Zone A reads' }).first() + await expect(authorizeButton).toBeVisible({ timeout: 30000 }) + 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( @@ -56,7 +56,7 @@ test('prepare zone balance and send tokens within Zone A', async ({ page }) => { await expect( page .locator('div[data-completed="true"]', { - has: page.getByText('Wait for the Zone A balance to reflect the transfer.'), + has: page.getByText('Wait for Zone A to show the updated private balance.'), }) .first(), ).toBeVisible({ timeout: 120000 }) diff --git a/e2e/swap-across-zones.test.ts b/e2e/swap-across-zones.test.ts index 29d062a9..c6db47bb 100644 --- a/e2e/swap-across-zones.test.ts +++ b/e2e/swap-across-zones.test.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test' -test('swap PathUSD from Zone A into BetaUSD on Zone B', async ({ page }) => { +test('swap pathUSD from Zone A into betaUSD on Zone B', async ({ page }) => { test.setTimeout(240000) const client = await page.context().newCDPSession(page) @@ -25,16 +25,16 @@ test('swap PathUSD from Zone A into BetaUSD on Zone B', async ({ page }) => { timeout: 30000, }) - const getFundsButton = page.getByRole('button', { name: 'Get testnet PathUSD' }).first() - const topUpButton = page - .getByRole('button', { - name: /^(Approve \+ top up|Top up) Zone A$/, - }) - .first() + const authorizeSourceButton = page.getByRole('button', { name: 'Authorize Zone A reads' }).first() + await expect(authorizeSourceButton).toBeVisible({ timeout: 30000 }) + 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' }) + .getByRole('button', { name: /^Swap 25 pathUSD into Zone B betaUSD$/i }) .first() - const prepareTargetAuthButton = page.getByRole('button', { name: 'Prepare Zone B auth' }).first() + const authorizeTargetButton = page.getByRole('button', { name: 'Authorize Zone B reads' }).first() await expect .poll( @@ -62,15 +62,13 @@ test('swap PathUSD from Zone A into BetaUSD on Zone B', async ({ page }) => { await expect(swapButton).toBeVisible({ timeout: 120000 }) await swapButton.click() - await expect(prepareTargetAuthButton).toBeVisible({ timeout: 120000 }) - await prepareTargetAuthButton.click() + await expect(authorizeTargetButton).toBeVisible({ timeout: 120000 }) + await authorizeTargetButton.click() await expect( page .locator('div[data-completed="true"]', { - has: page.getByText( - 'Prepare authenticated access for Zone B and read the BetaUSD balance.', - ), + has: page.getByText('Authorize private reads in Zone B and confirm the betaUSD balance.'), }) .first(), ).toBeVisible({ timeout: 120000 }) diff --git a/e2e/withdraw-from-a-zone.test.ts b/e2e/withdraw-from-a-zone.test.ts index 358314bf..38741e9e 100644 --- a/e2e/withdraw-from-a-zone.test.ts +++ b/e2e/withdraw-from-a-zone.test.ts @@ -3,6 +3,8 @@ 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', { @@ -25,33 +27,49 @@ test('prepare zone balance and withdraw from Zone A', async ({ page }) => { timeout: 20000, }) - const getFundsButton = page.getByRole('button', { name: 'Get testnet PathUSD' }).first() - const topUpButton = page - .getByRole('button', { - name: /^(Approve \+ top up|Top up) Zone A$/, - }) - .first() - const withdrawButton = page - .getByRole('button', { name: /^(Approve \+ withdraw|Withdraw) 100 PathUSD$/ }) - .first() + const authorizeButton = page.getByRole('button', { name: 'Authorize Zone A reads' }).first() + await expect(authorizeButton).toBeVisible({ timeout: 30000 }) + 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) + } - await expect(getFundsButton).toBeVisible({ timeout: 20000 }) - await getFundsButton.click() - await expect(topUpButton).toBeVisible({ timeout: 45000 }) + if (await topUpButton.isVisible()) { + await topUpButton.click() + } - await topUpButton.click() - await expect(withdrawButton).toBeVisible({ timeout: 45000 }) + await expect(withdrawButton).toBeVisible({ timeout: 90000 }) await withdrawButton.click() await expect( page .locator('div[data-completed="true"]', { - has: page.getByText('Wait for PathUSD to arrive on Moderato.'), + has: page.getByText('Wait for pathUSD to settle back to your public balance.'), }) .first(), ).toBeVisible({ - timeout: 45000, + timeout: 120000, }) await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }) diff --git a/src/components/guides/zones/DepositToZone.tsx b/src/components/guides/zones/DepositToZone.tsx index 3281df23..5df651ff 100644 --- a/src/components/guides/zones/DepositToZone.tsx +++ b/src/components/guides/zones/DepositToZone.tsx @@ -1,7 +1,7 @@ 'use client' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import * as React from 'react' -import { createClient, type Hex, parseAbi, parseUnits } from 'viem' +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' @@ -69,6 +69,7 @@ export function DepositToZone() { 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() @@ -98,6 +99,20 @@ function ConnectedZoneFlow(props: { address: Hex; mode: DepositMode }) { : 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, @@ -165,15 +180,18 @@ function ConnectedZoneFlow(props: { address: Hex; mode: DepositMode }) { depositSetupQuery.data.depositFee, ) + if (mode === 'encrypted' && encryptedDepositRequiresRootClient && !encryptedDepositClient) + throw new Error('encrypted deposit client not ready') + const receipt = mode === 'encrypted' ? ( await Actions.zone.encryptedDepositSync( - connectorClient as never, + (encryptedDepositClient ?? connectorClient) as never, { - account: connectorClient.account, + account: (encryptedDepositClient ?? connectorClient).account, amount: DEPOSIT_AMOUNT, - chain: connectorClient.chain as never, + chain: (encryptedDepositClient ?? connectorClient).chain as never, timeout: 60_000, token: pathUsd, zoneId: ZONE_ID, @@ -308,12 +326,18 @@ function ConnectedZoneFlow(props: { address: Hex; mode: DepositMode }) { stepThreeAction = ( ) } diff --git a/src/pages/guide/private-zones/deposit-to-a-zone.mdx b/src/pages/guide/private-zones/deposit-to-a-zone.mdx index 63353622..95a4801c 100644 --- a/src/pages/guide/private-zones/deposit-to-a-zone.mdx +++ b/src/pages/guide/private-zones/deposit-to-a-zone.mdx @@ -1,6 +1,7 @@ --- 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' diff --git a/src/pages/guide/private-zones/send-tokens-across-zones.mdx b/src/pages/guide/private-zones/send-tokens-across-zones.mdx index 5c405f3f..448dd7e4 100644 --- a/src/pages/guide/private-zones/send-tokens-across-zones.mdx +++ b/src/pages/guide/private-zones/send-tokens-across-zones.mdx @@ -1,6 +1,7 @@ --- 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' 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 index e1161b8b..d5406ba5 100644 --- a/src/pages/guide/private-zones/send-tokens-within-a-zone.mdx +++ b/src/pages/guide/private-zones/send-tokens-within-a-zone.mdx @@ -1,6 +1,7 @@ --- 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' diff --git a/src/pages/guide/private-zones/swap-across-zones.mdx b/src/pages/guide/private-zones/swap-across-zones.mdx index c7af1b4a..f0a182fa 100644 --- a/src/pages/guide/private-zones/swap-across-zones.mdx +++ b/src/pages/guide/private-zones/swap-across-zones.mdx @@ -1,6 +1,7 @@ --- 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' diff --git a/src/pages/guide/private-zones/withdraw-from-a-zone.mdx b/src/pages/guide/private-zones/withdraw-from-a-zone.mdx index 4e9139ad..b66f3798 100644 --- a/src/pages/guide/private-zones/withdraw-from-a-zone.mdx +++ b/src/pages/guide/private-zones/withdraw-from-a-zone.mdx @@ -1,6 +1,7 @@ --- 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' From 7f88a87a0c935d6037fd8b9fb66d33349c880033 Mon Sep 17 00:00:00 2001 From: Zygimantas <5236121+Zygimantass@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:35:48 +0200 Subject: [PATCH 23/25] fix: unblock CI for custom viem build --- pnpm-lock.yaml | 76 +++++++++++++++++++++++++++++++-------------- pnpm-workspace.yaml | 2 +- 2 files changed, 54 insertions(+), 24 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1a68380..2aa54a25 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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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: https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02 + version: https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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) @@ -1530,6 +1530,7 @@ packages: '@wagmi/connectors@8.0.1': resolution: {integrity: sha512-Rga0EDdcdUBlKtlUUPdBPAIlaFIkO8q0xcNObN/Q/CloM1zaruSFht1q3IaJKrytIDkncQa9uhHU6/imzysvpQ==} + version: 8.0.1 peerDependencies: '@base-org/account': ^2.5.1 '@coinbase/wallet-sdk': ^4.3.6 @@ -1561,6 +1562,7 @@ packages: '@wagmi/core@3.4.2': resolution: {integrity: sha512-01i0ILBe74G8eairY2AIKC4Atrd00xw7EckZ5luU1ARl/6789UH79wXHwJDkHyktXtjn6QoSoBRW2brtlS8SWg==} + version: 3.4.2 peerDependencies: '@tanstack/query-core': '>=5.0.0' ox: '>=0.11.1' @@ -1642,6 +1644,7 @@ packages: accounts@0.6.7: resolution: {integrity: sha512-bXTyx3AFoe98dnlavPsxp7Uoho+QXNdOeHNdsvzC5pzQ2idgK50yUiBTKXtI7+E8kSvvfzGQR8ZdwfgJS5bJHg==} + version: 0.6.7 peerDependencies: '@react-native-async-storage/async-storage': ^3.0.2 '@wagmi/core': '>=2' @@ -2951,6 +2954,7 @@ packages: mppx@0.5.12: resolution: {integrity: sha512-pr6epOYJd8Q6D+MRMc27G48IMh0naAGMMyY1cZYrxMXVwH8PPn1ZaqUwv31svcjOV+UpSrSAe/MP2hRWJ0KQ7A==} + version: 0.5.12 hasBin: true peerDependencies: '@modelcontextprotocol/sdk': '>=1.25.0' @@ -3157,6 +3161,15 @@ packages: typescript: optional: true + ox@https://pkg.pr.new/tempoxyz/ox-tmp-zones/ox@72b35e0: + resolution: {tarball: https://pkg.pr.new/tempoxyz/ox-tmp-zones/ox@72b35e0} + version: 0.14.11-dc1dc5f.0 + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} @@ -3832,8 +3845,9 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - viem@2.47.18: - resolution: {integrity: sha512-m3kr+/i8MddeY5fmB2y2v5B0vDL0x8R4v/8gai4Lh4jh8KOWlQqml7PFLtilNomoDm3mINxdA0JnYBJfknNoEg==} + viem@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02: + resolution: {tarball: https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02} + version: 2.47.7-c353007.0 peerDependencies: typescript: '>=5.0.4' peerDependenciesMeta: @@ -3948,6 +3962,7 @@ packages: wagmi@3.6.1: resolution: {integrity: sha512-GhOm/1FIhsendD+VmBknX+zCxYZCcysbraj/A7L7Lszm8+HgTdHj7eF6DrknKVG12NTXYdmM4vni+jHHrdBuaQ==} + version: 3.6.1 peerDependencies: '@tanstack/react-query': '>=5.0.0' react: '>=18' @@ -5426,18 +5441,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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(typescript@5.9.3)(zod@4.3.6)))(typescript@5.9.3)(viem@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(typescript@5.9.3)(zod@4.3.6)) + viem: https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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: https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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 +5554,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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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: https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(typescript@5.9.3)(zod@4.3.6) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - '@types/react' @@ -7168,11 +7183,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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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: https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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 +7292,21 @@ snapshots: transitivePeerDependencies: - zod + ox@https://pkg.pr.new/tempoxyz/ox-tmp-zones/ox@72b35e0(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 +8114,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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(typescript@5.9.3)(zod@4.3.6): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 @@ -8092,7 +8122,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: https://pkg.pr.new/tempoxyz/ox-tmp-zones/ox@72b35e0(typescript@5.9.3)(zod@4.3.6) ws: 8.18.3 optionalDependencies: typescript: 5.9.3 @@ -8250,14 +8280,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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(typescript@5.9.3)(zod@4.3.6)))(typescript@5.9.3)(viem@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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: https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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..2003d759 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,5 @@ strictDepBuilds: true -blockExoticSubdeps: true +blockExoticSubdeps: false trustPolicy: no-downgrade minimumReleaseAge: 1440 From a98ca3ee607c1296a6d6ebfba47232ca5a1a65b7 Mon Sep 17 00:00:00 2001 From: Zygimantas <5236121+Zygimantass@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:02:04 +0200 Subject: [PATCH 24/25] fix: deps --- package.json | 2 +- pnpm-lock.yaml | 59 ++++++++++++++++++++------------------------- pnpm-workspace.yaml | 1 + 3 files changed, 28 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index cd8757d9..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": "https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02", + "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 2aa54a25..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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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: https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02 - version: https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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) @@ -1530,7 +1530,6 @@ packages: '@wagmi/connectors@8.0.1': resolution: {integrity: sha512-Rga0EDdcdUBlKtlUUPdBPAIlaFIkO8q0xcNObN/Q/CloM1zaruSFht1q3IaJKrytIDkncQa9uhHU6/imzysvpQ==} - version: 8.0.1 peerDependencies: '@base-org/account': ^2.5.1 '@coinbase/wallet-sdk': ^4.3.6 @@ -1562,7 +1561,6 @@ packages: '@wagmi/core@3.4.2': resolution: {integrity: sha512-01i0ILBe74G8eairY2AIKC4Atrd00xw7EckZ5luU1ARl/6789UH79wXHwJDkHyktXtjn6QoSoBRW2brtlS8SWg==} - version: 3.4.2 peerDependencies: '@tanstack/query-core': '>=5.0.0' ox: '>=0.11.1' @@ -1644,7 +1642,6 @@ packages: accounts@0.6.7: resolution: {integrity: sha512-bXTyx3AFoe98dnlavPsxp7Uoho+QXNdOeHNdsvzC5pzQ2idgK50yUiBTKXtI7+E8kSvvfzGQR8ZdwfgJS5bJHg==} - version: 0.6.7 peerDependencies: '@react-native-async-storage/async-storage': ^3.0.2 '@wagmi/core': '>=2' @@ -2954,7 +2951,6 @@ packages: mppx@0.5.12: resolution: {integrity: sha512-pr6epOYJd8Q6D+MRMc27G48IMh0naAGMMyY1cZYrxMXVwH8PPn1ZaqUwv31svcjOV+UpSrSAe/MP2hRWJ0KQ7A==} - version: 0.5.12 hasBin: true peerDependencies: '@modelcontextprotocol/sdk': '>=1.25.0' @@ -3161,9 +3157,8 @@ packages: typescript: optional: true - ox@https://pkg.pr.new/tempoxyz/ox-tmp-zones/ox@72b35e0: - resolution: {tarball: https://pkg.pr.new/tempoxyz/ox-tmp-zones/ox@72b35e0} - version: 0.14.11-dc1dc5f.0 + ox@0.14.17: + resolution: {integrity: sha512-jOzNb2Wlfzsr8z/GoCtd1bf6OSRuWuysvbhnHGD+7fV1WRbcBR6B0RYoe3xWnUedF7zp4l5APmS7CzAhUok/lA==} peerDependencies: typescript: '>=5.4.0' peerDependenciesMeta: @@ -3845,9 +3840,8 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - viem@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02: - resolution: {tarball: https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02} - version: 2.47.7-c353007.0 + viem@2.48.0: + resolution: {integrity: sha512-0uLzTAUNKPpY9Cf3OBCPdwClXx9CEHAkoVYnxMPdHt7cRI1DobMso+pHZvU7itD+hFwE4htmp9QfP+5lb+kn0g==} peerDependencies: typescript: '>=5.0.4' peerDependenciesMeta: @@ -3962,7 +3956,6 @@ packages: wagmi@3.6.1: resolution: {integrity: sha512-GhOm/1FIhsendD+VmBknX+zCxYZCcysbraj/A7L7Lszm8+HgTdHj7eF6DrknKVG12NTXYdmM4vni+jHHrdBuaQ==} - version: 3.6.1 peerDependencies: '@tanstack/react-query': '>=5.0.0' react: '>=18' @@ -5441,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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(typescript@5.9.3)(zod@4.3.6)))(typescript@5.9.3)(viem@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(typescript@5.9.3)(zod@4.3.6)) - viem: https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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: https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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 @@ -5554,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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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: https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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' @@ -7183,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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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: https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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) @@ -7292,7 +7285,7 @@ snapshots: transitivePeerDependencies: - zod - ox@https://pkg.pr.new/tempoxyz/ox-tmp-zones/ox@72b35e0(typescript@5.9.3)(zod@4.3.6): + ox@0.14.17(typescript@5.9.3)(zod@4.3.6): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -8114,7 +8107,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - viem@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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 @@ -8122,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: https://pkg.pr.new/tempoxyz/ox-tmp-zones/ox@72b35e0(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 @@ -8280,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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(typescript@5.9.3)(zod@4.3.6)))(typescript@5.9.3)(viem@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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@https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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: https://pkg.pr.new/tempoxyz/viem-tmp-zones/viem@c8d5c02(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 2003d759..fcfbc0f0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,6 +13,7 @@ minimumReleaseAgeExclude: - accounts - mppx - incur + - ox - viem patchedDependencies: From bba04e1321c462de711898ef3404b299d0408997 Mon Sep 17 00:00:00 2001 From: Zygimantas <5236121+Zygimantass@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:03:49 +0200 Subject: [PATCH 25/25] fix: correctness --- e2e/deposit-to-a-zone.test.ts | 5 ++- e2e/send-tokens-across-zones.test.ts | 10 ++++-- e2e/send-tokens-within-a-zone.test.ts | 5 ++- e2e/swap-across-zones.test.ts | 10 ++++-- e2e/withdraw-from-a-zone.test.ts | 5 ++- src/components/guides/Demo.tsx | 3 ++ .../guides/zones/SendTokensAcrossZones.tsx | 5 +-- src/lib/useRootWebAuthnAccount.ts | 27 +++++++++------ src/lib/useZoneAuthorization.ts | 24 ++++++++++++-- src/pages/protocol/zones/proving.mdx | 33 ++++++++++--------- 10 files changed, 90 insertions(+), 37 deletions(-) diff --git a/e2e/deposit-to-a-zone.test.ts b/e2e/deposit-to-a-zone.test.ts index a57f5847..56b2e73e 100644 --- a/e2e/deposit-to-a-zone.test.ts +++ b/e2e/deposit-to-a-zone.test.ts @@ -25,8 +25,11 @@ test('prepare zone access and deposit to Zone A', async ({ page }) => { timeout: 30000, }) - const authorizeButton = page.getByRole('button', { name: 'Authorize Zone A reads' }).first() + 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() diff --git a/e2e/send-tokens-across-zones.test.ts b/e2e/send-tokens-across-zones.test.ts index 7e1ea954..5a88b4a2 100644 --- a/e2e/send-tokens-across-zones.test.ts +++ b/e2e/send-tokens-across-zones.test.ts @@ -25,14 +25,19 @@ test('send pathUSD from Zone A into Zone B', async ({ page }) => { timeout: 30000, }) - const authorizeSourceButton = page.getByRole('button', { name: 'Authorize Zone A reads' }).first() + 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: 'Authorize Zone B reads' }).first() + const authorizeTargetButton = page + .getByRole('button', { name: /^Authoriz(?:e|ing) Zone B reads$/i }) + .first() await expect .poll( @@ -61,6 +66,7 @@ test('send pathUSD from Zone A into Zone B', async ({ page }) => { await sendButton.click() await expect(authorizeTargetButton).toBeVisible({ timeout: 120000 }) + await expect(authorizeTargetButton).toBeEnabled({ timeout: 90000 }) await authorizeTargetButton.click() await expect( diff --git a/e2e/send-tokens-within-a-zone.test.ts b/e2e/send-tokens-within-a-zone.test.ts index f656dabe..a8b9a743 100644 --- a/e2e/send-tokens-within-a-zone.test.ts +++ b/e2e/send-tokens-within-a-zone.test.ts @@ -25,8 +25,11 @@ test('prepare zone balance and send tokens within Zone A', async ({ page }) => { timeout: 30000, }) - const authorizeButton = page.getByRole('button', { name: 'Authorize Zone A reads' }).first() + 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() diff --git a/e2e/swap-across-zones.test.ts b/e2e/swap-across-zones.test.ts index c6db47bb..2cf3edcf 100644 --- a/e2e/swap-across-zones.test.ts +++ b/e2e/swap-across-zones.test.ts @@ -25,8 +25,11 @@ test('swap pathUSD from Zone A into betaUSD on Zone B', async ({ page }) => { timeout: 30000, }) - const authorizeSourceButton = page.getByRole('button', { name: 'Authorize Zone A reads' }).first() + 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() @@ -34,7 +37,9 @@ test('swap pathUSD from Zone A into betaUSD on Zone B', async ({ page }) => { const swapButton = page .getByRole('button', { name: /^Swap 25 pathUSD into Zone B betaUSD$/i }) .first() - const authorizeTargetButton = page.getByRole('button', { name: 'Authorize Zone B reads' }).first() + const authorizeTargetButton = page + .getByRole('button', { name: /^Authoriz(?:e|ing) Zone B reads$/i }) + .first() await expect .poll( @@ -63,6 +68,7 @@ test('swap pathUSD from Zone A into betaUSD on Zone B', async ({ page }) => { await swapButton.click() await expect(authorizeTargetButton).toBeVisible({ timeout: 120000 }) + await expect(authorizeTargetButton).toBeEnabled({ timeout: 90000 }) await authorizeTargetButton.click() await expect( diff --git a/e2e/withdraw-from-a-zone.test.ts b/e2e/withdraw-from-a-zone.test.ts index 38741e9e..3f07c303 100644 --- a/e2e/withdraw-from-a-zone.test.ts +++ b/e2e/withdraw-from-a-zone.test.ts @@ -27,8 +27,11 @@ test('prepare zone balance and withdraw from Zone A', async ({ page }) => { timeout: 20000, }) - const authorizeButton = page.getByRole('button', { name: 'Authorize Zone A reads' }).first() + 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() diff --git a/src/components/guides/Demo.tsx b/src/components/guides/Demo.tsx index ceb60a3c..6c21f5ce 100644 --- a/src/components/guides/Demo.tsx +++ b/src/components/guides/Demo.tsx @@ -598,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/zones/SendTokensAcrossZones.tsx b/src/components/guides/zones/SendTokensAcrossZones.tsx index 89f13bf7..7841ed48 100644 --- a/src/components/guides/zones/SendTokensAcrossZones.tsx +++ b/src/components/guides/zones/SendTokensAcrossZones.tsx @@ -19,7 +19,8 @@ import { } from '../../../lib/private-zones.ts' import { useRootWebAuthnAccount } from '../../../lib/useRootWebAuthnAccount.ts' import { useZoneAuthorization, type ZoneAuthClientLike } from '../../../lib/useZoneAuthorization.ts' -import { Button, ExplorerLink, Login, Logout, ReceiptHash, Step } from '../Demo' +import { Button, ExplorerLink, Logout, ReceiptHash, Step } from '../Demo' +import { SignInButtons } from '../EmbedPasskeys' import { pathUsd } from '../tokens' import { useStickyStepCompletion } from './useStickyStepCompletion.ts' @@ -77,7 +78,7 @@ export function SendTokensAcrossZones() { : } + actions={connected ? : } error={undefined} number={1} title="Create or use a passkey account on the public chain." diff --git a/src/lib/useRootWebAuthnAccount.ts b/src/lib/useRootWebAuthnAccount.ts index 2ca157d4..bdd661db 100644 --- a/src/lib/useRootWebAuthnAccount.ts +++ b/src/lib/useRootWebAuthnAccount.ts @@ -3,24 +3,31 @@ import { useQuery } from '@tanstack/react-query' import { Account } from 'viem/tempo' import { useConnection } from 'wagmi' -import { config, webAuthnRpId } from '../wagmi.config.ts' -type RootWebAuthnCredential = Parameters[0] +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' && webAuthnRpId), - queryKey: ['root-webauthn-account', address, webAuthnRpId], + enabled: Boolean(address && connector?.id === 'webAuthn'), + queryKey: ['root-webauthn-account', address], queryFn: async () => { - if (!webAuthnRpId) throw new Error('webauthn RP ID is not configured') - - const credential = await config.storage?.getItem('webAuthn.activeCredential') - if (!credential) throw new Error('webauthn credential not available') + if (!address) throw new Error('account address not ready') + if (!connector) throw new Error('connector not ready') - return Account.fromWebAuthnP256(credential as RootWebAuthnCredential, { - rpId: webAuthnRpId, + const provider = (await connector.getProvider()) as RootWebAuthnAccountProvider + return provider.getAccount({ + accessKey: false, + address: address as `0x${string}`, + signable: true, }) }, refetchOnReconnect: false, diff --git a/src/lib/useZoneAuthorization.ts b/src/lib/useZoneAuthorization.ts index b41d1e7f..e236c7d6 100644 --- a/src/lib/useZoneAuthorization.ts +++ b/src/lib/useZoneAuthorization.ts @@ -4,6 +4,8 @@ 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<{ @@ -44,7 +46,10 @@ export function useZoneAuthorization(parameters: { if (accountToken) await storage.setItem(chainStorageKey, accountToken) try { - const info = await zoneClient.zone.getAuthorizationTokenInfo() + const info = await withTimeout( + zoneClient.zone.getAuthorizationTokenInfo(), + zoneAuthorizationInfoTimeoutMs, + ) const expired = info.expiresAt <= BigInt(Math.floor(Date.now() / 1000)) const matchesAccount = info.account.toLowerCase() === lowerAddress @@ -94,12 +99,27 @@ export function useZoneAuthorization(parameters: { } } +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') return true + if (name === 'HttpRequestError' || name === 'TimeoutError') return true const message = getErrorMessage(error) return /authorization token/i.test(message) diff --git a/src/pages/protocol/zones/proving.mdx b/src/pages/protocol/zones/proving.mdx index a63e39b4..d20ac4e6 100644 --- a/src/pages/protocol/zones/proving.mdx +++ b/src/pages/protocol/zones/proving.mdx @@ -3,6 +3,8 @@ 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 @@ -71,23 +73,22 @@ pub fn prove_zone_batch(witness: BatchWitness) -> Result ### Execution Flow -```mermaid -flowchart TD - A[Batch witness] --> 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] + 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] -``` + 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.