This guide covers building an iframe wrapper with Privy for authentication (email OTP), embedded wallets, and multi-chain smart wallet support across 6 chains.
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
| What | Import path | Exported name |
|---|
| Smart wallet provider | @privy-io/react-auth/smart-wallets | SmartWalletsProvider |
| Smart wallet hook | @privy-io/react-auth/smart-wallets | useSmartWallets |
| Per-chain client | @privy-io/react-auth/smart-wallets | useSmartWallets().getClientForChain |
| Auth provider | @privy-io/react-auth | PrivyProvider |
| usePrivy | @privy-io/react-auth | usePrivy |
| Email login | @privy-io/react-auth | useLoginWithEmail |
| Create wallet | @privy-io/react-auth | useCreateWallet |
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