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 Account와 CDP 임베디드 지갑 통합

기존 Base Account 사용자와 CDP 임베디드 지갑을 통한 신규 사용자 모두를 원활하게 지원하는 온체인 앱을 구축하는 방법을 알아보세요. 통합 인증과 지갑 관리를 제공합니다.

개요

이 통합을 통해 앱은 두 가지 유형의 사용자를 지원할 수 있습니다:
  • 기존 Base 사용자: 친숙한 경험을 위해 Base Account로 연결
  • 신규 온체인 사용자: 이메일, 모바일, 또는 소셜 인증을 통해 CDP 임베디드 지갑 생성
두 유형의 사용자 모두 선호하는 지갑 유형을 사용하면서 동일한 앱 기능을 이용할 수 있습니다.

구축할 내용

  • 통합 인증 플로우: 두 가지 지갑 유형을 모두 지원하는 단일 로그인
  • 자동 지갑 감지: 사용자의 기존 지갑 상태에 따른 스마트 라우팅
  • 일관된 사용자 경험: 두 지갑 유형 모두 동일한 앱 기능에 접근

사전 요구 사항

  • Node.js 18+ 설치
  • React 애플리케이션 (Next.js 권장)
  • Project ID가 있는 CDP Portal 계정
  • Wagmi 및 React hooks 기본 지식

설치

CDP 임베디드 지갑과 Base Account 지원에 필요한 패키지를 설치합니다:
npm install @coinbase/cdp-core @coinbase/cdp-hooks @base-org/account @tanstack/react-query viem wagmi

단계별 구현

네이티브 CDP + Base Account 통합이 개발 중이므로, 이 가이드는 두 지갑 유형을 별도의 조율된 커넥터를 통해 지원하는 이중 커넥터 방식을 사용합니다. Base Account Wagmi 커넥터와 CDP의 React 프로바이더 시스템을 함께 사용하면 두 지갑 유형의 지갑 지속성을 올바르게 처리하는 통합 경험을 만들 수 있습니다.

1단계: 환경 변수 설정

CDP 프로젝트의 환경 변수를 생성합니다:
# .env.local
NEXT_PUBLIC_CDP_PROJECT_ID=your_cdp_project_id
NEXT_PUBLIC_APP_NAME="Your App Name"
CDP Project ID는 CDP Portal에서 가져옵니다. ⚠️ 중요: 유효한 NEXT_PUBLIC_CDP_PROJECT_ID가 없으면 앱이 “Project ID is required” 오류와 함께 로드되지 않습니다. 또한 CORS를 위해 CDP Portal → Wallets → Embedded Wallet 설정에서 도메인을 설정해야 합니다.

2단계: Base Account 지원을 위한 Wagmi 설정

Base Account 커넥터로 Wagmi를 설정합니다 (임베디드 지갑은 CDP React 프로바이더를 통해 별도로 처리됩니다):
// config/wagmi.ts
import { createConfig, http } from 'wagmi';
import { base, baseSepolia } from 'wagmi/chains';
import { baseAccount } from 'wagmi/connectors';

// Base Account 커넥터
const baseAccountConnector = baseAccount({
  appName: process.env.NEXT_PUBLIC_APP_NAME || 'Your App',
});

// Wagmi 설정 (Base Account만 해당 - 임베디드 지갑은 CDP React 프로바이더가 처리)
export const wagmiConfig = createConfig({
  connectors: [baseAccountConnector],
  chains: [baseSepolia, base], // 테스트를 위해 baseSepolia를 먼저 배치
  transports: {
    [base.id]: http(),
    [baseSepolia.id]: http(),
  },
});

3단계: 애플리케이션 프로바이더 설정

필요한 프로바이더로 애플리케이션을 래핑합니다. 중요: 임베디드 지갑 인증 상태를 올바르게 관리하려면 CDPHooksProvider를 사용하세요:
// app/layout.tsx
'use client';

import { WagmiProvider } from 'wagmi';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { CDPHooksProvider } from '@coinbase/cdp-hooks';
import { wagmiConfig } from '../config/wagmi';

