Skip to main content

Documentation Index

Fetch the complete documentation index at: https://base-a060aa97-meyer9-discv5-p2p-protocol-id.mintlify.app/llms.txt

Use this file to discover all available pages before exploring further.

This guide walks you through building an onchain tally app on Base from scratch. You will connect wallets, read and write to a smart contract, detect wallet capabilities, and fall back gracefully for wallets that do not support batching.

What you’ll build

  • A Next.js app that connects wallets and handles connection state
  • Contract reads and writes against a deployed counter on Base Sepolia
  • Batch transaction support for smart wallets via EIP-5792
  • A graceful fallback for wallets that do not support batching
Base is a fast, low-cost Ethereum L2 built to bring the next billion users onchain. Low gas fees make batch transactions practical and real-time UX possible. Every pattern in this guide works on any EVM chain.

Steps

1

Set up your project

Create a new Next.js app and install the required dependencies.
Terminal
npx create-next-app@latest my-base-app --typescript --tailwind --app
cd my-base-app
npm install wagmi viem @tanstack/react-query @base-org/account
2

Configure Wagmi for Base

Create the Wagmi config with Base Sepolia, then wrap your app in the required providers.
config/wagmi.ts
import { http, createConfig, createStorage, cookieStorage } from 'wagmi'
import { baseSepolia } from 'wagmi/chains'
import { baseAccount, injected } from 'wagmi/connectors'

export const config = createConfig({
  chains: [baseSepolia],
  connectors: [
    injected(),
    baseAccount({
      appName: 'My Base App',
    }),
  ],
  storage: createStorage({ storage: cookieStorage }),
  ssr: true,
  transports: {
    [baseSepolia.id]: http('https://sepolia.base.org'),
  },
})

declare module 'wagmi' {
  interface Register {
    config: typeof config
  }
}
ssr: true combined with cookieStorage prevents Next.js hydration mismatches. The baseAccount connector connects users via the Base Account SDK smart wallet — you will detect its capabilities in step 7. The injected connector handles browser extension wallets like MetaMask.
app/providers.tsx
'use client'

import { WagmiProvider } from 'wagmi'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { type ReactNode } from 'react'
import { config } from '@/config/wagmi'

const queryClient = new QueryClient()

export function Providers({ children }: { children: ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </WagmiProvider>
  )
}
Wrap your root layout with <Providers>.
3

Connect wallets

Create a component that handles all four wallet connection states.
components/ConnectWallet.tsx
'use client'

import { useAccount, useConnect, useDisconnect } from 'wagmi'

export function ConnectWallet() {
  const { address, isConnected, isConnecting, isReconnecting } = useAccount()
  const { connect, connectors } = useConnect()
  const { disconnect } = useDisconnect()

  if (isReconnecting) return <div>Reconnecting...</div>

  if (!isConnected) {
    return (
      <div className="flex flex-col gap-2">
        {connectors.map((connector) => (
          <button
            key={connector.uid}
            onClick={() => connect({ connector })}
            disabled={isConnecting}
          >
            Connect {connector.name}
          </button>
        ))}
      </div>
    )
  }

  return (
    <div className="flex items-center gap-3">
      <span className="font-mono text-sm">
        {address?.slice(0, 6)}...{address?.slice(-4)}
      </span>
      <button onClick={() => disconnect()}>Disconnect</button>
    </div>
  )
}
useAccount exposes four states: isConnecting, isReconnecting, isConnected, and isDisconnected. Checking only isConnected causes UI flashes on page load — handle all four.
4

Deploy a contract with Foundry

Install Foundry and initialize a contracts directory inside your project.
Terminal
mkdir contracts && cd contracts
curl -L https://foundry.paradigm.xyz | bash
foundryup
forge init --no-git
The --no-git flag prevents Foundry from initialising a nested git repository inside your project.
Configure Base Sepolia in your environment file.
contracts/.env
BASE_SEPOLIA_RPC_URL="https://sepolia.base.org"
If https://sepolia.base.org is unreachable, use an alternative public endpoint such as https://base-sepolia-rpc.publicnode.com. For production apps, use a dedicated RPC provider.
Load the variable and import your deployer key securely.
Terminal
source .env
cast wallet import deployer --interactive
Never share or commit your private key. cast wallet import stores it in ~/.foundry/keystores, which is not tracked by git.
cast wallet import --interactive requires a TTY (interactive terminal). In scripted or CI environments, pass the key directly instead:
Terminal
forge create ./src/Counter.sol:Counter \
  --rpc-url $BASE_SEPOLIA_RPC_URL \
  --private-key $DEPLOYER_PRIVATE_KEY
Deploy the contract.
Terminal
forge create ./src/Counter.sol:Counter \
  --rpc-url $BASE_SEPOLIA_RPC_URL \
  --account deployer
Verify the deployment by reading the initial counter value.
Terminal
cast call <CONTRACT_ADDRESS> "number()(uint256)" --rpc-url $BASE_SEPOLIA_RPC_URL
You need testnet ETH to pay for deployment. Get free Base Sepolia ETH from one of the network faucets.
5

Read contract data

