# A Developer's Deep Dive into Base Accounts - The Complete Guide
> From zero to production: passkeys, gasless UX, one-tap payments, and automated subscriptions
**Published by:** [HeimLabs](https://paragraph.com/@heimlabs/)
**Published on:** 2025-09-26
**Categories:** web3, web3dev, base, buildonbase, cdp
**URL:** https://paragraph.com/@heimlabs/a-developers-deep-dive-into-base-accounts-the-complete-guide
## Content
Seed phrases, gas pop-ups, “approve then transfer” — the classic wallet UX slows users (and your growth) to a crawl. Base Accounts fix this. Built on ERC-4337, they’re a programmable, multi-chain account layer that unlocks passkeys, social recovery, sponsored gas, batching, and more. In this hands-on guide, you’ll learn why Base Accounts matter and how to ship a production-grade app: passwordless auth, one-tap USDC payments, gasless calls, sub-accounts for zero-prompt interactions, and automated subscriptions with spend permissions.What You’ll Build Scaffold a Next.js + wagmi app with “Sign in with Base.” Add Base Pay for one-tap USDC checkout (testnet friendly). Use Paymasters to sponsor gas (true gasless UX). Create Sub-Accounts for zero-prompt actions. Automate billing with Spend Permissions (revocable allowances).(Watch the Full Video here) Why Base Accounts (vs. EOAs)Powered by Account Abstraction (ERC-4337), Base Accounts bring modern app UX to onchain.System ArchitectureComponents at a glanceBase Account SDK & UI: Auth, checkout, sub-accounts, permissions.wagmi: Chain config + connectors.Paymaster: Sponsors user gas.Sub-Accounts: App-scoped, embedded wallets for zero-prompt actions.Spend Permissions: Off-chain signed allowances you can execute on a schedule.Part 1 — Project Scaffolding & Setup1. Create the app# Next.js + wagmi npm create wagmi@latest base-accounts-demo cd base-accounts-demo2. Install Base Accounts SDK & UInpm install @base-org/account @base-org/account-ui3. Configure wagmi with Base + Base Sepoliaimport { http, createConfig } from 'wagmi'; import { baseSepolia } from 'wagmi/chains'; import { baseAccount } from '@wagmi/connectors'; export const config = createConfig({ chains: [baseSepolia], connectors: [ baseAccount({ appName: 'Base Accounts Demo', appLogoUrl: 'https://base.org/logo.png', }), ], transports: { [baseSepolia.id]: http(), }, // Enable server-side rendering ssr: true, }); export function getConfig() { return config }This enables “Sign in with Base” as a first-class connector.Part 2 — Passwordless Auth (SIWE)Use EIP-4361 (SIWE) with a server-side nonce + signature verification. Never verify on the client.1. UI: a consistent, trusted sign-in + Client: fetch nonce → request signatimport React from 'react' import { createBaseAccountSDK } from '@base-org/account' type LoginFormProps = { onSignIn?: (address: string) => void; isSigningIn?: boolean; }; export function LoginForm({ onSignIn, isSigningIn }: LoginFormProps) { const handleSignIn = async () => { try { const provider = createBaseAccountSDK({ appName: 'Base Accounts Demo' }).getProvider() const nonce = window.crypto.randomUUID().replace(/-/g, '') const response = await provider.request({ method: 'wallet_connect', params: [ { version: '1', capabilities: { signInWithEthereum: { // chainid must be a hex string chainId: "0x" + (8453).toString(16), nonce, }, }, }, ], }) as { accounts: { address: string; capabilities?: { signInWithEthereum?: { message: string; signature: `0x${string}` } } }[] } // Extract SIWE message & signature and verify on server const account = response.accounts[0] const { address } = account const siwe = account.capabilities?.signInWithEthereum as { message: string; signature: `0x${string}` } const message = siwe?.message const signature = siwe?.signature const verifyRes = await fetch('/api/auth/verify', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ address, message, signature }), }) if (!verifyRes.ok) { const err = await verifyRes.json().catch(() => ({})) throw new Error(err?.error || 'Verification failed') } onSignIn?.(address) } catch (err) { console.error('Sign in failed:', err) } } return ( ) }2. Backend: verify and create a sessionimport { NextRequest, NextResponse } from 'next/server' // Demo behavior: // - This route mirrors the agent project: accepts an address and issues a simple session cookie. // - It intentionally does NOT verify a signed message (no viem.verifyMessage) to keep the demo minimal. // To enforce SIWE-style verification in production: // 1) Request { address, message, signature } from the client. // 2) Extract & validate a one-time nonce persisted in a shared store (e.g. Redis/DB). // 3) Verify with viem's public client verifyMessage before issuing a session. const nonces = new Set() export async function POST(request: NextRequest) { try { const { address } = await request.json() if (!address) { return NextResponse.json({ error: 'Missing address' }, { status: 400 }) } // Create session token (simplified - in production use proper JWT with SESSION_SECRET) // For demo purposes, using simple encoding - in production, use proper JWT: // const jwt = require('jsonwebtoken') // const sessionToken = jwt.sign({ address, timestamp: Date.now() }, process.env.SESSION_SECRET) const sessionToken = Buffer.from(`${address}:${Date.now()}`).toString('base64') const response = NextResponse.json({ ok: true, address, sessionToken }) response.cookies.set('session', sessionToken, { httpOnly: true, secure: false, sameSite: 'strict', maxAge: 60 * 60 * 24 * 7, }) return response } catch (error) { console.error('Auth verification error:', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } export async function GET() { const array = new Uint8Array(16) crypto.getRandomValues(array) const nonce = Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('') nonces.add(nonce) return NextResponse.json({ nonce }) }Tip: Persist nonces + sessions in Redis/DB in production.Part 3 — One-Tap Payments with Base Pay1. Drop-in checkout UI// components/CheckoutForm.tsx import { BasePayButton } from '@base-org/account-ui/react'; export function CheckoutForm({ onPay, isPaying }) { return (
Awesome Product - $1.00
); }2. Initiate payment and poll// pages/checkout.tsx (excerpt) import { pay, getPaymentStatus } from '@base-org/account'; import { useState } from 'react'; function CheckoutPage() { const [status, setStatus] = useState<'idle'|'pending'|'completed'|'failed'>('idle'); const handlePayment = async () => { setStatus('pending'); try { const { id } = await pay({ amount: '1.00', to: '0xRecipientAddress', testnet: true }); const poll = async () => { const { status: s, userTxHash } = await getPaymentStatus({ id, testnet: true }); if (s === 'completed') { setStatus('completed'); console.log('Tx:', userTxHash); } else if (s === 'failed') { setStatus('failed'); } else setTimeout(poll, 1000); }; poll(); } catch { setStatus('failed'); } }; }Part 4 — The Magic: Gasless + Zero-Prompt + Auto-BillingA) True Gasless UX with PaymastersProxy your Paymaster URL through your backend.import { ensureBaseProviderConnected, getBaseAccountProvider } from '@/lib/base-account-sdk' import { toHex } from 'viem' /** * Gas Sponsorship (Paymaster) functionality for Base Account * Complete implementation of sponsored transactions */ export interface PaymasterConfig { url: string type: 'eth' sponsorshipRules?: { maxGasLimit?: number allowedMethods?: string[] dailyLimit?: number } } // In production, use your own paymaster proxy endpoints for security and control export const PAYMASTER_CONFIGS = { default: { // Proxy or CDP Paymaster URL: set via env for security url: process.env.NEXT_PUBLIC_PAYMASTER_PROXY_SERVER_URL || 'https://api.developer.coinbase.com/rpc/v1/base-sepolia/demo', type: 'eth' as const, sponsorshipRules: { maxGasLimit: 1000000, dailyLimit: 10 } } } /** * Send sponsored transaction * In production: implement your own paymaster proxy for security and control */ /** * Send sponsored calls with custom configuration */ // Send a transaction sponsored by a Paymaster using wallet_sendCalls. // - userAddress: user's smart account address // - calls: array of low-level calls to execute // - chainId: Base Sepolia (84532) in this tutorial export async function sendSponsoredCalls( userAddress: `0x${string}`, chainId: number, calls: Array<{ to: `0x${string}` value?: string data?: `0x${string}` }>, config: PaymasterConfig = PAYMASTER_CONFIGS.default ): Promise { const provider = getBaseAccountProvider() await ensureBaseProviderConnected() try { const normalizedCalls = calls.map((c) => ({ to: c.to, data: (c.data || '0x') as `0x${string}`, value: toHex(c.value ? BigInt(c.value) : 0n), })) const txHash = await provider.request({ method: 'wallet_sendCalls', params: [{ from: userAddress, chainId: toHex(84532), version: '1', calls: normalizedCalls, capabilities: { paymasterService: { url: process.env.NEXT_PUBLIC_PAYMASTER_PROXY_SERVER_URL || 'https://api.developer.coinbase.com/rpc/v1/base-sepolia/demo', ...(config.sponsorshipRules && { data: config.sponsorshipRules }) } }, }], }) as string console.log('Sponsored calls sent:', txHash) return txHash } catch (error) { console.error('Sponsored calls failed:', error) return null } }The wallet clearly shows “Fee: Sponsored.”B) Eliminate Pop-ups with Sub-AccountsCreate an app-scoped, embedded wallet so frequent actions won’t prompt.import { ensureBaseProviderConnected, getBaseAccountProvider } from '@/lib/base-account-sdk' import { toHex } from 'viem' // Minimal interface for sub-account info as returned by wallet_getSubAccounts type SubAccountInfo = { address: `0x${string}` factory?: `0x${string}` factoryData?: `0x${string}` } type GetSubAccountsResponse = { subAccounts?: SubAccountInfo[] } type AddSubAccountResponse = SubAccountInfo /** * Comprehensive Sub-Account management for Base Account * Implements all sub-account capabilities from Base Account documentation */ /** * Get existing sub-accounts for the user */ export async function getSubAccounts( universalAddress: `0x${string}`, chainId: number, domain?: string ): Promise { const provider = getBaseAccountProvider() await ensureBaseProviderConnected() try { const { subAccounts } = (await provider.request({ method: 'wallet_getSubAccounts', params: [{ version: '1', account: universalAddress, domain: domain || window.location.origin, chainId: toHex(84532) }], })) as GetSubAccountsResponse return subAccounts || [] } catch (error) { console.error('Failed to get sub-accounts:', error) return [] } } /** * Create a new sub-account */ export async function createSubAccount( accountType: 'create' | 'import' = 'create', factory?: `0x${string}`, factoryData?: `0x${string}` ): Promise<`0x${string}` | null> { const provider = getBaseAccountProvider() await ensureBaseProviderConnected() try { const newSubAccount = (await provider.request({ method: 'wallet_addSubAccount', params: [{ version: '1', account: { type: accountType, factory, factoryData } }], })) as AddSubAccountResponse console.log('Created new sub-account:', newSubAccount.address) return newSubAccount.address } catch (error) { console.error('Failed to create sub-account:', error) return null } } /** * Get or create a sub-account */ export async function getOrCreateSubAccount( universalAddress: `0x${string}`, chainId: number, domain?: string ): Promise<`0x${string}`> { const provider = getBaseAccountProvider() await ensureBaseProviderConnected() // 1. Check if a sub-account already exists for the domain const existingSubAccounts = await getSubAccounts(universalAddress, chainId, domain) if (existingSubAccounts.length > 0) { console.log('Found existing sub-account:', existingSubAccounts[0].address) return existingSubAccounts[0].address } // 2. If not, create a new one const newSubAccountAddress = await createSubAccount() if (!newSubAccountAddress) { throw new Error('Failed to create sub-account') } return newSubAccountAddress } /** * Remove a sub-account */ export async function removeSubAccount( subAccountAddress: `0x${string}` ): Promise { const provider = getBaseAccountProvider() await ensureBaseProviderConnected() try { await provider.request({ method: 'wallet_removeSubAccount', params: [{ version: '1', account: subAccountAddress }], }) console.log('Removed sub-account:', subAccountAddress) return true } catch (error) { console.error('Failed to remove sub-account:', error) return false } } /** * Transfer funds between main account and sub-account */ export async function transferToSubAccount( fromAddress: `0x${string}`, toSubAccount: `0x${string}`, amount: string, chainId: number ): Promise { const provider = getBaseAccountProvider() await ensureBaseProviderConnected() try { const txHash = await provider.request({ method: 'wallet_sendCalls', params: [{ from: fromAddress, chainId: toHex(chainId), version: '1', calls: [{ to: toSubAccount, value: amount, data: '0x' }] }] }) as string console.log('Transfer to sub-account sent:', txHash) return txHash } catch (error) { console.error('Transfer to sub-account failed:', error) return null } } /** * Execute transaction from sub-account */ export async function executeFromSubAccount( subAccountAddress: `0x${string}`, calls: Array<{ to: `0x${string}` value?: string data?: `0x${string}` }>, chainId: number ): Promise { const provider = getBaseAccountProvider() await ensureBaseProviderConnected() try { const txHash = await provider.request({ method: 'wallet_sendCalls', params: [{ from: subAccountAddress, chainId: toHex(chainId), version: '1', calls }] }) as string console.log('Sub-account transaction sent:', txHash) return txHash } catch (error) { console.error('Sub-account transaction failed:', error) return null } } /** * Get sub-account balance */ export async function getSubAccountBalance( subAccountAddress: `0x${string}`, tokenAddress?: `0x${string}` ): Promise { const provider = getBaseAccountProvider() try { if (tokenAddress) { // Get ERC20 token balance using balanceOf(address) // 0x70a08231 is the function selector for balanceOf const balance = await provider.request({ method: 'eth_call', params: [{ to: tokenAddress, data: `0x70a08231000000000000000000000000${subAccountAddress.slice(2)}` }, 'latest'] }) as string return balance } else { // Get native ETH balance of the sub-account const balance = await provider.request({ method: 'eth_getBalance', params: [subAccountAddress, 'latest'] }) as string return balance } } catch (error) { console.error('Failed to get sub-account balance:', error) return null } }C) Automated Subscriptions with Spend PermissionsUser grants allowance (off-chain EIP-712).Cron job executes batched, gasless charges from the sub-account.import { getBaseAccountProvider } from '@/lib/base-account-sdk' import { requestSpendPermission as sdkRequestSpendPermission, prepareSpendCallData, } from '@base-org/account/spend-permission' // Simplified type alias for the permission object returned by the SDK export type Permission = any // Request a Spend Permission using the SDK helper // - account: user's Base Account (smart account) the permission applies to // - spender: your app's spender address that will execute future transfers // - token: ERC-20 token address (USDC on Base Mainnet in our UI) // - allowance: spend limit in token base units for each period // - periodInDays: recurring period window (e.g., 30 days) // - chainId: chain on which the permission is valid (8453 = Base Mainnet) export async function requestSpendPermission( account: `0x${string}`, spender: `0x${string}`, token: `0x${string}`, allowance: bigint, periodInDays: number, chainId: number = 8453, ) { const provider = getBaseAccountProvider() const permission = await sdkRequestSpendPermission({ provider, account, spender, token, chainId, allowance, periodInDays, }) return permission as Permission } // Prepare and execute spend calls using wallet_sendCalls v2 // - On first use, returned array typically includes approveWithSignature then spend // - On subsequent uses, only the spend call is included export async function executeSpendCalls( permission: Permission, amount: bigint | undefined, spender: `0x${string}`, chainId: number = 8453, ) { const provider = getBaseAccountProvider() const calls = await prepareSpendCallData(permission as any, amount ?? 'max-remaining-allowance') const txHash = (await provider.request({ method: 'wallet_sendCalls', params: [ { version: '2.0', atomicRequired: true, from: spender, calls: calls as any, }, ], })) as string return txHash } // Optional storage helper (demo): persist the permission in a backend store // In production, replace with your database and proper authentication export async function storeSpendPermission( permission: Permission, metadata?: { description?: string; category?: string; frequency?: 'monthly' | 'weekly' | 'daily' }, ): Promise { try { const response = await fetch('/api/subscriptions/save', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ signature: permission?.signature || '0x', spendPermissionData: permission, metadata: { createdAt: new Date().toISOString(), ...metadata }, }), }) return response.ok } catch (e) { return false } }import { NextResponse } from 'next/server' // In-memory store for demo (use database in production) const subscriptionsStore: Array<{ id: string signature: string spendPermissionData: any metadata: any createdAt: string }> = [] /** * Save spend permission signatures for subscriptions */ export async function POST(request: Request) { try { const body = await request.json() if (!body || !body.signature || !body.spendPermissionData) { return NextResponse.json({ error: 'Invalid payload' }, { status: 400 }) } const subscription = { id: `sub_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, signature: body.signature, spendPermissionData: body.spendPermissionData, metadata: body.metadata || {}, createdAt: new Date().toISOString() } subscriptionsStore.push(subscription) console.log('Saved subscription:', subscription.id) return NextResponse.json({ ok: true, subscriptionId: subscription.id, message: 'Subscription saved successfully' }) } catch (e) { console.error('Subscription save error:', e) return NextResponse.json({ error: 'Invalid JSON or processing error' }, { status: 400 }) } } /** * Get all subscriptions (for demo purposes) */ export async function GET(request: Request) { const { searchParams } = new URL(request.url) const userAddress = searchParams.get('address') let filteredSubscriptions = subscriptionsStore if (userAddress) { filteredSubscriptions = subscriptionsStore.filter(sub => sub.spendPermissionData.account?.toLowerCase() === userAddress.toLowerCase() ) } return NextResponse.json({ subscriptions: filteredSubscriptions, total: filteredSubscriptions.length }) } /** * Execute a subscription (for backend use) */ export async function PUT(request: Request) { try { const body = await request.json() const { subscriptionId, amount } = body if (!subscriptionId || !amount) { return NextResponse.json({ error: 'Missing subscriptionId or amount' }, { status: 400 }) } const subscription = subscriptionsStore.find(sub => sub.id === subscriptionId) if (!subscription) { return NextResponse.json({ error: 'Subscription not found' }, { status: 404 }) } // In production, this would: // 1. Validate the spend permission // 2. Execute the transaction on-chain // 3. Update subscription usage // 4. Return transaction hash console.log('Executed subscription:', subscriptionId, 'for amount:', amount) return NextResponse.json({ ok: true, transactionHash: `0x${Math.random().toString(16).slice(2, 66)}`, amount, timestamp: new Date().toISOString() }) } catch (e) { console.error('Subscription execution error:', e) return NextResponse.json({ error: 'Execution failed' }, { status: 500 }) } }Production notes: Store signed permissions securely server-side; add replay protection; monitor balances; expose a “Cancel subscription” UI that revokes the permission.Conclusion & Next StepsYou now have a full stack that feels like a modern app:Sign in with Base (no passwords).One-tap USDC payments via Base Pay.Gasless actions with Paymasters.Zero-prompt UX using Sub-Accounts.Automated billing with Spend Permissions.Next stepsSwap Base Sepolia → Base mainnet when ready.Add swap/NFT flows with OnchainKit components.Migrate keys/session state to a hardened backend.Instrument analytics for funnel visibility (auth → pay → retention).Resources & LinksBase Accounts Docs:Base Account Overview - Base DocumentationWhat is a Base Account and how the Base Account SDK lets you add universal sign-in and one-tap USDC payments to any app.https://docs.base.orgCoinbase Developer Docs:Coinbase Developer Docs - Coinbase Developer DocumentationExplore our API & SDK references, demos, and guides for building onchain apps.https://docs.cdp.coinbase.comBase Docs — network specifics & toolingBase - Base DocumentationThe #1 Ethereum Layer 2, incubated by Coinbasehttps://docs.base.orgHeimLabs — more tutorials and examplesGitHub - HeimLabs/coinbase-cdp-demos: A collection of Demo Projects exploring the Coinbase Developer PlatformA collection of Demo Projects exploring the Coinbase Developer Platform - HeimLabs/coinbase-cdp-demoshttps://github.comHeimLabs Website:HeimLabs | Trusted Blockchain Solutions ProviderRevolutionize your business with HeimLabs' blockchain development solutions. Our expert team offers end-to-end services for smart contracts, DApps & more.https://www.heimlabs.comCDP Discord:Discord - Group Chat That's All Fun & GamesDiscord is great for playing games and chilling with friends, or even building a worldwide community. Customize your own space to talk, play, and hang out.https://discord.comClap if this saved you hours, and share what you’re building with the CDP stack! Follow HeimLabs for unapologetically practical Web3 dev content. Twitter, LinkedIn. Happy Building 🚀
## Publication Information
- [HeimLabs](https://paragraph.com/@heimlabs/): Publication homepage
- [All Posts](https://paragraph.com/@heimlabs/): More posts from this publication
- [RSS Feed](https://api.paragraph.com/blogs/rss/@heimlabs): Subscribe to updates
- [Twitter](https://twitter.com/heimlabs): Follow on Twitter
- [Farcaster](https://farcaster.xyz/heimlabs): Follow on Farcaster