# P2P 매칭 구현 지침서 — Phase 1

> **전제 문서**: `P2P_MATCHING_ARCHITECTURE.md` (v0.3)
> **작성일**: 2026-05-31 (MASTER 지갑 통합 반영)
> **범위**: Core Services + Partner API

---

## 0. 사전 완료 항목

| 항목 | 파일 수 | 상태 |
|------|---------|------|
| DDL (6 테이블) | `CRYPTOMENTS_V2_DDL.sql` SECTION 8 | ✅ (운영 DB 미적용) |
| Enums (7개) | `P2pPartnerLockStatus`, `P2pLockHistoryType`, `P2pWithdrawStatus`, `P2pDepositStatus`, `P2pMatchStatus`, `P2pSettlementStatus`, `P2pRequestCurrency` | ✅ |
| Entities (6개) | `P2pPartnerLock`, `P2pLockHistory`, `P2pWithdrawOrder`, `P2pDepositOrder`, `P2pMatch`, `P2pSettlement` | ✅ |
| Repositories (6개) | 각 Entity 대응 `IXRepository` | ✅ |
| ErrorCodes (10개) | 980~989번대 | ✅ |
| Partner.p2pEnabled | `Partner.java` | ✅ |

### 핵심 설계 — MASTER 지갑 통합

```
별도 P2P 지갑 없음. MASTER 지갑을 직접 사용.

P2P 가용 잔액 = MASTER.available_balance - p2p_partner_locks.locked_balance
정산 경로:   MASTER(A) → MASTER(B) — Relayer.transferFrom() 기존 approve 활용
잔액 부족:   해당 사이트 P2P 매칭 제외 → TORQ LP 자동 fallback
```

---

## 1. Core Services 구현

### 1.1 P2pLockService — MASTER 지갑 P2P 잠금 관리

**위치**: `core/src/main/java/com/cryptoments/core/p2p/P2pLockService.java`

MASTER 지갑 잔액 중 P2P 매칭에 의해 잠기는 금액을 추적한다.
별도 충전(디파짓) 단계 없음 — MASTER 잔액이 곧 P2P 가용액.

