19. Withdrawal Lifecycle
출금 tx 가 거치는 상태 흐름 · 운영 액션 · chain 별 특이 제약이 페이지의 내용은 두 갈래입니다. 미리 구분해두면 어떤 부분을 그대로 받아들이고 어떤 부분은 본인 환경에서 검증할지 판단이 쉬워집니다.
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 : 서명 완료Figure 11. 출금 상태 흐름. 노란 박스 = 진행 중 / 초록 = 정상 완료 / 빨강 = 차단·취소·실패 / 파랑 = Solana Sign-Only 특수 종착. 정상 경로는 요청 → (AML) → Approver 승인 → 서명 → 송신 → confirmation 누적 → 확정. 곳곳에 두 시간 타임아웃 (Auth · Sig) 과 사용자 취소 가능 구간 (Bcast 이전까지) 이 존재. Solana 의 Sign-Only 운영은 Fireblocks 가 broadcast 하지 않고 서명된 payload 를 고객에게 반환해서 고객이 직접 broadcast 하는 분기 (특수 종착 SIGNED).
+ 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;
운영 액션 — 어느 상태에서 무엇이 가능한가
출금 진행 도중 운영자/사용자가 시스템에 가할 수 있는 action 의 종류와 그 action 이 허용되는 상태:
| 액션 | 가능한 상태 | 설명 |
|---|---|---|
| Cancel (취소) | BROADCASTING 이전까지 | 아직 blockchain 으로 송신되지 않은 시점이라면 출금을 취소 가능. broadcast 이후에는 chain 에 이미 들어가 있어 취소 못 함 |
| Retry (재시도) | FAILED 이후 | 실패한 출금을 같은 내용으로 다시 시도. 새 tx 가 아니라 같은 withdrawal 의 재시도로 추적 |
| Boost / Drop (EVM(?)) | BROADCASTING | EVM 에서 gas 가 부족해 stuck 된 tx 의 gas parameter 를 올려서 (boost) 또는 줄여서 (drop) 동일 nonce 로 교체 송신 — Replace-By-Fee(?) (RBF) 라고 부름 |
| Boost UTXO(?) tx | BROADCASTING | Bitcoin 의 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 개를 넘으면 추가 요청 거부
- 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 유효 시간 — 경합 가능성
위 표에서 본 것처럼 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)
);
| 컬럼 | 자료형 | 역할 |
|---|---|---|
id | BINARY(16) PK | 출금의 고유 식별자 |
transaction_id | BINARY(16) UNIQUE FK | 이 출금이 속한 transaction 의 id. transaction 1 개당 출금 1 개 (UNIQUE) |
withdrawal_vault_id | BINARY(16) FK | 어느 출금 전용 vault 에서 자금이 나가는지. round-robin selector 가 여기에 어느 vault 를 할당했는지 기록 |
destination_type | ENUM (5 값) | 송금 대상의 종류. vault=같은 workspace 안의 다른 vault / exchange=거래소 / whitelisted=Admin Quorum 으로 사전 등록된 외부 / one-time=일회용 (Policy 통과 필수) / network=on-chain network address |
destination_address | VARCHAR(128) | 실제 송금 대상 주소 |
tag_or_memo | VARCHAR(64) nullable | XRP/XLM 등 tag/memo 기반 chain 의 destination tag |
chain | VARCHAR(32) | 송금하는 blockchain. chains.id 와 매칭 |
chain_validity_expires_at | DATETIME(6) nullable | chain 측 tx 유효 시간 만료 시각. Algorand 50 분 / Tezos 30 분 / Polkadot 2 시간 등. 임박 시 운영 alert |
retry_count | INT | FAILED 후 retry 액션이 호출된 횟수 |
last_boost_at | DATETIME(6) nullable | RBF (Replace-By-Fee) 가 마지막으로 적용된 시각 |
cancel_attempted_at | DATETIME(6) nullable | 사용자가 취소를 요청한 시각 |
cancel_completed_at | DATETIME(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)
);
| 컬럼 | 자료형 | 역할 |
|---|---|---|
id | BINARY(16) PK | broadcast 시도의 고유 식별자 |
withdrawal_id | BINARY(16) FK | 어느 출금에 대한 시도인지 |
attempt_number | INT | 같은 출금에 대한 몇 번째 시도인지 (RBF / boost / drop 으로 새 시도가 발생하면 +1) |
raw_payload_cbor | BLOB | 서명 직전의 raw tx 데이터를 CBOR(?) 로 직렬화. byte-deterministic 해야 cross-DB 감사에서 비교 가능. set-once |
signed_payload | BLOB | 서명 후 chain 으로 송신할 최종 payload. set-once |
broadcasted_to_node | VARCHAR(128) | 어느 node endpoint 로 송신했는지 (debugging 용) |
broadcasted_at | DATETIME(6) | 송신 시각 |
tx_hash | VARCHAR(128) nullable | 송신 직후 node 가 응답한 tx hash. set-once. 응답 실패면 NULL |
outcome | ENUM (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)
);
| 컬럼 | 자료형 | 역할 |
|---|---|---|
id | BINARY(16) PK | confirmation event 의 고유 식별자 |
broadcast_attempt_id | BINARY(16) FK | 어느 broadcast 시도의 confirmation 인지. 같은 출금이라도 RBF 가 있었으면 어느 attempt 가 chain 에 들어갔는지 구분 |
block_hash | VARCHAR(128) | 이 confirmation 이 속한 block 의 해시. reorg 발생 시 같은 height 라도 다른 hash 가 나옴 — 그래서 height 만이 아니라 hash 까지 보존 |
block_height | BIGINT | block 번호. confirmation 횟수 카운트에 사용 |
confirmed_at | DATETIME(6) | 우리 시스템이 이 confirmation 을 관찰한 시각 |
reorged_at | DATETIME(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)
);
| 컬럼 | 자료형 | 역할 |
|---|---|---|
id | BINARY(16) PK | 이벤트 고유 식별자 |
withdrawal_id | BINARY(16) FK | 어느 출금에 대한 운영 action 인지 |
operation | ENUM (8 값) | 가한 action 의 종류. 위 "운영 액션" 표의 8 가지와 1:1 매칭 |
actor_user_id | BINARY(16) | action 을 가한 user 의 id. bypass-aml 같은 권한 action 의 책임 추적에 핵심 |
parameters_cbor | BLOB nullable | action 에 따른 부가 정보를 CBOR 로 저장. 예: boost 의 새 gas price, edit-notes 의 새 메모 텍스트 |
occurred_at | DATETIME(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 차원의 처리:
confirmations는 UPDATE/DELETE 안 함 — reorg 발생 시 해당 row 의reorged_at만 set 해서 "이 confirmation 은 무효화됐다" 표시. row 자체는 historical trail 로 보존- 새 chain branch 의 confirmation 은 새 row 로 INSERT — 같은 broadcast_attempt 가 다른 block_hash 에서 다시 confirm 되는 흐름
- tx 가 새 chain 에 결국 포함 안 된 경우: withdrawal 의 transaction status 를 COMPLETED 에서 FAILED 로 전이 + ledger 측에 reversal entry 발행 (잔액 원상복구). → 24. Storage Discipline 의 append-only/set-once 규율
- 같은 출금을 재시도: retry 액션으로 새 broadcast_attempts row INSERT.
retry_count+1
출금 측 reorg 가 실무적으로 발생하는 빈도는 DCCP 가 보수적이면 극히 낮지만, schema 가 표현 못 하면 발생 시 정정이 불가능합니다.