const queryClient = new QueryClient();

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <CDPHooksProvider 
          config={{
            projectId: process.env.NEXT_PUBLIC_CDP_PROJECT_ID!,
          }}
        >
          <WagmiProvider config={wagmiConfig}>
            <QueryClientProvider client={queryClient}>
              {children}
            </QueryClientProvider>
          </WagmiProvider>
        </CDPHooksProvider>
      </body>
    </html>
  );
}

4단계: 통합 인증 훅 생성

두 지갑 유형을 모두 관리하는 커스텀 훅을 구축합니다. CDPHooksProvider를 사용하면 사용자가 다시 로그인할 때 매번 새로운 지갑을 생성하는 대신 기존 임베디드 지갑을 가져올 수 있습니다.
// hooks/useUnifiedAuth.ts
import { useAccount, useConnect, useDisconnect } from 'wagmi';
import { useSignInWithEmail, useVerifyEmailOTP, useIsSignedIn, useEvmAddress, useSignOut } from '@coinbase/cdp-hooks';
import { useState, useEffect } from 'react';

export type WalletType = 'base_account' | 'embedded' | 'none';

export function useUnifiedAuth() {
  // Base Account를 위한 Wagmi 훅
  const { address: wagmiAddress, isConnected: wagmiConnected, connector } = useAccount();
  const { connect, connectors } = useConnect();
  const { disconnect: wagmiDisconnect } = useDisconnect();

  // 임베디드 지갑을 위한 CDP 훅 - CDPHooksProvider와 함께 동작
  const { signInWithEmail, isLoading: isSigningIn } = useSignInWithEmail();
  const { verifyEmailOTP, isLoading: isVerifying } = useVerifyEmailOTP();
  const { isSignedIn: cdpSignedIn } = useIsSignedIn();
  const { evmAddress: cdpAddress } = useEvmAddress();
  const { signOut } = useSignOut();

  const [walletType, setWalletType] = useState<WalletType>('none');
  const [flowId, setFlowId] = useState<string>('');

  // 활성 지갑 결정 및 우선순위 지정
  const address = wagmiConnected ? wagmiAddress : cdpAddress;
  const isConnected = wagmiConnected || cdpSignedIn;

  useEffect(() => {
    if (wagmiConnected && connector?.name === 'Base Account') {
      setWalletType('base_account');
    } else if (cdpSignedIn && cdpAddress) {
      setWalletType('embedded');
    } else {
      setWalletType('none');
    }
  }, [wagmiConnected, cdpSignedIn, connector, cdpAddress]);

  const connectBaseAccount = () => {
    const baseConnector = connectors.find(c => c.name === 'Base Account');
    if (baseConnector) {
      connect({ connector: baseConnector });
    }
  };

  const signInWithEmbeddedWallet = async (email: string) => {
    try {
      const response = await signInWithEmail({ email });

      // OTP 검증을 위한 flowId 캡처
      if (response && typeof response === 'object' && 'flowId' in response) {
        setFlowId(response.flowId as string);
      }

      return true;
    } catch (error) {
      console.error('Failed to sign in with email:', error);
      return false;
    }
  };

  const verifyOtpAndConnect = async (otp: string) => {
    try {
      // CDPReactProvider에서 verifyEmailOTP는 자동으로 사용자를 로그인시킴
      await verifyEmailOTP({ flowId, otp });
      return true;
    } catch (error) {
      console.error('Failed to verify OTP:', error);
      return false;
    }
  };

  const disconnect = async () => {
    if (wagmiConnected) {
      wagmiDisconnect();
    }

    if (cdpSignedIn || walletType === 'embedded') {
      try {
        await signOut();
      } catch (error) {
        console.error('CDP sign out failed:', error);
      }
    }
  };

  return {
    address,
    isConnected,
    walletType,
    connectBaseAccount,
    signInWithEmbeddedWallet,
    verifyOtpAndConnect,
    disconnect,
    isSigningIn,
    isVerifying,
  };
}