```java
@Service
@RequiredArgsConstructor
public class P2pLockService {

    private final P2pPartnerLockRepository lockRepo;
    private final P2pLockHistoryRepository historyRepo;
    private final WalletBalanceRepository walletBalanceRepo;
    private final WalletAssignmentRepository walletAssignmentRepo;

    /**
     * P2P 가용 잔액 조회.
     * = MASTER 지갑 available_balance - P2P locked_balance
     */
    public BigDecimal getAvailableForP2p(Long partnerId, Long networkId, Long currencyId) {
        // MASTER 지갑 잔액 조회
        BigDecimal masterBalance = getMasterBalance(partnerId, networkId, currencyId);

        // P2P 잠금 금액 조회
        P2pPartnerLock lock = lockRepo.findByPartnerIdAndNetworkId(partnerId, networkId);
        BigDecimal locked = (lock != null) ? lock.getLockedBalance() : BigDecimal.ZERO;

        return masterBalance.subtract(locked);
    }

    /**
     * 매칭 잠금 — 매칭 생성 시 locked_balance 증가.
     * @return true: 잠금 성공, false: 잔액 부족
     */
    @Transactional
    public boolean lockForMatch(Long partnerId, Long networkId, Long currencyId,
                                 BigDecimal usdtAmount, Long matchId) {
        BigDecimal available = getAvailableForP2p(partnerId, networkId, currencyId);
        if (available.compareTo(usdtAmount) < 0) {
            return false;
        }

        P2pPartnerLock lock = getOrCreateLock(partnerId, networkId);
        lock.setLockedBalance(lock.getLockedBalance().add(usdtAmount));
        lockRepo.save(lock);

        recordHistory(lock, P2pLockHistoryType.LOCK, usdtAmount,
                "P2P_MATCH", matchId, null, "매칭 잠금");
        return true;
    }

    /**
     * 매칭 해제 — 매칭 취소/실패 시 locked_balance 감소.
     */
    @Transactional
    public void unlockForMatch(Long partnerId, Long networkId,
                                BigDecimal usdtAmount, Long matchId) {
        P2pPartnerLock lock = lockRepo.findByPartnerIdAndNetworkId(partnerId, networkId);
        if (lock == null) return;

        lock.setLockedBalance(lock.getLockedBalance().subtract(usdtAmount));
        if (lock.getLockedBalance().compareTo(BigDecimal.ZERO) < 0) {
            lock.setLockedBalance(BigDecimal.ZERO);
        }
        lockRepo.save(lock);

        recordHistory(lock, P2pLockHistoryType.UNLOCK, usdtAmount,
                "P2P_MATCH", matchId, null, "매칭 해제");
    }

    /**
     * 정산 차감 — 은행 이체 확인 후 locked에서 차감 + settled_total 누적.
     */
    @Transactional
    public void settleFromLocked(Long partnerId, Long networkId,
                                  BigDecimal usdtAmount, Long settlementId, String txHash) {
        P2pPartnerLock lock = lockRepo.findByPartnerIdAndNetworkId(partnerId, networkId);
        if (lock == null) return;

        lock.setLockedBalance(lock.getLockedBalance().subtract(usdtAmount));
        lock.setSettledTotal(lock.getSettledTotal().add(usdtAmount));
        lockRepo.save(lock);

        recordHistory(lock, P2pLockHistoryType.SETTLE, usdtAmount,
                "P2P_SETTLEMENT", settlementId, txHash, "정산 차감");
    }

    /**
     * MASTER 지갑의 USDT 잔액 조회.
     */
    private BigDecimal getMasterBalance(Long partnerId, Long networkId, Long currencyId) {
        // wallet_assignments에서 파트너의 MASTER 지갑 조회
        // → wallet_balances에서 해당 지갑의 available_balance 조회
        // TODO: WalletAssignment 조회 → WalletBalance 조회 체인
        return BigDecimal.ZERO; // placeholder
    }

    private P2pPartnerLock getOrCreateLock(Long partnerId, Long networkId) {
        P2pPartnerLock lock = lockRepo.findByPartnerIdAndNetworkId(partnerId, networkId);
        if (lock == null) {
            lock = P2pPartnerLock.builder()
                    .partnerId(partnerId)
                    .networkId(networkId)
                    .lockedBalance(BigDecimal.ZERO)
                    .settledTotal(BigDecimal.ZERO)
                    .status(P2pPartnerLockStatus.ACTIVE)
                    .build();
            lockRepo.save(lock);
        }
        return lock;
    }

    private void recordHistory(P2pPartnerLock lock, P2pLockHistoryType type,
                                BigDecimal amount, String refType, Long refId,
                                String txHash, String memo) {
        P2pLockHistory history = P2pLockHistory.builder()
                .partnerLockId(lock.getId())
                .partnerId(lock.getPartnerId())
                .type(type)
                .amount(amount)
                .lockedAfter(lock.getLockedBalance())
                .referenceType(refType)
                .referenceId(refId)
                .txHash(txHash)
                .memo(memo)
                .build();
        historyRepo.save(history);
    }
}
```

### 1.2 P2pWithdrawService — 출금 주문 관리

**위치**: `core/src/main/java/com/cryptoments/core/p2p/P2pWithdrawService.java`

