# MASTER 지갑 생성 실패 수정 지침서

> **작성일**: 2026-03-29
> **우선순위**: P0 (파트너 온보딩 차단)
> **수정 대상**: `node-service/packages/common`, `node-service/packages/blockchain-api`
> **관련 이슈**: 파트너 생성 시 MASTER/HOT 지갑 파생에서 Duplicate Key 에러 발생

---

## 1. 현상

파트너 생성 → `activateChainsAndCreateWallets()` → blockchain-api `POST /api/wallet/derive` 호출 시 실패:

```
Error: Duplicate entry '0xF9cbB86D5fae82183AA8e0E4fFf05a4C917414FE-2'
       for key 'wallet_addresses.uk_address_network'
```

3개 네트워크(BSC/POLYGON/TRON) 모두 동일한 에러 발생.

---

## 2. 근본 원인

### 2.1 원인 1: walletType 미전달

`WalletDerivationService.ts` line 52-53에서 `getNextIndex()` 호출 시 `walletType`을 전달하지 않아 항상 기본값 `'HOT'`으로 동작:

```typescript
// 현재 (버그)
const derivationIndex =
    req.derivationIndex ?? (await walletIndexManagerRepo.getNextIndex(req.hdWalletId, req.networkId));
//                          ↑ walletType 파라미터 누락 → 기본값 'HOT'
```

### 2.2 원인 2: 기존 주소와 인덱스 충돌

`wallet_index_manager`에 MASTER 레코드가 없으므로 첫 호출 시 `last_index=0`으로 INSERT → index 0 반환.
그런데 **index 0은 이미 ADMIN 지갑이 사용 중**이므로 동일 주소가 파생되어 UK 위반.

### 2.3 핵심 이해: HD 파생 인덱스는 전역 공유

동일 HD Wallet + 동일 네트워크에서 파생 경로는:
```
base_path / 0 / {derivation_index}
예: m/44'/60'/1'/0/0  ← index 0
    m/44'/60'/1'/0/1  ← index 1
```

**wallet_type(ADMIN, GAS, MASTER, HOT 등)은 파생 경로에 영향을 주지 않는다.**
즉, 같은 HD wallet에서 같은 index를 사용하면 wallet_type이 달라도 동일한 주소가 생성된다.

---

## 3. 현재 DB 상태

### 3.1 hd_wallets

| id | network_id | derivation_base_path | current_index |
|----|------------|---------------------|---------------|
| 2  | 2 (BSC)    | m/44'/60'/1'        | 0             |
| 3  | 3 (POLYGON)| m/44'/60'/3'        | 0             |
| 4  | 4 (TRON)   | m/44'/195'/2'       | 0             |

### 3.2 wallet_addresses (기존 인프라 지갑)

| id | network_id | wallet_type | partner_id | hd_wallet_id | derivation_index |
|----|------------|-------------|------------|--------------|------------------|
| 1  | 2 (BSC)    | ADMIN       | NULL       | **NULL**     | 0                |
| 2  | 2 (BSC)    | GAS         | NULL       | **NULL**     | 1                |
| 8  | 2 (BSC)    | RELAYER     | NULL       | **NULL**     | 3                |
| 5  | 3 (POLYGON)| ADMIN       | NULL       | **NULL**     | 0                |
| 6  | 3 (POLYGON)| GAS         | NULL       | **NULL**     | 1                |
| 9  | 3 (POLYGON)| RELAYER     | NULL       | **NULL**     | 2                |
| 3  | 4 (TRON)   | ADMIN       | NULL       | **NULL**     | 0                |
| 4  | 4 (TRON)   | GAS         | NULL       | **NULL**     | 1                |
| 10 | 4 (TRON)   | RELAYER     | NULL       | **NULL**     | 2                |

**주의**: 기존 인프라 지갑은 `hd_wallet_id = NULL`이다. 하지만 동일 seed에서 파생되었으므로 같은 index를 사용하면 같은 주소가 나온다.

### 3.3 wallet_index_manager (현재)

| id | network_id | hd_wallet_id | wallet_type | last_index |
|----|------------|--------------|-------------|------------|
| 19 | 2          | 2            | HOT         | 0          |
| 20 | 3          | 3            | HOT         | 0          |
| 21 | 4          | 4            | HOT         | 0          |

MASTER 레코드 없음. HOT은 이전 실패한 시도에서 생성됨.

### 3.4 사용 중인 인덱스 요약

