# 출금 대상 HOT 지갑 문제 — 분석 및 대응 방안

> **작성일**: 2026-05-16
> **발단**: Partner 36 BSC MASTER 온체인 잔액(563 USDT) vs 원장 잔액(199 USDT) 차이 조사

---

## 1. 문제 원인

출금(withdrawal) 시 `to_address`가 **같은 파트너의 HOT 지갑**인 경우, 다음과 같은 순환이 발생한다:

```
1. MASTER → HOT 전송 (출금)        → 원장 DEBIT 기록 ✅
2. Relayer sweep: HOT → MASTER    → 원장 CREDIT 없음 ❌
3. 결과: 온체인 잔액은 그대로인데 원장만 출금액만큼 부족
```

Relayer는 HOT 지갑의 **전체 잔액**을 sweep하므로, 출금으로 보낸 금액도 함께 MASTER로 돌아오지만 이 복귀분이 입금으로 인식되지 않는다.

### 발견된 사례 (전수 조사)

**패턴 1: MASTER → 자기 HOT** (sweep으로 복귀, CREDIT 누락)

| 출금 ID | 파트너 | 체인 | 금액 | HOT 지갑 ID | 비고 |
|---------|--------|------|------|-------------|------|
| 2 | 8 | TRON | 1 USDT | 47 | v1 데이터, 보정 불필요 |
| 3 | 8 | TRON | 1 USDT | 46 | v1 데이터, 보정 불필요 |
| 43 | 15 | BSC | 10 USDT | 153 | v1 데이터, 보정 불필요 |
| 44 | 15 | BSC | 10 USDT | 198 | v1 데이터, 보정 불필요 |
| 45 | 15 | BSC | 10 USDT | 198 | v1 데이터, 보정 불필요 |
| **141** | **36** | **BSC** | **365 USDT** | **549** | **✅ ADJUSTMENT 완료** |

**패턴 2: MASTER → 자기 MASTER** (자기 자신에게 전송, DEBIT만 기록)

| 출금 ID | 파트너 | 체인 | 금액 | MASTER 지갑 ID | 비고 |
|---------|--------|------|------|---------------|------|
| **136** | **36** | **Polygon** | **10 USDT** | **523** | **✅ ADJUSTMENT 완료** |

---

## 2. 대응 방안 비교

### 방안 A: 출금 검증 단계에서 차단 (권장)

출금 요청 시 `to_address`가 **같은 파트너의 HOT/POOL 지갑**이면 거부한다.

```java
// WithdrawalService 또는 출금 검증 로직
public void validateWithdrawalAddress(Long partnerId, Long networkId, String toAddress) {
    // 같은 파트너 + 같은 네트워크의 HOT/POOL 지갑인지 확인
    WalletAddress wallet = walletAddressRepository.findByAddress(toAddress);
    
    if (wallet != null 
        && wallet.getPartnerId().equals(partnerId)
        && wallet.getNetworkId().equals(networkId)
        && (wallet.getWalletType() == WalletType.HOT 
            || wallet.getWalletType() == WalletType.POOL)) {
        throw new BadRequestException(
            ErrorCodes.WITHDRAWAL_TO_OWN_HOT.code(),
            "자기 파트너 HOT/POOL 지갑으로의 출금은 허용되지 않습니다."
        );
    }
}
```

**장점**: 문제 자체를 원천 차단. 구현 간단.
**단점**: 운영상 자기 HOT으로 보내야 하는 정당한 케이스가 있다면 불편.

### 방안 B: 허용하되, Webhook에서 자동 입금 처리

출금은 허용하고, HOT 지갑에 도착하면 Webhook/Monitor가 **입금(deposit)**으로 자동 인식하여 CREDIT을 생성한다.

```
출금 요청 → MASTER → HOT 전송 (DEBIT 기록)
         → Monitor 감지 → 입금 처리 (CREDIT 기록)
         → 원장: DEBIT -365 + CREDIT +365 = 순 영향 0
         → Relayer sweep → MASTER 복귀 (이미 deposit으로 처리되었으므로 정상)
```

**장점**: 모든 전송이 정확하게 원장에 기록됨. 유연함.
**단점**: 같은 TX가 출금+입금 이중 기록 → 거래량 부풀림. Webhook 의존.

### 방안 C: 하이브리드 (A + B 안전망)

