# P2P 매칭 — 그룹 범위 + LP 라우팅 지침서 (DDL v2.9)

작성일: 2026-08-13
대상: Cowork 서브에이전트 (구현)
선행: P2P 수수료 재설계 v2.8 (`67b4dc0` / `137d0b3` / admin `3cbbabc`), 웹훅 재정의 (`5432258` / admin `947c515`)

---

## 1. 목적

최상위 총판(그룹) 단위로 **자기 그룹 안에서만 매칭할지, 외부 그룹과도 매칭할지**를 제어한다.

그룹이 자립 가능한 유동성을 갖추면 격리해 운영하고, 그전까지는 전체 풀을 공유해 매칭률을 확보하기 위함이다.

### 결정 사항

| 항목 | 결정 |
|---|---|
| 기본값 | **크로스 매칭 허용** (`TRUE`) |
| 설정 단위 | **최상위 총판 1개 필드.** 하위 파트너는 상속만 하고 개별 설정 없음 |
| 설정 주체 | **어드민 전용.** 파트너 콘솔에 노출하지 않는다 |
| 크로스 판정 | **양쪽 그룹이 모두 허용**해야 성립. 한쪽이라도 "내부만"이면 불가 |
| 같은 그룹 | 설정과 무관하게 **항상 매칭** |
| 그룹 판정 | `partners.root_partner_id` 비정규화 컬럼 |

### 판정 매트릭스

| | 출금 그룹 내부만 | 출금 그룹 외부 허용 |
|---|---|---|
| **입금 그룹 내부만** | 크로스 불가 | 크로스 불가 |
| **입금 그룹 외부 허용** | 크로스 불가 | **크로스 가능** |

양쪽 AND 로 판정하는 이유 — 한쪽 설정만 보면 "내부만"으로 걸어둔 그룹의 유동성이 외부로 새거나, 반대로 그 그룹의 구매자가 외부 유동성에 붙는다. "내부만"이 확실한 격리를 보장해야 한다.

### 레그별 적용 범위 — ⚠️ 이 설정은 P2P 레그에만 걸린다

폴백 체인 3단계 중 **P2P 레그의 후보 풀만** 좁힌다. 나머지 두 레그는 **현행 그대로** 동작한다.

| 레그 | 그룹 제한 | 근거 |
|---|---|---|
| ① P2P | **적용** | 파트너 간 유동성 거래. 격리 대상이 바로 이것 |
| ② TORQ (LP) | **미적용** | 다른 그룹이 아니라 **유동성 공급원**이다. 그룹 격리와 성격이 다르다 |
| ③ PARTNER | **미적용** | 구매자가 **입금 파트너 자신의 서비스 계좌**로 이체하는 FIAT 경로. 애초에 자기 자신이라 그룹 개념이 없다. USDT 가 플랫폼에서 이동하지 않아 잠금·게이트도 없고, 서비스 계좌만 있으면 항상 성립하는 최종 안전망 |

즉 **"내부만"으로 걸어도 폴백 체인은 그대로다.** 내부 P2P 유동성이 부족하면 기존과 동일하게 TORQ → PARTNER 로 넘어간다. 바뀌는 것은 ① 단계에서 볼 수 있는 출금 주문 후보뿐이다.

> **재검토 지점** — 이 정의는 "그룹 격리 = P2P 상대 제한"이라는 현재 판단에 기반한다. 그룹이 완전 자립(외부 LP 도 쓰지 않음)을 원하는 단계가 오면 LP 사용 여부를 별도 필드로 분리해야 한다. `p2p.torq_fallback_enabled` 가 현재 글로벌 설정으로만 존재한다.

### 범위 밖 (다음 작업)

- `p2p.max_p2p_legs` (현재 2)
- 후보 정렬 기준 (현재 잔여 DESC → FIFO)

---

## 2. 왜 `root_partner_id` 인가

현재 운영 트리는 **최대 깊이 1단**이다(전체 53곳 중 36 루트 + 17 자식, 2단 이상 0). 그래서 `COALESCE(parent_partner_id, id)` 로도 루트를 구할 수 있다.

