# 원금 선차감의 환율 드리프트 봉합 — 원장 = 온체인 (2026-08-21)

repo `cryptoments` · **DDL 없음**
`P2P_PRINCIPAL_UPFRONT_GUIDE.md` (커밋 `d56527f`, **미배포**) 의 **결함 수정**이다.

---

## 무엇이 깨졌나

원금 선차감을 넣으면서 **차감 기준과 유출 기준을 분리**해 버렸다.

```
차감   withdrawals.amount        USDT 고정        WithdrawalService:1253
유출   match.usdt_amount         KRW ÷ 현재환율   P2pSettlementService:362
환급   Remainder.usdt            KRW ÷ 현재환율   P2pWithdrawService:645-647
```

주문의 `krw_amount` 는 고정인데 `usdt_amount`·`exchange_rate` 는 계속 덮인다:

| 덮는 곳 | 조건 |
|---|---|
| `P2pWithdrawService.refreshOrderMarketRate:245-266` | 2시간 잡, `PENDING` 만 |
| `P2pMatchingService:694-699` (**통합 매칭**) | 레그 후보마다, **주문 상태 무관** |

그래서 항상 어긋난다:

```
D = W − [ Σ kᵢ/Rᵢ + (K−Σkᵢ)/Rc ]
전액 매칭·단일 환율이면  D = W · (R − R₀)/R
```

- **환율 상승** → 차감만 당하고 유출이 적다 → 온체인 잉여(유령 USDT) 누적
- **환율 하락** → 유출·환급이 차감보다 크다 → **실제 자금 유출**.
  ☠️ 특히 **무매칭 취소는 온체인 전송이 0 인데 환급만 커져 원장에 USDT 를 순수 생성한다**
  (R 1371→1300 이면 10 USDT 요청에 10.546 환급)

**실측**: 주문 50 은 11시간 만에 0.363% (10 → 9.963663) 벌어졌다. 대기 며칠이면 1~3% 도 가능하다
(P2P 출금 주문은 자동 만료가 없다 — 2026-06-11 결정).

## 구 모델엔 없던 문제다

구 모델은 정산 시 `match.usdt_amount` 를 DEBIT 했다 — **온체인으로 나간 바로 그 값**이다.

```
원장 차감 총액 = Σ match.usdt_amount = 온체인 유출 총액     ← 항등식, 자동 성립
```

환율이 리프레시돼도 차감과 유출이 **같은 값을 참조**하므로 드리프트가 생길 수 없었다.
구 모델의 결함은 "미매칭 원금이 가용잔액에 남는다" 였고 정합성 자체는 온전했다.
**드리프트는 `d56527f` 가 만든 것이다.**

## 환율 동결은 답이 아니다 — 검토했고 기각한다

리프레시 잡은 두 가지 이유로 존재한다(`P2pWithdrawService:234-241`):
① 자동 만료가 없는 주문이 생성 시점 환율로 굳으면 **불공정 체결**
② KRW 액면을 고정해야 구매자 송금액이 안정적이고 **스크래핑(정확 KRW 대조)** 이 동작한다

동결하면 드리프트가 **구매자 쪽으로 옮겨갈 뿐**이고(스테일 환율로 USDT 를 받는다),
`P2pMatchingService:694-701` 의 통합 경로가 주문 환율을 읽지도 않으므로 잡만 꺼도 무효다.
게다가 더스트 하향 조정(`:722-738`)의 시차가 커져 **P2P 레그가 되돌려지고 전액 LP 로 새는** 빈도가 오른다.

---

## 고치는 원칙 — 원장이 실제 자금 이동과 같아지게 한다

> **선차감은 예약이고, 실제 이동이 확정될 때마다 그 자리에서 정산한다.**

규칙 두 개면 닫힌다.

```
정산 시   조정 = W × 레그KRW / K − 레그 usdt_amount     (양수 CREDIT · 음수 DEBIT)
종결 시   환급 = W × 잔여KRW / K                        (차감 기준으로 되돌린다)
```