```java
@Service
@RequiredArgsConstructor
public class P2pWithdrawService {

    private final P2pWithdrawOrderRepository withdrawRepo;
    private final PartnerRepository partnerRepo;
    private final PriceService priceService;

    private static final int EXPIRY_MINUTES = 30;

    /**
     * 출금 주문 생성 — A사이트 고객이 KRW/USDT 출금 요청.
     * 상태: PENDING (매칭 대기)
     */
    @Transactional
    public P2pWithdrawOrder createOrder(Long partnerId, String partnerUserId,
                                         P2pRequestCurrency requestCurrency, BigDecimal amount,
                                         String bankCode, String bankName,
                                         String accountNumber, String accountHolder) {
        Partner partner = partnerRepo.findOne(partnerId);
        if (partner == null || !partner.getP2pEnabled()) {
            throw new BadRequestException(ErrorCodes.P2P_NOT_ENABLED);
        }

        // 거래 시작 시점 환율 확정
        BigDecimal exchangeRate = priceService.getUsdtKrwRate();

        Long krwAmount;
        BigDecimal usdtAmount;
        if (requestCurrency == P2pRequestCurrency.KRW) {
            krwAmount = amount.longValue();
            usdtAmount = amount.divide(exchangeRate, 18, RoundingMode.DOWN);
        } else {
            usdtAmount = amount;
            krwAmount = amount.multiply(exchangeRate).setScale(0, RoundingMode.DOWN).longValue();
        }

        // TODO: 출금 수수료 계산 (총판 설정 기준)

        P2pWithdrawOrder order = P2pWithdrawOrder.builder()
                .orderCode("pwo_" + generateId())
                .partnerId(partnerId)
                .partnerUserId(partnerUserId)
                .requestCurrency(requestCurrency)
                .krwAmount(krwAmount)
                .usdtAmount(usdtAmount)
                .exchangeRate(exchangeRate)
                .matchedAmount(0L)
                .confirmedAmount(0L)
                .bankCode(bankCode).bankName(bankName)
                .accountNumber(accountNumber).accountHolder(accountHolder)
                .feeRate(BigDecimal.ZERO).feeAmount(0L)
                .status(P2pWithdrawStatus.PENDING)
                .expiresAt(LocalDateTime.now().plusMinutes(EXPIRY_MINUTES))
                .build();

        withdrawRepo.save(order);
        return order;
    }

    public P2pWithdrawOrder getByOrderCode(String orderCode) {
        P2pWithdrawOrder order = withdrawRepo.findByOrderCode(orderCode);
        if (order == null) throw new NotFoundException(ErrorCodes.P2P_WITHDRAW_ORDER_NOT_FOUND);
        return order;
    }

    @Transactional
    public void cancelOrder(String orderCode, String reason) {
        P2pWithdrawOrder order = getByOrderCode(orderCode);
        if (order.getStatus() != P2pWithdrawStatus.PENDING
                && order.getStatus() != P2pWithdrawStatus.PARTIALLY_MATCHED) {
            throw new BadRequestException(ErrorCodes.P2P_ORDER_NOT_CANCELLABLE);
        }
        // TODO: PARTIALLY_MATCHED 시 기존 매칭 취소 + 잠금 해제
        order.setStatus(P2pWithdrawStatus.CANCELLED);
        order.setCancelledAt(LocalDateTime.now());
        order.setCancelReason(reason);
        withdrawRepo.save(order);
    }

    private String generateId() {
        return java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 12);
    }
}
```

### 1.3 P2pDepositService — 입금 주문 관리

**위치**: `core/src/main/java/com/cryptoments/core/p2p/P2pDepositService.java`

```java
@Service
@RequiredArgsConstructor
public class P2pDepositService {

    private final P2pDepositOrderRepository depositOrderRepo;
    private final P2pMatchingService matchingService;
    private final PartnerRepository partnerRepo;

    private static final BigDecimal DEPOSIT_FEE_RATE = new BigDecimal("0.02"); // 2%
    private static final int EXPIRY_MINUTES = 30;

    /**
     * 입금 매칭 요청 — B사이트 고객이 KRW 입금(USDT 구매).
     * 매칭 우선순위: P2P 출금자 → TORQ LP → 대기
     */
    @Transactional
    public P2pDepositOrder createAndMatch(Long partnerId, String partnerUserId, Long krwAmount) {
        Partner partner = partnerRepo.findOne(partnerId);
        if (partner == null) throw new NotFoundException(ErrorCodes.PARTNER_NOT_FOUND);

        Long feeAmount = BigDecimal.valueOf(krwAmount)
                .multiply(DEPOSIT_FEE_RATE)
                .setScale(0, RoundingMode.DOWN).longValue();

        P2pDepositOrder order = P2pDepositOrder.builder()
                .orderCode("pdo_" + generateId())
                .partnerId(partnerId)
                .partnerUserId(partnerUserId)
                .krwAmount(krwAmount)
                .remainingAmount(krwAmount)
                .feeRate(DEPOSIT_FEE_RATE)
                .feeAmount(feeAmount)
                .status(P2pDepositStatus.PENDING)
                .expiresAt(LocalDateTime.now().plusMinutes(EXPIRY_MINUTES))
                .build();
        depositOrderRepo.save(order);

        // 매칭 시도
        matchingService.tryMatchDeposit(order);
        return order;
    }

    public P2pDepositOrder getByOrderCode(String orderCode) {
        P2pDepositOrder order = depositOrderRepo.findByOrderCode(orderCode);
        if (order == null) throw new NotFoundException(ErrorCodes.P2P_DEPOSIT_ORDER_NOT_FOUND);
        return order;
    }

    private String generateId() {
        return java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 12);
    }
}
```