**그러나 다층 그룹이 들어올 예정이다.** 손자 파트너가 생기는 순간 `COALESCE` 는 부모를 루트로 잘못 잡는다. 실제로는 다른 그룹인데 같은 그룹으로 판정하거나 그 반대가 되고, **에러 없이 조용히 틀린다.**

지금 깊이가 얕을 때 컬럼을 넣으면 백필이 한 줄이다. 다층이 들어온 뒤에 발견하면 이미 잘못된 매칭이 쌓여 있다.

동기화 지점은 하나다 — **파트너 생성 시 부모의 `root_partner_id` 복사**(부모가 최상위면 부모의 `id`). 파트너 이동 기능은 현재 없으므로 대상이 아니다.

> ⚠️ 파트너 이동(부모 변경) 기능을 나중에 추가하면 **하위 서브트리 전체의 `root_partner_id` 를 갱신**해야 한다. 컬럼 주석에 이 사실을 남길 것.

---

## 3. DDL (v2.9)

### 3.1 헤더 개정

```
-- ║  v2.9 (2026-08-13): P2P 매칭 그룹 범위 제어 —                        ║
-- ║         partners.root_partner_id (최상위 총판 비정규화) +             ║
-- ║         p2p_cross_group_matching (최상위에만 의미, 기본 허용).        ║
-- ║         양쪽 그룹이 모두 허용해야 크로스 매칭 성립.                    ║
-- ║         지침서: v2-docs/P2P_MATCHING_GROUP_SCOPE_GUIDE.md            ║
-- ║         테이블 수 변화 없음(54개)                                     ║
```

### 3.2 ALTER

```sql
ALTER TABLE partners
  ADD COLUMN root_partner_id BIGINT NULL
    COMMENT '최상위 총판 id (비정규화). 자기 자신이 최상위면 자기 id. 생성 시 부모 값 복사.
             ⚠️ 파트너 이동(부모 변경) 기능 추가 시 하위 서브트리 전체를 갱신해야 한다 (v2.9)'
    AFTER parent_partner_id,
  ADD COLUMN p2p_cross_group_matching BOOLEAN NOT NULL DEFAULT TRUE
    COMMENT 'P2P 크로스 그룹 매칭 허용 — 최상위 총판 행에만 의미(하위는 루트 값을 따름).
             FALSE: 자기 그룹 안에서만 매칭. 크로스는 양쪽 그룹이 모두 TRUE 여야 성립.
             어드민 전용 설정 — 파트너 콘솔 미노출 (v2.9)'
    AFTER root_partner_id;

CREATE INDEX idx_root_partner ON partners (root_partner_id);

-- LP 라우팅 (§6)
ALTER TABLE partners
  ADD COLUMN lp_provider VARCHAR(20) NULL
    COMMENT 'LP 공급자 (TORQ / TORQ2) — 최상위 총판 행에만 의미(하위는 루트 값을 따름).
             NULL = TORQ. LP 장애 시 다른 LP 폴백 없음. TORQ2 는 리베이트 없음.
             레그 수령 네트워크도 이 값이 정한다(구매자 선택 아님) (v2.9)'
    AFTER p2p_cross_group_matching;

ALTER TABLE torq_trades
  ADD COLUMN lp_provider VARCHAR(20) NOT NULL DEFAULT 'TORQ'
    COMMENT 'LP 공급자 (TORQ / TORQ2) — 웹훅 라우팅·리베이트 집계의 유일한 근거.
             escrow_id 는 provider 간 유일하지 않으므로 조회는 반드시
             (lp_provider, escrow_id) 로 한다. 리베이트는 TORQ 만 (v2.9)'
    AFTER partner_user_id;

-- ⚠️ 운영 실측: 인덱스명은 idx_escrow_id 이고 NON-unique 다 (DDL 파일은 uk_escrow_id UNIQUE 로
--    적혀 있었으나 운영과 어긋나 있었다 — v2.9 에서 파일을 운영 기준으로 정정).
--    현재 데이터는 escrow_id 가 유일하므로(1305/1305) 복합 UNIQUE 추가는 무손실로 성공한다.
ALTER TABLE torq_trades
  DROP INDEX idx_escrow_id,
  ADD UNIQUE KEY uk_lp_escrow_id (lp_provider, escrow_id);

-- tx_hash 는 UNIQUE 로 만들지 않는다 — idx_tx_hash(NON-unique) 유지. 근거는 아래 참조.
```