5단계: 인증 컴포넌트 구축

두 가지 인증 옵션을 제공하는 컴포넌트를 생성합니다:
// components/WalletAuthButton.tsx
'use client';

import { useState } from 'react';
import { useUnifiedAuth } from '../hooks/useUnifiedAuth';

export function WalletAuthButton() {
  const {
    address,
    isConnected,
    walletType,
    connectBaseAccount,
    signInWithEmbeddedWallet,
    verifyOtpAndConnect,
    disconnect,
    isSigningIn,
    isVerifying,
  } = useUnifiedAuth();

  const [authStep, setAuthStep] = useState<'select' | 'email' | 'otp'>('select');
  const [email, setEmail] = useState('');
  const [otp, setOtp] = useState('');

  // 연결된 상태
  if (isConnected && address) {
    const walletDisplay = {
      base_account: { name: 'Base Account', icon: '🟦' },
      embedded: { name: 'Embedded Wallet', icon: '📱' },
    }[walletType] || { name: 'Connected', icon: '✅' };

    return (
      <div className="flex items-center space-x-3 px-4 py-2 bg-green-50 border border-green-200 rounded-lg">
        <span>{walletDisplay.icon}</span>
        <div>
          <div className="font-medium text-green-800">{walletDisplay.name}</div>
          <div className="text-xs text-green-600 font-mono">
            {address.slice(0, 6)}...{address.slice(-4)}
          </div>
        </div>
        <button onClick={() => disconnect()} className="text-sm text-red-600">
          연결 해제
        </button>
      </div>
    );
  }

  // OTP 검증
  if (authStep === 'otp') {
    return (
      <div className="space-y-4 p-4 border rounded-lg">
        <div className="text-center">
          <h3 className="font-semibold">이메일을 확인하세요</h3>
          <p className="text-sm text-gray-600">{email}으로 발송된 코드를 입력하세요</p>
        </div>

        <input
          type="text"
          value={otp}
          onChange={(e) => setOtp(e.target.value)}
          placeholder="000000"
          maxLength={6}
          className="w-full px-3 py-2 border rounded text-center font-mono"
        />

        <div className="space-y-2">
          <button
            onClick={() => verifyOtpAndConnect(otp)}
            disabled={otp.length !== 6 || isVerifying}
            className="w-full px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
          >
            {isVerifying ? '계정 생성 중...' : '인증 및 계정 생성'}
          </button>

          <button
            onClick={() => setAuthStep('email')}
            className="w-full px-4 py-2 text-gray-600 hover:text-gray-800"
          >
            뒤로
          </button>
        </div>
      </div>
    );
  }

  // 이메일 입력
  if (authStep === 'email') {
    return (
      <div className="space-y-4 p-4 border rounded-lg">
        <h3 className="font-semibold text-center">계정 생성</h3>

        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          placeholder="your@email.com"
          className="w-full px-3 py-2 border rounded"
        />

        <div className="space-y-2">
          <button
            onClick={async () => {
              const success = await signInWithEmbeddedWallet(email);
              if (success) setAuthStep('otp');
            }}
            disabled={!email || isSigningIn}
            className="w-full px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
          >
            {isSigningIn ? '코드 발송 중...' : '인증 코드 발송'}
          </button>

          <button
            onClick={() => setAuthStep('select')}
            className="w-full px-4 py-2 text-gray-600 hover:text-gray-800"
          >
            뒤로
          </button>
        </div>
      </div>
    );
  }

  // 초기 선택
  return (
    <div className="space-y-3">
      <h2 className="text-xl font-bold text-center mb-4">지갑 연결</h2>

      <button
        onClick={connectBaseAccount}
        className="w-full p-4 border-2 border-blue-200 rounded-lg hover:bg-blue-50"
      >
        <div className="flex items-center space-x-3">
          <span className="text-2xl">🟦</span>
          <div className="text-left">
            <div className="font-semibold">Base로 로그인</div>
            <div className="text-sm text-gray-600">Base Account가 있습니다</div>
          </div>
        </div>
      </button>

      <button
        onClick={() => setAuthStep('email')}
        className="w-full p-4 border-2 border-gray-200 rounded-lg hover:bg-gray-50"
      >
        <div className="flex items-center space-x-3">
          <span className="text-2xl">📱</span>
          <div className="text-left">
            <div className="font-semibold"> 계정 생성</div>
            <div className="text-sm text-gray-600">이메일로 시작하기</div>
          </div>
        </div>
      </button>
    </div>
  );
}

