19. Withdrawal Lifecycle

출금 tx 가 거치는 상태 흐름 · 운영 액션 · chain 별 특이 제약
본 페이지의 출처 — 어디까지가 Fireblocks 정의이고, 어디부터가 추정인가

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

Fireblocks 공식 문서에 정의된 사실 — 그대로 받아들여도 안전

  • 출금 tx 가 거치는 상태 (17 status) 의 이름과 의미 (Submitted · Pending Authorization · Pending Signature · Broadcasting · Confirming · Completed 등)
  • 운영 액션 의 적용 가능 상태 — cancel · retry · boost · drop · rescreen · bypass · dismiss · edit notes
  • 시간 제약 — Pending Authorization / Pending Signature 의 2 시간 타임아웃, Cancelling 30 초, Broadcasting 약 1 분
  • chain 별 특이 제약 — EVM nonce 순차성 · BTC 25-tx unconfirmed chain · Solana per-vault 5 + workspace 600 · Algorand 50 분 signing window · Tezos 30 분 · Polkadot 2 시간 validity
  • Solana Sign-Only 경로 (NOT BROADCAST BY FIREBLOCKS 태그) · Raw Signing 의 cached signature 경로

저자가 합리적으로 추정해서 그린 설계 — 참고용, 본인 환경에서 검증 권장

  • withdrawals · broadcast_attempts · confirmations · withdrawal_operations 의 구체 schema
  • chain validity 만료 임박을 어떻게 alert 할지의 threshold
  • round-robin vault selector 의 application-layer 알고리즘 (DB 가 아닌 코드 영역)
  • reorg 발생 시 이미 confirmed 된 출금을 어떻게 정정할지의 schema 표현

Withdrawal = Outgoing Transaction

Withdrawal (출금) 은 transaction state machine 의 출금 측면 (outgoing) 의 표현. 14. Transaction State 가 다루는 17 status state machine 중 출금 경로를 customer 가 보는 관점에서 정리한 것입니다 — 본 페이지는 흐름 + 운영 액션 + chain 별 특이 제약을 중심으로.

Outgoing Transaction Flow — 출금 상태 흐름

고객이 출금 요청을 누르고 자금이 외부 주소에 도달하기까지 출금 tx 가 거치는 단계. 각 상태의 이름과 의미는 Fireblocks 공식 정의를 그대로 따랐고, 그 사이 전이 (어디서 어디로 갈 수 있는지) 는 17 status 의 각 상태 설명을 종합해서 그렸습니다.

stateDiagram-v2
  direction LR

  state "📝 출금 요청 접수\n(SUBMITTED)" as Sub
  state "🔍 AML 심사 대기\n(PENDING_AML_SCREENING)" as Aml
  state "🛡️ Approver 승인 대기\n(PENDING_AUTHORIZATION)\n2h 안에 승인 필요" as Auth
  state "✍️ 서명 대기\n(PENDING_SIGNATURE)\n2h 안에 서명 필요" as Sig
  state "📡 blockchain 송신 중\n(BROADCASTING)\n약 1분" as Bcast
  state "⏳ confirmation 누적\n(CONFIRMING)" as Conf
  state "✅ 출금 확정\n(COMPLETED)" as Done
  state "🚫 Policy 차단\n(BLOCKED)" as Blk
  state "🚫 AML 거절\n(REJECTED)" as Rej
  state "↩️ 취소\n(CANCELLED)" as Cancel
  state "❌ 실패\n(FAILED)" as Fail
  state "🖊️ 서명만 반환\n(SIGNED)\nSolana Sign-Only" as Signed

  [*] --> Sub : AML 미사용
  [*] --> Aml : AML 사용
  Aml --> Auth : 심사 통과
  Aml --> Rej : 심사 거절
  Sub --> Auth
  Auth --> Sig : Approver 승인
  Auth --> Blk : Policy 차단
  Auth --> Fail : 2h timeout
  Sig --> Bcast : 서명 완료
