# P2P 잔여 USDT 전환 — 조사 결과 및 선행 과제

> 작성 2026-08-22 · 출처: `OPEN_API_MEMBER_CATEGORY_DESIGN.md` §4.2 에서 분리
> **Open API 범위 밖**이다. surface 가 회원 페이지·파트너 콘솔·core·scheduler 에 걸쳐 있다.
> 그러나 **Open API B(P2P 출금)를 파트너에게 여는 전제 조건**이다 — 이 흐름이 없으면 회원이 잔여를 받을 방법이 없다.

## 단계별 온체인 유무 (논의 전 반드시 고정할 것)

| 단계 | 온체인 |
|---|---|
| P2P 출금 접수 (원장 DEBIT + 주문 생성 + 원장 CHARGE) | **없음** |
| 매칭 정산 (MASTER A → MASTER B) | 있음 |
| **잔여 USDT 전환 출금** (신규 `withdrawals` → 릴레이어 → 회원 주소) | **있음** |

이 구분이 없으면 "실패" 논의가 계속 엉킨다. 아래 P6 은 세 번째 단계의 온체인 실패를 말한다.
원장 기록 실패는 같은 트랜잭션이라 롤백되므로 별도로 상정하지 않는다.

## 정책 — 취소는 없다. USDT 전환만 (확정 2026-08-22)

> P2P 출금은 **취소되지 않는다.** 매칭되지 않은 잔여는 **USDT로 전환해 회원 주소로 출금**하고 주문을 종결한다.
>
> ★ 파트너 회계 원칙에 따라 **이 절 전체가 파트너 관심사 밖**이다. P2P든 USDT든 파트너 장부에서는
> T0에 이미 나간 돈이고, **회수하더라도 그건 재입금이지 출금의 되돌림이 아니다.**
> 여기 적는 이유는 **B의 전제 조건**이기 때문이다 — 이 흐름이 없으면 회원이 잔여를 받을 방법이 없다.

- Open API에 **`/cancel` 엔드포인트를 만들지 않는다.**
- 종결 시 주문 status는 `CANCELLED` 가 아니라 **`COMPLETED`** 이고, `refund_type = USDT_WITHDRAW`, `remainder_resolution = CONVERTED` 가 찍힌다.
- 이 정책은 코드에 이미 반영돼 있다 — 잔여 "취소"(`cancelRemaining`)는 **2026-08-17에 제거**됐다.

#### ⚠️ 운영 실적: 이 경로는 한 번도 돌아간 적이 없다 (2026-08-22 실측)

| 지표 | 실측 |
|---|---|
| `p2p_withdraw_orders` | 45건 |
| `usdt_convert_status` | **전건 NULL** |
| `usdt_convert_address` 보유 | **0건** |
| `refund_type = USDT_WITHDRAW` | **0건** |
| `withdrawals` where `parent_withdrawal_id IS NOT NULL` | **0건** — 전환 출금이 생성된 적 자체가 없다 |
| `refund_type = CANCEL` | **11건** (+ `remainder_resolution=CANCELLED` 2건) |
| `p2p_withdraw_entries` RELEASE | 13건 — **전부 취소 경로** |

**없애려는 것으로 100% 운영해왔고, 표준으로 삼으려는 것은 한 번도 안 써봤다.**
따라서 이 경로는 **신규 기능에 준하는 검증**이 필요하다. "이미 있는 코드"로 취급하면 안 된다.

#### ☠️ P1 — 지금 상태로는 전환 출금이 자동으로 나가지 않는다 (최우선)

```java
// WithdrawalService.createP2pRemainderWithdrawal
.status(WithdrawalStatus.REQUESTED)   // 하드코딩
```
```typescript
// node-service/packages/relayer-api/src/services/WithdrawalPoller.ts:424
const approved = await withdrawalRepo.findByStatus('APPROVED', { ... });
```

**릴레이어는 `APPROVED` 만 폴링한다.** 전환으로 만들어진 잔여 출금은 `REQUESTED` 로 앉아 있고,
누군가 어드민에서 승인해야 나간다. **자동 만료도 없어서** 방치하면 영구히 `REQUESTED` 로 남으면서
파트너 가용잔액을 `pendingWithdrawalHold` 로 계속 잠근다.

**✅ 확정 조치: 일반 출금과 동일 경로로 만든다.**