합하면
```
Σ조정 + 환급 = W − Σ(레그 usdt_amount)
→ 원장 순차감 = Σ 레그 usdt_amount = 온체인 유출 총액   ✓
```

검산 (주문 50: W=10, K=13,710, R=1376):

| 시나리오 | 원장 | 온체인 |
|---|---|---|
| 전액 매칭·정산 | −10 + 0.036337 = **−9.963663** | −9.963663 ✓ |
| 무매칭 취소 | −10 + 10 = **0** | 0 ✓ |
| 절반 매칭 후 취소 | −10 + (조정) + 5 = −(레그 usdt) | 일치 ✓ |

---

## §A. 환급 기준 정정 — `P2pWithdrawService.applyRemainderResolution`

지금 `r.usdt`(= `order.usdtAmount × 잔여KRW / K`, **드리프트된 값**)를 쓴다. 바꾼다:

```
환급 USDT = withdrawal.amount × 잔여KRW / K        (scale 18, DOWN)
```

- `withdrawal` 은 이미 `principalRefundTarget` 이 들고 있다 — **추가 조회하지 마라**
- ☠️ `order.getUsdtAmount()` 를 쓰지 마라. 그게 이 버그의 원인이다
- `remainder_usdt` **컬럼 값은 그대로 두라** — 그건 회원 화면 표기용(KRW 기준 USDT 환산)이고
  파트너 원장 환급액과 목적이 다르다. **두 값이 달라지는 것이 정상**임을 주석으로 남겨라
- `K`(`order.getKrwAmount()`)가 0 이거나 null 이면 환급 0 + WARN

## §B. 정산 시 드리프트 조정 — `P2pSettlementService.completeSettlement`

원금 DEBIT 이 사라진 자리(2-1)에 **조정 기입**을 넣는다. **신규 건에만** (과도기 건은 구 모델대로
레그 DEBIT 을 하므로 조정 대상이 아니다 — `P2pPrincipalModel` 로 판별).

```
share      = W × match.krw_amount / K          (scale 18, DOWN)
adjustment = share − match.usdt_amount
adjustment > 0  →  credit(adjustment)          선차감이 실제보다 컸다
adjustment < 0  →  debit(|adjustment|)         실제가 선차감보다 컸다
adjustment == 0 →  아무것도 하지 않는다        (행을 만들지 마라)
```

- `reference_type` = `WITHDRAWAL`, `reference_id` = **출금 ID** (차감·환급과 같은 축)
- `description` 에 **matchCode 와 환율 근거**를 남겨라 — 나중에 이 행이 왜 생겼는지 추적해야 한다
- ☠️ `debit` 은 잔액 부족 시 예외를 던진다. **정산 중에 예외가 나면 매칭이 정산되지 못한다.**
  음수 조정은 대개 아주 작지만(드리프트분), 잔액이 빠듯하면 터질 수 있다.
  → 음수 조정은 `debit` 대신 **`recordAdjustment` 계열**(예외 없는 경로)을 쓸지 검토하고,
  없으면 **try/catch 로 감싸 ERROR 로그 + 정산은 계속**하라. 정산이 조정 때문에 멈추면 안 된다.
  **어느 쪽을 골랐는지 근거와 함께 보고하라**
- `K`가 0/null 이거나 `match.krw_amount` 가 null 이면 조정 생략 + WARN
- **PARTNER/TORQ 레그는 대상이 아니다** — `withdraw_order_id` 가 없다. 기존 가드 그대로

## §C. 반올림 잔차

`share` 를 레그마다 DOWN 으로 자르므로 `Σ share` 가 `W` 에 미세하게 못 미칠 수 있다
(레그 수 × 1e-18 수준). **마지막 레그에서 몰아주는 보정을 하지 마라** — 어느 레그가 마지막인지
정산 시점에 알 수 없다. 잔차는 종결 시 환급(`§A`)이 `W × 잔여KRW/K` 로 흡수한다.
전액 매칭이면 잔여 0 이라 잔차가 그대로 남지만 **1e-18 규모**다 — 무시한다.

---

## 절대 규칙