> **`torq_trades.tx_hash` 에 UNIQUE 를 걸지 않는 이유**
>
> 이 컬럼은 **LP 의 릴리스 tx** 다. 한 행 = 한 escrow 이므로 두 행이 같은 해시를 갖는 경우는 **LP 가 여러 escrow 를 한 트랜잭션으로 묶어 보낼 때**다. TORQ 는 escrow 단위로 릴리스해 실측 1176 건이 전부 distinct 지만, 그것은 **TORQ 의 구현 특성이지 우리가 강제한 계약이 아니다.** 동작을 모르는 LP(TORQ2)를 붙이려는 시점에 배치 전송을 금지하는 제약을 거는 것은 위험하다.
>
> DDL 파일이 `uk_torq_trades_tx_hash` UNIQUE 로 선언하고 운영이 `idx_tx_hash` NON-unique 였던 드리프트는 **파일을 운영에 맞추는 방향으로** 해소한다 (v2.9).
>
> ⚠️ 대신 **`TorqTradeRepository.findByTxHash` 가 단건 반환이라 중복 발생 시 `TooManyResultsException` 이 난다.** 호출부가 `BusinessEventClassifier` / `WebhookProcessingService` 라 웹훅 처리가 통째로 멎는다. `findByP2pMatchId` 와 동일하게 **List 반환 + 결정적 선택**으로 바꿔야 한다 (별도 항목).

> ⚠️ **`p2p_match_id` 에는 UNIQUE 를 걸 수 없다.** 매칭 855 에 `torq_trades` 2행(id 808/escrow 810 CANCELLED, id 809/escrow 812 COMPLETED)이 실재한다 — 2026-07-22 TORQ 금액 불일치 수동 보정의 흔적이고, `p2p_matches.torq_escrow_id` 는 여전히 취소된 810 을 가리킨다. 레그↔거래 1:1 은 **보장되지 않는 가정**이다. §6.3 참조.

### 3.3 백필

```sql
-- 트리 깊이 1단이므로 한 번에 끝난다. 2단 이상이 생긴 뒤라면 재귀 CTE 로 바꿀 것.
UPDATE partners SET root_partner_id = COALESCE(parent_partner_id, id);

-- 검증 — 루트가 자기 자신이거나, 루트 행의 parent 가 NULL 이어야 한다
SELECT p.id, p.partner_code, p.parent_partner_id, p.root_partner_id, r.parent_partner_id AS root_parent
  FROM partners p LEFT JOIN partners r ON r.id = p.root_partner_id
 WHERE p.root_partner_id IS NULL
    OR (r.parent_partner_id IS NOT NULL);
-- 결과 0건이어야 한다
```

---

## 4. 구현

### 4.1 그룹 해석 — `P2pGroupResolver` (신설 또는 기존 서비스에 추가)

```java
/** 이 파트너가 속한 그룹(최상위 총판) id */
public Long resolveRootPartnerId(Long partnerId) { ... }

/** 이 그룹이 크로스 매칭을 허용하는가 — 루트 행의 값을 읽는다 */
public boolean isCrossGroupAllowed(Long rootPartnerId) { ... }
```

`root_partner_id` 가 있으므로 순회가 필요 없다. 컬럼이 NULL 인 이상 데이터는 **자기 자신을 루트로 폴백**하고 `WARN` 로그를 남긴다(백필 누락 방어).

### 4.2 매칭 후보 SQL — ⚠️ 반드시 SQL 에서 좁힌다

`common/.../mapper/P2pMatchingMapper.java` 의 **세 메서드 전부**에 조건을 추가한다.