+ broadcast Sig --> Signed : Sign-Only
(고객이 broadcast) Sig --> Cancel : 사용자 취소 Sig --> Fail : 2h timeout Bcast --> Conf : chain 진입 Bcast --> Cancel : 사용자 취소 Conf --> Done : DCCP 임계 도달 Conf --> Fail : reorg / 실패 Done --> [*] Cancel --> [*] Fail --> [*] Blk --> [*] Rej --> [*] Signed --> [*] classDef good fill:#dcfce7,stroke:#16a34a,stroke-width:2px; classDef bad fill:#fee2e2,stroke:#dc2626,stroke-width:2px; classDef wait fill:#fef3c7,stroke:#d97706; classDef special fill:#e0e7ff,stroke:#6366f1; class Done good; class Blk,Rej,Cancel,Fail bad; class Sub,Aml,Auth,Sig,Bcast,Conf wait; class Signed special;
Figure 11. 출금 상태 흐름. 노란 박스 = 진행 중 / 초록 = 정상 완료 / 빨강 = 차단·취소·실패 / 파랑 = Solana Sign-Only 특수 종착. 정상 경로는 요청 → (AML) → Approver 승인 → 서명 → 송신 → confirmation 누적 → 확정. 곳곳에 두 시간 타임아웃 (Auth · Sig) 과 사용자 취소 가능 구간 (Bcast 이전까지) 이 존재. Solana 의 Sign-Only 운영은 Fireblocks 가 broadcast 하지 않고 서명된 payload 를 고객에게 반환해서 고객이 직접 broadcast 하는 분기 (특수 종착 SIGNED).

운영 액션 — 어느 상태에서 무엇이 가능한가

출금 진행 도중 운영자/사용자가 시스템에 가할 수 있는 action 의 종류와 그 action 이 허용되는 상태:

액션가능한 상태설명
Cancel (취소)BROADCASTING 이전까지아직 blockchain 으로 송신되지 않은 시점이라면 출금을 취소 가능. broadcast 이후에는 chain 에 이미 들어가 있어 취소 못 함
Retry (재시도)FAILED 이후실패한 출금을 같은 내용으로 다시 시도. 새 tx 가 아니라 같은 withdrawal 의 재시도로 추적
Boost / Drop (EVM(?))BROADCASTINGEVM 에서 gas 가 부족해 stuck 된 tx 의 gas parameter 를 올려서 (boost) 또는 줄여서 (drop) 동일 nonce 로 교체 송신 — Replace-By-Fee(?) (RBF) 라고 부름
Boost UTXO(?) txBROADCASTINGBitcoin 의 RBF — 같은 input 을 쓰는 새 tx 를 더 높은 fee 로 송신해서 mempool 의 기존 tx 를 대체
Rescreen / Bypass AML(?)PENDING_AML_SCREENING 또는 AML 거절 후Rescreen 은 AML 심사를 다시 돌리는 것 (false positive 의심 시), Bypass 는 Admin 권한으로 심사를 우회해서 강제 진행하는 것
Dismiss완료/실패된 출금 카드를 UI 상에서만 숨김 처리. DB 의 상태는 그대로
Edit transaction notes모든 상태운영 메모 (transaction notes) 만 수정. tx 자체에는 영향 없음

Chain 별 출금 특이 제약

같은 "출금" 인데 chain 마다 정체·실패 요인이 다릅니다. 각 chain 이 어떤 운영 제약을 가지는지를 알면 vault 수와 timeout 설계에 반영할 수 있습니다.

