# LP Adapter 설계 — 총판 그룹별 유동성 공급자

> 작성 2026-08-14. 상태: **설계 — 구현 대기**
>
> 결정 사항 (2026-08-14 확정)
> ```
> 대상 LP     TORQ (기본) · TORQ2
> 역량        거래 역량은 전부 필수 구현 — 미지원은 예외
> 리베이트    LP 포트가 아니다 — TORQ 와의 계약이라 TORQ 전용 (§6.1)
> 라우팅      최상위 총판(root_partner_id)의 lp_provider
> ```
>
> ⚠️ **TORQ2 = BSC 전제를 철회했다 (2026-08-14, TORQ 회신).** 그 전제는 근거가 있어서가 아니라
> v2.9 코드 주석에 적어둔 그 시점의 이해가 설계 문서로 옮겨진 것이었다. TORQ 확인 결과
> **체인은 미확정**이고, TORQ2 의 정의는 체인이 아니다.
> ```
> Buyer Premium 3% (TORQ 2%) · 리베이트 없음 · 독립 인프라(별도 서버·DB·LP풀)
> ```
> 체인이 확정될 때까지 `lpNetworkOf` 의 TORQ2 예외는 그대로 둔다 — 원래 의도가 그것이었다.
>
> 선행 작업: v2.9(`0567c55`)가 라우팅 골격을 이미 넣었다. 이 문서는 **그 위에 무엇이 더 필요한가**를 다룬다.

---

## 1. 결론부터 — 어댑터는 얇다, 체인이 두껍다

TORQ2 가 "같은 시스템, 체인만 다름"이라면 **API 계약이 동일**하므로 어댑터 레이어 자체는 거의 필요 없다. 이미 `TorqClient` 가 `provider` 파라미터를 받고, 설정도 provider 별로 해석된다.

**진짜 작업은 체인이다.** 지금 코드는 여러 곳에서 TRON 을 가정하고 있고, 그 가정이 TORQ2(BSC)에서 정확히 깨진다.

---

## 2. P0 — 지금 TORQ2 를 켜면 원장이 틀린 체인에 쌓인다

### 2.1 실측

```java
// P2pMatchingService.fillRemainderWithTorq — 수신 주소는 provider 체인으로 올바르게 고른다
Long networkId = lpNetworkOf(lpProvider, order.getNetworkId());        // 481-489
WalletAddress master = walletAddressRepo
    .findByPartnerIdAndNetworkIdAndWalletType(partnerId, networkId, MASTER);   // 508
```

```java
// P2pSettlementService.creditTorqLeg — 정산은 TRON 을 그냥 박는다
// "TORQ 레그는 TRON 기준(receiveAddress=파트너 TRON MASTER). 매칭에 네트워크 미저장 → 기본 TRON."
Long networkId = DEFAULT_NETWORK_ID;                    // 549 — 3(TRON)
Long currencyId = getCurrencyIdForNetwork(networkId);   // 550 — 3(TRON USDT)
```

**원인: `p2p_matches` 에 네트워크 컬럼이 없다.** 매칭 생성 시점에 정한 체인이 저장되지 않으니, 정산이 알 방법이 없어 기본값을 쓴다.

### 2.2 TORQ2 에서 벌어지는 일

```
LP 가 파트너 BSC MASTER 로 USDT 전송        ← 온체인 사실
creditTorqLeg 가 TRON 원장에 크레딧          ← 장부
createTorqDeposit 도 TRON 으로 입금 기록      ← 입금 내역
```

파트너 잔액이 **자금이 없는 체인**에 쌓이고, BSC 온체인 자금은 장부에 안 잡힌다. 출금하려면 TRON 잔액은 있는데 실물이 없고, BSC 는 실물이 있는데 잔액이 없다.

이 계열의 사고는 전례가 있다 — 파트너36 온체인 잉여 규명(2026-07-22), 웹훅 분류기 오탐 드랍. **둘 다 "온체인과 원장이 어긋난" 같은 병이고, 사후 규명에 며칠씩 들었다.**