Define your contract address and ABI, then read the current counter value.
config/counter.ts
export const COUNTER_ADDRESS = '0x...' as const

export const counterAbi = [
  {
    type: 'function',
    name: 'number',
    inputs: [],
    outputs: [{ name: '', type: 'uint256' }],
    stateMutability: 'view',
  },
  {
    type: 'function',
    name: 'increment',
    inputs: [],
    outputs: [],
    stateMutability: 'nonpayable',
  },
] as const
as const is required. Without it, wagmi cannot infer function names, argument types, or return types from the ABI.
components/CounterDisplay.tsx
'use client'

import { useReadContract } from 'wagmi'
import { baseSepolia } from 'wagmi/chains'
import { COUNTER_ADDRESS, counterAbi } from '@/config/counter'

export function CounterDisplay() {
  const { data: count, isLoading, isError } = useReadContract({
    address: COUNTER_ADDRESS,
    abi: counterAbi,
    functionName: 'number',
    chainId: baseSepolia.id,
  })

  if (isLoading && count === undefined) return <p>Loading...</p>
  if (isError && count === undefined) return <p>Failed to read contract</p>

  return <p className="text-5xl font-bold">{count?.toString()}</p>
}
isError can be true while data still holds a valid cached value from a previous successful fetch. Always gate error renders on data === undefined so stale data is preferred over an error message.
6

Write to a contract

Send a transaction and surface all three confirmation states to the user.
components/IncrementButton.tsx
'use client'

import { useEffect } from 'react'
import {
  useWriteContract,
  useWaitForTransactionReceipt,
  useChainId,
  useSwitchChain,
} from 'wagmi'
import { readContractQueryOptions } from 'wagmi/query'
import { useQueryClient } from '@tanstack/react-query'
import { baseSepolia } from 'wagmi/chains'
import { config } from '@/config/wagmi'
import { COUNTER_ADDRESS, counterAbi } from '@/config/counter'

export function IncrementButton() {
  const chainId = useChainId()
  const { switchChain, isPending: isSwitching } = useSwitchChain()
  const { data: hash, isPending, writeContract } = useWriteContract()
  const { isLoading: isConfirming, isSuccess } =
    useWaitForTransactionReceipt({ hash })
  const queryClient = useQueryClient()

  useEffect(() => {
    if (isSuccess) {
      queryClient.invalidateQueries({
        queryKey: readContractQueryOptions(config, {
          address: COUNTER_ADDRESS,
          abi: counterAbi,
          functionName: 'number',
          chainId: baseSepolia.id,
        }).queryKey,
      })
    }
  }, [isSuccess, queryClient])

  if (chainId !== baseSepolia.id) {
    return (
      <button onClick={() => switchChain({ chainId: baseSepolia.id })}>
        {isSwitching ? 'Switching...' : 'Switch to Base Sepolia'}
      </button>
    )
  }

  return (
    <div>
      <button
        onClick={() =>
          writeContract({
            address: COUNTER_ADDRESS,
            abi: counterAbi,
            functionName: 'increment',
            chainId: baseSepolia.id,
          })
        }
        disabled={isPending || isConfirming}
      >
        {isPending
          ? 'Confirm in Wallet...'
          : isConfirming
          ? 'Confirming...'
          : 'Increment'}
      </button>
      {isSuccess && <p>Confirmed!</p>}
      {hash && (
        <a href={`https://sepolia.basescan.org/tx/${hash}`} target="_blank">
          View on Basescan
        </a>
      )}
    </div>
  )
}
useReadContract caches its result and does not automatically refetch after a write. Use queryClient.invalidateQueries with the read’s query key to trigger a single refetch when a transaction confirms.
Surface three states to the user: waiting for wallet signature, waiting for on-chain confirmation, and success.
Without useSwitchChain, calling writeContract while the wallet is on the wrong network causes wagmi to attempt a background chain switch. If the user misses or dismisses the wallet popup, the button stays at “Confirm in Wallet…” indefinitely with no error and no recovery path.
7

Detect wallet capabilities

Smart wallets support batch transactions via EIP-5792. EOAs do not. Detect support before attempting to batch.
hooks/useWalletCapabilities.ts
import { useCapabilities } from 'wagmi'
import { baseSepolia } from 'wagmi/chains'
import { useMemo } from 'react'

export function useWalletCapabilities() {
  const { data: capabilities } = useCapabilities()

  const supportsBatching = useMemo(() => {
    const atomic = capabilities?.[baseSepolia.id]?.atomic
    return atomic?.status === 'ready' || atomic?.status === 'supported'
  }, [capabilities])

  const supportsPaymaster = useMemo(() => {
    return capabilities?.[baseSepolia.id]?.paymasterService?.supported === true
  }, [capabilities])

  return { supportsBatching, supportsPaymaster }
}
useChainId() returns the wallet’s current chain, not your deployment chain. A MetaMask user on Ethereum mainnet would get incorrect capability results. Always check capabilities against the chain where your contract is deployed.
See Batch Transactions with Wagmi for a deeper look at EIP-5792 capability detection.
8

Batch transactions with fallback

