18. Deposit Observation Lifecycle
Incoming tx flow + DCCP(?) confirmation policy + zero-confirmation webhook이 페이지의 내용은 두 갈래입니다. 미리 구분해두면 어떤 부분을 그대로 받아들이고 어떤 부분은 본인 환경에서 검증할지 판단이 쉬워집니다.
Fireblocks 공식 문서에 정의된 사실 — 그대로 받아들여도 안전
- 입금 tx 가 거치는 상태의 이름과 의미 (Pending AML Screening · Confirming · Completed · Rejected …)
- DCCP (Deposit Control and Confirmation Policy) — 입금을 몇 번 확정해야 사용 가능한지 정하는 정책
- zero-confirmation 입금 에 대한 webhook 이 여러 번 발송될 수 있다 는 규칙
- "입금을 수동으로 확정 처리" 하는 권한은 Admin 만 가진다는 점
저자가 합리적으로 추정해서 그린 설계 + 일반 운영 패턴 — 참고용, 본인 환경에서 검증 권장
- 위 상태들을 실제 DB 의 어떤 테이블·컬럼 으로 영속화할지의 구체 schema
- 잔액을 ledger 에 반영하는 정확한 시점 (Confirming 의 어느 순간 — Fireblocks 자료에 명시 없음)
- reorg(?) (블록 재구성) 가 일어났을 때 이미 기록한 입금 row 를 어떻게 정정할지
- 직접 chain 관찰 메커니즘 — EVM
eth_getLogs, UTXO block scan, XRP/XLM WebSocket subscription 같은 chain 별 RPC 패턴 (각 blockchain 의 공개 표준이지 Fireblocks-specific 은 아님)
Chain 관찰 — 입금을 우리 시스템이 어떻게 알아채는가
아래 흐름의 시작점인 "chain 관찰" 이 실제로 어떻게 일어나는지. 우리 vault 주소로 자금이 들어왔다는 사실을 시스템이 인지하는 방법은 크게 두 갈래입니다.
접근 1 — Custody SaaS (Fireblocks 등) 가 보내는 알림 받기
Fireblocks 같은 custody SaaS 를 사용하면 chain 관찰을 SaaS 가 대신합니다. 우리 시스템은 HTTPS endpoint 하나만 열어두고 SaaS 가 보내는 webhook (알림 callback) 을 받으면 됩니다.
- 장점: chain 별 node 인프라 운영 부담이 없음. SaaS 가 지원하는 모든 chain 을 사실상 동시에 지원
- 단점: SaaS 의존, 비용, 실시간성이 SaaS 의 webhook 발송 정책에 종속
- 주의: webhook 이 같은 입금에 대해 여러 번 올 수 있음 (zero-confirmation 정책 시) — 아래 "Zero-Confirmation 입금에 대한 다중 webhook" 절의 idempotency 처리 필수
접근 2 — 직접 chain 관찰 (자체 node + 인덱싱)
자체 운영하는 경우 chain 마다 다른 감지 메커니즘이 필요합니다. blockchain 마다 데이터 모델과 RPC(?) API 가 다르기 때문에:
| chain 모델 | 대표 감지 메커니즘 | 특이 사항 |
|---|---|---|
| EVM (Ethereum 계열) | 새 block 마다 eth_getLogs (ERC-20 Transfer event filter) + eth_getBlockByNumber (native transfer 스캔) | 우리 주소가 to/from 인 tx 와 ERC-20 Transfer event 를 동시에 추적 |
| UTXO(?) (Bitcoin 계열) | block 마다 tx output 스캔 + 우리 주소가 output 으로 등장하는 tx 매칭 | 주소 단위 push 알림이 표준화 안 됨 → block 전체 훑기 또는 ZMQ subscription |
| Account + tag (XRP / XLM) | WebSocket account subscription — 우리 주소로의 incoming payment 가 발생할 때마다 server 가 push | destination tag/memo 까지 함께 수신 |
| Solana | getSignaturesForAddress polling 또는 WebSocket logsSubscribe | finalized(?) vs confirmed commitment 의 구분 필요 |
Internal transaction 감지의 어려움 (EVM 한정)
EVM 의 internal transaction (smart contract 실행 중 발생한 native transfer) 은 평범한 tx 조회로 안 보여서 별도 메커니즘이 필요합니다. 옵션:
- Archive node 직접 운영 →
debug_traceTransaction또는trace_filterRPC. node 가 무겁고 운영 비용 큼 - Indexer 서비스 위임 — Alchemy / QuickNode / Infura 등 외부 RPC provider 의 trace API. 운영 부담 ↓, 외부 의존 ↑
- Subgraph / Indexer 데이터 — The Graph 같은 외부 인덱싱 서비스가 미리 추출해둔 데이터 조회
자세히는 아래 "EVM 의 internal transaction" 절.
관찰된 raw 사건이 schema 에 들어가는 시점
어떤 접근을 쓰든, 관찰된 raw 사건은 아래의 chain_events 테이블에 append-only 로 INSERT 됩니다 — 이 시점이 다음 상태 흐름 도식의 출발점 ([*] 노드). 매칭 / 잔액 반영 / DCCP 확인은 그 이후의 deposit_observations row 에서 단계적으로 일어납니다.
★ 위 표의 chain 별 RPC API 패턴은 각 blockchain 의 공개 표준 (public domain). Fireblocks 자료 attested 가 아니라 일반적인 운영 패턴 — 직접 구축할 때 참고용.
Incoming Transaction Flow — 입금 상태 흐름
외부에서 누군가가 우리 vault 의 주소로 입금한 tx 를 우리 시스템이 관찰한 이후, 어떤 단계를 거쳐 "이제 사용해도 안전" 한 확정 상태로 보내는지의 흐름. 각 상태의 이름과 의미는 Fireblocks 공식 정의를 그대로 따랐고, 그 사이를 잇는 전이 (어디서 어디로 갈 수 있는지) 는 Fireblocks 문서에 그림으로 그려져 있지 않아 각 상태의 설명을 종합해서 그렸습니다.
stateDiagram-v2 direction LR state "🔍 AML 심사 대기\n(PENDING_AML_SCREENING)" as Aml state "📨 외부 확인 대기\n(PENDING_3RD_PARTY)\nTravel Rule 등" as Trd state "⏳ confirmation 누적\n(CONFIRMING)\nblockchain 확인 횟수 채우는 중" as Conf state "✅ 입금 확정\n(COMPLETED)\n자금 사용 가능" as Done state "🚫 차단\n(REJECTED)\nAdmin 수동 해제 필요" as Rej state "❌ 실패\n(FAILED)\nreorg 등" as Fail [*] --> Aml : chain 관찰Figure 10. 입금 상태 흐름. 노란 박스 = 진행 중 / 초록 = 확정 성공 / 빨강 = 차단 또는 실패. 핵심 전이 2 가지: ① AML/KYC 사용 여부가 시작 분기를 가른다 (사용 시 PENDING_AML_SCREENING → … → CONFIRMING, 미사용 시 바로 CONFIRMING). ② CONFIRMING → COMPLETED 는 DCCP (Deposit Control and Confirmation Policy) 가 정한 chain confirmation 횟수에 도달하는 순간 일어남. REJECTED 는 입금 케이스에서 특히 강한 의미 — 자산은 chain 상 우리 주소에 들어와 있지만 Admin 이 수동으로 unfreeze 하기 전까지 wallet 잔액 사용 불가.
+ AML/KYC 사용 [*] --> Conf : chain 관찰
+ AML/KYC 미사용 Aml --> Rej : AML 심사 거절 Aml --> Trd : Travel Rule 필요 Trd --> Conf : 외부 확인 통과 Conf --> Done : DCCP 임계 도달 Conf --> Rej : API 로 freeze 요청 Conf --> Fail : reorg / 처리 실패 Done --> [*] Rej --> [*] Fail --> [*] classDef good fill:#dcfce7,stroke:#16a34a,stroke-width:2px; classDef bad fill:#fee2e2,stroke:#dc2626,stroke-width:2px; classDef wait fill:#fef3c7,stroke:#d97706; class Done good; class Rej,Fail bad; class Aml,Trd,Conf wait;
DCCP — 입금/출금에 필요한 confirmation 횟수 정책
Bitcoin block 이 1 개 채굴되었다고 입금이 "확정" 일까요? 아닙니다. 짧은 시간 안에 그 block 이 다른 chain branch 에 밀려 사라지면 (reorg) 입금도 같이 사라지므로, 그 위에 다음 block 들이 어느 정도 쌓이는 (= confirmation 누적) 시점까지 기다려야 안전합니다. DCCP (Deposit Control and Confirmation Policy) 가 chain 마다 그 "몇 번 confirm 되면 확정으로 보겠다" 의 횟수를 정합니다.
- 확정 전 (lock 상태): 입금된 자산은 wallet 잔액으로 보이지만 사용 불가. 출금이나 다른 거래의 input 으로 못 씁니다
- 확정 후 (DCCP 임계 도달): 잔액이 사용 가능 상태로 전환. UTXO(?) 기반 chain 은 그 output 을 새 tx 의 input 으로 즉시 사용 가능
- 출금 측에도 동일 정책: 우리 출금 tx 가 chain 에서 N 번 confirm 될 때까지 "송금 완료" 처리 안 함 (incoming 과 outgoing 양쪽에 적용)
- 예외적으로 정책을 chain 별 customize 하거나 override 하는 메커니즘이 별도 존재 (본 문서 범위 밖)
Zero-Confirmation 입금에 대한 다중 webhook
DCCP 가 "0 confirmation" 으로 설정된 chain — 즉 block 에 등장하자마자 입금 확정으로 처리하는 운영 — 의 경우, Fireblocks 가 같은 입금에 대해 여러 번 알림 (webhook) 을 보낼 수 있습니다. 예:
- block 에 처음 등장한 순간 — "blockchain appearance"
- 첫 번째 confirmation 누적 — "first confirmation"
- 그 이후 추가 confirmation — "additional"
같은 입금을 여러 번 처리하면 잔액이 중복 반영될 수 있습니다. DB 측은 같은 알림이 두 번 와도 한 번만 효력 발생하도록 중복 방지 (idempotency) 가 필요. 아래 deposit_webhook_deliveries 테이블의 UNIQUE KEY (deposit_observation_id, notification_type) 이 그 역할을 합니다.
수동 입금 확정 — Admin 권한
자동 확정 흐름이 막히는 케이스가 있습니다 — 예: DCCP 임계까지 도달했지만 시스템 버그로 자동 ledger 반영이 안 됨 · AML 심사가 false positive 로 묶임 · 비표준 입금 패턴이라 자동 address 매칭이 실패. 이런 비정상 케이스를 위해 Admin 만 사용할 수 있는 "수동 확정 (manually confirm and credit inbound)" 권한 이 별도로 정의되어 있습니다. Admin 이 자기 책임으로 그 입금을 정상 잔액으로 처리하는 path. DB 에는 어느 Admin 이 수동 처리했는지 (deposit_observations.manual_credit_actor_user_id) 가 반드시 남아 감사에 쓰입니다.
EVM(?) 의 internal transaction — "안 보이는" 입금
Ethereum 계열 (EVM) 에서 우리 주소로의 입금은 두 가지 경로로 들어올 수 있습니다.
- 직접 송금 (native transfer) — 누군가가 자기 지갑에서 우리 주소로 직접 ETH 를 보냄. 평범한 tx, 고유한 tx hash 가 chain 에 남고 일반 tx 조회로 그대로 보입니다
- 간접 송금 (internal transaction) — 누군가가 smart contract 의 함수를 호출하고, 그 contract 가 실행 도중에 우리 주소로 ETH 를 전송. 이 경우 우리 주소를 직접 가리키는 tx 가 chain 에 없습니다 — contract 호출 tx 의 실행 trace 안에서 일어난 native transfer 일 뿐이라 별도의 tx hash 가 없고, 평범한 tx 조회로는 안 보입니다 ("internal" 이라는 이름의 이유)
Fireblocks 는 모든 EVM chain 의 1번 (직접 송금) 은 알아챕니다. 그러나 2번 (internal transaction) 까지 자동 감지해서 알림 보내는 chain 은 일부에 한정 (Ethereum · Optimism · Arbitrum · Avalanche · Base · Celo 등). Fireblocks 가 정확히 어떤 메커니즘 (trace API / archive node / 다른 방식) 으로 감지하는지는 공식 문서에 명시되어 있지 않습니다.
본 schema 의 chain_events.event_type ENUM 에 'internal-transfer' 가 'native-transfer' 와 별도 값으로 있는 이유 — 이 두 종류를 구분해서 영속화할 수 있도록 하기 위함.
DB Schema
입금 관찰은 세 테이블의 협업으로 모델링합니다. 위 흐름의 각 단계가 DB 의 어떤 row 와 컬럼으로 영속화되는지 1:1 매핑됩니다.
4.1 chain_events — chain 에서 관찰한 raw 사건 (append-only)
-- ★ append-only — 한 번 INSERT 된 row 는 UPDATE / DELETE 절대 불가
CREATE TABLE chain_events (
id BINARY(16) PRIMARY KEY,
chain VARCHAR(32) NOT NULL,
block_hash VARCHAR(128) NOT NULL,
block_height BIGINT NOT NULL,
tx_hash VARCHAR(128), -- internal tx 는 parent tx hash, 순수 internal-only 라 없으면 NULL
log_index INT, -- 같은 tx 안의 event 순번 또는 internal trace 위치
event_type ENUM('native-transfer', 'internal-transfer', 'erc20-transfer') NOT NULL,
to_address VARCHAR(128) NOT NULL,
amount_raw VARCHAR(80) NOT NULL, -- 큰 수 표현 위해 decimal string
observed_at DATETIME(6) NOT NULL,
KEY (chain, block_height),
KEY (to_address),
UNIQUE KEY (chain, tx_hash, log_index)
);
| 컬럼 | 자료형 | 역할 |
|---|---|---|
id | BINARY(16) PK | 사건 고유 식별자 |
chain | VARCHAR(32) | 어느 blockchain 에서 관찰된 사건인지 (예: BTC, ETH, XRP). chains.id 와 매칭 |
block_hash | VARCHAR(128) | 이 사건이 들어 있던 block 의 해시. reorg 발생 시 같은 height 라도 다른 block_hash 가 생김 — 그래서 height 만이 아니라 hash 까지 보존 |
block_height | BIGINT | 그 block 의 height (번호). 시간 순서 인덱싱에 사용 |
tx_hash | VARCHAR(128) nullable | 그 사건이 일어난 tx 의 해시. internal transaction 의 경우 contract 호출 tx 의 hash 가 들어가고, 순수 trace-only internal 이면 NULL 가능 |
log_index | INT nullable | 한 tx 안에서 여러 event 가 발생할 때 (예: ERC-20 transfer 여러 번, internal trace 여러 단계) 순번을 구분 |
event_type | ENUM (3 값) | native-transfer = 평범한 직접 송금 / internal-transfer = smart contract 실행 중 발생한 native 전송 / erc20-transfer = token contract 의 Transfer event |
to_address | VARCHAR(128) | 받는 주소. 우리 vault 주소 매칭은 deposit_observations.matched_address_id 에서 별도 처리 |
amount_raw | VARCHAR(80) | 금액. ETH/BTC 등의 큰 수와 소수 처리를 위해 decimal string 으로 저장 (BIGINT 로는 표현 불가능) |
observed_at | DATETIME(6) | 우리 시스템이 이 사건을 관찰해서 row 를 만든 시각 (chain 의 block timestamp 와는 별개) |
UNIQUE (chain, tx_hash, log_index) | — | 같은 chain 에서 같은 (tx, log_index) 조합으로 중복 INSERT 방지 |
4.2 deposit_observations — chain_event 를 우리 vault 의 입금으로 해석한 row
CREATE TABLE deposit_observations (
id BINARY(16) PRIMARY KEY,
chain_event_id BINARY(16) NOT NULL, -- FK → chain_events.id
matched_address_id BINARY(16), -- 우리 vault 의 address (NULL = 매칭 실패)
asset_wallet_id BINARY(16), -- 어느 asset wallet 의 입금인지
workspace_id BINARY(16), -- 어느 workspace 에 속하는지
status ENUM('observed','aml-pending','aml-rejected','confirming','dccp-cleared','ledger-credited','manually-credited','failed') NOT NULL,
confirmations INT NOT NULL DEFAULT 0, -- 현재까지 누적된 confirmation 횟수
required_confirmations INT NOT NULL, -- DCCP 가 정한 임계
manual_credit_actor_user_id BINARY(16), -- 수동 확정 시 Admin 의 id
cleared_at DATETIME(6), -- ★ set-once — 확정 시각, 한 번 set 후 변경 불가
KEY (workspace_id, status),
KEY (matched_address_id)
);
| 컬럼 | 자료형 | 역할 |
|---|---|---|
id | BINARY(16) PK | 입금 관찰의 고유 식별자 |
chain_event_id | BINARY(16) FK | 이 입금이 어느 raw chain 사건에서 비롯됐는지 (chain_events.id) |
matched_address_id | BINARY(16) nullable | 받는 주소가 우리 vault 의 어떤 address 와 매칭되었는지. NULL = 매칭 실패 (해당 주소가 우리 시스템 소속이 아님 — 그러나 chain_event 자체는 보관) |
asset_wallet_id | BINARY(16) nullable | 매칭된 address 가 속한 asset wallet |
workspace_id | BINARY(16) nullable | 매칭된 asset wallet 의 workspace |
status | ENUM (8 값) | 입금의 현재 처리 단계. observed 관찰 → aml-pending 심사 대기 → confirming 누적 → dccp-cleared 임계 도달 → ledger-credited 잔액 반영 완료 / aml-rejected 심사 거절 / manually-credited Admin 수동 / failed reorg 등 실패 |
confirmations | INT | 현재까지 chain 에서 누적된 confirmation 횟수. block 이 새로 쌓일 때마다 +1 |
required_confirmations | INT | DCCP 정책이 이 chain 에 대해 정한 임계 (예: BTC 3, ETH 12) |
manual_credit_actor_user_id | BINARY(16) nullable | Admin 이 수동 확정 처리한 경우 그 Admin 의 user id. 자동 확정이면 NULL |
cleared_at | DATETIME(6) nullable | 입금이 확정 처리된 시각. set-once — 한 번 set 후 UPDATE 불가 |
4.3 deposit_webhook_deliveries — webhook 중복 방지 (idempotency)
CREATE TABLE deposit_webhook_deliveries (
id BINARY(16) PRIMARY KEY,
deposit_observation_id BINARY(16) NOT NULL, -- FK → deposit_observations.id
notification_type ENUM('blockchain-appearance','first-confirmation','additional-confirmation','completion') NOT NULL,
delivered_at DATETIME(6) NOT NULL,
UNIQUE KEY (deposit_observation_id, notification_type)
);
| 컬럼 | 자료형 | 역할 |
|---|---|---|
id | BINARY(16) PK | 전송 기록의 고유 식별자 |
deposit_observation_id | BINARY(16) FK | 어느 입금에 대한 알림인지 |
notification_type | ENUM (4 값) | 알림 종류. zero-confirmation 운영에서는 같은 입금에 대해 blockchain-appearance → first-confirmation → additional-confirmation → completion 순으로 여러 번 발송될 수 있음 |
delivered_at | DATETIME(6) | 해당 알림이 우리 시스템에 도착한 시각 |
UNIQUE (deposit_observation_id, notification_type) | — | 같은 입금에 대해 같은 종류 알림이 중복 INSERT 되는 것을 막아 idempotency 강제. 같은 webhook 이 두 번 와도 두 번째는 거부 |
Reorg (블록 재구성) 처리 — schema 가 반드시 표현해야 할 path
입금이 block 에 한 번 들어왔다고 끝이 아닙니다. 짧은 시간 안에 그 block 이 다른 chain branch 에 밀려 사라지면 (reorg) 입금도 같이 사라져야 합니다. DCCP 가 충분히 보수적인 confirmation 횟수를 요구하면 실제 발생 빈도는 극히 낮지만, schema 가 이 시나리오를 표현하지 못하면 발생 시 정정할 방법이 없습니다.
- BTC: 6 confirmation 이후 reorg 는 역사적으로 거의 발생 안 함 — DCCP 3~6 가 일반적
- ETH: 짧은 fork 가 12 confirmation 이내까지 가능 — DCCP 12~32 권장
- L2 (Arbitrum / Optimism / Base): sequencer rollback 위험이 별도 — 단순 block confirmation 이외의 finality 신호 필요
- Solana: fork 가 자주 발생하지만 짧은 시간 안에 정착 — finalized commitment 까지 대기
Schema 차원의 reorg 처리는 세 단계로:
chain_events는 절대로 UPDATE/DELETE 안 함 (append-only(?)). reorg 가 발생해도 이전 row 는 그대로 두고, "그 block 이 무효화되었다" 는 사실을 새 row 로 INSERT (reversing event). 즉 사건의 역사 자체가 남도록deposit_observations.status전이: 영향받은 입금 row 의 status 를confirming→failed로 UPDATE. status 컬럼은 set-once(?) 가 아니어서 변경 가능 (단cleared_at같은 set-once 컬럼은 그대로 유지)- 이미 ledger credit 된 경우 reversal entry: 잔액 반영까지 진행됐던 입금이라면 ledger 평면에 reversal 회계 entry 를 발행해서 잔액을 원상복구. 단순 UPDATE 가 아니라 +N 입금에 대응하는 -N reversal 을 새 ledger row 로 추가 (append-only 회계). → 24. Storage Discipline 의 append-only/set-once 규율