Skip to main content
This guide covers building an iframe wrapper with Privy for authentication (email OTP), embedded wallets, and multi-chain smart wallet support across 6 chains.
For the complete one-shot LLM prompt, see /llms-iframe-privy-multichain.txt.

Tech Stack

  • React (19+) with TypeScript
  • Vite (6+) as build tool
  • Tailwind CSS v4 for styling
  • @privy-io/react-auth — authentication, embedded wallets, and smart wallets
  • permissionless "^0.3.3" — required by Privy’s smart wallet internals
  • viem — Ethereum interactions
  • vite-plugin-node-polyfills — Privy requires Node.js polyfills in Vite

Critical Import Paths

WhatImport pathExported name
Smart wallet provider@privy-io/react-auth/smart-walletsSmartWalletsProvider
Smart wallet hook@privy-io/react-auth/smart-walletsuseSmartWallets
Per-chain client@privy-io/react-auth/smart-walletsuseSmartWallets().getClientForChain
Auth provider@privy-io/react-authPrivyProvider
usePrivy@privy-io/react-authusePrivy
Email login@privy-io/react-authuseLoginWithEmail
Create wallet@privy-io/react-authuseCreateWallet

Privy Provider Setup

import { PrivyProvider } from '@privy-io/react-auth'
import { SmartWalletsProvider } from '@privy-io/react-auth/smart-wallets'
import { ethereum, polygon, base, arbitrum, gnosis, hyperEvm } from './config/chains'

<PrivyProvider
  appId={PRIVY_APP_ID}
  config={{
    appearance: { theme: 'dark' },
    embeddedWallets: {
      showWalletUIs: false,
      ethereum: {
        createOnLogin: 'users-without-wallets',
      },
    },
    defaultChain: polygon,
    supportedChains: [ethereum, polygon, base, arbitrum, gnosis, hyperEvm],
  }}
>
  <SmartWalletsProvider>
    {children}
  </SmartWalletsProvider>
</PrivyProvider>

Configuration Gotchas

1. showWalletUIs: false

Privy’s default shows a transaction simulation modal before every sendTransaction. Disable it when the wrapper handles the full tx flow.

2. Chain RPC URLs must use authenticated providers

Privy uses the chain’s rpcUrls.default during Safe initialization. If it rate-limits, the smart wallet client silently stays undefined. Use Alchemy endpoints for supported chains.

3. Smart wallets are counterfactual

The address is valid before the contract exists on-chain. Do not call getCode() or send dummy transactions — Privy deploys atomically on first real tx.

4. useCreateWallet() fallback

createOnLogin only fires during the login flow. Use useCreateWallet() as a runtime fallback for existing users who don’t have an embedded wallet yet.

5. Use getClientForChain for cross-chain tx

This is the #1 multi-chain runtime bug. The client from useSmartWallets() is bound to defaultChain (Polygon). Calling smartWalletClient.sendTransaction() directly always routes through the Polygon bundler, regardless of the bytecode’s chainId.
const { client: smartWalletClient, getClientForChain } = useSmartWallets()

// For every transaction, resolve the chain-specific client:
const chainId = bytecodes[0]?.chainId ?? 137
const chainClient = await getClientForChain({ id: chainId })
const txHash = await chainClient.sendTransaction({ calls })

6. Privy Dashboard configuration

Each chain must have a bundler URL and Policy ID configured in the Privy Dashboard. Without this, getClientForChain returns undefined. See the Privy Integration guide for detailed dashboard setup instructions.

processBytecode Implementation

import { useSmartWallets } from '@privy-io/react-auth/smart-wallets'
import { createPublicClient, http } from 'viem'
import { getChainById, getRpcUrl } from '@/config/chains'

const { client: smartWalletClient, getClientForChain } = useSmartWallets()

async function processBytecode(clientTxId: string, bytecodes: BytecodeTransaction[]) {
  if (!smartWalletClient) return

  sendToIframe({ type: 'HOST_ACK', clientTxId })
  sendToIframe({ type: 'SIGNATURE_PROMPTED', clientTxId })

  // Determine chain from first bytecode
  const chainId = bytecodes[0]?.chainId ?? 137

  // CRITICAL: use getClientForChain, not smartWalletClient directly
  const chainClient = await getClientForChain({ id: chainId })
  if (!chainClient) {
    sendToIframe({
      type: 'SIGNATURE_ERROR', clientTxId,
      code: 'CHAIN_NOT_CONFIGURED',
      message: `Chain ${chainId} is not configured in the Privy Dashboard.`,
    })
    return
  }

  const calls = bytecodes.map((bc) => ({
    to: bc.to as `0x${string}`,
    data: bc.data as `0x${string}`,
    value: bc.value ? BigInt(bc.value) : 0n,
  }))

  const txHash = await chainClient.sendTransaction({ calls })

  sendToIframe({ type: 'SIGNED', clientTxId })
  sendToIframe({ type: 'TX_SUBMITTED', clientTxId, chainId, txHash })

  // Wait for confirmation using chain-specific public client
  const publicClient = createPublicClient({
    chain: getChainById(chainId)!,
    transport: http(getRpcUrl(chainId)),
  })
  const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash })

  sendToIframe({
    type: 'TX_CONFIRMED', clientTxId,
    txHash: receipt.transactionHash,
    blockNumber: Number(receipt.blockNumber),
    confirmations: 1,
  })
  sendToIframe({ type: 'TX_FINALIZED', clientTxId, txHash: receipt.transactionHash })
}

Environment Variables

VITE_WEBVIEW_URL=https://deframe-sdk-iframe.vercel.app
VITE_ALCHEMY_RPC_ID=your_alchemy_api_key
VITE_ALCHEMY_POLICY_ID=your_alchemy_gas_policy_id
VITE_DEFRAME_API_URL=https://api.deframe.com
VITE_DEFRAME_API_KEY=your_deframe_api_key
VITE_ALLOWED_ORIGINS=*
VITE_APP_PRIVY_APP_ID=your_privy_app_id

Documentation References