### 2.3 실제로 깨지는 것은 TORQ 레그 하나다

레그별로 나눠 보면 결함의 범위가 좁다.

| 레그 | 체인 유도 | 상태 |
|---|---|---|
| **P2P** | `wo.getNetworkId()` — 출금 주문의 체인 | ✅ 정확하다. 코드 주석도 "정산 네트워크는 반드시 출금 주문의 체인" |
| **PARTNER** | 유도 근거 없음 (`withdraw_order_id` NULL) | ⚠️ 다만 INNER FIAT — 온체인 이동이 없어 원장 라벨일 뿐 |
| **TORQ** | 유도 근거 없음 — `withdraw_order_id` NULL 이고 `torq_trades` 에도 network 컬럼이 없다 | ❌ `DEFAULT_NETWORK_ID` 하드코딩 |

`lpNetworkOf` 가 매칭 시점에 provider 체인을 계산해 **수신 주소를 고르는 데까지만 쓰고 버린다.**

> **주소로 역추적할 수 없다.** `torq_trades.receive_address` 가 있지만 **EVM 주소는 체인 간 동일**하다. 같은 MASTER 주소가 BSC 와 Polygon 에 함께 존재하면 어느 체인인지 판별되지 않는다. 어딘가에 저장하는 것 외에 방법이 없다.

### 2.4 조치 — `p2p_matches.network_id` (2026-08-14 확정)

저장 위치는 `torq_trades`(TORQ 레그만, 최소 변경) 와 `p2p_matches`(전 레그 통일) 두 후보가 있었고 **후자로 결정**했다.

근거: 트랙 3에서 `p2p_settlements` 를 제거하면 원장의 `reference_id` 가 매칭을 가리킨다. 그때 **원장 한 줄에서 체인을 아는 데 한 홉이면 된다.** 레그 유형마다 다른 곳을 보는 유도 로직이 셋으로 갈리는 것도 막는다(수취 계좌 해석이 이미 3벌로 갈려 있는 것과 같은 병이다).

```
① p2p_matches.network_id 신설 (DDL v2.10 반영 완료)
② 매칭 생성 시점에 저장
     createMatch       = wo.getNetworkId()
     createPartnerLeg  = order.networkId ?: DEFAULT      ← 지금은 인자로 받고 버린다
     fillRemainderWithTorq = lpNetworkOf(lpProvider, ...) 결과
③ 읽는 쪽을 매칭으로 통일 — creditTorqLeg / createTorqDeposit 의 DEFAULT_NETWORK_ID 제거
④ 과거 백필 (DDL_V2_10_MIGRATION.sql §2-1b)
```

#### 왜 매칭이 체인을 알아야 하나 — 레그별 체인은 정상 동작이다

매칭은 네트워크 무관이고(2026-06-11), 정산 체인은 **매칭별 출금 주문 기준**이다. 즉 **한 입금 주문의 레그들이 서로 다른 체인일 수 있고, 그것이 설계된 동작이다.**

```
판매 1만원 TRON + 판매 2만원 BSC  →  구매 3만원 매칭
  레그 A  TRON 1만   정산 TX 1
  레그 B  BSC  2만   정산 TX 2      ← 체인이 다르니 전송도 따로 나간다
```

실측으로 확인된다 — 입금 주문 45.

```
매칭 69  P2P  TRON(3)  20,000  SETTLED   정산 체인 3
매칭 70  P2P  BSC(1)   18,000  SETTLED   정산 체인 1
```

**정산은 이미 레그 단위다**(`confirmBankTransfer(matchId)` → `startSettlementForMatch(match)`). 레그 수만큼 정산과 TX 가 생긴다. 막을 것도 지정할 것도 없다.

문제는 **그 체인이 어디 적혀 있느냐**다.

```
P2P 레그    p2p_settlements.network_id      ← 트랙 3에서 이 테이블을 제거한다
TORQ 레그   어디에도 없다                    ← 정산 행 자체를 안 만든다(creditTorqLeg)
                                             매칭 49 는 SETTLED 인데 체인 흔적이 0이다
```