- **DDL·DML 금지. DB 접속 금지. 운영 서버 접속 금지**
- `withdrawals.amount` 를 기준으로 삼아라 — **불변임을 확인했다**(Java 전체에서 `setAmount` 호출 0건)
- `order.usdt_amount` / `exchange_rate` 를 **환급·조정 계산에 쓰지 마라**
- 리프레시 잡 · 통합 매칭의 재가격(`P2pMatchingService:694-699`)을 **건드리지 마라** — 기각했다
- `p2p_withdraw_entries`(KRW 원장)를 건드리지 마라 — 대수적으로 닫혀 있고 드리프트가 없다
- 과도기 건(`P2P_PENDING`)은 조정·환급 **둘 다 대상이 아니다** — 구 모델은 이미 정합하다
- 정산이 조정 실패로 멈추면 안 된다 (§B)
- MyBatis `<script>` 안에 `<` `<=` `<>` 금지
- 새 라이브러리 금지 · **git commit / push 하지 마라**

## 완료 기준

```
1  ★ 환급 = withdrawal.amount × 잔여KRW/K — order.usdtAmount 를 쓰지 않음을 코드로 증명
2  ★ 정산 시 드리프트 조정이 신규 건에만 들어간다 (과도기 건 제외) — 코드 경로로 증명
3  ★ 원장 순차감 = Σ 레그 usdt_amount 임을 산식으로 증명 (전액매칭 · 부분+취소 · 무매칭취소)
4  ★ 무매칭 취소 시 환급이 정확히 withdrawal.amount 다
5  조정이 실패해도 정산은 완료된다 (선택한 방식과 근거를 보고)
6  remainder_usdt 컬럼 값은 무변경 (화면 표기용)
7  리프레시 잡 · 통합 매칭 재가격 · KRW 원장 무변경 (diff 증명)
8  ./gradlew 전 모듈 컴파일 통과
9  git commit·push 하지 않았다
```

## 보고

- 수정 파일:라인 + 한 줄
- **1~4 를 코드 경로로 증명**
- **주문 50 실측값으로 검산**하라 (W=10, K=13,710, 현재 rate 1,376):
  ① 전액 매칭·정산 ② 무매칭 취소 ③ 6,855원만 매칭·정산 후 잔여 취소
  각각 파트너 원장 순변동 · 온체인 유출 · 두 값이 같은지
- §B 에서 음수 조정 처리 방식을 무엇으로 골랐는지와 근거
- 지침이 실제 코드와 어긋난 지점 — 고치지 말고 먼저 보고
- 빌드 결과

## 착수 전 필수 확인

```
core/.../p2p/P2pWithdrawService.java        :639-652 computeRemainder · :664-687 principalRefundTarget
                                            :688-725 applyRemainderResolution (환급 자리)
core/.../p2p/P2pSettlementService.java      :541-565 잠금·원금DEBIT 자리 (조정을 넣을 곳)
                                            :304, :362 온체인 전송액 출처
core/.../settlement/SettlementService.java  :119-190 credit · :195-222 debit(예외) · :250-280 조정 계열
common/.../util/P2pPrincipalModel.java      과도기 판별자
core/.../withdrawal/WithdrawalService.java  :1234-1263 요청 시점 DEBIT (W 의 출처)
v2-docs/P2P_PRINCIPAL_UPFRONT_GUIDE.md      선행 변경
```

지침과 다르면 **멈추고 보고하라.**

---

## 남는 노출 (이번 범위 밖 — 보고만)

- **회원이 요청한 USDT(W)와 실제 팔린 USDT(Σ레그)가 다르다.** 환율이 오르면 회원은 10 을 내고
  9.96 어치만 팔린 셈이 된다. 이 차이는 **구 모델에도 경제적으로 존재했고**(원장에만 안 보였다),
  이번 수정으로 원장에는 정직하게 드러난다. **누가 흡수할지는 정책 결정**이다 — 코드로 정하지 마라
- 사후 리컨실 잡은 여전히 없다. 원장↔온체인 대사는 수동이다