| 메서드 | 용도 | LIMIT |
|---|---|---|
| `findMatchableWithdrawOrdersForUpdate` | 통합/분할 매칭 후보 | 10 |
| `findExactMatchesForUpdate` | 완전일치 (레거시 경로) | 5 |
| `findAvailableWithdrawOrders` | **라우팅 판정용** | 100 |

> ⚠️ **`LIMIT` 이 Java 필터보다 먼저 적용된다.** 상위 N건을 DB에서 먼저 잠근 뒤 Java 에서 거르는 구조라, 그룹 조건을 Java 후처리로 넣으면 그 N건이 전부 타 그룹으로 채워져 **후보 0건**이 된다(기아 상태).

```sql
JOIN partners wp ON wp.id = o.partner_id
  AND wp.krw_enabled = 1 AND wp.p2p_withdraw_enabled = 1
  AND wp.status = 'ACTIVE'
JOIN partners wroot ON wroot.id = wp.root_partner_id
...
WHERE ...
  AND (
        wp.root_partner_id = #{depositRootId}
     OR (#{depositCrossAllowed} = 1 AND wroot.p2p_cross_group_matching = 1)
  )
```

- `#{depositRootId}` / `#{depositCrossAllowed}` 는 Java 가 입금 파트너 기준으로 해석해 넘긴다
- 같은 그룹이면 첫 조건에서 통과 — 설정과 무관하게 항상 매칭
- `<script>` 내부이므로 `<`·`<=`·`<>` 사용 금지. 위 조건은 `=` 와 `OR` 만 쓰므로 안전

### 4.3 `P2pMatchingService`

- `tryMatchDeposit` / `tryMatchDepositUnified` / 레거시 완전일치·분할 경로에서 매퍼 호출 시 두 파라미터를 전달
- **`checkRoute` 에도 반드시 반영한다.** 라우팅 판정이 매칭기와 다른 풀을 보면, 라우터가 P2P 를 고르고 실제 매칭은 실패한다 (코드 주석에 이미 경고된 기왕의 사고 패턴)
- `partnerId` 가 null 인 오버로드가 있으면 처리 정책을 정해 보고할 것

### 4.4 파트너 콘솔 풀 현황 — 게이트 동기화 계약

`partner-api/.../mapper/PartnerP2pPoolMapper.java` 의 `MATCHABLE_FROM_WHERE` 가 매칭기 게이트를 복제하고 있고, 클래스 JavaDoc 에 **"매칭 가능 판정 게이트는 `P2pMatchingMapper.findAvailableWithdrawOrders` 와 완전 일치시킨다"** 는 계약이 명시돼 있다.

그룹 조건을 여기에도 반영하라. 안 하면 화면에 "가용 5억"이 뜨는데 실제 매칭 가능액은 자기 그룹 몫뿐인 상태가 된다.

`PartnerP2pPoolService` 의 USDT 합산 범위도 같이 좁힌다.

### 4.5 어드민 API·UI

- 파트너 상세에 `p2pCrossGroupMatching` 토글 추가. **최상위 총판일 때만 노출·수정 가능**
- 하위 파트너 화면에는 "그룹 설정을 따름 (현재: 허용/내부만)" 정도로 읽기 전용 표시
- 파트너 콘솔(`partner-ui`)에는 **노출하지 않는다**
- 응답 DTO에 `rootPartnerId` / `rootPartnerName` 을 함께 주면 어느 그룹인지 확인이 쉽다

### 4.6 건드리지 않는 것

- **INNER/ONCHAIN 정산 분기** — `fromPartnerId.equals(toPartnerId)` 정확 비교다. 같은 그룹이라도 파트너가 다르면 ONCHAIN 유지. 그룹 기준으로 바꾸면 파트너별 MASTER 지갑 정합성이 깨진다
- **수수료 계산** — 입금 수수료는 입금 파트너 체인, 출금 수수료는 출금 파트너 체인으로 각자 산출된다. 크로스 그룹이어도 정합
- **LP 라우팅** — P2P 레그는 파트너 간 직거래라 LP 를 타지 않는다

