18. Deposit Observation Lifecycle

Incoming tx flow + DCCP(?) confirmation policy + zero-confirmation webhook
본 페이지의 출처 — 어디까지가 Fireblocks 정의이고, 어디부터가 추정인가

이 페이지의 내용은 두 갈래입니다. 미리 구분해두면 어떤 부분을 그대로 받아들이고 어떤 부분은 본인 환경에서 검증할지 판단이 쉬워집니다.

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 가 pushdestination tag/memo 까지 함께 수신
SolanagetSignaturesForAddress polling 또는 WebSocket logsSubscribefinalized(?) vs confirmed commitment 의 구분 필요
Internal transaction 감지의 어려움 (EVM 한정)

EVM 의 internal transaction (smart contract 실행 중 발생한 native transfer) 은 평범한 tx 조회로 안 보여서 별도 메커니즘이 필요합니다. 옵션:

  • Archive node 직접 운영debug_traceTransaction 또는 trace_filter RPC. 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 관찰
+ 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;
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 잔액 사용 불가.

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) 에서 우리 주소로의 입금은 두 가지 경로로 들어올 수 있습니다.

  1. 직접 송금 (native transfer) — 누군가가 자기 지갑에서 우리 주소로 직접 ETH 를 보냄. 평범한 tx, 고유한 tx hash 가 chain 에 남고 일반 tx 조회로 그대로 보입니다
  2. 간접 송금 (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)
);
컬럼자료형역할
idBINARY(16) PK사건 고유 식별자
chainVARCHAR(32)어느 blockchain 에서 관찰된 사건인지 (예: BTC, ETH, XRP). chains.id 와 매칭
block_hashVARCHAR(128)이 사건이 들어 있던 block 의 해시. reorg 발생 시 같은 height 라도 다른 block_hash 가 생김 — 그래서 height 만이 아니라 hash 까지 보존
block_heightBIGINT그 block 의 height (번호). 시간 순서 인덱싱에 사용
tx_hashVARCHAR(128) nullable그 사건이 일어난 tx 의 해시. internal transaction 의 경우 contract 호출 tx 의 hash 가 들어가고, 순수 trace-only internal 이면 NULL 가능
log_indexINT nullable한 tx 안에서 여러 event 가 발생할 때 (예: ERC-20 transfer 여러 번, internal trace 여러 단계) 순번을 구분
event_typeENUM (3 값)native-transfer = 평범한 직접 송금 / internal-transfer = smart contract 실행 중 발생한 native 전송 / erc20-transfer = token contract 의 Transfer event
to_addressVARCHAR(128)받는 주소. 우리 vault 주소 매칭은 deposit_observations.matched_address_id 에서 별도 처리
amount_rawVARCHAR(80)금액. ETH/BTC 등의 큰 수와 소수 처리를 위해 decimal string 으로 저장 (BIGINT 로는 표현 불가능)
observed_atDATETIME(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)
);
컬럼자료형역할
idBINARY(16) PK입금 관찰의 고유 식별자
chain_event_idBINARY(16) FK이 입금이 어느 raw chain 사건에서 비롯됐는지 (chain_events.id)
matched_address_idBINARY(16) nullable받는 주소가 우리 vault 의 어떤 address 와 매칭되었는지. NULL = 매칭 실패 (해당 주소가 우리 시스템 소속이 아님 — 그러나 chain_event 자체는 보관)
asset_wallet_idBINARY(16) nullable매칭된 address 가 속한 asset wallet
workspace_idBINARY(16) nullable매칭된 asset wallet 의 workspace
statusENUM (8 값)입금의 현재 처리 단계. observed 관찰 → aml-pending 심사 대기 → confirming 누적 → dccp-cleared 임계 도달 → ledger-credited 잔액 반영 완료 / aml-rejected 심사 거절 / manually-credited Admin 수동 / failed reorg 등 실패
confirmationsINT현재까지 chain 에서 누적된 confirmation 횟수. block 이 새로 쌓일 때마다 +1
required_confirmationsINTDCCP 정책이 이 chain 에 대해 정한 임계 (예: BTC 3, ETH 12)
manual_credit_actor_user_idBINARY(16) nullableAdmin 이 수동 확정 처리한 경우 그 Admin 의 user id. 자동 확정이면 NULL
cleared_atDATETIME(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)
);
컬럼자료형역할
idBINARY(16) PK전송 기록의 고유 식별자
deposit_observation_idBINARY(16) FK어느 입금에 대한 알림인지
notification_typeENUM (4 값)알림 종류. zero-confirmation 운영에서는 같은 입금에 대해 blockchain-appearancefirst-confirmationadditional-confirmationcompletion 순으로 여러 번 발송될 수 있음
delivered_atDATETIME(6)해당 알림이 우리 시스템에 도착한 시각
UNIQUE (deposit_observation_id, notification_type)같은 입금에 대해 같은 종류 알림이 중복 INSERT 되는 것을 막아 idempotency 강제. 같은 webhook 이 두 번 와도 두 번째는 거부

Reorg (블록 재구성) 처리 — schema 가 반드시 표현해야 할 path

입금이 block 에 한 번 들어왔다고 끝이 아닙니다. 짧은 시간 안에 그 block 이 다른 chain branch 에 밀려 사라지면 (reorg) 입금도 같이 사라져야 합니다. DCCP 가 충분히 보수적인 confirmation 횟수를 요구하면 실제 발생 빈도는 극히 낮지만, schema 가 이 시나리오를 표현하지 못하면 발생 시 정정할 방법이 없습니다.

chain 별 reorg 위험도 — DCCP 설정의 근거
  • 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 처리는 세 단계로:

  1. chain_events 는 절대로 UPDATE/DELETE 안 함 (append-only(?)). reorg 가 발생해도 이전 row 는 그대로 두고, "그 block 이 무효화되었다" 는 사실을 새 row 로 INSERT (reversing event). 즉 사건의 역사 자체가 남도록
  2. deposit_observations.status 전이: 영향받은 입금 row 의 status 를 confirmingfailed 로 UPDATE. status 컬럼은 set-once(?) 가 아니어서 변경 가능 (단 cleared_at 같은 set-once 컬럼은 그대로 유지)
  3. 이미 ledger credit 된 경우 reversal entry: 잔액 반영까지 진행됐던 입금이라면 ledger 평면에 reversal 회계 entry 를 발행해서 잔액을 원상복구. 단순 UPDATE 가 아니라 +N 입금에 대응하는 -N reversal 을 새 ledger row 로 추가 (append-only 회계). → 24. Storage Discipline 의 append-only/set-once 규율