# 출금 수신주소 내부 지갑 차단 + 자가전송 원장 방어 지침서

> 작성: 2026-08-31 / 대상: Cowork 서브에이전트 / 범위: **Spring Boot(core 모듈)만**. node-service 는 이번 범위 밖.

## 0. 배경 — 왜 고치나

**사건(2026-08-31, 출금 1416 `wdr_2608_c15939be`)**

파트너 61(아지트)의 P2P 출금 주문 `pwo_c50fa93f0aa1` 잔여분을 회원이 USDT로 전환 요청하면서
수신 주소에 **파트너 본인의 MASTER 지갑 주소**(`TPyBHPpcr36xgr9J1fubLqpR2Ve2obvX5m`,
`wallet_addresses` id=964, wallet_type=MASTER, partner_id=61)를 입력했다.

- 온체인 tx `874f1921c200e297068d408677516951d6e0f9ab836ebfc1da05293267292452` 를 디코딩하면
  릴레이어 `transferFrom(from, to, amount)` 의 **from == to == 파트너 61 MASTER**.
  즉 자기 자신에게 보낸 self-transfer — **자금은 1원도 움직이지 않았다.**
- 그런데 `WithdrawalService.onTxConfirmed()` 의 `USER_PAYOUT` 분기가 무조건 DEBIT 을 기록
  (`ledger_entries` id 7806, 16.630513376717281272 USDT).
- 결과: **온체인 잔액은 그대로인데 파트너 원장만 감소.** 회원도 돈을 못 받았다.
- 원장은 `ledger_entries` id **7814** `ADJUSTMENT` 로 보정 완료(백업 `bak_ledger_p61_wd1416_20260831`).
  이 지침서는 **재발 방지 코드**를 다룬다.