6단계: 각 지갑 유형별 트랜잭션 처리

각 지갑 유형에 맞게 동작하는 트랜잭션 컴포넌트를 생성합니다:
// components/SendTransaction.tsx
import { useState } from 'react';
import { parseEther } from 'viem';
import { useSendTransaction, useWaitForTransactionReceipt, useAccount, useSwitchChain } from 'wagmi';
import { base, baseSepolia } from 'wagmi/chains';
import { useUnifiedAuth } from '../hooks/useUnifiedAuth';

export function SendTransaction() {
  const { address, walletType } = useUnifiedAuth();
  const { chain } = useAccount();
  const { switchChain } = useSwitchChain();
  const [amount, setAmount] = useState('');
  const [recipient, setRecipient] = useState('');

  const { data: hash, sendTransaction, isPending, error } = useSendTransaction();
  const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash });

  const handleTransaction = async () => {
    if (!address || !amount || !recipient) return;

    try {
      sendTransaction({
        to: recipient as `0x${string}`,
        value: parseEther(amount),
      });
    } catch (error) {
      console.error('Transaction failed:', error);
    }
  };

  // 지갑 유형에 따른 안내 표시
  const getTransactionGuidance = () => {
    switch (walletType) {
      case 'base_account':
        return {
          title: 'Base Account 트랜잭션',
          description: '패스키로 확인하라는 프롬프트가 표시됩니다',
          icon: '🔐'
        };
      case 'embedded':
        return {
          title: '임베디드 지갑 트랜잭션',
          description: '트랜잭션이 자동으로 서명됩니다',
          icon: '⚡'
        };
      default:
        return { title: '트랜잭션 발송', description: '', icon: '💸' };
    }
  };

  const guidance = getTransactionGuidance();

  if (!address) return null;

  return (
    <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow">
      <div className="text-center mb-6">
        <div className="text-3xl mb-2">{guidance.icon}</div>
        <h3 className="text-lg font-bold">{guidance.title}</h3>
        <p className="text-sm text-gray-600">{guidance.description}</p>

        {/* 네트워크 표시기 및 전환 */}
        <div className="mt-3 p-2 bg-gray-50 rounded border">
          <div className="flex items-center justify-between">
            <span className="text-sm">
              네트워크: <strong>{chain?.name || '알 수 없음'}</strong>
            </span>
            <div className="space-x-1">
              {chain?.id !== baseSepolia.id && (
                <button
                  onClick={() => switchChain({ chainId: baseSepolia.id })}
                  className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
                >
Sepolia
                </button>
              )}
              {chain?.id !== base.id && (
                <button
                  onClick={() => switchChain({ chainId: base.id })}
                  className="px-2 py-1 text-xs bg-green-100 text-green-700 rounded hover:bg-green-200"
                >
Mainnet
                </button>
              )}
            </div>
          </div>
        </div>
      </div>

      <div className="space-y-4">
        <div>
          <label className="block text-sm font-medium mb-1">금액 (ETH)</label>
          <input
            type="number"
            value={amount}
            onChange={(e) => setAmount(e.target.value)}
            placeholder="0.001"
            step="0.001"
            className="w-full px-3 py-2 border border-gray-300 rounded"
          />
        </div>

        <div>
          <label className="block text-sm font-medium mb-1">수신 주소</label>
          <input
            type="text"
            value={recipient}
            onChange={(e) => setRecipient(e.target.value)}
            placeholder="0x..."
            className="w-full px-3 py-2 border border-gray-300 rounded font-mono text-sm"
          />
        </div>

        {error && (
          <div className="p-3 bg-red-50 border border-red-200 rounded">
            <p className="text-sm text-red-800">오류: {error.message}</p>
          </div>
        )}

        <button
          onClick={handleTransaction}
          disabled={!amount || !recipient || isPending || isConfirming}
          className="w-full px-4 py-3 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
        >
          {isPending || isConfirming ? '처리 중...' : '트랜잭션 발송'}
        </button>

        {isSuccess && hash && (
          <div className="p-3 bg-green-50 border border-green-200 rounded text-center">
            <p className="text-green-800 font-medium mb-2">트랜잭션 확인됨!</p>
            <a
              href={`https://${chain?.id === baseSepolia.id ? 'sepolia.' : ''}basescan.org/tx/${hash}`}
              target="_blank"
              rel="noopener noreferrer"
              className="text-blue-600 hover:text-blue-800 text-sm underline"
            >
              {chain?.id === baseSepolia.id ? 'Sepolia ' : ''}Basescan에서 보기
            </a>
          </div>
        )}
      </div>
    </div>
  );
}