---

## 5. 검증

### 5.1 그룹 판정

| 케이스 | 기대 |
|---|---|
| 최상위 총판 | `root_partner_id = 자기 id` |
| 1단 하위 | `root_partner_id = 부모 id` |
| 신규 생성 | 부모의 `root_partner_id` 복사 |
| 컬럼 NULL (이상 데이터) | 자기 자신으로 폴백 + `WARN` |

### 5.2 매칭 판정

| 입금 그룹 | 출금 그룹 | 같은 그룹? | 기대 |
|---|---|---|---|
| 허용 | 허용 | 다름 | **매칭됨** |
| 허용 | 내부만 | 다름 | 매칭 안 됨 |
| 내부만 | 허용 | 다름 | 매칭 안 됨 |
| 내부만 | 내부만 | 다름 | 매칭 안 됨 |
| 내부만 | 내부만 | **같음** | **매칭됨** |

### 5.3 게이트 일치

`findAvailableWithdrawOrders`(라우팅), `findMatchableWithdrawOrdersForUpdate`(매칭), `PartnerP2pPoolMapper`(화면) **세 곳이 같은 결과 집합**을 내는지 확인하라. 하나라도 어긋나면 라우터가 P2P 를 고르고 매칭이 실패하거나, 화면 수치와 실제가 다르다.

### 5.4 회귀

- 전 파트너 `p2p_cross_group_matching = TRUE` 인 상태에서 **매칭 결과가 변경 전과 동일**해야 한다. 기본값이 허용이므로 배포만으로는 동작이 바뀌지 않는다
- 같은 파트너 내 매칭(실측 99.91%)이 영향받지 않는지 확인

### 5.5 코딩 규칙

MyBatis `<script>` 내 `<`·`<=`·`<>` 금지 (2026-06-11 기동 실패 장애). `ORDER BY`/`LIMIT`/`COUNT` 직접 작성 금지.

### 5.6 빌드

```bash
./gradlew :common:compileJava :core:compileJava :admin-api:compileJava \
          :partner-api:compileJava :open-api:compileJava :scheduler:compileJava
cd ../cryptoments-admin/admin-ui && npm run build
```

---

## 6. LP 라우팅 (TORQ / TORQ2) — 분기 구조만 선반영

현재 LP 는 TORQ 하나다. TORQ2(BSC) 는 아직 없다. **이번 작업은 분기 이음매만 만들고 TORQ2 구현체·자격증명·엔드포인트는 만들지 않는다.**

### 6.1 확정 사항

| 항목 | 결정 |
|---|---|
| 설정 위치 | **최상위 총판** (`root_partner_id`) — 그룹 범위와 같은 자리 |
| 하위 개별 설정 | **없음** |
| 미지정 총판 | `TORQ` (기본) |
| LP 장애 시 다른 LP 폴백 | **없음** — 해당 LP 실패 시 그대로 파트너 레그로 |
| TORQ2 리베이트 | **없음** |

### 6.2 왜 지금 하는가 — ③만은 소급이 불가능하다

나머지 이음매는 TORQ2 가 실제로 생길 때 붙여도 된다. 그러나 **`torq_trades` 의 provider 각인은 다르다.** 나중에 컬럼을 추가하면 그 시점까지 쌓인 거래가 어느 LP 였는지 알 수 없다. 지금은 전량 TORQ 라 백필이 자명하지만, TORQ2 가 돌기 시작한 뒤에는 복구할 방법이 없다. **③은 이번에 반드시 넣는다.**

### 6.3 다섯 이음매

**① LP 결정** — `partners.lp_provider VARCHAR(20) NULL`

최상위 총판 행에만 의미가 있다. `NULL` = `TORQ`. 조회는 §4.1 `P2pGroupResolver` 의 root 해석을 **그대로 재사용**한다 — 같은 트리를 두 번 타지 않는다.

```java
public enum LpProvider { TORQ, TORQ2 }

/** 하위 파트너는 최상위 총판의 값을 따른다. 미지정이면 TORQ. */
LpProvider resolveLpProvider(Long partnerId);
```