`p2p_matches.network_id` 가 그 자리를 받는다. 레그가 자기 체인을 들고 있어야 정산이 레그별로 제 체인에서 나간다.

> ⚠️ **③이 이 결정의 조건이다.** P2P 레그 정산이 계속 `wo.getNetworkId()` 를 읽으면 같은 사실에 출처가 둘이 된다 — 재설계 문서 6종이 공통으로 지목한 바로 그 결함("계산된 상태를 컬럼에 담아두고 여러 곳에서 갱신한다")을 새로 만드는 셈이다.
>
> **저장 후에는 매칭이 유일한 읽기 지점이다.** `wo.getNetworkId()` 는 매칭 생성 시점의 입력으로만 쓴다.

> ④ 가 안전한 근거: `torq_trades` 1,318건 전량이 `lp_provider=TORQ` 다(2026-08-14 실측). TORQ2 거래는 아직 0건이라 과거 데이터에 다른 체인이 섞여 있지 않다.

**②는 TORQ2 도입 여부와 무관하게 옳다.** 지금은 우연히 맞고 있을 뿐이다 — 매칭이 자기 체인을 모른다는 사실 자체가 결함이다.

### 2.5 PARTNER 레그도 같은 병이다

`fillRemainderWithPartner` 는 체인을 계산해서 넘기는데 **`createPartnerLeg` 가 받아서 버린다.**

```java
Long networkId = order.getNetworkId() != null ? order.getNetworkId() : DEFAULT_NETWORK_ID;
createPartnerLeg(order, need, networkId, rate);   // ← 파라미터로 받지만 builder 에 안 넣는다
```

주석은 "networkId/rate는 createPartnerLeg가 표시용으로 사용"이라고 하는데, 실제 빌더에 `networkId` 가 없다. `rate` 만 쓴다.

> **`withdraw_order_id` 로 레그를 가르지 말 것.** DDL 주석은 "P2P/PARTNER 레그"가 출금주문을 갖는다고 하지만 **PARTNER 레그는 standing 주문을 만들지 않아 전량 NULL 이다**(실측 32/32 — 2026-07-01 정책). 출금주문 보유 여부로 분기하는 코드가 있으면 PARTNER 를 TORQ 와 같이 취급하게 된다.

---

## 3. 체인 가정이 박혀 있는 나머지 지점

`network_id` 를 넣어도 아래는 따로 풀어야 한다.

| 위치 | 현재 | TORQ2 에서 |
|---|---|---|
| `P2pSettlementService:1069` | `getCurrencyIdForNetwork` — default TRON USDT | 매핑 없는 체인이 조용히 TRON 이 됨 |
| `BusinessEventClassifier` | TORQ 레그 인바운드 판별 | 체인 구분 없이 금액창으로 판별 → 오탐 여지 확대 |
| `open-api application.yml` | `torq.tron-network-id` (평면) | provider 별이어야 한다 |

> `TorqRebateService` 의 TRON 하드코딩(`TRON_NETWORK_ID = 3L` · SETTLEMENT 지갑 · `.network("TRON")`)은
> **풀지 않는다.** 리베이트는 TORQ 전용이고 TORQ 는 TRON 이므로, 그 상수는 우연이 아니라 사실이다(§6.1).

> ⚠️ **`getCurrencyIdForNetwork` 의 default 폴백을 제거한다.** 매핑 없는 체인이 조용히 TRON USDT 가 되는 것이 위 2.2 사고를 증폭시킨다. 없으면 예외를 던지고 레그를 만들지 않는 편이 낫다 — `lpNetworkOf` 가 TORQ2 에 대해 이미 그렇게 하고 있다(아래 §4.2).

---

## 4. v2.9 가 이미 해놓은 것

새로 만들 필요가 없다. 확인만 하면 된다.

