<100 subscribers


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.
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)

Powered by Account Abstraction (ERC-4337), Base Accounts bring modern app UX to onchain.

Components at a glance
Base 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.
# Next.js + wagmi
npm create wagmi@latest base-accounts-demo
cd base-accounts-demonpm install @base-org/account @base-org/account-uiimport { 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.
Use EIP-4361 (SIWE) with a server-side nonce + signature verification. Never verify on the client.
import 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 (
<button type="button" onClick={handleSignIn} disabled={!!isSigningIn} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '12px 16px',
backgroundColor: '#ffffff', border: '1px solid #e5e7eb', borderRadius: 8, cursor: isSigningIn ? 'not-allowed' : 'pointer',
minWidth: 180, height: 44
}}>
<div style={{ width: 16, height: 16, backgroundColor: '#0052FF', borderRadius: 2 }} />
<span>{isSigningIn ? 'Signing in...' : 'Sign in with Base'}</span>
</button>
)
}import { 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<string>()
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.
// components/CheckoutForm.tsx
import { BasePayButton } from '@base-org/account-ui/react';
export function CheckoutForm({ onPay, isPaying }) {
return (
<div>
<h3>Awesome Product - $1.00</h3>
<BasePayButton onClick={onPay} disabled={isPaying} />
</div>
);
}// 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');
}
};
}Proxy 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<string | null> {
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.”
Create an app-scoped, embedded wallet so frequent actions won’t prompt.
User 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<boolean> {
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.
You 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 steps
Swap 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).
Base Accounts Docs:
Coinbase Developer Docs:
Base Docs — network specifics & tooling
HeimLabs — more tutorials and examples
HeimLabs Website:
CDP Discord:
Clap 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 🚀
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.
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)

Powered by Account Abstraction (ERC-4337), Base Accounts bring modern app UX to onchain.

Components at a glance
Base 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.
# Next.js + wagmi
npm create wagmi@latest base-accounts-demo
cd base-accounts-demonpm install @base-org/account @base-org/account-uiimport { 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.
Use EIP-4361 (SIWE) with a server-side nonce + signature verification. Never verify on the client.
import 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 (
<button type="button" onClick={handleSignIn} disabled={!!isSigningIn} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '12px 16px',
backgroundColor: '#ffffff', border: '1px solid #e5e7eb', borderRadius: 8, cursor: isSigningIn ? 'not-allowed' : 'pointer',
minWidth: 180, height: 44
}}>
<div style={{ width: 16, height: 16, backgroundColor: '#0052FF', borderRadius: 2 }} />
<span>{isSigningIn ? 'Signing in...' : 'Sign in with Base'}</span>
</button>
)
}import { 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<string>()
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.
// components/CheckoutForm.tsx
import { BasePayButton } from '@base-org/account-ui/react';
export function CheckoutForm({ onPay, isPaying }) {
return (
<div>
<h3>Awesome Product - $1.00</h3>
<BasePayButton onClick={onPay} disabled={isPaying} />
</div>
);
}// 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');
}
};
}Proxy 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<string | null> {
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.”
Create an app-scoped, embedded wallet so frequent actions won’t prompt.
User 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<boolean> {
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.
You 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 steps
Swap 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).
Base Accounts Docs:
Coinbase Developer Docs:
Base Docs — network specifics & tooling
HeimLabs — more tutorials and examples
HeimLabs Website:
CDP Discord:
Clap 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 🚀
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<SubAccountInfo[]> {
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<boolean> {
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<string | null> {
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<string | null> {
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<string | null> {
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
}
}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<SubAccountInfo[]> {
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<boolean> {
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<string | null> {
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<string | null> {
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<string | null> {
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
}
}Share Dialog
Share Dialog
HeimLabs
HeimLabs
1 comment
Crypto shouldn’t be scary. Base Accounts (built with @coinbasedev) bring passkeys, gasless payments & one-tap checkout—no seed phrases. Our guide shows how it works (with a demo). Read 👇 https://paragraph.com/@heimlabs/a-developers-deep-dive-into-base-accounts-the-complete-guide