- `determineInitialStatus`(자동승인 임계치)를 적용해 `APPROVED`/`PENDING_APPROVAL` 을 결정한다.
- 동시에 `requestWithdrawal` 이 하던 **정책 검증도 태운다** — 현재는 `createP2pRemainderWithdrawal` 이 `requestWithdrawal` 을 우회해 **최소금액·화이트리스트·MASTER 온체인 잔액 검증을 전부 건너뛴다.** 잔여가 0.5 USDT여도 출금 행이 만들어진다.
- ⚠️ **순서 제약은 반드시 유지한다** — 환급(CREDIT)이 신규 출금 생성보다 **먼저**여야 한다. 원금은 T0에 이미 빠졌으므로, 환급 전에 `freeze` 하면 없는 돈을 동결한다.
- 임계치 미만이면 `PENDING_APPROVAL` 로 남는다 → 그 대기를 누가 푸는지 운영 절차 필요.

#### 흐름 — **신청 = 정지, 정지 완료 후 전액, 자동 실행** (확정 2026-08-22)

```
① 신청 (회원 또는 파트너)  → usdt_convert_status = REQUESTED + 주소 저장
                            → 신규 매칭 차단 + 재가격 중단   ← 이 순간 "거래 정지"
② 진행 중 레그 마무리       → 정산되든 취소되든 자연 종결까지 기다린다 (강제 취소 없음)
③ matched == confirmed 도달 → 잔여 전액 재계산
④ 자동 실행                 → completeDirectWithdrawal (승인 절차 없음)
```

**세 가지 원칙**

1. **부분 출금은 없다.** 금액 파라미터 자체를 두지 않는다. 항상 잔여 **전액**이다.
2. **기준 시점은 신청이 아니라 정지 완료(③)** 다. 신청 시점 스냅샷은 **회원 화면 표시용 예상액**일 뿐이고, 실제 지급액은 ③에서 재계산한 값이다.
3. **철회·승인·거절이 없다.** 신청하면 되돌릴 수 없고, ③에 도달하면 사람 개입 없이 나간다.

#### 정지는 이미 구현돼 있다 ✅

`usdt_convert_status = 'REQUESTED'` 가 두 곳에서 배제된다 — 신규 매칭도, 재가격(환율 갱신)도 멈춘다.

```sql
-- MATCHABLE_WHERE (매칭 후보 3개 쿼리 전부)
AND (o.usdt_convert_status IS NULL OR o.usdt_convert_status != 'REQUESTED')
-- repriceWithdrawOrder (재가격 UPDATE)
AND (usdt_convert_status IS NULL OR usdt_convert_status != 'REQUESTED')
```

#### ☠️ P7 — 신청 게이트가 정책과 반대다 (수정 필요)

```java
// requestUsdtConvert — 현행
long matched = order.getMatchedAmount();
long confirmed = order.getConfirmedAmount();
if (matched != confirmed) {
    throw new ConflictException(P2P_CONVERT_NOT_ALLOWED,
            "입금 확인 대기 중인 매칭이 있어 USDT 전환을 요청할 수 없습니다.");
}
```

현행은 **"진행 중 거래가 없어야 신청할 수 있다"** 이다. "신청하면 정지된다"가 아니라 **거래가 이미 다 끝나 있어야 신청이 받아들여진다.**

**그래서 정지가 걸리지 않는다.** 매칭이 붙어 있는 동안 신청이 거부되고, 거부당하는 사이 또 새 매칭이 붙는다. 유동성이 있으면 회원이 신청 타이밍을 영영 못 잡는다.

| | 현행 | 확정 정책 |
|---|---|---|
| 신청 게이트 | `matched == confirmed` 필수 | **제거** — 언제든 신청 가능 = 즉시 정지 |
| 진행 중 레그 | (애초에 없어야 함) | **그대로 마무리** |
| 금액 확정 시점 | 요청 시점 스냅샷 | **정지 완료(③) 시점 재계산** |
| 실행 게이트 | 스냅샷 ≠ 현재값이면 409 | `matched == confirmed` 만 확인, **금액 대조 없음** |

⚠️ **승인 시점 금액 대조를 제거해야 한다.** 진행 중 레그가 마무리되면 잔여가 반드시 변한다(정산되면 줄고
취소되면 는다). 현행 대조는 그 변화를 "금액이 달라졌다"며 409로 막는데, **확정 정책에서는 그 변화가
정상이고 오히려 그것이 최종 기준**이다. 대조가 막던 원래 위험(재가격 개입)은 이미 `REQUESTED` 배제로
차단돼 있으므로 대조를 없애도 안전하다.

#### ☠️ P8 — 자동 실행 스케줄러가 없다 (신규 구현)

"승인 없이 자동 실행"이 확정됐으므로 **③을 감지해 ④를 트리거하는 주체**가 필요하다. 현재는 없다.

