“폐배터리 이력 관리”를 블록체인으로 풀어보자는 프로젝트가 시작됐다. 전체 구조는 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 미들웨어를 사용했다. 문제는 저장소다.
| localStorage | chrome.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한 번으로 반영된다.
세 가지로 정리하면.
- viem은 ethers.js의 좋은 대안 — 타입 추론과 tree-shaking이 확실히 낫다. 다만
defineChain으로 커스텀 네트워크를 만들 때 문서가 부족해서 소스코드를 읽어야 했다. - chrome.storage.local + zustand persist는 async hydration이 함정 —
rehydrate()를 명시적으로 호출하고 플래그를 관리하는 패턴이 필요하다. - 브라우저 호환성은 처음부터 챙겨야 한다 —
Buffer,crypto.randomBytes같은 Node.js API를 나중에 교체하면 고칠 곳이 산발적이다. Web Crypto API와 Uint8Array로 시작하는 게 맞다.
다음 편: 앵커링 — Solidity 스마트 컨트랙트부터 Etherscan까지
Footnotes
-
monorepo — 여러 프로젝트/패키지를 하나의 저장소에서 관리하는 방식. pnpm workspace, Turborepo, Nx 등으로 구성한다. ↩
-
viem — TypeScript 기반 Ethereum 라이브러리. ethers.js 대비 번들 크기 ~60% 작고 타입 추론이 강력하다. ↩
-
mnemonic (BIP-39) — 프라이빗 키를 12개 영단어로 인코딩한 것. 2048개 단어 목록에서 선택되며, 경우의 수 2^128. ↩
-
BIP-44 — HD 지갑의 계정 경로 표준.
m/44'/60'/0'/0/N에서 60은 Ethereum, N은 N번째 계정. ↩ -
OWASP 2023 PBKDF2 권장 — 비밀번호 해싱 시 최소 600,000회 반복. GPU 성능 향상에 따라 기준이 올라간다. ↩
-
Side Panel API — Chrome MV3에서 브라우저 옆에 고정된 패널을 띄우는 API. Popup보다 화면이 넓어 지갑 UI에 적합하다. ↩
-
zustand — React 상태 관리 라이브러리. Redux 대비 보일러플레이트가 적고 persist 미들웨어로 저장소 동기화가 간편하다. ↩
-
CRXJS — Vite 기반 Chrome Extension 빌드 플러그인. manifest.json 자동 처리, HMR 지원. Vite 6 이후 호환성 이슈가 있다. ↩
-
Glassmorphism — 반투명 배경 + blur 효과로 유리 느낌을 내는 UI 디자인 트렌드.
backdrop-filter: blur()CSS 속성을 사용한다. ↩