Skip to main content

Documentation Index

Fetch the complete documentation index at: https://daehan-base.mintlify.app/llms.txt

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

이 가이드는 Base 위에서 온체인 집계 앱을 처음부터 빌드하는 과정을 안내합니다. 지갑을 연결하고, 스마트 컨트랙트를 읽고 쓰고, 지갑 기능을 감지하고, 배치를 지원하지 않는 지갑에 대한 우아한 폴백을 처리합니다.

빌드할 것

  • 지갑을 연결하고 연결 상태를 처리하는 Next.js 앱
  • Base Sepolia에 배포된 카운터에 대한 컨트랙트 읽기 및 쓰기
  • EIP-5792를 통한 스마트 지갑의 배치 트랜잭션 지원
  • 배치를 지원하지 않는 지갑을 위한 우아한 폴백
Base는 다음 10억 명의 사용자를 온체인으로 데려오기 위해 구축된 빠르고 저렴한 Ethereum L2입니다. 낮은 가스 수수료로 배치 트랜잭션이 실용적이고 실시간 UX가 가능해집니다. 이 가이드의 모든 패턴은 모든 EVM 체인에서 작동합니다.

단계

1

프로젝트 설정

새 Next.js 앱을 만들고 필요한 의존성을 설치합니다.
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

Base를 위한 Wagmi 설정

Base Sepolia로 Wagmi 설정을 만들고 앱을 필요한 프로바이더로 감쌉니다.
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: truecookieStorage의 조합은 Next.js 하이드레이션 불일치를 방지합니다. baseAccount 커넥터는 Base Account SDK 스마트 지갑을 통해 사용자를 연결하며, 7단계에서 기능을 감지합니다. injected 커넥터는 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>
  )
}
루트 레이아웃을 <Providers>로 감쌉니다.
3

지갑 연결

네 가지 지갑 연결 상태를 모두 처리하는 컴포넌트를 만듭니다.
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>재연결...</div>

  if (!isConnected) {
    return (
      <div className="flex flex-col gap-2">
        {connectors.map((connector) => (
          <button
            key={connector.uid}
            onClick={() => connect({ connector })}
            disabled={isConnecting}
          >
            {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()}>연결 해제</button>
    </div>
  )
}
useAccountisConnecting, isReconnecting, isConnected, isDisconnected 네 가지 상태를 노출합니다. isConnected만 확인하면 페이지 로드 시 UI가 깜박입니다 — 네 가지 모두 처리하세요.
4

Foundry로 컨트랙트 배포

Foundry를 설치하고 프로젝트 내에 contracts 디렉토리를 초기화합니다.
Terminal
mkdir contracts && cd contracts
curl -L https://foundry.paradigm.xyz | bash
foundryup
forge init --no-git
--no-git 플래그는 Foundry가 프로젝트 내에 중첩된 git 저장소를 초기화하는 것을 방지합니다.
환경 파일에 Base Sepolia를 설정합니다.
contracts/.env
BASE_SEPOLIA_RPC_URL="https://sepolia.base.org"
https://sepolia.base.org에 접근할 수 없는 경우 https://base-sepolia-rpc.publicnode.com과 같은 대체 공개 엔드포인트를 사용하세요. 프로덕션 앱의 경우 전용 RPC 프로바이더를 사용하세요.
변수를 로드하고 배포자 키를 안전하게 가져옵니다.
Terminal
source .env
cast wallet import deployer --interactive
개인 키를 절대 공유하거나 커밋하지 마세요. cast wallet import는 git에서 추적되지 않는 ~/.foundry/keystores에 키를 저장합니다.
cast wallet import --interactive는 TTY(인터랙티브 터미널)가 필요합니다. 스크립트나 CI 환경에서는 키를 직접 전달하세요:
Terminal
forge create ./src/Counter.sol:Counter \
  --rpc-url $BASE_SEPOLIA_RPC_URL \
  --private-key $DEPLOYER_PRIVATE_KEY
컨트랙트를 배포합니다.
Terminal
forge create ./src/Counter.sol:Counter \
  --rpc-url $BASE_SEPOLIA_RPC_URL \
  --account deployer
초기 카운터 값을 읽어 배포를 확인합니다.
Terminal
cast call <CONTRACT_ADDRESS> "number()(uint256)" --rpc-url $BASE_SEPOLIA_RPC_URL
배포 비용을 지불하려면 테스트넷 ETH가 필요합니다. 네트워크 파우셋에서 무료 Base Sepolia ETH를 받으세요.
5

컨트랙트 데이터 읽기

컨트랙트 주소와 ABI를 정의한 다음 현재 카운터 값을 읽습니다.
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는 필수입니다. 이것이 없으면 wagmi가 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>로딩...</p>
  if (isError && count === undefined) return <p>컨트랙트 읽기 실패</p>

  return <p className="text-5xl font-bold">{count?.toString()}</p>
}
isErrortrue여도 이전의 성공적인 페치에서 data에 유효한 캐시된 값이 남아 있을 수 있습니다. 오류 메시지보다 오래된 데이터가 선호되도록 항상 data === undefined를 조건으로 오류를 렌더링하세요.
6

컨트랙트에 쓰기

트랜잭션을 전송하고 세 가지 확인 상태를 사용자에게 표시합니다.
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 ? '전환 중...' : 'Base Sepolia로 전환'}
      </button>
    )
  }

  return (
    <div>
      <button
        onClick={() =>
          writeContract({
            address: COUNTER_ADDRESS,
            abi: counterAbi,
            functionName: 'increment',
            chainId: baseSepolia.id,
          })
        }
        disabled={isPending || isConfirming}
      >
        {isPending
          ? '지갑에서 확인...'
          : isConfirming
          ? '확인...'
          : '증가'}
      </button>
      {isSuccess && <p>확인 완료!</p>}
      {hash && (
        <a href={`https://sepolia.basescan.org/tx/${hash}`} target="_blank">
          Basescan에서 보기
        </a>
      )}
    </div>
  )
}
useReadContract는 결과를 캐시하며 쓰기 후 자동으로 다시 가져오지 않습니다. 트랜잭션이 확인될 때 단일 재페치를 트리거하려면 읽기의 쿼리 키와 함께 queryClient.invalidateQueries를 사용하세요.
사용자에게 세 가지 상태를 표시합니다: 지갑 서명 대기, 온체인 확인 대기, 성공.
useSwitchChain 없이 잘못된 네트워크에서 writeContract를 호출하면 wagmi가 백그라운드 체인 전환을 시도합니다. 사용자가 지갑 팝업을 놓치거나 닫으면 버튼이 오류 없이 “지갑에서 확인 중…”에 무기한 머물게 됩니다.
7

