This guide covers creating and using an ERC-4337 smart wallet from a native iOS or Android app. By the end, your app will be able to:
Create an embedded wallet on mobile that serves as the smart wallet’s signer
Derive a deterministic smart wallet address from the signer
Send gas-sponsored transactions via a hybrid client-server architecture
Privy’s native smart wallet integration (SmartWalletsProvider) is available for React and React Native. For iOS and Android apps, a hybrid architecture enables smart wallet functionality. The mobile app creates an embedded wallet and signs UserOperation hashes. A Node.js backend manages the smart wallet infrastructure.This architecture keeps wallet keys on the device (self-custodial). Server-side tooling handles ERC-4337 operations.
Step 2: Set up the server-side smart wallet infrastructure
Use permissionless and viem on the backend to derive the smart wallet address. This example uses Kernel (ZeroDev). Any ERC-4337 smart account implementation works.
import {toKernelSmartAccount} from 'permissionless/accounts';import {createPublicClient, http} from 'viem';import {base} from 'viem/chains';import {entryPoint07Address} from 'viem/account-abstraction';import {toAccount} from 'viem/accounts';const publicClient = createPublicClient({ chain: base, transport: http()});function createRemoteSigner(signerAddress: `0x${string}`) { return toAccount({ address: signerAddress, async signMessage({message}) { // This will be replaced with the mobile app's signature // See Step 4 for the full implementation throw new Error('Use prepareUserOperation flow instead'); }, async signTransaction() { throw new Error('Use prepareUserOperation flow instead'); }, async signTypedData() { throw new Error('Use prepareUserOperation flow instead'); } });}async function getSmartWalletAddress(signerAddress: `0x${string}`) { const remoteSigner = createRemoteSigner(signerAddress); const kernelAccount = await toKernelSmartAccount({ client: publicClient, owners: [remoteSigner], entryPoint: { address: entryPoint07Address, version: '0.7' } }); return kernelAccount.address;}
The smart wallet address is deterministic. Given the same signer address, the derived smart wallet
address is always the same, even before deployment.
This example shows the complete flow for a gas-sponsored USDC transfer.
iOS (Swift)
Android (Kotlin)
Server (Node.js)
import PrivySDKfunc sendSponsoredUSDC(wallet: EmbeddedEthereumWallet, to: String, amount: String) async throws -> String { // 1. Prepare the UserOperation on the server let prepareResponse = try await yourBackend.prepareUserOperation( signerAddress: wallet.address, to: "0xUSDC_CONTRACT_ADDRESS", value: "0x0", data: encodeTransferData(to: to, amount: amount) ) // 2. Sign the UserOperation hash let request = EthereumRpcRequest( method: "personal_sign", params: [prepareResponse.userOpHash, wallet.address] ) let signature = try await wallet.provider.request(request) // 3. Submit to bundler via server let txHash = try await yourBackend.submitUserOperation( userOpHash: prepareResponse.userOpHash, signature: signature ) return txHash}
import io.privy.android.EmbeddedEthereumWalletimport io.privy.android.EthereumRpcRequestsuspend fun sendSponsoredUSDC( wallet: EmbeddedEthereumWallet, to: String, amount: String): String { // 1. Prepare the UserOperation on the server val prepareResponse = yourBackend.prepareUserOperation( signerAddress = wallet.address, to = "0xUSDC_CONTRACT_ADDRESS", value = "0x0", data = encodeTransferData(to, amount) ) // 2. Sign the UserOperation hash val signResult = wallet.provider.request( request = EthereumRpcRequest.personalSign( prepareResponse.userOpHash, wallet.address ) ) val signature = signResult.getOrThrow().result // 3. Submit to bundler via server return yourBackend.submitUserOperation( userOpHash = prepareResponse.userOpHash, signature = signature )}
import {toKernelSmartAccount} from 'permissionless/accounts';import {createSmartAccountClient} from 'permissionless';import {createPimlicoClient} from 'permissionless/clients/pimlico';import {createPublicClient, http, encodeFunctionData, parseAbi} from 'viem';import {base} from 'viem/chains';import {entryPoint07Address, bundlerActions} from 'viem/account-abstraction';import {toAccount} from 'viem/accounts';const PIMLICO_API_KEY = 'YOUR_PIMLICO_API_KEY';const bundlerUrl = `https://api.pimlico.io/v2/base/rpc?apikey=${PIMLICO_API_KEY}`;const publicClient = createPublicClient({ chain: base, transport: http()});const pimlicoClient = createPimlicoClient({ transport: http(bundlerUrl), entryPoint: { address: entryPoint07Address, version: '0.7' }});// POST /prepare-user-operationasync function handlePrepare( signerAddress: `0x${string}`, to: `0x${string}`, value: bigint, data: `0x${string}`) { const remoteSigner = toAccount({ address: signerAddress, async signMessage() { throw new Error('Signing handled by mobile client'); }, async signTransaction() { throw new Error('Signing handled by mobile client'); }, async signTypedData() { throw new Error('Signing handled by mobile client'); } }); const kernelAccount = await toKernelSmartAccount({ client: publicClient, owners: [remoteSigner], entryPoint: { address: entryPoint07Address, version: '0.7' } }); const smartAccountClient = createSmartAccountClient({ account: kernelAccount, chain: base, bundlerTransport: http(bundlerUrl), paymaster: pimlicoClient, userOperation: { estimateFeesPerGas: async () => (await pimlicoClient.getUserOperationGasPrice()).fast } }); const userOp = await smartAccountClient.prepareUserOperation({ calls: [{to, value, data}] }); const userOpHash = kernelAccount.getUserOpHash({ ...userOp, chainId: base.id, entryPointAddress: entryPoint07Address, entryPointVersion: '0.7' }); return {userOp, userOpHash, smartWalletAddress: kernelAccount.address};}// POST /submit-user-operationasync function handleSubmit(userOp: any, signature: `0x${string}`) { const bundlerClient = publicClient.extend( bundlerActions({ entryPoint: { address: entryPoint07Address, version: '0.7' } }) ); const hash = await bundlerClient.sendUserOperation({ ...userOp, signature }); const receipt = await bundlerClient.waitForUserOperationReceipt({hash}); return receipt.receipt.transactionHash;}