### 1.4 P2pMatchingService — 매칭 엔진 (핵심)

**위치**: `core/src/main/java/com/cryptoments/core/p2p/P2pMatchingService.java`

⚠️ **비관적 잠금 (FOR UPDATE) 필수** — 동시 매칭 방지

```java
@Service
@RequiredArgsConstructor
@Slf4j
public class P2pMatchingService {

    private final P2pWithdrawOrderRepository withdrawRepo;
    private final P2pDepositOrderRepository depositOrderRepo;
    private final P2pMatchRepository matchRepo;
    private final P2pLockService lockService;
    private final P2pMatchingMapper matchingMapper;  // FOR UPDATE 쿼리
    // private final TorqService torqService;  // Phase 2: TORQ fallback

    private static final int MATCH_EXPIRY_MINUTES = 10;

    /**
     * 입금 주문에 대해 P2P 매칭 시도.
     *
     * 1순위: P2P 출금 대기자 (정확매칭 → 부분매칭)
     * 2순위: TORQ LP (Phase 2)
     * 3순위: 매칭 대기 ("찾는 중...")
     */
    @Transactional
    public void tryMatchDeposit(P2pDepositOrder depositOrder) {
        long remaining = depositOrder.getRemainingAmount();
        if (remaining <= 0) return;

        // ── 1순위: P2P 출금 대기자 ──

        // 정확 매칭 시도
        P2pWithdrawOrder exactMatch = matchingMapper.findExactMatchForUpdate(remaining);
        if (exactMatch != null) {
            Long networkId = getNetworkForPartner(exactMatch.getPartnerId());
            Long currencyId = getCurrencyForNetwork(networkId);
            BigDecimal usdtNeeded = BigDecimal.valueOf(remaining)
                    .divide(exactMatch.getExchangeRate(), 18, RoundingMode.UP);

            if (lockService.lockForMatch(exactMatch.getPartnerId(), networkId,
                    currencyId, usdtNeeded, null)) {
                createMatch(exactMatch, depositOrder, remaining, networkId);
                remaining = 0;
            }
        }

        // 부분 매칭 시도
        if (remaining > 0) {
            List<P2pWithdrawOrder> candidates = matchingMapper.findMatchableWithdrawOrdersForUpdate();
            for (P2pWithdrawOrder wo : candidates) {
                if (remaining <= 0) break;

                long available = wo.getKrwAmount() - wo.getMatchedAmount();
                if (available <= 0) continue;

                long matchAmount = Math.min(available, remaining);
                BigDecimal usdtNeeded = BigDecimal.valueOf(matchAmount)
                        .divide(wo.getExchangeRate(), 18, RoundingMode.UP);

                Long networkId = getNetworkForPartner(wo.getPartnerId());
                Long currencyId = getCurrencyForNetwork(networkId);

                if (!lockService.lockForMatch(wo.getPartnerId(), networkId,
                        currencyId, usdtNeeded, null)) {
                    continue;  // MASTER 잔액 부족 → 다음 출금자
                }

                createMatch(wo, depositOrder, matchAmount, networkId);
                remaining -= matchAmount;
            }
        }

        // 결과 반영
        if (remaining <= 0) {
            depositOrder.setStatus(P2pDepositStatus.MATCHED);
            depositOrder.setRemainingAmount(0L);
        } else if (remaining < depositOrder.getKrwAmount()) {
            depositOrder.setStatus(P2pDepositStatus.MATCHING);
            depositOrder.setRemainingAmount(remaining);
        } else {
            // ── 2순위: TORQ LP (Phase 2) ──
            // torqService.createTrade(...)
            depositOrder.setStatus(P2pDepositStatus.MATCHING);  // "찾는 중..."
        }
        depositOrderRepo.save(depositOrder);
    }

    /**
     * 매칭 레코드 생성 + 양쪽 상태 업데이트.
     */
    private void createMatch(P2pWithdrawOrder wo, P2pDepositOrder dpo,
                              long krwAmount, Long networkId) {
        BigDecimal usdtAmount = BigDecimal.valueOf(krwAmount)
                .divide(wo.getExchangeRate(), 18, RoundingMode.DOWN);

        P2pMatch match = P2pMatch.builder()
                .matchCode("pm_" + generateId())
                .withdrawOrderId(wo.getId())
                .depositOrderId(dpo.getId())
                .krwAmount(krwAmount)
                .usdtAmount(usdtAmount)
                .exchangeRate(wo.getExchangeRate())
                .status(P2pMatchStatus.CREATED)
                .expiresAt(LocalDateTime.now().plusMinutes(MATCH_EXPIRY_MINUTES))
                .build();
        matchRepo.save(match);

        // lockForMatch에서 matchId가 필요하면 여기서 업데이트
        // (이미 위에서 lock 완료)

        // 출금 주문 업데이트
        wo.setMatchedAmount(wo.getMatchedAmount() + krwAmount);
        if (wo.getMatchedAmount() >= wo.getKrwAmount()) {
            wo.setStatus(P2pWithdrawStatus.FULLY_MATCHED);
            wo.setMatchedAt(LocalDateTime.now());
        } else {
            wo.setStatus(P2pWithdrawStatus.PARTIALLY_MATCHED);
        }
        withdrawRepo.save(wo);

        log.info("P2P 매칭: match={}, withdraw={}, deposit={}, krw={}",
                match.getMatchCode(), wo.getOrderCode(), dpo.getOrderCode(), krwAmount);
    }

    /**
     * 은행 이체 확인 (CODEF API / 스케줄러에서 호출).
     */
    @Transactional
    public void confirmBankTransfer(Long matchId, String bankTransferRef) {
        P2pMatch match = matchRepo.findOne(matchId);
        if (match == null) throw new NotFoundException(ErrorCodes.P2P_MATCH_NOT_FOUND);
        if (match.getStatus() != P2pMatchStatus.BANK_PENDING
                && match.getStatus() != P2pMatchStatus.CREATED) return;

        match.setStatus(P2pMatchStatus.BANK_CONFIRMED);
        match.setBankTransferRef(bankTransferRef);
        match.setBankConfirmedAt(LocalDateTime.now());
        matchRepo.save(match);

        // 출금 주문 confirmed_amount 업데이트
        P2pWithdrawOrder wo = withdrawRepo.findOne(match.getWithdrawOrderId());
        wo.setConfirmedAmount(wo.getConfirmedAmount() + match.getKrwAmount());
        withdrawRepo.save(wo);

        // 전체 확인 완료 → 정산 시작
        if (wo.getConfirmedAmount() >= wo.getKrwAmount()) {
            wo.setStatus(P2pWithdrawStatus.SETTLING);
            withdrawRepo.save(wo);
            // TODO: P2pSettlementService.startSettlement(wo)
        }
    }

    // TODO: 파트너의 주력 네트워크 조회 로직
    private Long getNetworkForPartner(Long partnerId) { return 3L; } // TRON 기본
    private Long getCurrencyForNetwork(Long networkId) { return 3L; } // USDT 기본

    private String generateId() {
        return java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 12);
    }
}
```

