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 체인에서 작동합니다.
프로젝트 설정
새 Next.js 앱을 만들고 필요한 의존성을 설치합니다.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
Base를 위한 Wagmi 설정
Base Sepolia로 Wagmi 설정을 만들고 앱을 필요한 프로바이더로 감쌉니다.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와 cookieStorage의 조합은 Next.js 하이드레이션 불일치를 방지합니다. baseAccount 커넥터는 Base Account SDK 스마트 지갑을 통해 사용자를 연결하며, 7단계에서 기능을 감지합니다. injected 커넥터는 MetaMask 같은 브라우저 확장 지갑을 처리합니다.'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>로 감쌉니다. 지갑 연결
네 가지 지갑 연결 상태를 모두 처리하는 컴포넌트를 만듭니다.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>
)
}
useAccount는 isConnecting, isReconnecting, isConnected, isDisconnected 네 가지 상태를 노출합니다. isConnected만 확인하면 페이지 로드 시 UI가 깜박입니다 — 네 가지 모두 처리하세요.
Foundry로 컨트랙트 배포
Foundry를 설치하고 프로젝트 내에 contracts 디렉토리를 초기화합니다.mkdir contracts && cd contracts
curl -L https://foundry.paradigm.xyz | bash
foundryup
forge init --no-git
--no-git 플래그는 Foundry가 프로젝트 내에 중첩된 git 저장소를 초기화하는 것을 방지합니다.
환경 파일에 Base Sepolia를 설정합니다.BASE_SEPOLIA_RPC_URL="https://sepolia.base.org"
https://sepolia.base.org에 접근할 수 없는 경우 https://base-sepolia-rpc.publicnode.com과 같은 대체 공개 엔드포인트를 사용하세요. 프로덕션 앱의 경우 전용 RPC 프로바이더를 사용하세요.
변수를 로드하고 배포자 키를 안전하게 가져옵니다.source .env
cast wallet import deployer --interactive
개인 키를 절대 공유하거나 커밋하지 마세요. cast wallet import는 git에서 추적되지 않는 ~/.foundry/keystores에 키를 저장합니다.
cast wallet import --interactive는 TTY(인터랙티브 터미널)가 필요합니다. 스크립트나 CI 환경에서는 키를 직접 전달하세요:forge create ./src/Counter.sol:Counter \
--rpc-url $BASE_SEPOLIA_RPC_URL \
--private-key $DEPLOYER_PRIVATE_KEY
컨트랙트를 배포합니다.forge create ./src/Counter.sol:Counter \
--rpc-url $BASE_SEPOLIA_RPC_URL \
--account deployer
초기 카운터 값을 읽어 배포를 확인합니다.cast call <CONTRACT_ADDRESS> "number()(uint256)" --rpc-url $BASE_SEPOLIA_RPC_URL
배포 비용을 지불하려면 테스트넷 ETH가 필요합니다. 네트워크 파우셋에서 무료 Base Sepolia ETH를 받으세요. 컨트랙트 데이터 읽기
컨트랙트 주소와 ABI를 정의한 다음 현재 카운터 값을 읽습니다.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>
}
isError가 true여도 이전의 성공적인 페치에서 data에 유효한 캐시된 값이 남아 있을 수 있습니다. 오류 메시지보다 오래된 데이터가 선호되도록 항상 data === undefined를 조건으로 오류를 렌더링하세요.
컨트랙트에 쓰기
트랜잭션을 전송하고 세 가지 확인 상태를 사용자에게 표시합니다.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가 백그라운드 체인 전환을 시도합니다. 사용자가 지갑 팝업을 놓치거나 닫으면 버튼이 오류 없이 “지갑에서 확인 중…”에 무기한 머물게 됩니다.
지갑 기능 감지
스마트 지갑은 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를 사용한 배치 트랜잭션을 참조하세요. 폴백을 포함한 배치 트랜잭션
스마트 지갑에는 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>
)
}
supportsBatching이 true인지 확인하지 않고 useSendCalls를 호출하지 마세요. EOA에 대해 호출하면 오류가 발생합니다.
페이지 조합
컴포넌트들을 하나의 페이지로 구성합니다.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>
)
}
개발 서버를 시작합니다.
다음 단계
- 메인넷으로 이동 —
config/wagmi.ts의 chains 배열과 트랜스포트에 base를 추가하고, 컨트랙트를 Base 메인넷에 재배포한 후 COUNTER_ADDRESS를 업데이트하세요.
- 가스 스폰서 —
useSendCalls와 paymasterService 기능을 사용하여 사용자의 트랜잭션 수수료를 대신 지불하세요. 가스 스폰서를 참조하세요.
- 읽기 호출 배치 — viem의
multicall을 통해 읽기를 배치하여 RPC 왕복을 줄이세요.
- 낙관적 업데이트 — TanStack Query의
onMutate 콜백을 사용하여 확인 전에 UI를 업데이트하세요.
- Wagmi 설정 참조 — 추가 설정 옵션을 위해 전체 Wagmi 설정 가이드를 검토하세요.