# BSC↔Polygon Network Swap 수정 + Native Token Collection 배제

> **작성**: 2026-04-04
> **상태**: DB 수정 완료 / Spring Boot 코드 수정 필요

---

## 1. 문제 요약

v1→v2 마이그레이션 시 인프라 지갑(ADMIN, GAS, RELAYER)의 BSC↔Polygon `network_id`가 교차 할당됨.
HOT/MASTER 지갑은 v1 데이터 기반으로 정상 마이그레이션됐으나, `create-infra` API로 별도 생성된 인프라 지갑만 chain 라벨이 반대로 기록됨.

### 근본 원인

`migrate_v1_to_v2.py`의 `INFRA_WALLETS` / `INFRA_HD_WALLETS` / `INFRA_RELAYER_CONTRACTS` 데이터에서 BSC↔Polygon chain 라벨이 반대로 입력됨.
`CHAIN_TO_NETWORK` 런타임 매핑이 잘못된 chain 라벨을 그대로 변환하여 DB에 잘못된 `network_id` 할당.

---

## 2. 영향 범위

### 2-1. wallet_addresses (6건 — GAS는 이전 세션에서 수정 완료)

| id | address (앞 10자) | wallet_type | 수정 전 network_id | 수정 후 network_id |
|----|-------------------|-------------|--------------------|--------------------|
| 1  | 0xF9cbB86D.. | ADMIN   | 2 (Polygon) | **1 (BSC)** |
| 3  | 0x00113718.. | ADMIN   | 1 (BSC)     | **2 (Polygon)** |
| 4  | 0x85a1c69F.. | GAS     | 2 (Polygon) | **1 (BSC)** ✅ 이전 수정 |
| 6  | 0x5b8Bd80C.. | GAS     | 1 (BSC)     | **2 (Polygon)** ✅ 이전 수정 |
| 7  | 0x6e8719d2.. | RELAYER | 2 (Polygon) | **1 (BSC)** |
| 8  | 0xE36131e3.. | RELAYER | 1 (BSC)     | **2 (Polygon)** |

### 2-2. relayer_contracts (2건)

| id | 수정 전 network_id | 수정 후 network_id | owner_address_id |
|----|--------------------|--------------------|------------------|
| 1  | 2 (Polygon) | **1 (BSC)** | 1 (ADMIN BSC) |
| 2  | 1 (BSC)     | **2 (Polygon)** | 3 (ADMIN Polygon) |

### 2-3. hd_wallets (DB 미수정 — 주의 필요)

현재 DB:
- id=1: network_id=2(Polygon), derivation=`m/44'/60'/1'`
- id=2: network_id=1(BSC), derivation=`m/44'/60'/3'`

실제 운영 패턴 (HOT/MASTER 지갑 기준):
- derivation coin_index 1 → network_id=1 (BSC) 지갑에 사용
- derivation coin_index 3 → network_id=2 (Polygon) 지갑에 사용

**hd_wallets 수정은 HOT/MASTER 지갑 생성에 영향**을 줄 수 있으므로 신중히 판단 필요.
현재는 network_id 기반 HD wallet 조회 시 교차 매핑이 일관되게 적용되고 있어 실동작에는 문제 없음.

---

## 3. 수정 완료 사항 (DB — 2026-04-04 적용)

```sql
-- ADMIN wallet_addresses
UPDATE wallet_addresses SET network_id = 1 WHERE id = 1 AND wallet_type = 'ADMIN';
UPDATE wallet_addresses SET network_id = 2 WHERE id = 3 AND wallet_type = 'ADMIN';

-- RELAYER wallet_addresses
UPDATE wallet_addresses SET network_id = 1 WHERE id = 7 AND wallet_type = 'RELAYER';
UPDATE wallet_addresses SET network_id = 2 WHERE id = 8 AND wallet_type = 'RELAYER';

-- relayer_contracts (UNIQUE 제약 우회)
UPDATE relayer_contracts SET network_id = 99 WHERE id = 1;
UPDATE relayer_contracts SET network_id = 2 WHERE id = 2;
UPDATE relayer_contracts SET network_id = 1 WHERE id = 1;

-- collection_queue: BNB(NATIVE) 좀비 레코드 정리
UPDATE collection_queue SET status = 'CANCELLED' WHERE id = 2 AND currency_id = 4;
```