| | 위치 |
|---|---|
| `LpProvider` enum (TORQ · TORQ2) | `common/enums/LpProvider.java` |
| 그룹 라우팅 — 최상위 총판의 `lp_provider` | `TorqService.resolveLpProvider` → `groupResolver` |
| 거래별 LP 각인 (소급 불변) | `torq_trades.lp_provider`, `createTrade` 시점 |
| provider 스코프 조회 | `UNIQUE (lp_provider, escrow_id)` — escrow_id 는 LP 간 유일하지 않다 |
| provider 별 자격증명 해석 | `TorqProviderProperties` — `torq.providers.<P>.{base-url,api-key,api-secret,webhook-secret}` |
| provider 별 웹훅 경로 | `POST /webhooks/torq/{provider}` |
| 클라이언트 provider 파라미터 | `TorqClient` 전 메서드에 오버로드 존재 |
| LP 레그 네트워크 결정 | `P2pMatchingService.lpNetworkOf` |

### 4.1 폴백은 없다 — 유지한다

```
LP 장애·미설정 → 다른 LP 로 넘기지 않는다 → 그대로 파트너 레그로
```

LP 를 바꾸면 **체인이 바뀌고 수신 주소가 바뀐다.** 장애 시 자동으로 다른 LP 에 태우면 파트너가 예상하지 못한 체인으로 자금을 받는다. 파트너 레그 폴백이 안전하다.

### 4.2 TORQ2 매핑 부재는 의도적으로 예외다

```java
if (provider == LpProvider.TORQ2) {
    throw new IllegalStateException("LP provider TORQ2 의 네트워크 매핑이 아직 없습니다.");
}
```

빈 껍데기를 두지 않고 예외를 던진다 — 오설정의 원인이 되기 때문이다. **이 예외를 지우는 것이 TORQ2 도입의 마지막 단계여야 한다.** 먼저 지우면 매핑 없이 레그가 만들어진다.

---

## 5. TORQ2 도입 체크리스트

순서대로. **앞 단계를 건너뛰면 조용히 잘못된 체인으로 흐른다.**

```
① p2p_matches.network_id 신설 + createMatch 저장 + creditTorqLeg 사용   ← §2.3, TORQ2 무관하게 선행
② TORQ2 자격증명·엔드포인트 확보 → torq.providers.TORQ2.* 주입
   (현재 yml 에 providers 블록이 아예 없다 — 주입 경로부터 만들어야 한다)
③ TORQ2 웹훅 시크릿 합의 + /webhooks/torq/TORQ2 등록
④ 해당 그룹 파트너 전원에게 TORQ2 체인 MASTER 지갑 발급
   ⚠️ 없으면 LP 레그가 에러 없이 스킵되어 전량 파트너 레그로 흐른다 (코드 주석에 이미 경고)
⑤ 리베이트 격리 확인 (§6.1) — TORQ2 거래가 리베이트 집계에 섞이지 않는지
   ✅ v2.9 에서 이미 처리됨. findByLpProviderAndStatusAndRebateStatus(TORQ, ...) 로 필터
   확인만 하고 코드는 건드리지 않는다
⑥ getCurrencyIdForNetwork default 폴백 제거
⑦ lpNetworkOf 의 TORQ2 예외를 실제 매핑으로 교체            ← 마지막
⑧ 최상위 총판 1곳만 lp_provider=TORQ2 로 전환해 소액 E2E
```

---

## 6. 역량 — 거래 역량은 전부 필수

같은 시스템이므로 TORQ2 도 아래를 전부 구현한다고 전제한다.

```
견적 · 거래생성 · 수락 · 이체신고 · 취소 · 조회
분쟁 제기 · 증빙 전달
금액 정정 (TORQ_AMOUNT_CORRECTION_INTERFACE.md)
```

미지원이면 `UnsupportedOperationException` 이 아니라 **`LpCapabilityException` 을 도메인 예외로 던지고 관리자에게 사유가 보이게 한다.** 런타임 예외가 스택트레이스로만 남으면 "왜 정정 버튼이 안 되는지"를 아무도 모른다.

### 6.1 리베이트는 LP 포트가 아니다