지갑 기능 감지

스마트 지갑은 EIP-5792를 통해 배치 트랜잭션을 지원합니다. EOA는 지원하지 않습니다. 배치를 시도하기 전에 지원 여부를 감지하세요.
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()는 배포 체인이 아닌 지갑의 현재 체인을 반환합니다. Ethereum 메인넷에 있는 MetaMask 사용자는 잘못된 기능 결과를 얻게 됩니다. 항상 컨트랙트가 배포된 체인에 대한 기능을 확인하세요.
EIP-5792 기능 감지에 대한 자세한 내용은 Wagmi를 사용한 배치 트랜잭션을 참조하세요.
8

폴백을 포함한 배치 트랜잭션

스마트 지갑에는 useSendCalls를, EOA에는 useWriteContract를 사용합니다. 컴포넌트는 렌더링 시점에 어떤 경로를 선택할지 감지합니다.
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>먼저 지갑을 연결하세요.</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 ? '전환 중...' : '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
          ? '지갑에서 확인...'
          : isConfirming
          ? '확인...'
          : 'x2 증가 (배치)'}
      </button>
      {isSuccess && <p>배치 확인 완료!</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 ? '전환 중...' : 'Base Sepolia로 전환'}
      </button>
    )
  }

  return (
    <button
      onClick={() =>
        writeContract({
          address: COUNTER_ADDRESS,
          abi: counterAbi,
          functionName: 'increment',
          chainId: baseSepolia.id,
        })
      }
      disabled={isPending || isConfirming}
    >
      {isPending ? '지갑에서 확인 중...' : isConfirming ? '확인 중...' : '증가'}
    </button>
  )
}
supportsBatchingtrue인지 확인하지 않고 useSendCalls를 호출하지 마세요. EOA에 대해 호출하면 오류가 발생합니다.
9

페이지 조합

컴포넌트들을 하나의 페이지로 구성합니다.
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">온체인 집계</h1>
      <ConnectWallet />
      <CounterDisplay />
      <BatchIncrement />
    </main>
  )
}
개발 서버를 시작합니다.
Terminal
npm run dev

다음 단계

  • 메인넷으로 이동config/wagmi.tschains 배열과 트랜스포트에 base를 추가하고, 컨트랙트를 Base 메인넷에 재배포한 후 COUNTER_ADDRESS를 업데이트하세요.
  • 가스 스폰서useSendCallspaymasterService 기능을 사용하여 사용자의 트랜잭션 수수료를 대신 지불하세요. 가스 스폰서를 참조하세요.
  • 읽기 호출 배치 — viem의 multicall을 통해 읽기를 배치하여 RPC 왕복을 줄이세요.
  • 낙관적 업데이트 — TanStack Query의 onMutate 콜백을 사용하여 확인 전에 UI를 업데이트하세요.
  • Wagmi 설정 참조 — 추가 설정 옵션을 위해 전체 Wagmi 설정 가이드를 검토하세요.