**같은 유형이 처음이 아니다.** `ledger_entries` id **315**(2026-05-16, 출금 #136, 파트너 36)가
`PARTNER_WITHDRAW` 로 자기 MASTER 에 보낸 동일 사고이고 같은 방식으로 보정돼 있다.
즉 **두 출금 타입에서 각각 한 번씩 이미 발생**했다.

**근본 원인 2개**

1. 수신 주소 검증이 `AddressValidator` 의 **네트워크별 문자열 포맷 검사뿐**이다.
   그 주소가 우리 `wallet_addresses` 에 있는 내부 지갑인지는 아무도 보지 않는다.
2. `onTxConfirmed` 는 `SETTLEMENT_WITHDRAW` 에만 `isOwnMasterAddress()` 예외를 두고,
   `USER_PAYOUT` 은 `default ->` 로 무조건 DEBIT 한다. **판별 함수는 이미 있는데 안 걸려 있다.**

---

## 1. 작업 범위 (3건)

| # | 위치 | 내용 | 우선순위 |
|---|---|---|---|
| A | `WithdrawalService` | 내부 지갑 판별 가드 메서드 신설 | P0 |
| B | `P2pWithdrawService.requireValidConvertAddress()` | P2P USDT 전환 주소에 내부 지갑 전면 차단 | P0 |
| C | `WithdrawalService.validatePolicy()` / `onTxConfirmed()` | 일반 출금 타입별 차단 + DEBIT 방어선 | P0 |

DDL 변경 없음. 신규 테이블 없음. **ErrorCode 1개만 추가.**

---

## 2. A — 가드 메서드 신설

**파일**: `core/src/main/java/com/cryptoments/core/withdrawal/WithdrawalService.java`

### A-1. ErrorCode 추가

**파일**: `common/src/main/java/com/cryptoments/common/exception/ErrorCodes.java`

출금 360번대(`INVALID_ADDRESS_FORMAT`=363, `UNSUPPORTED_NETWORK_TYPE`=364) 바로 뒤에 추가:

```java
/**
 * 수신 주소가 시스템 내부 지갑 (400).
 *
 * <p>2026-08-31 출금 1416 — P2P USDT 전환 수신주소에 파트너 본인 MASTER 가 들어가
 * 릴레이어가 {@code transferFrom(from==to)} 를 태웠다. 자금은 움직이지 않았는데
 * {@code onTxConfirmed} 가 DEBIT 만 기록해 원장이 16.63 USDT 과소가 됐다.
 * 온체인은 자가전송을 막아주지 않으므로 <b>접수 단계에서 거부</b>해야 한다.
 * 동일 유형 선례: ledger 315(2026-05-16, 출금 #136, PARTNER_WITHDRAW).
 */
public static final ErrorCode INTERNAL_ADDRESS_NOT_ALLOWED =
        new ErrorCode("365", "시스템 내부 지갑 주소로는 출금할 수 없습니다.");
```

### A-2. 가드 메서드

`WithdrawalService` 는 이미 `walletAddressRepository` 를 주입받고 있다(기존 `isOwnMasterAddress`,
`checkMasterBalance` 가 사용). `WalletAddressRepository.findByAddressAndNetworkId(String, Long)` 가
이미 존재하므로 **신규 repository 메서드 불필요**.

```java
/**
 * 수신 주소가 우리 시스템의 내부 지갑({@code wallet_addresses})인지 검사하고, 그렇다면 거부한다.
 *
 * <p><b>왜 필요한가 (2026-08-31 출금 1416)</b>: 회원이 P2P USDT 전환 수신주소에 파트너 본인
 * MASTER 를 넣어 릴레이어가 {@code transferFrom(from==to)} 를 태웠다. 온체인은 자가전송을
 * 정상 SUCCESS 로 처리하고, {@code onTxConfirmed} 는 DEBIT 만 남겨 원장이 과소가 됐다.
 * <b>UI 검증은 방어선이 아니다</b> — 파트너 API 직접 호출·관리자 콘솔도 같은 관문을 지난다.
 *
 * <p>⚠️ <b>{@link #requireValidAddressForNetwork} 안에 넣지 말 것.</b> 그 메서드는
 * {@code SETTLEMENT_WITHDRAW}(본인 MASTER 유입이 <b>정상 경로</b> — {@code onTxConfirmed} 가
 * CREDIT 으로 받는다)와 {@code PARTNER_WITHDRAW}(타 파트너 MASTER 로의 정상 이체, 파트너
 * 44↔45 가 상시 사용)도 공유한다. 호출부에서 타입별로 골라 써야 한다.
 *
 * @param address    이미 trim·형식검증을 통과한 수신 주소
 * @param networkId  {@code blockchain_networks.id}
 * @param selfOnly   true 면 <b>같은 파트너 소유 내부 지갑만</b> 거부(타 파트너 지갑은 허용),
 *                   false 면 소유자 불문 모든 내부 지갑 거부
 * @param partnerId  출금 주체 파트너 — {@code selfOnly=true} 일 때만 사용
 * @throws InvalidRequestException (400) 내부 지갑인 경우
 */
private void assertNotInternalWallet(String address, Long networkId,
                                     boolean selfOnly, Long partnerId) {
    if (address == null || networkId == null) {
        return;
    }
    WalletAddress internal = walletAddressRepository.findByAddressAndNetworkId(address, networkId);
    if (internal == null) {
        return; // 외부 주소 — 정상
    }
    if (selfOnly && !java.util.Objects.equals(internal.getPartnerId(), partnerId)) {
        return; // 타 파트너 지갑 — 이 경로에서는 허용
    }
    log.error("☠️ 내부 지갑 주소로의 출금 시도 차단 — address={}, networkId={}, "
                    + "walletAddressId={}, walletType={}, ownerPartnerId={}, requesterPartnerId={}",
            address, networkId, internal.getId(), internal.getWalletType(),
            internal.getPartnerId(), partnerId);
    throw new InvalidRequestException(ErrorCodes.INTERNAL_ADDRESS_NOT_ALLOWED,
            "수신 주소가 시스템 내부 지갑입니다(" + internal.getWalletType() + "). "
                    + "본인 소유 외부 지갑 주소를 입력해 주세요.");
}
```

`assertNotInternalWallet` 은 B 에서도 호출해야 하므로 **`public` 으로 선언**한다
(`P2pWithdrawService` 가 `WithdrawalService` 를 주입받아 호출).

> ⚠️ 상태 검사만 하고 값을 반환하지 않는다. 기존 `requireValidAddressForNetwork` 는 trim 된
> 주소를 **반환**하므로, 호출 순서는 항상 `requireValidAddressForNetwork` → 그 반환값으로
> `assertNotInternalWallet` 이다. 순서를 뒤집으면 후행 공백이 붙은 주소로 조회해 못 잡는다
> (실사례: 출금 966 후행 공백).

---

## 3. B — P2P USDT 전환 주소 차단

**파일**: `core/src/main/java/com/cryptoments/core/p2p/P2pWithdrawService.java` (약 924행)

### 현재

```java
private String requireValidConvertAddress(P2pWithdrawOrder order, String address) {
    Withdrawal origin = withdrawalService.findById(order.getWithdrawalId());
    return withdrawalService.requireValidAddressForNetwork(address, origin.getNetworkId());
}
```

### 수정 후

```java
/**
 * P2P 잔여 USDT 전환 수신주소 검증 — 형식 + <b>내부 지갑 전면 차단</b>.
 *
 * <p>이 경로의 수신자는 <b>회원 개인 지갑</b>이다. 우리 내부 지갑(MASTER/HOT/GAS/POOL/
 * RELAYER)이 정답인 경우가 존재하지 않으므로 소유자 불문 전부 거부한다({@code selfOnly=false}).
 * 2026-08-31 출금 1416 은 파트너 본인 MASTER 였지만, 타 파트너 MASTER 였다면 자금이 실제로
 * 남의 운영지갑에 들어가 회수 불가가 된다 — 더 나쁘다.
 */
private String requireValidConvertAddress(P2pWithdrawOrder order, String address) {
    Withdrawal origin = withdrawalService.findById(order.getWithdrawalId());
    String normalized =
            withdrawalService.requireValidAddressForNetwork(address, origin.getNetworkId());
    withdrawalService.assertNotInternalWallet(
            normalized, origin.getNetworkId(), false, order.getPartnerId());
    return normalized;
}
```

이 메서드는 이미 **두 경로**가 공유한다 — 확인만 하고 별도 수정 불필요:
- `requestUsdtConvert()` (789행) — 회원이 주소 입력하는 시점
- `convertToDirectWithdrawal()` (697행) — 파트너/관리자가 직접 전환하는 시점

---

## 4. C — 일반 출금 경로

**파일**: `core/src/main/java/com/cryptoments/core/withdrawal/WithdrawalService.java`

### C-1. 접수 단계 — `validateToAddressFormat()` (1913행)

타입별 규칙이 다르다. **표를 그대로 구현할 것.**

| `withdrawal_type` | 내부 지갑 규칙 | `selfOnly` |
|---|---|---|
| `USER_PAYOUT` | 내부 지갑 전면 차단 | `false` |
| `PARTNER_WITHDRAW` | 타 파트너 MASTER 허용 / **본인 지갑만 차단** | `true` |
| `SETTLEMENT_WITHDRAW` | **차단하지 않음** (현행 유지) | — |
| `null` | `USER_PAYOUT` 과 동일 취급 | `false` |

```java
private void validateToAddressFormat(Withdrawal withdrawal) {
    if (withdrawal.getToAddress() == null) {
        return; // P2P 전환 경로 — 주소는 전환 시점에 입력·검증된다
    }
    withdrawal.setToAddress(
            requireValidAddressForNetwork(withdrawal.getToAddress(), withdrawal.getNetworkId()));

    // 내부 지갑 차단 — 타입별 규칙 (2026-08-31 출금 1416 / 2026-05-16 출금 136)
    //   SETTLEMENT_WITHDRAW 는 본인 MASTER 유입이 설계된 정상 경로이므로 제외한다
    //   (onTxConfirmed 가 isOwnMasterAddress 로 CREDIT 처리).
    WithdrawalType type = withdrawal.getWithdrawalType() == null
            ? WithdrawalType.USER_PAYOUT : withdrawal.getWithdrawalType();
    if (type != WithdrawalType.SETTLEMENT_WITHDRAW) {
        assertNotInternalWallet(
                withdrawal.getToAddress(),
                withdrawal.getNetworkId(),
                type == WithdrawalType.PARTNER_WITHDRAW,   // selfOnly
                withdrawal.getPartnerId());
    }
}
```

> ⚠️ `PARTNER_WITHDRAW` 를 `selfOnly=true` 로 두는 이유: 파트너 44↔45 가 **서로의 MASTER 로**
> 매일 정상 이체를 한다(운영 데이터 20건+). 전면 차단하면 그 업무가 즉시 멈춘다.
> 반면 **자기 자신**에게 보내는 건 언제나 무의미하고 원장만 깎는다(ledger 315 사례).

> ⚠️ `withdrawal_address_whitelist` 는 대안이 아니다.
> `partner_withdrawal_policies.address_whitelist_enabled` 가 켜진 파트너만 적용되는 옵트인이라
> 파트너 61 처럼 꺼둔 곳은 그냥 통과한다. 이 가드는 화이트리스트와 **독립적으로** 항상 돈다.

### C-2. 확정 단계 방어선 — `onTxConfirmed()` (1093~1116행)

`isOwnMasterAddress(withdrawal)` 는 **이미 존재하는 private 메서드**다(1950행). 재사용한다.

```java
switch (withdrawal.getWithdrawalType() == null
        ? WithdrawalType.USER_PAYOUT : withdrawal.getWithdrawalType()) {
    case SETTLEMENT_WITHDRAW -> {
        // ...(기존 코드 그대로)...
    }
    default -> {
        // 자가전송 방어선 (2026-08-31 출금 1416) — 수신처가 <b>본인 MASTER</b> 면
        //   릴레이어 transferFrom(from==to) 라 온체인 자금 이동이 0 이다. 여기서 DEBIT 하면
        //   원장만 깎여 온체인 대비 과소가 된다. 접수 가드(C-1)가 1차 방어선이고 이건 2차다.
        //   ⚠️ <b>본인 MASTER 일 때만</b> 스킵한다 — 타 파트너 MASTER 로 간 USER_PAYOUT 은
        //      자금이 실제로 나가므로 DEBIT 이 맞다.
        if (isOwnMasterAddress(withdrawal)) {
            log.error("☠️ 자가전송 출금 확정 — 원장 DEBIT 스킵: withdrawalId={}, code={}, "
                            + "partnerId={}, toAddress={}, amount={}, txHash={}",
                    withdrawalId, withdrawal.getWithdrawalCode(), withdrawal.getPartnerId(),
                    withdrawal.getToAddress(), withdrawal.getAmount(), txHash);
        } else {
            settlementService.debit(withdrawal.getPartnerId(), withdrawal.getCurrencyId(),
                    withdrawal.getNetworkId(), withdrawal.getAmount(),
                    LedgerReferenceType.WITHDRAWAL, withdrawalId,
                    withdrawal.getWithdrawalCode() + " 출금 확정");
        }
    }
}
```

> `isOwnMasterAddress` 는 `wallet_type = MASTER` 만 본다. HOT 지갑으로의 자가전송은
> 릴레이어 sweep 으로 MASTER 에 복귀하므로 원장 처리가 달라진다(ledger 314 선례).
> **이번 범위에서는 MASTER 만 다룬다** — 범위를 넓히지 말 것.

---

## 5. 코딩 규칙 (준수 필수)

- Java 17. `@Data` 금지. Lombok 은 `@Getter @Setter @Builder(toBuilder=true) @NoArgsConstructor @AllArgsConstructor`.
- 예외는 `ErrorCodes` 상수를 참조하는 도메인 예외(`InvalidRequestException` 등). 문자열 코드 하드코딩 금지.
- **MyBatis `<script>` 안에서 `<`, `<=`, `<>` 직접 사용 금지** — 이번 작업엔 SQL 추가가 없어야 정상이지만,
  혹시 쓰게 되면 `!=` / `&lt;` / `<![CDATA[ ]]>` 를 쓸 것 (2026-06-11 전 서비스 다운 원인).
- Entity 에 `@XIgnoreColumn` 으로 JOIN 데이터 추가 금지.
- 신규 DTO 멤버 변수에는 JavaDoc 필수. (이번 작업은 DTO 추가 없음)
- 기존 주석의 "왜"를 **지우지 말 것**. 새 주석도 무엇을 하는지가 아니라 **왜 그런지**를 적을 것.

## 6. 완료 기준

1. `./gradlew :common:compileJava :core:compileJava` 성공.
2. `./gradlew :admin-api:compileJava :partner-api:compileJava :open-api:compileJava` 성공
   (`assertNotInternalWallet` 을 public 으로 올리므로 시그니처 파급 확인).
3. 아래가 코드로 확인될 것:
   - `ErrorCodes.INTERNAL_ADDRESS_NOT_ALLOWED` (365) 존재
   - `WithdrawalService.assertNotInternalWallet(String, Long, boolean, Long)` public 존재
   - `P2pWithdrawService.requireValidConvertAddress` 가 `selfOnly=false` 로 호출
   - `validateToAddressFormat` 이 `SETTLEMENT_WITHDRAW` 를 제외하고 호출,
     `PARTNER_WITHDRAW` 만 `selfOnly=true`
   - `onTxConfirmed` 의 `default` 분기에 `isOwnMasterAddress` 가드 존재
4. **커밋/push 하지 말 것.** 변경 파일 목록과 diff 요약만 보고할 것 — 오케스트레이터가 리뷰 후 판정한다.

## 7. 회귀 주의 (건드리면 안 되는 것)

- `requireValidAddressForNetwork` 의 **시그니처와 동작을 바꾸지 말 것.** 여러 경로가 공유한다.
- `onTxConfirmed` 의 `SETTLEMENT_WITHDRAW` 분기는 **손대지 말 것.**
  2026-07-08 이중차감 버그를 고친 코드다(`realized_balance` 는 `withdrawSettlement` 에서 이미 차감).
- `withdrawal.getToAddress() == null` 조기 return 을 없애지 말 것.
  `createAndConvertToP2p` 는 수신 주소 없이 접수하며 운영 데이터에 50건 있다. 거부하면 P2P 전환 출금이 전부 막힌다.
- 화이트리스트 검증([4]) 로직은 그대로 둘 것.
