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 buildpnpm --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.tsbytesToHexhexToBytes 두 함수를 만들어 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/apichainid 파라미터를 추가하는 방식이다. 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 다국어 사이트를 망가뜨리는 이유