EVM (Ethereum · Polygon · Arbitrum 등)
같은 주소에서 출금하는 tx 는 0, 1, 2, 3 … 순서 번호 (nonce(?)) 를 반드시 순서대로 처리합니다. 한 tx 가 stuck 되면 (수수료 부족 등) 그 뒤 번호의 tx 가 전부 대기 상태로 묶입니다. 출금 vault 가 하나라면 그 vault 의 출금 전체가 멈춤.
출금 전용 vault 를 여러 개 두고 돌아가며 사용 (round-robin).
Bitcoin
아직 block 에 확정되지 않은 (unconfirmed) tx 는 Bitcoin 의 mempool 에 쌓이는데, 한 tx 의 잔돈 (change) 을 다음 tx 의 input 으로 이어 쓰는 chain 이 25 개에 도달하면 26 번째 tx 는 네트워크가 거부합니다 (Bitcoin Core 기본 설정(?)).
출금 vault 를 여러 개로 분산.
Solana
두 단계 제약:
  • vault 한 개당 동시 5 tx: 6 번째 출금 요청은 "Submitted" 상태로 줄을 서고, 최대 2 시간 안에 처리되지 않으면 자동 취소
  • workspace 전체에서 동시 600 tx: workspace 안의 모든 vault 의 진행 중 tx 합이 600 개를 넘으면 추가 요청 거부
EVM/BTC 와 결정적으로 다른 점 — 5 를 초과해도 막히는 게 아니라 기다리는 상태입니다 (앞 tx 가 끝나면 자동으로 다음 차례). 즉 출금 vault 1 개로도 충분히 운영 가능. 짧은 시간에 한꺼번에 많은 출금이 몰리는 경우에만 vault 여러 개로 분산이 의미 있음.
Algorand
서명 후 broadcast 까지의 유효 시간 (signing window) 이 약 50 분. 이 시간 안에 chain 에 진입하지 않으면 tx 가 무효화됩니다. Approver 승인이 늦어지면 chain 측 유효 시간이 먼저 만료되는 경합 위험.
Tezos
서명 후 유효 시간이 약 30 분 + 한 계정의 mempool 에 동시에 1 tx 만 허용. 두 번째 출금은 첫 번째가 block 에 들어갈 때까지 대기. Algorand 보다도 빡빡한 운영 제약.
Polkadot
tx 의 유효 시간이 2 시간. Algorand/Tezos 보다는 여유가 있지만 여전히 Approval workflow 와 경합 가능.

Approval 흐름 vs chain 유효 시간 — 경합 가능성

⚠️ 짧은 chain validity 와 긴 approval workflow 의 충돌

위 표에서 본 것처럼 Algorand 50 분, Tezos 30 분, Polkadot 2 시간 같은 chain 들은 tx 의 유효 시간이 매우 짧습니다. 반면 Admin Quorum(?) 의 일반 approval workflow 는 최대 7 일 까지 기다릴 수 있습니다.

이 차이가 문제를 만듭니다 — Approver 가 승인할 때쯤이면 이미 chain 측 유효 시간이 만료되어 출금이 fail 처리될 수 있음. 특히 야간/주말에 들어온 출금 요청은 다음 영업 시간에 승인되는데 그동안 chain 유효 시간이 다 흘러가 버립니다.

DB 측 대응:

  • withdrawals.chain_validity_expires_at 컬럼에 그 출금의 chain 측 만료 시각 기록
  • 임박 (예: 만료 15 분 전) 시 운영 alert 발송 → Approver 가 우선 처리하도록 push
  • 만료된 출금은 자동 fail 처리 후 같은 의도의 새 출금 재생성 (retry 액션) 안내

Solana Sign-Only — Fireblocks 가 broadcast 안 하는 특수 경로

일반 출금은 Fireblocks 가 서명 후 직접 blockchain 에 broadcast 합니다. 그러나 Solana 의 Sign-Only 운영은 다릅니다 — Fireblocks 는 서명까지만 하고, 서명된 payload 를 고객 시스템에 그대로 반환합니다. 고객이 자기 인프라로 직접 broadcast.

왜? — 고객 측 dApp 이나 거래소 통합에서 broadcast 타이밍이나 nonce/fee 계산을 자체 제어해야 하는 경우. 그런 운영에 대비한 분기.

  • 이 경로의 출금은 NOT BROADCAST BY FIREBLOCKS 태그가 자동으로 붙음 — 운영팀이 즉시 식별 가능
  • 이후 on-chain 에서 그 tx 가 관찰되면 Completed 로, 관찰 안 되면 (고객이 broadcast 안 했거나 실패) Failed 로 전이
  • Solana 자체의 signing window 안에 broadcast 가 일어나지 않으면 자동 무효 처리