| Network  | 사용 중 인덱스       | 다음 안전 인덱스 |
|----------|---------------------|-----------------|
| BSC      | 0, 1, 3             | **4**           |
| POLYGON  | 0, 1, 2             | **3**           |
| TRON     | 0, 1, 2             | **3**           |

---

## 4. 수정 방안

### 설계 원칙

`wallet_index_manager`는 **wallet_type별로 별도 시퀀스**를 관리하되,
새 wallet_type의 첫 레코드 생성 시 **해당 hd_wallet+network의 전체 MAX derivation_index를 조회**하여 충돌을 방지한다.

---

### 4.1 수정 파일 1: `WalletIndexManagerRepo.ts`

**파일**: `node-service/packages/common/src/db/repositories/WalletIndexManagerRepo.ts`

#### 변경 전:

```typescript
async getNextIndex(hdWalletId: number, networkId: number, walletType = 'HOT'): Promise<number> {
    const rows = await query<WalletIndexManagerRow[]>(
        `SELECT * FROM wallet_index_manager
   WHERE hd_wallet_id = ? AND network_id = ? AND wallet_type = ?`,
        [hdWalletId, networkId, walletType],
    );

    if (!rows[0]) {
        // 첫 파생 — 레코드 생성
        await execute(
            `INSERT INTO wallet_index_manager (hd_wallet_id, network_id, wallet_type, last_index)
     VALUES (?, ?, ?, 0)`,
            [hdWalletId, networkId, walletType],
        );
        return 0;
    }

    const nextIndex = Number(rows[0].last_index) + 1;
    await execute(
        `UPDATE wallet_index_manager SET last_index = ?
   WHERE hd_wallet_id = ? AND network_id = ? AND wallet_type = ?`,
        [nextIndex, hdWalletId, networkId, walletType],
    );
    return nextIndex;
}
```

#### 변경 후:

```typescript
async getNextIndex(hdWalletId: number, networkId: number, walletType = 'HOT'): Promise<number> {
    const rows = await query<WalletIndexManagerRow[]>(
        `SELECT * FROM wallet_index_manager
   WHERE hd_wallet_id = ? AND network_id = ? AND wallet_type = ?`,
        [hdWalletId, networkId, walletType],
    );

    if (!rows[0]) {
        // ── 첫 파생: 기존 wallet_addresses에서 해당 네트워크의 MAX index를 조회 ──
        // hd_wallet_id가 NULL인 인프라 지갑(ADMIN/GAS/RELAYER)도 동일 seed에서 파생되었으므로 포함
        const maxRows = await query<RowDataPacket[]>(
            `SELECT COALESCE(MAX(derivation_index), -1) AS max_idx
             FROM wallet_addresses
             WHERE network_id = ?`,
            [networkId],
        );
        const startIndex = Number(maxRows[0].max_idx) + 1;

        await execute(
            `INSERT INTO wallet_index_manager (hd_wallet_id, network_id, wallet_type, last_index)
     VALUES (?, ?, ?, ?)`,
            [hdWalletId, networkId, walletType, startIndex],
        );
        return startIndex;
    }

    const nextIndex = Number(rows[0].last_index) + 1;
    await execute(
        `UPDATE wallet_index_manager SET last_index = ?
   WHERE hd_wallet_id = ? AND network_id = ? AND wallet_type = ?`,
        [nextIndex, hdWalletId, networkId, walletType],
    );
    return nextIndex;
}
```

**변경 요약**:
- `!rows[0]` (첫 파생) 분기에서 무조건 `0`이 아닌, `wallet_addresses` 테이블의 `MAX(derivation_index) + 1`로 시작
- `WHERE network_id = ?`만 사용 (hd_wallet_id가 NULL인 인프라 지갑도 커버)

---

### 4.2 수정 파일 2: `WalletDerivationService.ts`

**파일**: `node-service/packages/blockchain-api/src/services/WalletDerivationService.ts`

#### 변경 전 (line 52-53):

```typescript
const derivationIndex =
    req.derivationIndex ?? (await walletIndexManagerRepo.getNextIndex(req.hdWalletId, req.networkId));
```

#### 변경 후:

```typescript
const derivationIndex =
    req.derivationIndex ?? (await walletIndexManagerRepo.getNextIndex(
        req.hdWalletId, req.networkId, req.walletType || 'HOT'));
```

**변경 요약**: `walletType`을 `getNextIndex`에 전달하여 wallet_type별 독립 시퀀스 관리.

---

## 5. 수정 후 DB 정리 (필요 시)