---

## 4. 수정 완료 사항 (마이그레이션 스크립트)

`v2-docs/migrate_v1_to_v2.py` 수정:

1. **INFRA_HD_WALLETS**: chain 라벨 swap (`POLYGON`→`BSC`, `BSC`→`POLYGON`)
2. **INFRA_WALLETS (ADMIN)**: chain + derivation_path swap
3. **INFRA_WALLETS (RELAYER)**: chain + derivation_path swap
4. **INFRA_RELAYER_CONTRACTS**: chain + owner_chain swap

---

## 5. Native Token Collection 배제 — Spring Boot 코드 수정 필요

### 5-1. 현상

`collection_queue`에 BNB(NATIVE) 0.0000045가 QUEUED 상태로 들어감.
`CollectionBatchRunner`는 `contract_address` 체크에서 skip하지만, QUEUED 레코드가 영원히 남아 매 사이클 조회됨.

### 5-2. 원인

`DepositService.enqueueCollection()`이 입금의 `currencyId`를 그대로 enqueue — `currency_type` 필터 없음.

### 5-3. 수정 위치

**파일**: `core/src/main/java/com/cryptoments/core/deposit/DepositService.java`
**메서드**: `enqueueCollection()` (약 387행)

### 5-4. 수정 내용

```java
public CollectionQueue enqueueCollection(Long depositId) {
    Deposit deposit = depositRepository.findOne(depositId);
    if (deposit == null) {
        throw new NotFoundException(ErrorCodes.DEPOSIT_NOT_FOUND);
    }

    if (deposit.getWalletAddressId() == null) {
        throw new ConflictException(ErrorCodes.TRANSACTION_STATUS_INVALID,
                "지갑 주소가 없어 집금 불필요");
    }

    // ★ 추가: Native 토큰은 transferFrom 불가 → 집금 배제, 즉시 SETTLED
    Currency currency = currencyRepository.findOne(deposit.getCurrencyId());
    if (currency != null && currency.getCurrencyType() == CurrencyType.NATIVE) {
        log.info("Native 토큰 입금 — 집금 배제, 즉시 정산: depositId={}, symbol={}",
                depositId, currency.getSymbol());
        settleDeposit(depositId);
        return null;  // collection_queue에 enqueue하지 않음
    }

    // 기존 로직 계속...
    String collectionCode = generateCollectionCode();
    // ...
}
```

### 5-5. 호출부 null 처리

`onTxConfirmed()` 메서드에서 `enqueueCollection()` 호출 결과가 null일 수 있으므로 확인:

```java
// 현재 코드 (약 321행)
try {
    enqueueCollection(depositId);
} catch (Exception e) {
    log.warn("집금 enqueue 실패: depositId={}, error={}", depositId, e.getMessage());
}
```

현재 반환값을 사용하지 않으므로 null 반환해도 문제 없음. ✅

### 5-6. 검증 방법

BSC HOT 지갑에 소량의 BNB를 전송하여 입금 webhook이 trigger될 때:
1. `deposits` 테이블에 DETECTED → CONFIRMED → **SETTLED** 바로 전환 확인
2. `collection_queue`에 새 레코드가 생기지 않는 것 확인
3. `ledger_entries`에 원장 기록 정상 확인

---

## 6. 잔여 고려사항

### 6-1. hd_wallets network_id

현재 hd_wallets의 BSC↔Polygon network_id도 교차되어 있으나:
- HOT/MASTER 지갑 생성 시 동일한 교차 매핑이 일관되게 적용
- 실동작에 문제 없음 (BSC HOT은 coin_index=1 HD에서, Polygon HOT은 coin_index=3 HD에서 파생)
- 수정 시 기존 지갑 정합성 검증 필요 → 별도 작업으로 분리