### 1.5 P2pSettlementService — 정산 (MASTER → MASTER)

**위치**: `core/src/main/java/com/cryptoments/core/p2p/P2pSettlementService.java`

```java
@Service
@RequiredArgsConstructor
@Slf4j
public class P2pSettlementService {

    private final P2pSettlementRepository settlementRepo;
    private final P2pMatchRepository matchRepo;
    private final P2pWithdrawOrderRepository withdrawRepo;
    private final P2pDepositOrderRepository depositOrderRepo;
    private final P2pLockService lockService;
    private final WalletAssignmentRepository walletAssignmentRepo;
    // private final RelayerApiClient relayerClient;

    /**
     * 정산 시작 — 출금 주문의 모든 매칭이 BANK_CONFIRMED일 때.
     * MASTER(A) → MASTER(B) 온체인 전송.
     */
    @Transactional
    public void startSettlement(P2pWithdrawOrder wo) {
        List<P2pMatch> matches = matchRepo.findByWithdrawOrderId(wo.getId());

        for (P2pMatch match : matches) {
            if (match.getStatus() != P2pMatchStatus.BANK_CONFIRMED) continue;

            match.setStatus(P2pMatchStatus.SETTLING);
            matchRepo.save(match);

            P2pDepositOrder dpo = depositOrderRepo.findOne(match.getDepositOrderId());
            Long networkId = 3L; // TODO: 동적 조회

            // A사이트 MASTER 지갑 조회
            Long fromWalletId = getMasterWalletId(wo.getPartnerId(), networkId);
            // B사이트 MASTER 지갑 조회
            Long toWalletId = getMasterWalletId(dpo.getPartnerId(), networkId);

            P2pSettlement settlement = P2pSettlement.builder()
                    .settlementCode("ps_" + generateId())
                    .matchId(match.getId())
                    .fromPartnerId(wo.getPartnerId())
                    .toPartnerId(dpo.getPartnerId())
                    .fromWalletAddressId(fromWalletId)
                    .toWalletAddressId(toWalletId)
                    .krwAmount(match.getKrwAmount())
                    .usdtAmount(match.getUsdtAmount())
                    .exchangeRate(match.getExchangeRate())
                    .networkId(networkId)
                    .status(P2pSettlementStatus.PENDING)
                    .retryCount(0)
                    .build();
            settlementRepo.save(settlement);

            executeOnchainTransfer(settlement, match, wo, dpo);
        }
    }

    /**
     * 온체인 USDT 전송: Relayer.transferFrom(MASTER_A → MASTER_B)
     */
    private void executeOnchainTransfer(P2pSettlement settlement, P2pMatch match,
                                         P2pWithdrawOrder wo, P2pDepositOrder dpo) {
        try {
            settlement.setStatus(P2pSettlementStatus.PROCESSING);
            settlementRepo.save(settlement);

            // TODO: relayerClient.transferFrom(fromAddress, toAddress, amount, networkId)
            // String txHash = relayerClient.transferFrom(...);

            // 성공 시:
            // settlement.setTxHash(txHash);
            // settlement.setStatus(P2pSettlementStatus.COMPLETED);
            // settlement.setCompletedAt(LocalDateTime.now());
            // settlementRepo.save(settlement);

            // 잠금 차감
            // lockService.settleFromLocked(wo.getPartnerId(), networkId, match.getUsdtAmount(), settlement.getId(), txHash);

            // 매칭 완료
            // match.setStatus(P2pMatchStatus.SETTLED);
            // match.setSettledAt(LocalDateTime.now());
            // match.setSettlementTxHash(txHash);
            // matchRepo.save(match);

            // 출금/입금 주문 완료
            // wo.setStatus(P2pWithdrawStatus.COMPLETED);
            // dpo.setStatus(P2pDepositStatus.COMPLETED);

            // Webhook 알림
            // notificationService.send(...)

        } catch (Exception e) {
            log.error("P2P 정산 실패: {}", settlement.getSettlementCode(), e);
            settlement.setStatus(P2pSettlementStatus.FAILED);
            settlement.setFailedAt(LocalDateTime.now());
            settlement.setFailureReason(e.getMessage());
            settlement.setRetryCount(settlement.getRetryCount() + 1);
            settlementRepo.save(settlement);
        }
    }

    private Long getMasterWalletId(Long partnerId, Long networkId) {
        // wallet_assignments에서 MASTER 타입 지갑 조회
        // TODO: walletAssignmentRepo.findByPartnerIdAndNetworkIdAndWalletType(partnerId, networkId, WalletType.MASTER)
        return 0L; // placeholder
    }

    private String generateId() {
        return java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 12);
    }
}
```