Raw Signing — broadcast 자체가 없는 경로

또 다른 특수 경로. Fireblocks 가 단순히 "raw data 에 서명" 만 해주고 broadcast 는 아예 안 합니다. 임의의 메시지 (예: 인증용 challenge, 외부 시스템과의 handshake) 에 서명을 요청하는 use case.

  • 일반 raw signing: Pending Signature → (broadcast 단계 없음) → Completed
  • Cached signature: 같은 raw data 에 대한 서명이 이미 캐시에 있으면 전체 서명 ceremony 를 우회하고 Submitted → Completed 로 즉시 점프. 인증 challenge 같이 동일 데이터에 반복 서명이 필요한 시나리오 최적화

DB Schema

출금은 네 테이블의 협업으로 모델링합니다. 위 흐름의 각 단계가 DB 의 어떤 row 와 컬럼으로 영속화되는지 1:1 매핑됩니다.

4.1 withdrawals — 출금 본체

CREATE TABLE withdrawals (
  -- transaction 의 sub-aggregate. 1 출금 = 1 transaction 의 outgoing 측면.
  id                  BINARY(16) PRIMARY KEY,
  transaction_id      BINARY(16) NOT NULL UNIQUE,        -- transaction 본체 (sm-transaction.html)
  withdrawal_vault_id BINARY(16) NOT NULL,               -- 어느 출금 vault 에서 나가는지
  destination_type    ENUM('vault','exchange','whitelisted','one-time','network'),
  destination_address VARCHAR(128) NOT NULL,
  tag_or_memo         VARCHAR(64),                       -- XRP/XLM 등의 destination tag
  chain               VARCHAR(32) NOT NULL,
  chain_validity_expires_at DATETIME(6),                 -- Algorand/Tezos/Polkadot 의 짧은 유효 시간 추적
  retry_count         INT NOT NULL DEFAULT 0,            -- FAILED 후 재시도 횟수
  last_boost_at       DATETIME(6),                       -- 마지막 RBF (boost/drop) 시각
  cancel_attempted_at DATETIME(6),                       -- 취소 요청 시각
  cancel_completed_at DATETIME(6),                       -- ★ set-once — 취소 확정 시각
  KEY (withdrawal_vault_id),
  KEY (chain_validity_expires_at)
);
컬럼자료형역할
idBINARY(16) PK출금의 고유 식별자
transaction_idBINARY(16) UNIQUE FK이 출금이 속한 transaction 의 id. transaction 1 개당 출금 1 개 (UNIQUE)
withdrawal_vault_idBINARY(16) FK어느 출금 전용 vault 에서 자금이 나가는지. round-robin selector 가 여기에 어느 vault 를 할당했는지 기록
destination_typeENUM (5 값)송금 대상의 종류. vault=같은 workspace 안의 다른 vault / exchange=거래소 / whitelisted=Admin Quorum 으로 사전 등록된 외부 / one-time=일회용 (Policy 통과 필수) / network=on-chain network address
destination_addressVARCHAR(128)실제 송금 대상 주소
tag_or_memoVARCHAR(64) nullableXRP/XLM 등 tag/memo 기반 chain 의 destination tag
chainVARCHAR(32)송금하는 blockchain. chains.id 와 매칭
chain_validity_expires_atDATETIME(6) nullablechain 측 tx 유효 시간 만료 시각. Algorand 50 분 / Tezos 30 분 / Polkadot 2 시간 등. 임박 시 운영 alert
retry_countINTFAILED 후 retry 액션이 호출된 횟수
last_boost_atDATETIME(6) nullableRBF (Replace-By-Fee) 가 마지막으로 적용된 시각
cancel_attempted_atDATETIME(6) nullable사용자가 취소를 요청한 시각
cancel_completed_atDATETIME(6) nullable취소가 실제로 확정된 시각. set-once — 한 번 set 후 변경 불가

4.2 broadcast_attempts — broadcast 시도 이력 (append-only)