**② 자격증명 분리** — `TorqClient`

현재 `baseUrl` / `apiKey` / `apiSecret` 이 `@Value` 단일 주입이라 분기 자체가 불가능하다. provider 키 맵으로 바꾼다.

```yaml
torq:
  providers:
    TORQ:
      base-url: ${TORQ_BASE_URL:https://api.torqfi.net}
      api-key: ${TORQ_API_KEY:}
      api-secret: ${TORQ_API_SECRET:}
      webhook-secret: ${TORQ_WEBHOOK_SECRET:}
```

- 기존 평면 키(`torq.base-url` 등)는 **`TORQ` 항목의 폴백으로 유지**한다. 운영 env 를 건드리지 않고 배포하기 위함이다
- `TORQ2` 항목은 **만들지 않는다** — 값이 없는 껍데기는 오설정의 원인이 된다. 실제로 붙일 때 추가한다
- `TorqClient` 메서드에 `LpProvider` 파라미터를 받는 오버로드를 두고, 기존 시그니처는 `TORQ` 위임으로 남긴다

**③ 거래 각인** — `torq_trades.lp_provider` ⚠️ 필수 (DDL 은 §3.2)

기존 행은 `DEFAULT 'TORQ'` 로 전량 정합한다(현재 LP 가 하나뿐). `fillRemainderWithTorq` 와 단독 TORQ 경로 양쪽에서 insert 시 명시한다.

**`escrow_id` 인덱스도 `(lp_provider, escrow_id)` UNIQUE 로 교체한다.** 운영 인덱스는 `idx_escrow_id`(NON-unique)이고 현재 데이터는 유일하므로(1305/1305) 무손실로 성립한다.

> ⚠️ **레그↔거래 1:1 은 성립하지 않는다.** `p2p_match_id` 로 단건 조회하면 **지금 있는 데이터에서 이미 터진다** — 매칭 855 에 2행(id 808/escrow 810 `CANCELLED`, id 809/escrow 812 `COMPLETED`)이 있다. 2026-07-22 TORQ 금액 불일치 수동 보정이 대체 거래를 만들었고 `p2p_matches.torq_escrow_id` 는 취소된 810 에 남아 있다. `escrow_id` → `p2p_match_id` 로 조회 키를 옮길 때 **반드시 다건을 전제**해야 한다.

**④ 웹훅 수신** — 경로·시크릿

`TorqWebhookController` 가 `@PostMapping` (무경로) 하나이고 시크릿도 `@Value("${torq.webhook-secret:}")` 단일이다.

- `@PostMapping("/{provider}")` 를 **추가**하고, 기존 무경로 매핑은 `TORQ` 로 유지한다 — TORQ 측 등록 URL 을 바꾸지 않기 위함이다
- 서명 검증은 해당 provider 의 `webhook-secret` 으로 한다
- `escrowId` 는 **provider 간 유일하지 않다.** 조회는 반드시 `(lp_provider, escrow_id)` 로 한다 ⚠️

**⑤ 리베이트 필터** — TORQ2 는 리베이트가 없다

`TorqRebateService` / `TorqRebateSettlementJob` / `TorqRebateReconcileJob` 의 거래 수집이 `lp_provider = 'TORQ'` 만 보게 한다. 필터가 없으면 TORQ2 거래가 TORQ 리베이트 정산 요청에 섞여 들어가 **TORQ 측에서 거절되거나 금액이 어긋난다.**

### 6.4 체인 — LP 가 정한다 (구매자 선택 아님)

TORQ2 가 BSC 전용이라는 점은 **제약이 아니다.** 구매자는 체인을 고르지 않는다.

- LP 레그의 USDT 수령처는 구매자 지갑이 아니라 **구매자 파트너의 MASTER 지갑**이다
- 구매자가 받는 것은 **파트너 원장의 크레딧**이며, 이는 체인과 무관하다
- 즉 체인은 **LP ↔ 파트너 사이의 정산 디테일**이고 구매자에게 보이지 않는다