### 6-2. WithdrawalPoller — approve 사전 체크 추가

**파일**: `node-service/packages/relayer-api/src/services/WithdrawalPoller.ts`
**함수**: `executeWithdrawal()` (약 67행)

현재 코드는 approve 확인 없이 바로 `executeTransfer`를 호출한다.
MASTER/SETTLEMENT 지갑의 approve가 없으면 `estimateGas`에서 revert → FAILED 처리됨.

**수정 위치**: step 6 (CryptoRelayer 컨트랙트 조회) 이후, step 7 (TRON 에너지) 이전

```typescript
// ── 기존 step 6-1 이후에 추가 ──

// 6-2. approve 사전 체크 (DB → 미등록이면 auto-register + skip)
const approval = await walletApprovalRepo.findByWalletAndCurrency(
    fromWallet.id, withdrawal.currency_id
);

if (!approval || approval.status !== 'APPROVED') {
    // approve 미완료 — PENDING 자동 등록 + APPROVED로 되돌려서 다음 폴링에서 재시도
    if (!approval) {
        await walletApprovalRepo.insert({
            wallet_address_id: fromWallet.id,
            currency_id: withdrawal.currency_id,
            network_id: withdrawal.network_id,
            spender_address: relayerContract.contract_address,
        });
        logger.info('Approve PENDING auto-registered for withdrawal', {
            withdrawalId: withdrawal.id,
            walletAddressId: fromWallet.id,
            currencyId: withdrawal.currency_id,
        });
    }
    // nonce 미사용 — 출금 상태를 APPROVED로 유지 (FAILED로 만들지 않음)
    logger.warn('Approve not ready, deferring withdrawal', {
        withdrawalId: withdrawal.id,
        approvalStatus: approval?.status ?? 'NOT_REGISTERED',
    });
    return; // nonce 획득 전에 return하므로 release 불필요
}
```

**핵심**: 이 체크는 nonce 획득(step 3) **이전**에 넣는 것이 가장 좋다. 현재 코드 구조상 step 3에서 nonce를 먼저 획득하므로, approve 미완료 시 nonce를 획득했다가 release하는 비용이 발생한다.

**권장 구조 변경**:
```
1. from_wallet 결정
2. Relayer 선택
★ 2.5. approve 사전 체크 (여기서 return하면 nonce 낭비 없음)
3. nonce 획득
4. PROCESSING 상태
5~8. TX 실행
```

### 6-3. Node.js CollectionBatchRunner 방어 코드

현재 `currency.contract_address` null 체크로 native 토큰을 skip하지만, 명시적 방어 추가 권장:

```typescript
// CollectionBatchRunner.executeBatchBroadcast() — 현재 (약 129행)
if (!currency || !currency.contract_address) {
    logger.warn('Currency or contract not found', { currencyId: group.currency_id });
    return;
}

// 권장: currency_type 명시 체크 추가
if (!currency || !currency.contract_address || currency.currency_type === 'NATIVE') {
    logger.debug('Skip non-token currency', { currencyId: group.currency_id, type: currency?.currency_type });
    return;
}
```

### 6-3. CollectionQueueRepo.findQueuedWalletGroups() SQL 필터

```sql
-- 현재
SELECT wallet_address_id, network_id, currency_id, partner_id, COUNT(*) as queue_count
FROM collection_queue
WHERE status = 'QUEUED'
GROUP BY wallet_address_id, network_id, currency_id, partner_id

-- 권장: JOIN currencies로 TOKEN만 조회
SELECT cq.wallet_address_id, cq.network_id, cq.currency_id, cq.partner_id, COUNT(*) as queue_count
FROM collection_queue cq
JOIN currencies c ON cq.currency_id = c.id
WHERE cq.status = 'QUEUED' AND c.currency_type = 'TOKEN'
GROUP BY cq.wallet_address_id, cq.network_id, cq.currency_id, cq.partner_id
```
