의심 입금으로 출금 보류가 걸린 회원의 이의 문의가 왔다. 실제로 보냈고 체인에서도 보인다는 주장이어서 부과 로직을 봤다.
Table of contents
Open Table of contents
상황 — 실제를 안 보는 조건 넷
커맨드는 이것이었다.
// MarkSuspiciousDeposit — 매시 정각 cron
조건이 넷 있었다.
① 입금 상태 = 확인 대기(pending)
② 코인이 예외 목록에 없음
③ 회원이 예외 목록에 없음
④ 입금 레코드 생성 시각이 6시간 이전
MarkSuspiciousDeposit 의 조건 넷 어디에도 그 트랜잭션이 실제로 체인에 있는지 보는 것이 없다.
확인 대기 상태에 오래 머물러 있음
↓
의심 입금 → 출금 보류
pending 에 머문 시간만으로 판정하는 구조였다.
조건 ④의 created_at 이 체류를 재는 기준이 되는데 그 값이 실제 브로드캐스트 시점과 다르면 판정도 함께 어긋난다.
왜 체류로 잡나
실제 입금인지 확인하려면 노드에서 confirmations 를 봐야 한다.
[확인 수가 오름] 확인 후 판정
[확인 수가 0] 판정 못 함
의심 입금의 실체는 이중 지불 시도라 확인이 오를 가능성이 낮다.
같은 코인을 다른 곳에 쓰는 트랜잭션이 먼저 블록에 들어가면 우리 쪽에 보이던 트랜잭션은 mempool 에서 밀려나 기록이 안 생긴다. 확인할 자료가 없다는 것이 곧 그 건의 성격인 셈이다.
확인 수로 판정하면 정보가 없는 동안 아무 판단도 못 하고 가짜일수록 더 오래 못 잡는다. 잡으려는 대상이 그 방식에서 가장 잘 빠져나간다.
정상 입금 → 한 시간 안팎에 confirmed 로 넘어감
이중 지불 → pending 에 머물러 있음
그래서 역으로 confirmed 로 안 넘어가는 것을 잡는다. 확인할 수 없는 것을 확인하는 대신 확인할 수 있는 것으로 대신 판정하는 방식이다. 한 시간이라는 기준은 bitcoin.org 개발자 가이드의 결제 검증 절에서 온 것인데, 그 공식 문서는 0 확인 트랜잭션을 위험 분석 없이 신뢰하지 말라고 하고 6 확인이 쌓이는 데 약 한 시간이 든다고 적는다.
대응 — 먼저 걸고 나중에 푸는 순서
이 방식은 실제로 보낸 건까지 걸린다는 것을 전제한다.
실제로 보냈는데
↓
수수료가 낮아 블록에 못 들어감
↓
보류됨
네트워크가 혼잡하면 낮은 수수료 트랜잭션이 pending 에 남아 보류된다. 같은 가이드가 최소 수수료만 낸 트랜잭션은 오래 기다릴 각오를 하라고 적는 그 상황이다.
정상 입금인데 confirmed 로만 안 넘어간 건이 여기 걸린다. 이 오탐을 줄이는 대신 이중 지불을 놓치지 않는 쪽을 설계가 골랐다. 이유는 보류는 나중에 풀 수 있지만 이중 지불로 빠져나간 코인은 되찾을 수 없어서다.
그래서 철회 처리가 붙어 있었다.
노드에서 confirmations ≥ 6 이 확인되면
↓
해당 출금 보류 자동 해제
나중에 confirmations 가 6 을 넘기면 그 보류를 스스로 푼다.
[확인 후 보류] 이중 지불은 영영 확인 안 됨 → 보류 못 함
[먼저 보류] 이중 지불은 남고 정상은 풀림
목적이 이중 지불을 잡는 것이면 이 순서가 맞고 놓치는 비용이 잘못 거는 비용보다 크다고 본 설계다.
이의 대응이 달라졌다
구조를 알고 나니 물어볼 것이 바뀌었다.
[전] "실제로 보냈나"를 확인 — txid 증빙 요청
[후] "왜 상태가 안 넘어갔나"를 확인
실제로 보냈는지는 이 판정과 무관한 물음이었다.
부과 조건 넷 어디에도 체인 확인 여부가 없으니 txid 를 받아도 판정이 안 바뀐다. 물어야 할 것은 pending 에서 왜 안 넘어갔느냐였다.
봐야 할 것이 둘로 좁혀졌다.
① 상태가 왜 pending 에 머물러 있나
② 입금 레코드의 created_at 이 언제인가
둘째인 created_at 이 이번 건의 답이었고 그 값이 실제 브로드캐스트와 달랐다.
원인 — 두 설계가 만나는 자리
사용자가 수수료를 올려 다시 보낸 대체 트랜잭션은 새 행을 만들지 않고 기존 행의 txid 를 고치므로 created_at 이 원본 브로드캐스트 시각으로 남는다.
원본 브로드캐스트 09:10
대체 트랜잭션 20:40
created_at 09:10 (그대로)
↓
6시간 계산 → 이미 초과
대체 직후인데 6시간 이 이미 지난 것으로 계산된다.
created_at 이 최근 동작 시점을 안 가리키기 때문인데 실제로는 20:40 에 나갔지만 기준은 09:10 에 머물러 있다.
[덮어쓰기 설계] created_at 이 최근 동작 시점이 아님
[체류 판정] created_at 기준으로 경과를 잼
덮어쓰기 설계 는 같은 주소·같은 금액을 한 입금으로 묶어 행 수를 줄이려는 선택이고 체류 판정 은 확인할 수 없는 것을 대신 판정하려는 선택이다.
각각만 보면 둘 다 합리적이고 둘이 만나는 자리에서만 오탐이 생기므로 나란히 놓고 봐야 보인다.
제약 — 코드에 박힌 예외 목록
조건 둘과 셋을 보니 목록이 코드에 있었다.
$sql .= " AND d.coin NOT IN ('XRP', 'ETH')";
$sql .= " AND d.member_no NOT IN (" . implode(',', $marketMakers) . ")";
$rows = $this->db->query($sql)->result();
NOT IN 목록에 한 줄을 넣으려면 코드를 고치고 배포해야 한다.
운영이 판단하는 값이 개발 배포에 묶여 있는 셈이라 deposit_hold_exception 같은 표로 빼면 화면에서 넣을 수 있다.
deposit_hold_exception
type coin / member
value
reason
created_by
reason 과 created_by 를 같이 두면 나중에 왜 예외인지도 남는다.
다만 코드에 둔 데는 이유가 있을 수도 있다. 출금이 걸린 판정이라 커밋 이력과 리뷰를 거치게 하려던 것일 수 있고 어느 쪽이 나은지는 이 목록을 얼마나 자주 바꾸느냐에 달렸다.
커맨드가 매시 정각에 도는 것도 눈에 걸렸는데 그래서 자동 보류는 부과 시각이 전부 정각 근처에 몰린다. 부과 시각만 보고도 자동인지 수동인지 갈리는 셈이라 이번 조사에서도 그 구분을 썼다.
정리
- 실제 여부를 확인하지 않고 상태 체류만으로 자동 부과한다
- 확인 수로 판정하면 정보가 없는 동안 판단할 수 없다
- 이중 지불일수록 확인이 안 되니 그 방식은 대상을 놓친다
- 확인할 수 있는 것으로 대신 판정하는 구조다
- 먼저 걸고 확인되면 자동으로 철회한다
- 놓치는 비용이 잘못 거는 비용보다 크다고 본 설계다
- 이의 대응은 실제로 보냈나가 아니라 왜 상태가 안 넘어갔나를 본다
- 덮어쓰기 설계와 체류 판정이 만나면 오탐이 난다
- 각각은 합리적이라 나란히 놓아야 보인다
- 예외 목록이 코드에 박혀 있으면 추가에 배포가 필요하다
- 자동 실행 시각이 고정이면 자동인지 수동인지 구분된다