---

## 2. Mapper — FOR UPDATE 쿼리

**위치**: `common/src/main/java/com/cryptoments/common/mapper/P2pMatchingMapper.java`

```java
@Mapper
public interface P2pMatchingMapper {

    /** 매칭 가능한 출금 주문 (비관적 잠금) */
    @Select("<script>" +
            "SELECT * FROM p2p_withdraw_orders " +
            "WHERE status IN ('PENDING', 'PARTIALLY_MATCHED') " +
            "  AND (krw_amount - matched_amount) &gt; 0 " +
            "  AND expires_at &gt; NOW() " +
            "ORDER BY (krw_amount - matched_amount) DESC, created_at ASC " +
            "FOR UPDATE" +
            "</script>")
    List<P2pWithdrawOrder> findMatchableWithdrawOrdersForUpdate();

    /** 정확 매칭 출금 주문 (비관적 잠금) */
    @Select("SELECT * FROM p2p_withdraw_orders " +
            "WHERE status IN ('PENDING', 'PARTIALLY_MATCHED') " +
            "  AND (krw_amount - matched_amount) = #{amount} " +
            "  AND expires_at > NOW() " +
            "ORDER BY created_at ASC LIMIT 1 " +
            "FOR UPDATE")
    P2pWithdrawOrder findExactMatchForUpdate(@Param("amount") long amount);
}
```