1차 방어: **출금 검증에서 같은 파트너 HOT 차단** (방안 A)
2차 안전망: **Monitor에서 MASTER→HOT 전송 감지 시 알림** (방안 B의 축소판)

이 경우 타 파트너 HOT으로의 출금은 자연스럽게 처리된다:
- 출금 파트너: DEBIT 기록 (정상 출금)
- 수신 파트너: Monitor가 입금 감지 → CREDIT 기록 (정상 입금)

---

## 3. 권장안: 방안 A (같은 파트너 HOT 차단)

### 이유

1. **자기 HOT으로 출금하는 정당한 유스케이스가 없다** — HOT은 입금 전용 주소이며, MASTER에서 HOT으로 보내는 건 자금 순환일 뿐 실질적 가치 이동이 아님
2. **타 파트너 HOT은 정상 처리된다** — Monitor가 입금 감지 → 수신 파트너에 CREDIT
3. **구현이 간단하고 확실하다** — 출금 요청 시 1회 DB 조회만 추가

### 추가 검증 대상

같은 파트너의 **MASTER, GAS(FEE) 지갑**으로의 출금도 차단해야 한다:

| 출금 대상 | 차단 여부 | 이유 |
|-----------|----------|------|
| 같은 파트너 HOT | ✅ 차단 | sweep으로 순환 → 원장 갭 |
| 같은 파트너 POOL | ✅ 차단 | 동일 문제 |
| 같은 파트너 MASTER | ✅ 차단 | 자기 자신에게 출금 = 무의미 |
| 같은 파트너 GAS | ✅ 차단 | 시스템 지갑 |
| 타 파트너 HOT | ❌ 허용 | 정상 출금 + 정상 입금 |
| 외부 주소 | ❌ 허용 | 정상 출금 |

### 구현 위치

```
partner-api  → PartnerWithdrawalService.requestWithdrawal()
admin-api    → AdminWithdrawalService.createWithdrawal() (관리자 수동 출금)
```

### ErrorCodes 추가

```java
public static final ErrorCode WITHDRAWAL_TO_INTERNAL_WALLET = 
    new ErrorCode("W-601", "시스템 내부 지갑으로의 출금은 허용되지 않습니다.");
```

---

## 4. 기존 건 보정 현황 (2026-05-16 전수 조사 완료)

### 보정 완료

| 파트너 | 체인 | 출금 ID | 금액 | 패턴 | 보정 |
|--------|------|---------|------|------|------|
| 36 | BSC | 141 | 365 USDT | MASTER → 자기 HOT(549) | ✅ ADJUSTMENT +365 (ledger #314) |
| 36 | Polygon | 136 | 10 USDT | MASTER → 자기 MASTER(523) | ✅ ADJUSTMENT +10 (ledger #315) |

### 보정 불필요

| 파트너 | 체인 | 사유 |
|--------|------|------|
| 8 | TRON | v1 마이그레이션 데이터 — 온체인 기반 원장 세팅, DEBIT 없음 |
| 15 | BSC | v1 마이그레이션 데이터 — 온체인 기반 원장 세팅, DEBIT 없음 |
| 36 | TRON | 출금 없음, 원장=온체인=16 USDT 일치 |

### 보정 후 검증 결과

| 파트너 | 체인 | 온체인 | 원장 | 갭 |
|--------|------|--------|------|-----|
| 36 | BSC | 563.899 | 563.898 | 0.001 (dust) ✅ |
| 36 | Polygon | 305.760 | 305.760 | 0 ✅ |
| 36 | TRON | 16.000 | 16.000 | 0 ✅ |

---

## 5. 구현 체크리스트

- [ ] `ErrorCodes`에 `WITHDRAWAL_TO_INTERNAL_WALLET` 추가
- [ ] `PartnerWithdrawalService.requestWithdrawal()`에 검증 로직 추가
- [ ] `AdminWithdrawalService.createWithdrawal()`에 검증 로직 추가
- [ ] 파트너 8, 15 원장 갭 확인 및 필요 시 ADJUSTMENT 적용
- [ ] 단위 테스트: 같은 파트너 HOT/POOL/MASTER/GAS 주소로 출금 시 거부 확인
- [ ] 단위 테스트: 타 파트너 HOT 주소로 출금 시 허용 확인