```
P2pUsdtConvertExecuteJob (신규, 1분 주기)
  SELECT ... FROM p2p_withdraw_orders
   WHERE usdt_convert_status = 'REQUESTED'
     AND matched_amount = confirmed_amount      -- 진행 중 레그 없음
     AND refund_type IS NULL                    -- 미처리
     AND status NOT IN (터미널)
  → 주문별 FOR UPDATE → 분쟁 재확인 → 잔여 전액 재계산 → completeDirectWithdrawal
```

- 주문 단위로 실패를 격리한다(한 건 실패가 배치를 멈추지 않게).
- **분쟁(`DISPUTED`)은 실행 직전에 다시 확인한다** — 신청 후 분쟁이 생겼을 수 있고, 전환으로 주문을 닫으면 이후 관리자 판정이 같은 USDT를 재정산해 **이중지급**이 된다.
- 신청 후 ③에 영영 도달하지 못하는 주문(분쟁 파킹 등)은 배치가 계속 스킵한다 → **모니터링 대상**(§4.2 P10).

#### ☠️ P9 — 자동 실행이면 주소 검증이 유일한 방어선이다

승인 단계가 사라졌으므로 **사람이 주소를 볼 기회가 없다.** 잘못된 주소는 그대로 온체인에 나가고 회수 불가다.
현재 주소 검증은 `@NotBlank` **뿐**이다.

→ **신청 접수 시점에 체인별 포맷 검증을 강제한다.** EVM `0x`+40hex, TRON `T`+base58 34자.
화이트리스트가 설정된 파트너면 대조까지. **P2가 아니라 P0급으로 격상된다.**

#### ⚠️ P10 — 철회가 없다는 것의 무게

신청은 되돌릴 수 없다. 그래서 아래가 전부 **회복 불가능한 상태**로 이어질 수 있다.

| 상황 | 결과 |
|---|---|
| 주소를 잘못 입력 | 자동 실행되어 온체인으로 나감 — **회수 불가** (→ P9 주소 검증이 유일 방어) |
| 신청 후 분쟁 발생 | ③에 영영 도달 못 함 → 주문이 정지 상태로 무기한 대기 |
| 마음이 바뀜 | 되돌릴 수 없음 — 매칭 대기로 복귀 불가 |

→ **회원 UI에 확인 단계(주소 재입력 또는 명시적 확인)를 반드시 둔다.** 그리고 `REQUESTED` 상태로 N일 이상
머문 주문을 감시하는 알림이 필요하다(분쟁 파킹으로 잠긴 건을 운영이 인지해야 한다).

#### ☠️ P4 — 파트너 직접 전환의 강제 취소를 제거해야 한다

`convertToDirectWithdrawal` 은 진행 중 매칭을 **강제 취소**하고 바로 전환한다.

```java
assertNoParkedLegs(matchingService.cancelActiveMatchesForWithdrawOrder(
        order.getId(), "출금 주문 종결: convertToDirectWithdrawal"));
```

**"정지 후 남은 전액"이 아니라 "진행 건을 날리고 전액"이다.** 이체신고(`BANK_PENDING`) 레그는
`P2pDisputeParkedException` 으로 막히지만, **구매자가 입금 직전인 `MATCHED` 레그는 그냥 취소된다.**

→ **확정: 파트너 경로도 회원과 동일하게 "정지 후 대기"로 통일한다.** 강제 취소 경로를 Open API에
노출하지 않고, 파트너 신청도 `usdt_convert_status = REQUESTED` 만 걸고 배치(P8)가 실행한다.
콘솔의 기존 `convert-direct`(강제 취소 포함)는 **운영 예외 수단으로만** 남긴다.

#### 나머지 가드레일

| # | 가드 | 현재 상태 |
|---|------|-----------|
| **P5** | **소유권 검증** | `convertToDirectWithdrawal` / `requestUsdtConvert` 에 `partnerId` 파라미터가 없다. 호출부가 `verifyWithdrawOwnership` 을 먼저 해야 한다 — 안 하면 타 파트너 주문을 전환할 수 있다 |
| **P3** | **감사 로그** — 누가·언제·어느 주소로 신청했는지 | **파트너 콘솔 경로에는 없다.** 어드민 경로에만 있다. 자동 실행이라 사후 추적이 유일한 통제 수단이므로 콘솔 경로에도 남긴다 |

☠️ 감싸는 서비스에 **`@Transactional(noRollbackFor = P2pDisputeParkedException.class)` 를 선언**해야 한다. 안 하면 파킹 커밋이 롤백된다.

---

#### 원장 정합성 — 이중 차감이 아니다 (검증 완료)

