Skip to content
isdnetworks
Go back

상태에 머문 것만으로 부과했다

(수정됨)

의심 입금으로 출금 보류가 걸린 회원의 이의 문의가 왔다. 실제로 보냈고 체인에서도 보인다는 주장이어서 부과 로직을 봤다.

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 를 같이 두면 나중에 왜 예외인지도 남는다.

다만 코드에 둔 데는 이유가 있을 수도 있다. 출금이 걸린 판정이라 커밋 이력과 리뷰를 거치게 하려던 것일 수 있고 어느 쪽이 나은지는 이 목록을 얼마나 자주 바꾸느냐에 달렸다.

커맨드가 매시 정각에 도는 것도 눈에 걸렸는데 그래서 자동 보류는 부과 시각이 전부 정각 근처에 몰린다. 부과 시각만 보고도 자동인지 수동인지 갈리는 셈이라 이번 조사에서도 그 구분을 썼다.

정리


Share this post on:

Previous Post
신뢰할 수 없는 축
Next Post
지금 규약과 어긋난 옛 설정