Skip to content
Go back

Web3 신뢰 인프라 #1 — 지갑 SDK와 Chrome Extension

“폐배터리 이력 관리”를 블록체인으로 풀어보자는 프로젝트가 시작됐다. 전체 구조는 09. Wallet → 07. Anchoring → 02. DID/VC 세 개의 코어 기술을 각각 독립 SDK(@core/*)로 만들고, 공통 데모 앱(Onyx Extension)으로 통합하는 방식이다. 1편에서는 가장 아래 레이어인 지갑 SDK와 Chrome Extension을 다룬다.

프로젝트 구조

모노레포1로 구성했다. SDK와 앱을 한 저장소에서 관리하면 workspace:*로 의존성을 연결할 수 있고, SDK를 수정하면 앱에서 바로 반영된다.

packages/
├── @core/wallet/     ← 지갑 SDK (이번 편)
├── @core/anchor/     ← 앵커링 SDK (2편)
└── @core/did/        ← DID/VC SDK (3편)

apps/
└── wallet-extension/ ← Chrome Extension (Onyx)

pnpm workspace를 사용했고, pnpm --filter @core/wallet build → pnpm --filter wallet-extension build 순서로 SDK를 먼저 빌드한 뒤 Extension을 빌드한다.

SDK 설계 — @core/wallet

SDK의 모듈 구성은 지갑의 핵심 기능 단위로 나눴다.

모듈역할주요 함수
keyring/니모닉 생성·복구, 암호화createWallet, restoreWallet, encryptKey
account/HD 계정 파생, PK 추출deriveAccount, exportPrivateKey
provider/RPC 연결, 잔액 조회createProvider, getBalance
transfer/ETH·토큰 전송, 가스 추정sendETH, sendToken, estimateGas
transaction/이력 조회fetchHistory (Etherscan + eth_getLogs)
token/ERC-20 메타데이터·잔액getTokenMetadata, getTokenBalance

viem 선택

ethers.js v5가 오래됐고 v6 마이그레이션이 번거로운 시점이라 viem2을 선택했다. TypeScript 타입 추론이 확실히 좋고, 모듈 단위 import로 tree-shaking이 잘 된다.

// @core/wallet/src/provider/create-provider.ts
import { createPublicClient, http, defineChain } from 'viem';

export function createProvider(rpcUrl: string, chainId: number) {
  const chain = defineChain({
    id: chainId,
    name: `Chain ${chainId}`,
    nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
    rpcUrls: { default: { http: [rpcUrl] } },
  });
  return createPublicClient({ chain, transport: http() });
}

defineChain으로 커스텀 네트워크를 동적으로 생성할 수 있어서, 나중에 프라이빗 체인 연결할 때도 RPC URL과 Chain ID만 넣으면 된다.

니모닉과 HD 파생

니모닉3 생성은 @scure/bip39, HD 키 파생은 @scure/bip32를 사용했다. BIP-444 경로(m/44'/60'/0'/0/N)로 N번째 계정을 파생한다.

// 공통 파생 함수 — create-wallet, restore-wallet, derive-account에서 재사용
import { HDKey } from '@scure/bip32';
import { mnemonicToSeedSync } from '@scure/bip39';

export function deriveKeyFromMnemonic(mnemonic: string, index: number) {
  const seed = mnemonicToSeedSync(mnemonic);
  const hdKey = HDKey.fromMasterSeed(seed).derive(`m/44'/60'/0'/0/${index}`);
  const privateKeyHex = bytesToHex(hdKey.privateKey!);
  // ... address 파생
}

처음에 create-wallet.ts, restore-wallet.ts, derive-account.ts 세 파일에 동일한 파생 로직이 복붙되어 있었다. shared/derive.ts로 추출하면서 중복을 제거했다.

암호화 — AES-256-GCM + PBKDF2

니모닉은 평문으로 저장하면 안 된다. 사용자 비밀번호로 암호화한다.

비밀번호 "mypassword"
    ↓ PBKDF2 (600,000회 반복 + 랜덤 Salt 16바이트)
암호화 키 256비트
    ↓ AES-256-GCM (랜덤 IV 12바이트)
암호화된 니모닉 (hex 문자열로 저장)

PBKDF2 반복 횟수 600,000은 OWASP 2023 권장 기준5이다. GPU로 brute-force를 돌려도 8자리 비밀번호 전수조사에 수백 년이 걸린다.

저장 형식은 [Salt 16바이트] + [IV 12바이트] + [Ciphertext + AuthTag]를 hex 직렬화한 단일 문자열이다. Salt와 IV가 매번 랜덤이라 같은 비밀번호로 암호화해도 결과가 매번 다르다.

Chrome Extension — Side Panel

왜 Side Panel인가

처음에 Popup으로 만들었는데 화면이 너무 좁았다. Chrome MV3의 Side Panel API6를 쓰면 브라우저 옆에 고정된 패널로 띄울 수 있다.

{
  "side_panel": { "default_path": "index.html" },
  "permissions": ["sidePanel", "storage"]
}

chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }) — Extension 아이콘 클릭 시 자동으로 사이드 패널이 열린다.

zustand + chrome.storage.local

상태 관리는 zustand7 + persist 미들웨어를 사용했다. 문제는 저장소다.

localStoragechrome.storage.local
같은 origin JS 접근❌ 가능✅ 불가
XSS 공격 시 탈취❌ 가능✅ 불가
타 Extension 접근❌ 가능✅ 불가 (ID 격리)
포트 변경 시 유지❌ 소실✅ 유지

chrome.storage.local을 zustand persist 어댑터로 감쌌다.

const chromeStorageAdapter: StateStorage = {
  getItem: async (name) => {
    if (!isChromeExtension) return localStorage.getItem(name); // dev 폴백
    const result = await chrome.storage.local.get(name);
    return result[name] ?? null;
  },
  setItem: async (name, value) => {
    if (!isChromeExtension) { localStorage.setItem(name, value); return; }
    await chrome.storage.local.set({ [name]: value });
  },
  removeItem: async (name) => {
    if (!isChromeExtension) { localStorage.removeItem(name); return; }
    await chrome.storage.local.remove(name);
  },
};

개발 서버(localhost)에서는 chrome 객체가 없으니 localStorage로 자동 폴백한다.

잠금 정책 — isLocked를 persist에서 제외

partialize: (state) => ({
  isInitialized: state.isInitialized,
  accounts: state.accounts,
  encryptedMnemonic: state.encryptedMnemonic,
  encryptedKeys: state.encryptedKeys,
  // isLocked 제외 — 앱 열 때마다 항상 잠금 상태로 시작
}),

isLocked를 persist하면 해제 상태에서 브라우저 닫고 다시 열었을 때 잠금이 풀려 있다. 공유 컴퓨터에서 위험하다.

트러블 슈팅

(a) Buffer.from() → Uint8Array

SDK를 브라우저에서 쓰려는데 Buffer가 없어서 터졌다. Node.js 전용 API다. Buffer.from(bytes).toString('hex')를 3곳에서 쓰고 있었는데 전부 교체했다.

// ❌ 브라우저에서 ReferenceError: Buffer is not defined
const hex = Buffer.from(bytes).toString('hex');

// ✅ Uint8Array + 직접 변환
export function bytesToHex(bytes: Uint8Array): `0x${string}` {
  return ('0x' + Array.from(bytes)
    .map(b => b.toString(16).padStart(2, '0'))
    .join('')) as `0x${string}`;
}

shared/hex.ts에 bytesToHex와 hexToBytes 두 함수를 만들어 SDK 전체에서 재사용했다.

(b) zustand async hydration

chrome.storage.local은 비동기다. zustand의 onRehydrateStorage 콜백이 호출되지 않는 문제가 있었다. 정확히는, createJSONStorage에 async 어댑터를 넘기면 hydration 완료 시점을 zustand가 보장하지 않는다.

// 해결: 명시적으로 rehydrate 호출 + 플래그 세팅
useEffect(() => {
  useWalletStore.persist.rehydrate().then(() => {
    useWalletStore.getState().setHydrated();
  });
}, []);

hasHydrated 플래그가 true가 될 때까지 로딩 UI를 표시하고, 그 이후에 잠금 화면이나 대시보드를 렌더링한다.

(c) CRXJS + Vite 6 비호환

CRXJS8 플러그인이 Vite 6을 지원하지 않아서, manifest.json 처리와 멀티 엔트리 빌드를 직접 구현해야 했다.

// vite.config.ts — Rollup multi-entry
build: {
  rollupOptions: {
    input: {
      popup: resolve(__dirname, 'index.html'),
      background: resolve(__dirname, 'src/background/index.ts'),
    },
    output: { entryFileNames: '[name].js' },
  },
},

빌드 후 manifest.json을 dist에 복사하는 scripts/copy-manifest.js 스크립트를 추가했다. CRXJS 없이도 빌드가 동작한다.

(d) zustand 무한 렌더링

토큰 스토어에서 getTokensByChain이 매 렌더마다 새 배열을 반환해서 무한 렌더링이 발생했다. useMemo로 컴포넌트에서 필터링하는 방식으로 변경하고, 스토어에서는 배열 전체만 노출했다.

(e) Etherscan V1 → V2

Etherscan API가 V1을 deprecated시키면서 에러를 반환하기 시작했다. V2는 https://api.etherscan.io/v2/api에 chainid 파라미터를 추가하는 방식이다. V1에서 체인별 URL을 분기하던 코드를 단일 엔드포인트 + chainid 파라미터로 정리했다.

Onyx 디자인

디자인은 다크 Glassmorphism9 테마를 적용했다. 배경 #060912, 글래스 카드는 rgba(255,255,255,0.04) + backdrop-filter: blur(20px), 프라이머리 그라디언트는 #2e5bff → #7701d0.

폰트는 Inter(본문) + Geist(모노/라벨). 커스텀 스크롤바 4px 반투명. glass-card, glass-input, primary-gradient 클래스를 index.css에 정의해서 전체 앱에서 재사용했다.

회고

SDK를 모노레포에서 패키지로 분리한 게 나중에 큰 도움이 됐다. Extension에서도, 데모 웹에서도, CLI 도구에서도 같은 SDK를 import해서 쓸 수 있었다. @core/wallet을 수정하면 pnpm --filter wallet-extension build 한 번으로 반영된다.

세 가지로 정리하면.

  1. viem은 ethers.js의 좋은 대안 — 타입 추론과 tree-shaking이 확실히 낫다. 다만 defineChain으로 커스텀 네트워크를 만들 때 문서가 부족해서 소스코드를 읽어야 했다.
  2. chrome.storage.local + zustand persist는 async hydration이 함정 — rehydrate()를 명시적으로 호출하고 플래그를 관리하는 패턴이 필요하다.
  3. 브라우저 호환성은 처음부터 챙겨야 한다 — Buffer, crypto.randomBytes 같은 Node.js API를 나중에 교체하면 고칠 곳이 산발적이다. Web Crypto API와 Uint8Array로 시작하는 게 맞다.

다음 편: 앵커링 — Solidity 스마트 컨트랙트부터 Etherscan까지

Footnotes

  1. monorepo — 여러 프로젝트/패키지를 하나의 저장소에서 관리하는 방식. pnpm workspace, Turborepo, Nx 등으로 구성한다. ↩

  2. viem — TypeScript 기반 Ethereum 라이브러리. ethers.js 대비 번들 크기 ~60% 작고 타입 추론이 강력하다. ↩

  3. mnemonic (BIP-39) — 프라이빗 키를 12개 영단어로 인코딩한 것. 2048개 단어 목록에서 선택되며, 경우의 수 2^128. ↩

  4. BIP-44 — HD 지갑의 계정 경로 표준. m/44'/60'/0'/0/N에서 60은 Ethereum, N은 N번째 계정. ↩

  5. OWASP 2023 PBKDF2 권장 — 비밀번호 해싱 시 최소 600,000회 반복. GPU 성능 향상에 따라 기준이 올라간다. ↩

  6. Side Panel API — Chrome MV3에서 브라우저 옆에 고정된 패널을 띄우는 API. Popup보다 화면이 넓어 지갑 UI에 적합하다. ↩

  7. zustand — React 상태 관리 라이브러리. Redux 대비 보일러플레이트가 적고 persist 미들웨어로 저장소 동기화가 간편하다. ↩

  8. CRXJS — Vite 기반 Chrome Extension 빌드 플러그인. manifest.json 자동 처리, HMR 지원. Vite 6 이후 호환성 이슈가 있다. ↩

  9. Glassmorphism — 반투명 배경 + blur 효과로 유리 느낌을 내는 UI 디자인 트렌드. backdrop-filter: blur() CSS 속성을 사용한다. ↩


Share this post on:

Comments


Previous Post
Web3 신뢰 인프라 #2 — 앵커링, Solidity 스마트 컨트랙트부터 Etherscan까지
Next Post
Chrome 페이지 번역이 Next.js 다국어 사이트를 망가뜨리는 이유