기존 wallet_index_manager의 HOT 레코드(이전 실패 시도에서 생성, last_index=0)를 정리해야 한다.
그래야 HOT 지갑 생성 시에도 올바른 시작 인덱스를 사용한다.

```sql
-- 잘못된 HOT 레코드 삭제 (코드 수정 후 첫 호출 시 올바르게 재생성됨)
DELETE FROM wallet_index_manager WHERE wallet_type = 'HOT' AND last_index = 0;
```

**주의**: 이 DELETE는 코드 수정 배포 후에 실행할 것. 코드 수정 전에 삭제하면 동일 버그 재발.

---

## 6. 수정 후 예상 동작

파트너(id=3) 생성 시 MASTER 지갑 파생 흐름:

1. `createMasterWallet(partnerId=3, networkId=2)` 호출
2. `deriveWallet(networkId=2, walletType='MASTER')` 호출
3. `getNextIndex(hdWalletId=2, networkId=2, walletType='MASTER')` 호출
4. MASTER 레코드 없음 → `SELECT MAX(derivation_index) FROM wallet_addresses WHERE network_id=2` → **3** (RELAYER가 index 3)
5. `startIndex = 3 + 1 = 4`
6. `INSERT INTO wallet_index_manager (2, 2, 'MASTER', 4)` → return **4**
7. `hdDerivation.derive(seed, "m/44'/60'/1'", 'BSC', 4)` → 새로운 고유 주소 생성
8. `INSERT INTO wallet_addresses (network_id=2, address=새주소, wallet_type='MASTER', partner_id=3, derivation_index=4)` → 성공

---

## 7. 배포 절차

1. **코드 수정** (VS Code)
   - `WalletIndexManagerRepo.ts` 수정
   - `WalletDerivationService.ts` 수정

2. **빌드**
   ```bash
   cd node-service
   pnpm build
   ```

3. **배포** (node-01 서버)
   ```bash
   # rsync 또는 git pull로 배포
   # PM2 재시작
   pm2 restart blockchain-api --update-env
   ```

4. **DB 정리** (배포 완료 후)
   ```sql
   DELETE FROM wallet_index_manager WHERE wallet_type = 'HOT' AND last_index = 0;
   ```

5. **검증**: admin UI에서 파트너 생성 → MASTER/HOT 지갑 정상 생성 확인

---

## 8. 검증 체크리스트

- [ ] 기존 파트너(id=1,2,3) MASTER 지갑 생성 정상 동작
- [ ] 새 파트너 생성 시 MASTER/HOT 지갑 3개 네트워크 모두 생성
- [ ] `wallet_addresses`에 중복 address+network 없음
- [ ] `wallet_index_manager` 레코드가 올바른 `last_index` 보유
- [ ] `derivation_index`가 기존 인프라 지갑과 겹치지 않음

---

## 9. 추가 고려사항 (향후)

### 9.1 hd_wallets.current_index 동기화

현재 `hd_wallets.current_index`가 모두 `0`으로, 실제 사용 중인 인덱스와 불일치.
지갑 파생 성공 후 `hd_wallets.current_index`도 업데이트하는 로직 추가 권장.

### 9.2 인프라 지갑의 hd_wallet_id 보정

기존 ADMIN/GAS/RELAYER 지갑의 `hd_wallet_id`가 NULL이다.
정확한 인덱스 추적을 위해 올바른 `hd_wallet_id`를 채워넣는 것을 권장:

```sql
-- BSC (hd_wallet_id=2)
UPDATE wallet_addresses SET hd_wallet_id = 2 WHERE network_id = 2 AND hd_wallet_id IS NULL;
-- POLYGON (hd_wallet_id=3)
UPDATE wallet_addresses SET hd_wallet_id = 3 WHERE network_id = 3 AND hd_wallet_id IS NULL;
-- TRON (hd_wallet_id=4)
UPDATE wallet_addresses SET hd_wallet_id = 4 WHERE network_id = 4 AND hd_wallet_id IS NULL;
```

이 보정 후에는 `getNextIndex`의 WHERE 조건을 `hd_wallet_id = ?`로 더 정확하게 변경 가능.
단, 현재 수정에서는 `network_id`만으로 충분 (1 network = 1 hd_wallet 구조).

### 9.3 동시성 보호

현재 `getNextIndex`에는 비관적 잠금이 없다.
동시에 같은 wallet_type+network에 대해 파생 요청이 오면 race condition 가능.
향후 `SELECT ... FOR UPDATE` 적용 권장 (현재는 파트너 생성이 순차적이므로 당장 문제 없음).