-- ★ append-only — broadcast 시도할 때마다 새 row 가 추가됨 (UPDATE 없음)
CREATE TABLE broadcast_attempts (
  id                  BINARY(16) PRIMARY KEY,
  withdrawal_id       BINARY(16) NOT NULL,             -- FK → withdrawals.id
  attempt_number      INT NOT NULL,                    -- 1, 2, 3 ... (RBF 시 +1)
  raw_payload_cbor    BLOB NOT NULL,                   -- ★ set-once — 서명 전 raw tx 데이터
  signed_payload      BLOB NOT NULL,                   -- ★ set-once — 서명 후 송신할 payload
  broadcasted_to_node VARCHAR(128) NOT NULL,           -- 어느 node 로 송신했는지
  broadcasted_at      DATETIME(6) NOT NULL,
  tx_hash             VARCHAR(128),                    -- ★ set-once — node 가 응답한 tx hash
  outcome             ENUM('submitted','rejected','dropped','replaced') NOT NULL,
  UNIQUE KEY (withdrawal_id, attempt_number)
);
컬럼자료형역할
idBINARY(16) PKbroadcast 시도의 고유 식별자
withdrawal_idBINARY(16) FK어느 출금에 대한 시도인지
attempt_numberINT같은 출금에 대한 몇 번째 시도인지 (RBF / boost / drop 으로 새 시도가 발생하면 +1)
raw_payload_cborBLOB서명 직전의 raw tx 데이터를 CBOR(?) 로 직렬화. byte-deterministic 해야 cross-DB 감사에서 비교 가능. set-once
signed_payloadBLOB서명 후 chain 으로 송신할 최종 payload. set-once
broadcasted_to_nodeVARCHAR(128)어느 node endpoint 로 송신했는지 (debugging 용)
broadcasted_atDATETIME(6)송신 시각
tx_hashVARCHAR(128) nullable송신 직후 node 가 응답한 tx hash. set-once. 응답 실패면 NULL
outcomeENUM (4 값)submitted=네트워크 수락 / rejected=즉시 거부 (가스 부족 등) / dropped=mempool 에서 떨어짐 / replaced=후속 RBF tx 가 대체
UNIQUE (withdrawal_id, attempt_number)같은 출금에 같은 attempt_number 가 두 번 들어가지 않도록 강제

4.3 confirmations — confirmation 누적 (append-only)

-- ★ append-only — block 이 새로 쌓일 때마다 새 row 추가
CREATE TABLE confirmations (
  id                   BINARY(16) PRIMARY KEY,
  broadcast_attempt_id BINARY(16) NOT NULL,           -- FK → broadcast_attempts.id
  block_hash           VARCHAR(128) NOT NULL,         -- 그 confirmation 의 block hash
  block_height         BIGINT NOT NULL,               -- block 번호
  confirmed_at         DATETIME(6) NOT NULL,          -- 우리 시스템이 confirmation 을 인지한 시각
  reorged_at           DATETIME(6),                   -- reorg 발생 시 set, row 자체는 보존
  KEY (broadcast_attempt_id, block_height)
);
컬럼자료형역할
idBINARY(16) PKconfirmation event 의 고유 식별자
broadcast_attempt_idBINARY(16) FK어느 broadcast 시도의 confirmation 인지. 같은 출금이라도 RBF 가 있었으면 어느 attempt 가 chain 에 들어갔는지 구분
block_hashVARCHAR(128)이 confirmation 이 속한 block 의 해시. reorg 발생 시 같은 height 라도 다른 hash 가 나옴 — 그래서 height 만이 아니라 hash 까지 보존
block_heightBIGINTblock 번호. confirmation 횟수 카운트에 사용
confirmed_atDATETIME(6)우리 시스템이 이 confirmation 을 관찰한 시각
reorged_atDATETIME(6) nullable나중에 그 block 이 reorg 으로 무효화되면 이 시각이 set. row 자체는 삭제하지 않고 reorged_at 만 채워서 이력 보존