---

## 3. Partner API

**위치**: `partner-api/src/main/java/.../controller/P2pController.java`

```
POST   /api/v1/p2p/withdraw-orders          — 출금 주문 생성
GET    /api/v1/p2p/withdraw-orders/{code}    — 출금 주문 조회
POST   /api/v1/p2p/withdraw-orders/{code}/cancel — 출금 주문 취소
POST   /api/v1/p2p/deposit-orders            — 입금 매칭 요청
GET    /api/v1/p2p/deposit-orders/{code}     — 입금 주문 조회
GET    /api/v1/p2p/deposit-orders/{code}/matches — 매칭 정보 (계좌 포함)
```

### DTO (partner-api/.../dto/p2p/)

| DTO | 필드 |
|-----|------|
| `CreateP2pWithdrawRequest` | partnerUserId, requestCurrency, amount, bankCode, bankName, accountNumber, accountHolder |
| `P2pWithdrawOrderResponse` | orderCode, status, krwAmount, usdtAmount, exchangeRate, matchedAmount, expiresAt |
| `CreateP2pDepositRequest` | partnerUserId, krwAmount |
| `P2pDepositOrderResponse` | orderCode, status, krwAmount, remainingAmount, expiresAt |
| `P2pMatchResponse` | matchCode, krwAmount, bankName, accountNumber, accountHolder, status, expiresAt |

---

## 4. 구현 순서 체크리스트

### Phase 1a — common 모듈 (✅ 완료)
- [x] DDL 6개 테이블 + partners.p2p_enabled
- [x] Enum 7개, Entity 6개, Repository 6개
- [x] ErrorCodes 980~989

### Phase 1b — core 모듈 (→ IntelliJ 구현)
- [ ] `core/src/.../p2p/` 패키지 생성
- [ ] P2pLockService (§1.1) — MASTER 잔액 기반 잠금
- [ ] P2pWithdrawService (§1.2) — 출금 주문
- [ ] P2pDepositService (§1.3) — 입금 주문
- [ ] P2pMatchingService (§1.4) — **핵심**, FOR UPDATE
- [ ] P2pSettlementService (§1.5) — MASTER→MASTER 정산
- [ ] P2pMatchingMapper (§2) — FOR UPDATE 쿼리

### Phase 1c — partner-api 모듈 (→ IntelliJ 구현)
- [ ] P2pController (§3)
- [ ] DTO 5개

### Phase 1d — 운영 환경
- [ ] 운영 DB DDL 적용
- [ ] 빌드 확인

---

## 5. 주의사항

### 5.1 비관적 잠금
- 매칭 엔진에서 출금 주문 SELECT 시 반드시 `FOR UPDATE`
- 모든 잠금 연산은 단일 `@Transactional` 내에서 완료

### 5.2 MASTER 지갑 Approve
- MASTER 지갑은 이미 `approve(relayer, MAX_UINT)` 완료 상태
- 별도 approve 작업 불필요 — `Relayer.transferFrom()` 즉시 사용 가능

### 5.3 TORQ 통합 (Phase 2)
- `P2pMatchingService.tryMatchDeposit()`에서 P2P 미매칭 시 `TorqService.createTrade()` 호출
- 현재 구조만 준비, 실제 연결은 Phase 2

### 5.4 정산 → 입금 처리
- MASTER(A) → MASTER(B) 전송 후 B사이트 입장에서는 **일반 입금(deposit)**으로 처리
- 기존 deposits 테이블에 기록, B고객 잔액 반영
- Webhook: `p2p.withdraw.completed` (A), 기존 `deposit.confirmed` (B)
