📋 시스템 개요
ERC-4337 Account Abstraction 기반 스테이블코인 블록체인 어댑터 서비스
StableCoin BC Adapter는 블록체인과 비즈니스 서비스 사이의 중간 계층(Adapter)으로, 입금 감지 · 출금 · 결제 · 정산 · 집금 · 대사 등 스테이블코인 관련 모든 On-chain 오퍼레이션을 처리합니다.
헥사고날 아키텍처(Ports & Adapters)를 채택하여 비즈니스 로직과 인프라를 분리하였으며, NestJS 없이 순수 TypeScript로 수동 조합(Composition Root) 방식을 사용합니다.
🏗️ 아키텍처
헥사고날 아키텍처 (Ports & Adapters Pattern)
Domain 계층은 외부에 의존하지 않으며, Port 인터페이스를 통해 인프라와 소통합니다.
🛠️ 기술 스택
| 카테고리 | 기술 | 용도 |
|---|---|---|
| 런타임 | Node.js 22 + TypeScript (strict) | 메인 런타임 |
| 메시징 | kafkajs | 비동기 이벤트 기반 통신 |
| 블록체인 | ethers.js v6 | RPC 호출, CREATE2, ABI 인코딩 |
| 데이터베이스 | better-sqlite3 | 계정/설정/아웃박스/키 관리 (4개 DB) |
| 분산 락 | Redis (ioredis) | 동시성 제어, Nonce 충돌 방지 |
| 키 관리 | NHN Cloud SKM | AES-256-GCM 암호화/복호화 |
| 로깅 | pino | 구조화 JSON 로깅 (KST) |
| 검증 | zod | 입력 스키마 유효성 검사 |
| 번들러 | ERC-4337 Bundler HTTP API | UserOperation 제출 |
🔄 전체 플로우
Adapter 서비스의 전체 메시지 흐름 개요
메시지 처리 파이프라인
데이터 포맷 변환 규칙
- 외부 → 내부
snake_caseJSON →parseJsonToCamel()→camelCase객체 - 내부 처리모든 내부 로직은
camelCase사용 - 내부 → 외부
camelCase→toSnakePayload()→snake_caseJSON
부트스트랩 시퀀스
🗺️ E2E 유즈케이스 흐름
App(프론트) → Wallet BE → BC Adapter → Blockchain 전체 흐름을 유즈케이스별로 정리
토큰 전송
이벤트 감지
Settlement 전송
ERC-20: ethCall(balanceOf)
잔액 반환특정 블록 기준
비동기 호출
💰 입금 (Deposit)
WebSocket 기반 입금 감지 및 Kafka 이벤트 발행
입금 처리 플로우
입금 메시지 스키마
| 필드 | 타입 | 설명 |
|---|---|---|
chainId | string | 체인 ID |
txHash | 0x + 64자 hex | 트랜잭션 해시 |
fromAddress | EVM 주소 | 송신자 주소 |
toAddress | EVM 주소 | 수신자 주소 |
amount | numeric string | 입금 금액 |
symbol | string(1-20) | 토큰 심볼 |
transactionDatetime | yyyyMMddHHmmss | 트랜잭션 일시 (KST) |
핵심 동작 방식
- 메시지 보장Outbox 테이블에 먼저 저장 → Kafka 발행 시도 → 성공 시 Outbox 삭제
- 발행 실패 시Outbox에 남아 5초 간격 스케줄러가 재시도 (무한 재시도, at-least-once)
- 후속 처리입금 감지 후
collectDeposit()비동기 호출 → HotWallet로 자동 집금 - 발행 토픽
adapter.deposit.detected
🔍 입금 감지 (Deposit Detection)
WebSocket 서버를 통한 실시간 입금 이벤트 수신
WebSocket 서버 설정
- Host0.0.0.0 (기본값, 환경변수
DEPOSIT_WS_HOST) - Port8080 (기본값, 환경변수
DEPOSIT_WS_PORT) - Path/ws (기본값, 환경변수
DEPOSIT_WS_PATH) - Ping 간격30초마다 ping 프레임 전송
- 비응답 처리30초 내 pong 미수신 시 연결 강제 종료
이벤트 처리 파이프라인
📤 출금 (Withdraw)
사용자 계정에서 외부 주소로 Native/ERC-20 토큰 전송
출금 처리 플로우
동시성 제어 상세
- 락 키
withdraw:nonce:{chainId}:{senderAddress} - 락 TTL30초 (자동 해제)
- 락 재시도최대 10회, 100ms 간격
- Nonce 충돌 재시도1회 (100ms 대기 후 새 Nonce 조회하여 재실행)
- Pending Nonce 대기100ms 폴링, 최대 3초 (이전 tx 컨펌 대기)
실행 분기 로직
| 조건 | 실행 경로 | 번들러 메서드 |
|---|---|---|
| Native 토큰 (ETH 등) | Native Transfer | sendNativeTransfer |
| ERC-20 토큰 (USDC 등) | ERC-20 Transfer | sendErc20Transfer |
에러 처리
💳 결제 (Payment)
HotWallet에서 Settlement 주소로 ERC-20 토큰 전송
결제 처리 플로우
핵심 처리 규칙
- 전송 주체HotWallet 주소 → Settlement 주소
- 토큰 유형ERC-20만 지원 (Native 미지원)
- 토큰 해석DB 조회 → 환경변수 폴백 → DEFAULT_DECIMALS 폴백
- 결제 체인환경변수
PAYMENT_CHAIN_NAME(기본: KCP) - 락 키 공유출금과 동일한
withdraw:nonce:프리픽스 사용 (의도적) - Nonce 재시도충돌 시 1회 재시도 (출금과 동일 로직)
🏦 정산 (Settlement)
EOFS Split을 통한 가맹점/수수료 분배
정산 처리 플로우
EOFS Split 상세
- Split 방식하나의 트랜잭션에서 가맹점 주소 + 수수료 주소로 동시 분배
- Nonce 조회블록체인 직접 ethCall (
payerNonce함수) - 번들러 Nonce와 별도 - 락 키
settlement:nonce:{chainId}:{settlementAddress} - 에러 재시도EFS024 에러 시 100ms 대기 → Nonce 재조회 → 1회 재시도
📥 정산 집금 (Settlement Collect)
외부 주소에서 HotWallet으로 자금 회수
정산 집금 처리 플로우
- 수집 방향fromAddress → HotWallet 주소로 자금 회수
- 번들러 메서드
executeEoaFundedSplit(Settlement과 동일) - 응답 토픽
adapter.settlement.collect.result
🗂️ 집금 (Collection)
사용자 계정의 입금된 토큰을 HotWallet로 수집
집금 처리 플로우
두 가지 실행 모드
| 모드 | 트리거 | 범위 |
|---|---|---|
collectDeposit | 입금 감지 후 자동 호출 | 특정 1개 토큰만 집금 |
collectAfterDeploy | 계정 배포 완료 후 자동 호출 | 해당 계정의 모든 토큰 집금 |
재시도 전략
- Native 잔액 재시도잔액이 0이면 1초 대기 후 재시도, 최대 3회
- Nonce 충돌 재시도100ms 대기 → Nonce 캐시 무효화 → 1회 재시도
- Nonce 관리인메모리 캐시 (성공: advance, 실패: invalidate)
- 에러 처리실패 시 에러를 삼키고(swallow) 로그만 기록 (비동기 호출이므로)
✅ 컨펌 (Confirm)
트랜잭션 컨펌 상태 확인 및 확정
컨펌 처리 플로우
컨펌 상태 판정 로직
| 조건 | 상태 | confirmCount | 설명 |
|---|---|---|---|
| Receipt 없음 | PENDING (TXPD) | 0 | 아직 마이닝되지 않음 |
| Receipt 있음 + status null | PENDING (TXPD) | 0 | 상태 미확정 |
| Receipt status = FAILED | BusinessError 발생 | - | 트랜잭션 실패 |
| Receipt status = SUCCESS, confirmCount > 0 | CONFIRMED (TXCF) | latest - receipt.blockNumber + 1 | 확정 완료 |
confirmCount = latestBlockNumber - receipt.blockNumber + 1체인별 최소 컨펌 수는 ConfigRegister에서 설정됩니다.
💎 잔액 조회 (Balance)
멀티 체인 Native + ERC-20 토큰 잔액 조회
잔액 조회 플로우
- 병렬 처리모든 체인 × 토큰 조합을 병렬로 조회
- Native 예외KRW 심볼은 Native 잔액 조회 제외
- ERC-20 조회
BALANCE_OF_SELECTOR+ address 인코딩으로 ethCall - 응답 포맷배열:
[{chainId, symbol, address, balance}]
📊 대사 (Reconciliation)
특정 시점의 온체인 잔액을 집계하여 대사 데이터 생성
대사 처리 플로우
핵심 로직
- 별도 프로세스메인 Adapter와 분리된 독립 Worker로 실행 (
reconcile.ts) - Consumer Group
bc-reconcile(메인과 분리) - 시점 검색KST 날짜시간 → Unix 타임스탬프 → Binary Search로 블록 번호 특정
- 배치 크기100개 주소씩 잔액 조회
- blockTag특정 블록 번호를 기준으로 point-in-time 잔액 조회
집계 그룹
| 그룹 | 대상 | 설명 |
|---|---|---|
userRaw | HotWallet | HotWallet 잔액 |
platformRaw | Settlement Fee 주소 | 플랫폼 수수료 주소 잔액 |
notDeployRaw | 미배포 계정 | 아직 배포 안 된 계정들의 잔액 합산 |
🆕 계정 생성 (Account Create)
CREATE2를 이용한 스마트 계정 주소 예측
- 주소 생성CREATE2 (factory + salt + bytecodeHash) → 결정적 주소 계산
- 병렬 처리모든 활성 체인에 대해
Promise.allSettled로 동시 계산 - 체인 선택FUJI 체인이 있으면 우선 선택, 없으면 첫 번째 체인
- 검증계산된 주소가 EVM 패턴 (
0x + 40자 hex) 일치 확인
🚀 계정 배포 (Account Deploy)
CREATE2 프록시 기반 스마트 계정 온체인 배포
- 배포 방식CREATE2 프록시 + EOFS (EOA Funded Split) 멀티스텝
- Registry 확인2초 간격 폴링, 최대 10회 (총 20초)
- 후속 처리배포 완료 후
collectAfterDeploy비동기 호출 → 모든 토큰 집금 - 중복 방지Redis 분산 락으로 동시 배포 요청 차단
🗑️ 계정 삭제 (Account Delete)
SQLite에서 계정 레코드 삭제
- 응답없음 (NoReply 패턴)
- 처리DB 삭제만 수행, 온체인 작업 없음
⚙️ 설정 등록 (Config Register)
체인/토큰 설정을 RPC로 조회하여 DB 등록
블록 타임 계산 방법
- 샘플 크기100블록 (fallback: 1블록)
- 계산
(latestTimestamp - pastTimestamp) / 100→ 초 단위 floor - FinalizedFinalized 블록 지원 시 confirmation 자동 계산, 미지원 시 1
- 응답없음 (NoReply 패턴)
📨 Kafka 토픽 목록
전체 Kafka 토픽 매핑 및 핸들러 연결
요청 토픽 (Inbound)
| 토픽 | 핸들러 | 응답 패턴 |
|---|---|---|
adapter.account.create | AccountService | Request-Reply |
adapter.account.deploy | AccountDeployService | Request-Reply |
adapter.account.delete | AccountDeleteService | NoReply |
adapter.withdraw.request | WithdrawService | Request-Reply |
adapter.payment.request | PaymentService | Request-Reply |
adapter.common.confirm | ConfirmService | Request-Reply |
adapter.balance.inquiry | BalanceService | Request-Reply |
adapter.settlement.request | SettlementService | Request-Reply |
adapter.settlement.collect.request | SettlementCollectService | Request-Reply |
adapter.config.create | ConfigRegisterService | NoReply |
adapter.reconciliation.inquiry | ReconciliationService | Request-Reply (별도 프로세스) |
응답/이벤트 토픽 (Outbound)
| 토픽 | 발행 주체 |
|---|---|
adapter.account.created | 계정 생성 완료 |
adapter.account.deployed | 계정 배포 완료 |
adapter.withdraw.result | 출금 결과 |
adapter.payment.result | 결제 결과 |
adapter.common.confirmed | 컨펌 결과 |
adapter.balance.result | 잔액 조회 결과 |
adapter.settlement.result | 정산 결과 |
adapter.settlement.collect.result | 정산 집금 결과 |
adapter.reconciliation.result | 대사 결과 |
adapter.deposit.detected | 입금 감지 이벤트 |
adapter.dlq | 파싱/유효성 에러 DLQ |
⚠️ 에러 처리
에러 계층 구조 및 처리 전략
에러 계층 구조
에러 코드 분류 (총 49개)
| 카테고리 | 설명 | 예시 코드 |
|---|---|---|
| Validation | 입력값 유효성 실패 | VALIDATION_ERROR |
| RPC | 블록체인 RPC 호출 실패 | RPC_CALL_FAILED |
| Bundler | 번들러 API 호출 실패 | BUNDLER_SEND_FAILED |
| Kafka | 메시지 발행 실패 | KAFKA_PUBLISH_FAILED |
| DB | 데이터베이스 작업 실패 | DB_QUERY_FAILED |
| Lock | 분산 락 획득/해제 실패 | LOCK_ACQUIRE_FAILED |
| KMS | 키 관리 서비스 실패 | KMS_DECRYPT_FAILED |
| Business | 비즈니스 규칙 위반 | TX_FAILED |
Kafka 에러 응답 구조
🔒 분산 락 전략
Redis 기반 분산 락과 프로세스 큐 조합
2단계 직렬화 메커니즘
서비스별 락 키 매핑
| 서비스 | 락 키 패턴 | TTL | 재시도 |
|---|---|---|---|
| Withdraw | withdraw:nonce:{chainId}:{senderAddress} | 30초 | 10회 × 100ms |
| Payment | withdraw:nonce:{chainId}:{hotWalletAddress} | 30초 | 10회 × 100ms |
| Settlement | settlement:nonce:{chainId}:{settlementAddr} | 30초 | 10회 × 100ms |
| Collection | collection:{chainId}:{address} | 30초 | 10회 × 100ms |
| AccountDeploy | deploy:{chainId}:{address} | 30초 | 10회 × 100ms |
🔢 Nonce 관리
블록체인 트랜잭션 Nonce 충돌 방지 전략
Nonce 재시도 로직 (executeWithNonceRetry)
Pending Nonce 진행 대기 (waitForPendingNonceAdvance)
- 폴링 간격100ms
- 최대 대기3,000ms (3초)
- 성공 조건currentNonce > previousNonce
- 타임아웃 시경고 로그 후 현재 Nonce로 진행 (실패하지 않음)
Nonce 충돌 감지 기준
인메모리 Nonce 캐시 (Collection 전용)
- 키 형식
{chainId}:{address} - 성공 시
advance(key)→ 캐시 +1 증가 - 실패 시
invalidate(key)→ 캐시 삭제 (다음 호출 시 재조회) - 동시 조회 방지
fetchInProgressMap으로 중복 RPC 조회 차단
📦 Outbox 패턴
메시지 발행 보장을 위한 Outbox 패턴 구현
Outbox 스케줄러
- 폴링 간격5초 (5,000ms)
- 배치 크기500건
- 재시도 횟수무한 (성공할 때까지)
- 실패 처리개별 건 실패 시 경고 로그 → 다음 폴링에서 재시도
- 보장 수준At-Least-Once (최소 1회 발행 보장)
❤️ Health Check
서비스 상태 모니터링 엔드포인트
- 엔드포인트
GET /health - Host0.0.0.0 (기본, 환경변수
HEALTH_HOST) - Port8081 (기본, 환경변수
HEALTH_PORT) - 체크 타임아웃3,000ms (환경변수
HEALTH_CHECK_TIMEOUT_MS)
점검 항목
| 컴포넌트 | 점검 방법 | 타임아웃 |
|---|---|---|
| Kafka Producer | Connected 상태 확인 | - |
| Redis | PING 명령 | 3,000ms |
| Account DB | SELECT 1 | 동기 |
| Config DB | SELECT 1 | 동기 |
| Outbox DB | SELECT 1 | 동기 |
| RPC (체인별) | getBlockNumber() | 3,000ms |
응답 코드
- 200 OK모든 컴포넌트 정상
- 503 Unavailable1개 이상 컴포넌트 이상