7단계: 앱 완성

메인 애플리케이션에 모든 것을 조합합니다:
// app/page.tsx
'use client';

import { WalletAuthButton } from '../components/WalletAuthButton';
import { SendTransaction } from '../components/SendTransaction';
import { useAccount } from 'wagmi';

export default function HomePage() {
  const { isConnected } = useAccount();

  return (
    <div className="min-h-screen bg-gray-50 py-12 px-4">
      <div className="max-w-2xl mx-auto">
        <div className="text-center mb-8">
          <h1 className="text-3xl font-bold mb-2">CDP + Base Account 데모</h1>
          <p className="text-gray-600">
            Base Account와 임베디드 지갑 사용자를 모두 지원하는
          </p>
        </div>

        <div className="space-y-6">
          <WalletAuthButton />
          {isConnected && <SendTransaction />}
        </div>
      </div>
    </div>
  );
}

문제 해결

일반적인 문제

Base Account 커넥터가 표시되지 않음
  • Base Account SDK(@base-org/account)가 설치되고 최신 상태인지 확인
  • wagmi 설정에 Base Account 커넥터가 포함되어 있는지 확인
  • 앱이 Base 또는 Base Sepolia 네트워크에서 실행 중인지 확인
CDP 임베디드 지갑 인증 실패
  • 환경 변수에 CDP Project ID가 올바른지 확인
  • 중요: CDP Portal → Wallets → Embedded Wallet 설정 → Allowed domains에 도메인(예: http://localhost:3000, http://localhost:3001)을 추가해야 합니다
  • 필요한 CDP 패키지(위 참조)가 모두 설치되어 있는지 확인
로그인할 때마다 새 지갑이 생성됨
  • 레이아웃에 올바른 설정으로 CDPHooksProvider를 사용하고 있는지 확인
  • CDP Project ID가 올바르게 설정되어 있는지 확인
  • 훅이 @coinbase/cdp-hooks에서 일관되게 임포트되고 있는지 확인
사용자가 지갑 유형 간 전환 불가
  • 다른 유형의 지갑을 연결하기 전에 올바른 연결 해제 플로우 구현
  • 전환 시 캐시된 인증 상태 초기화
  • 지갑 유형 선택을 위한 명확한 UI 안내 제공

향상된 통합 예정

Base Account와 CDP 임베디드 지갑의 네이티브 통합을 적극 개발 중이며, 다음 기능이 지원될 예정입니다:
  • 통합 커넥터: 두 지갑 유형을 원활하게 처리하는 단일 CDP 커넥터
  • 스펜드 퍼미션: 서브 어카운트가 상위 Base Account 잔액을 한도 내에서 접근 가능
  • 서브 어카운트 생성: Base Account 사용자가 앱별 서브 어카운트 생성 가능

리소스

향상된 임베디드 지갑 Base Account 통합 기능 업데이트를 위해 CDP 문서를 주기적으로 확인하세요.