```
T0  P2P 접수      ledger: DEBIT W, FEE f      entries: CHARGE +K
T1..n 매칭·정산   entries: LOCK/UNLOCK/SETTLE  온체인 유출: Σ레그usdt
Tk  전환 종결     ledger: CREDIT R            entries: RELEASE −잔여 → 잔액 0
                  R = W − Σ레그usdt (환율 환산 아님)
                  withdrawals 신규 행(amount=R) + freeze(R)
Tk+1 온체인 확정  ledger: DEBIT R             온체인 유출: R
```

```
순 원장차감 = −W + R − R = −W
온체인 유출 = Σ레그usdt + R = Σ레그usdt + (W − Σ레그usdt) = W
```

**항등식이 성립한다.** 지급액은 **환율 환산이 아니라** `withdrawals.amount`(요청 시점 차감액, 불변)에서
미실패 레그 usdt 합을 뺀 값이다 — 2026-08-21 드리프트 봉합의 결과이며, 시세 환산으로 되돌리면
초과 지급이 발생한다(실측 주문 52: W=10인데 환산값 10.021802).

#### P6 — 전환 출금의 온체인 실패

**단계별로 온체인이 어디 있는지부터 정리한다** — 이 구분이 없으면 논의가 계속 엉킨다.

| 단계 | 온체인 |
|---|---|
| **B — P2P 출금 접수** (원장 DEBIT + 주문 생성 + 원장 CHARGE) | **없음** |
| 매칭 정산 (MASTER A → MASTER B) | 있음 |
| **잔여 USDT 전환 출금** (신규 `withdrawals` → 릴레이어 → 회원 주소) | **있음** |

여기서 말하는 실패는 **세 번째**다. 첫 번째(B)에는 온체인이 없으므로 그런 실패가 존재하지 않는다.
원장 기록 실패는 같은 트랜잭션 안이라 롤백되므로 별도로 상정하지 않는다.

**결론만 남긴다:**

- 전환 출금이 온체인 실패하면 `credit(partner, R)` 만 남는데, **이는 의도된 동작이다.** R은 물리적으로
  MASTER에 그대로 있고, 원장은 자금의 현재 위치를 기록할 뿐이다.
- ☠️ **CREDIT을 역분개하지 말 것.** `FAILED` 는 터미널이라 재지급하려면 새 출금이 필요하고,
  새 출금은 파트너 잔액을 요구한다. CREDIT이 없으면 **회원에게 영영 못 준다.** CREDIT = 재지급 재원.
- 남는 문제는 **"회원이 못 받은 걸 아무도 모른다"** 하나 — 실패 시 회원 통지 + 운영 알람. **P2 통지 이슈**다.

#### 수수료 — **전액 부과 유지** (확정)

수수료는 **요청 전액 기준으로 T0에 확정**되고 환급이 없다. **매칭이 0건이어서 전액을 USDT로 되돌려받아도
수수료는 전액 낸다.** 출금 행위 자체에 대한 대가로 보는 것이 오너 확정 정책이다.

- 코드 변경 없음(현행 그대로).
- ⚠️ **파트너 문서에 명시 필수** — "P2P 출금 수수료는 매칭 성사 여부와 무관하게 요청 시점에 확정되며 환급되지 않습니다."
- 참고: 이 구조 때문에 전환·취소가 반복되면 파트너 원장이 음수가 될 수 있다(`recordFee` 가 음수를 허용한다 — 의도된 설계).

#### `cancelOrder` 는 **완전히 삭제할 수 없다**

Open API에는 노출하지 않지만, 콘솔에서는 남겨야 한다. 전환으로 닫을 수 없는 주문이 있기 때문이다.

| 상황 | 전환 가능? | 남는 수단 |
|---|---|---|
| `withdrawal_id == NULL` (레거시/직접 생성 주문) | ❌ `P2P_CONVERT_NOT_ALLOWED` | `cancelOrder` / `forceSettle` |
| 잔여 == 0 | ❌ | 정산 완료로 자연 종결 |
| `DISPUTED` 레그 존재 | ❌ `1026` | **어느 것도 불가** — 관리자 판정 선행 필수 |
| 파트너가 이미 원화 지급 완료 | 부적절 | `forceSettle` |

> 실측상 `withdrawal_id == NULL` 주문은 운영에 없을 가능성이 높다(전 주문이 `approveAsP2p` 경로 생성).
> 그래도 **DISPUTED 건은 전환도 취소도 안 되므로**, 파트너에게는 "분쟁 중 주문은 관리자 판정이 먼저"임을 문서화한다.

### 참고: 쓰면 안 되는 경로

`POST /api/partner/p2p/withdraw-orders`(직접 주문 생성)는 **2026-08-21 폐기**되어 항상 409다.
`P2pWithdrawService.createOrder` 도 같은 게이트가 있다. 원금·수수료를 걸 `withdrawals` 행이 없어서 막아둔 경로다.

---