리베이트는 **우리와 TORQ 사이의 상업 계약**이지 LP 가 유동성을 공급하는 방식이 아니다.

**입장이 명확하다 — 리베이트를 원하면 TORQ 를 쓰면 된다.** 리베이트는 TORQ 를 선택할 유인이고, 다른 LP 는 리베이트가 아니라 다른 이유(체인 등)로 선택하는 것이다. 따라서 "다른 LP 에도 리베이트를 붙여달라"는 요구는 이 구조에서 나오지 않는다.

따라서 포트에 넣지 않는다. 넣으면 모든 LP 구현체가 "우리는 리베이트 안 줍니다"를 구현해야 하고, 계약이 없다는 사실이 코드에서 예외로 표현된다.

**진짜 위험은 반대 방향이다** — TORQ2 거래가 리베이트 집계에 섞이면 **받을 자격 없는 리베이트를 청구**하게 된다.

```java
// 이미 막혀 있다 (v2.9) — TorqRebateService:164
torqTradeRepository.findByLpProviderAndStatusAndRebateStatus(
        LpProvider.TORQ, COMPLETED, NONE);
```

클래스 주석에도 명시돼 있다: "대상은 `LpProvider.TORQ` 거래뿐이다 (v2.9). TORQ2 는 리베이트가 없다."

> 리베이트 금액은 **잔차 연속값**이지 고정 0.8% 가 아니다. 0.8% 는 상한이다. 집계에 이물이 섞여도 요율이 이상해 보이지 않으므로 **금액 검증으로는 오염을 못 잡는다** — provider 필터가 유일한 방어선이다.

---

## 7. 확정 필요

| 항목 | 내용 |
|---|---|
| ~~TORQ2 리베이트~~ | ✅ 해소 (2026-08-14) — **TORQ2 는 리베이트 없음.** enum 주석과 v2.9 구현이 옳았다. §6.1 |
| **TORQ2 체인** | ⏳ **TORQ 도 미확정.** 확정 통보 대기. 그 전까지 `lpNetworkOf` 예외 유지 |
| **TORQ2 escrow_id** | 독립 DB 라 시퀀스가 TORQ 와 겹칠 것으로 예상 — 확인 요청함. `(lp_provider, escrow_id)` 로 대응돼 있으나, **escrow 번호를 단독 표시하는 로그·운영 화면**은 정리 대상 |
| **TORQ2 Buyer Premium 3%** | `getQuote` 응답에 반영되는지 확인 요청함. 반영되면 우리 쪽 별도 계산 불필요 |
| **그룹 전환 시점** | 이미 진행 중인 거래가 있는 그룹의 `lp_provider` 를 바꾸면? 거래별로 각인돼 있어 기존 건은 안전하나, **파트너 MASTER 지갑이 양쪽 체인에 다 있어야 하는 기간**이 생긴다 |
| **정산 체인 혼재** | 한 파트너가 TRON·BSC 양쪽에 잔액을 갖게 된다. 출금 시 체인 선택·잔액 표시가 이미 그것을 다루는지 확인 필요 |

---

## 8. 구현 계획 편입

[P2P_REDESIGN_IMPLEMENTATION_PLAN.md](./P2P_REDESIGN_IMPLEMENTATION_PLAN.md) 의 트랙과 겹치는 지점.

```
§2.3 ①②③ (network_id)   → 독립 선행. 어느 트랙보다 먼저 해도 된다
                            createMatch 를 건드리므로 트랙 4(출금원장)와 같은 파일
§3 리베이트 provider 화   → 독립
금액 정정 구현            → 트랙 1(분쟁) 이후 — dispute_events 에 AMOUNT_ADJUSTED 를 남겨야 하고
                            매칭 금액 변경 연쇄가 트랙 4(출금원장)의 잠금 재계산과 겹친다
```

> **`network_id` 신설을 가장 먼저 하는 것을 권한다.** additive 이고, TORQ2 와 무관하게 현재 결함이며, 나중에 할수록 백필 대상이 늘어난다.