따라서 LP 레그의 네트워크는 주문값이 아니라 **provider 가 정한다.**

```java
// TORQ  → 기존 기본 네트워크
// TORQ2 → BSC
Long networkId = lpNetworkOf(provider);
```

`P2pWidgetRouteRequest.networkId`("매칭 대상 체인")는 **P2P 레그의 정산 체인**을 가리키는 값이므로 그대로 둔다. LP 레그만 provider 기준으로 바꾼다.

**유일한 요건은 파트너가 그 체인의 MASTER 지갑을 갖고 있어야 한다는 것**이고, 그 가드는 이미 있다.

```java
WalletAddress master = walletAddressRepo
        .findByPartnerIdAndNetworkIdAndWalletType(order.getPartnerId(), networkId, WalletType.MASTER);
if (master == null || master.getAddress() == null) {
    log.warn("TORQ fallback 불가 — 파트너 MASTER 미확보: ...");
    return false;   // → 파트너 레그로 폴백
}
```

> ⚠️ TORQ2 도입 시 **해당 그룹 파트너 전원에게 BSC MASTER 지갑을 먼저 발급**해야 한다. 없으면 LP 레그가 조용히 스킵되어 전량 파트너 레그로 흐른다 — 에러 없이 매칭 구성만 바뀌므로 눈치채기 어렵다. 도입 시 사전 점검 쿼리를 돌릴 것.

### 6.5 이번 범위

| 항목 | 이번 |
|---|---|
| `LpProvider` enum | **한다** |
| `partners.lp_provider` + 해석 | **한다** |
| `torq_trades.lp_provider` + insert 명시 | **한다** (소급 불가) |
| `TorqClient` provider 맵 | **한다** (`TORQ` 항목만) |
| 웹훅 `/{provider}` + `(provider, escrow_id)` 조회 | **한다** |
| 리베이트 `lp_provider='TORQ'` 필터 | **한다** |
| LP 레그 네트워크를 `lpNetworkOf(provider)` 로 | **한다** — `TORQ` 는 현재 기본값 반환(동작 불변) |
| TORQ2 자격증명·엔드포인트·BSC 매핑 | **안 한다** |
| 어드민 UI 의 LP 선택 | **안 한다** — 값이 하나뿐이라 고를 게 없다. DB 직접 설정 |

---

## 7. 함정

| # | 함정 | 대응 |
|---|---|---|
| 1 | 그룹 조건을 Java 후처리로 넣음 | `LIMIT` 이 먼저 적용된다. 반드시 SQL |
| 2 | `checkRoute` 미반영 | 라우터가 P2P 선택 → 매칭 실패 |
| 3 | 풀 현황 화면 미반영 | 게이트 동기화 계약 위반, 파트너 오판 |
| 4 | `COALESCE` 로 루트 판정 | 2단 이상에서 조용히 틀림. 컬럼을 쓸 것 |
| 5 | 백필 누락 → `root_partner_id` NULL | 자기 자신 폴백 + WARN. 배포 전 검증 쿼리로 0건 확인 |
| 6 | 하위 파트너에 개별 설정 노출 | 루트 값만 읽는다. 하위 값은 의미 없음 |
| 7 | INNER 정산을 그룹 기준으로 변경 | 하지 말 것. 파트너별 MASTER 지갑 정합성이 깨진다 |

---

## 8. 배포

1. DDL v2.9 적용 + `root_partner_id` 백필 + 검증 쿼리 0건 확인 — **사람이 실행**
2. 코드 구현 → 빌드 → 커밋 → push
3. `spring:deploy-production` ▶ + `admin-ui` ▶
4. 배포 직후 §5.4 회귀 확인 — 기본값이 허용이라 동작 변화가 없어야 한다
5. 격리가 필요한 그룹이 생기면 어드민에서 개별로 `FALSE` 설정

> 이 변경은 **기본값이 현행 동작과 같으므로** 배포 자체로는 매칭이 바뀌지 않는다. 파트너 공지 불필요.