4.4 withdrawal_operations — 운영 액션 이력 (append-only)

-- ★ append-only — 운영자/사용자가 출금에 가한 모든 action 의 trail
CREATE TABLE withdrawal_operations (
  id              BINARY(16) PRIMARY KEY,
  withdrawal_id   BINARY(16) NOT NULL,                -- FK → withdrawals.id
  operation       ENUM('cancel','retry','boost','drop','rescreen-aml','bypass-aml','dismiss','edit-notes') NOT NULL,
  actor_user_id   BINARY(16) NOT NULL,                -- 이 action 을 가한 user
  parameters_cbor BLOB,                               -- action 에 따른 추가 파라미터 (예: 새 gas price)
  occurred_at     DATETIME(6) NOT NULL,
  KEY (withdrawal_id, occurred_at)
);
컬럼자료형역할
idBINARY(16) PK이벤트 고유 식별자
withdrawal_idBINARY(16) FK어느 출금에 대한 운영 action 인지
operationENUM (8 값)가한 action 의 종류. 위 "운영 액션" 표의 8 가지와 1:1 매칭
actor_user_idBINARY(16)action 을 가한 user 의 id. bypass-aml 같은 권한 action 의 책임 추적에 핵심
parameters_cborBLOB nullableaction 에 따른 부가 정보를 CBOR 로 저장. 예: boost 의 새 gas price, edit-notes 의 새 메모 텍스트
occurred_atDATETIME(6)action 이 발생한 시각

Round-Robin 운영 — 여러 출금 vault 분산

EVM / Bitcoin 의 sequential bottleneck (nonce gap · 25-tx chain) 을 회피하기 위해 출금 전용 vault 를 여러 개 두고 dispatcher 가 차례로 분산합니다. 시각화와 비교 (단일 vault 의 stuck vs round-robin) 는 5. Wallets · Addresses · Asset Wallet 페이지의 "Withdrawal Vault Round-Robin" 절에 있습니다.

DB 측 표현은 단순 — withdrawals.withdrawal_vault_id 컬럼에 dispatcher 가 할당한 vault 의 id 가 들어갈 뿐입니다. dispatcher 자체의 round-robin 알고리즘 (어느 vault 가 다음 차례인지, vault 의 stuck 상태 판별 등) 은 application 코드 영역이며 본 schema 의 표현 대상은 아닙니다.

Reorg (블록 재구성) 처리 — 출금 측에서도 schema 가 표현해야 할 path

출금 측 reorg 시나리오: 우리 출금 tx 가 chain 에 confirm 되어 COMPLETED 처리됐는데, 그 block 이 reorg 으로 사라지면? 입금 측과 본질은 같지만 위험의 방향이 다릅니다 — 우리는 자산을 외부에 "이미 보냈다" 고 처리했지만 chain 상에는 사라진 상태. 만약 자금이 reorged 새 chain branch 에 다시 포함되지 않으면 출금 실패. 새 chain branch 에 다시 포함되면 tx 가 재실행되어 정상 완료.

Schema 차원의 처리:

  1. confirmations 는 UPDATE/DELETE 안 함 — reorg 발생 시 해당 row 의 reorged_at 만 set 해서 "이 confirmation 은 무효화됐다" 표시. row 자체는 historical trail 로 보존
  2. 새 chain branch 의 confirmation 은 새 row 로 INSERT — 같은 broadcast_attempt 가 다른 block_hash 에서 다시 confirm 되는 흐름
  3. tx 가 새 chain 에 결국 포함 안 된 경우: withdrawal 의 transaction status 를 COMPLETED 에서 FAILED 로 전이 + ledger 측에 reversal entry 발행 (잔액 원상복구). → 24. Storage Discipline 의 append-only/set-once 규율
  4. 같은 출금을 재시도: retry 액션으로 새 broadcast_attempts row INSERT. retry_count +1

출금 측 reorg 가 실무적으로 발생하는 빈도는 DCCP 가 보수적이면 극히 낮지만, schema 가 표현 못 하면 발생 시 정정이 불가능합니다.