Use useSendCalls for smart wallets and useWriteContract for EOAs. The component detects which path to take at render time.
components/BatchIncrement.tsx
'use client'

import { useEffect } from 'react'
import {
  useSendCalls,
  useWaitForCallsStatus,
  useWriteContract,
  useWaitForTransactionReceipt,
  useAccount,
  useChainId,
  useSwitchChain,
} from 'wagmi'
import { readContractQueryOptions } from 'wagmi/query'
import { useQueryClient } from '@tanstack/react-query'
import { encodeFunctionData } from 'viem'
import { baseSepolia } from 'wagmi/chains'
import { config } from '@/config/wagmi'
import { useWalletCapabilities } from '@/hooks/useWalletCapabilities'
import { COUNTER_ADDRESS, counterAbi } from '@/config/counter'

const counterQueryKey = readContractQueryOptions(config, {
  address: COUNTER_ADDRESS,
  abi: counterAbi,
  functionName: 'number',
  chainId: baseSepolia.id,
}).queryKey

export function BatchIncrement() {
  const { isConnected } = useAccount()
  const { supportsBatching } = useWalletCapabilities()

  if (!isConnected) return <p>Connect your wallet first.</p>

  return supportsBatching ? <BatchFlow /> : <SequentialFlow />
}

function BatchFlow() {
  const chainId = useChainId()
  const { switchChain, isPending: isSwitching } = useSwitchChain()
  const { data, sendCalls, isPending } = useSendCalls()
  const { isLoading: isConfirming, isSuccess } = useWaitForCallsStatus({
    id: data?.id,
  })
  const queryClient = useQueryClient()

  useEffect(() => {
    if (isSuccess) {
      queryClient.invalidateQueries({ queryKey: counterQueryKey })
    }
  }, [isSuccess, queryClient])

  if (chainId !== baseSepolia.id) {
    return (
      <button onClick={() => switchChain({ chainId: baseSepolia.id })}>
        {isSwitching ? 'Switching...' : 'Switch to Base Sepolia'}
      </button>
    )
  }

  const incrementData = encodeFunctionData({
    abi: counterAbi,
    functionName: 'increment',
  })

  return (
    <div>
      <button
        onClick={() =>
          sendCalls({
            calls: [
              { to: COUNTER_ADDRESS, data: incrementData },
              { to: COUNTER_ADDRESS, data: incrementData },
            ],
            chainId: baseSepolia.id,
          })
        }
        disabled={isPending || isConfirming}
      >
        {isPending
          ? 'Confirm in Wallet...'
          : isConfirming
          ? 'Confirming...'
          : 'Increment x2 (Batch)'}
      </button>
      {isSuccess && <p>Batch confirmed!</p>}
    </div>
  )
}

function SequentialFlow() {
  const chainId = useChainId()
  const { switchChain, isPending: isSwitching } = useSwitchChain()
  const { data: hash, isPending, writeContract } = useWriteContract()
  const { isLoading: isConfirming, isSuccess } =
    useWaitForTransactionReceipt({ hash })
  const queryClient = useQueryClient()

  useEffect(() => {
    if (isSuccess) {
      queryClient.invalidateQueries({ queryKey: counterQueryKey })
    }
  }, [isSuccess, queryClient])

  if (chainId !== baseSepolia.id) {
    return (
      <button onClick={() => switchChain({ chainId: baseSepolia.id })}>
        {isSwitching ? 'Switching...' : 'Switch to Base Sepolia'}
      </button>
    )
  }

  return (
    <button
      onClick={() =>
        writeContract({
          address: COUNTER_ADDRESS,
          abi: counterAbi,
          functionName: 'increment',
          chainId: baseSepolia.id,
        })
      }
      disabled={isPending || isConfirming}
    >
      {isPending ? 'Confirm in Wallet...' : isConfirming ? 'Confirming...' : 'Increment'}
    </button>
  )
}
Never call useSendCalls without first confirming supportsBatching is true. Calling it against an EOA will throw.
9

Assemble the page

Compose the components into a single page.
app/page.tsx
import { ConnectWallet } from '@/components/ConnectWallet'
import { CounterDisplay } from '@/components/CounterDisplay'
import { BatchIncrement } from '@/components/BatchIncrement'

export default function Home() {
  return (
    <main className="min-h-screen flex flex-col items-center justify-center gap-8 p-8">
      <h1 className="text-3xl font-bold">Onchain Tally</h1>
      <ConnectWallet />
      <CounterDisplay />
      <BatchIncrement />
    </main>
  )
}
Start the development server.
Terminal
npm run dev

Next steps

  • Go to mainnet — add base to your chains array and transports in config/wagmi.ts, redeploy your contract to Base mainnet, and update COUNTER_ADDRESS.
  • Sponsor gas — use the paymasterService capability with useSendCalls to cover your users’ transaction fees. See Sponsor Gas.
  • Batch read calls — reduce RPC round trips by batching reads via viem’s multicall.
  • Optimistic updates — update the UI before confirmation using TanStack Query’s onMutate callback.
  • Wagmi setup reference — review the full Wagmi setup guide for additional configuration options.