# 내부 지갑 간 네이티브 토큰 전송 API 지침서

**대상**: VS Code (blockchain-api)
**우선순위**: Phase 0 운영 필수
**날짜**: 2026-03-20

---

## 배경

현재 인프라 지갑(ADMIN/GAS/RELAYER) 간 네이티브 토큰 전송은 별도 스크립트를 작성해야 합니다.
이를 blockchain-api의 정식 API 엔드포인트로 추가하여, Admin Console에서 직접 호출할 수 있게 합니다.

**이미 구현되어 있는 것**:
- `ChainProvider.sendNative(params)` — EVM(`EvmProvider`), TVM(`TronProvider`) 모두 구현 완료
- `keyManager.decrypt()` — wallet_keys 복호화
- `chainProviderFactory.get(networkId)` — 네트워크별 provider 조회

**추가할 것**: system-admin 라우트에 API 엔드포인트 2개

---

## API 설계

### 1. `POST /api/admin/wallet/transfer-native` — 지갑 간 네이티브 전송

내부 지갑(`wallet_addresses`에 등록된)에서 다른 내부 지갑으로 네이티브 토큰을 전송합니다.

```
POST /api/admin/wallet/transfer-native
Content-Type: application/json

{
  "fromAddressId": 1,        // wallet_addresses.id (발신 지갑)
  "toAddressId": 2,          // wallet_addresses.id (수신 지갑)
  "amount": "0.078"          // human-readable 수량 (BNB/POL/TRX 단위)
}
```

**응답**:
```json
{
  "txHash": "0x...",
  "from": "0xF9cb...14FE",
  "to": "0x85a1...DA0c",
  "amount": "0.078",
  "symbol": "BNB",
  "networkId": 2
}
```

### 2. `POST /api/admin/wallet/distribute-native` — 1/N 균등 분배

지정된 소스 지갑에서 여러 대상 지갑에 균등 분배합니다.

```
POST /api/admin/wallet/distribute-native
Content-Type: application/json

{
  "fromAddressId": 1,          // 소스 지갑
  "toAddressIds": [2, 8],      // 대상 지갑 목록
  "mode": "equal"              // "equal" = 잔액을 (대상수+1)로 나눠 분배
}
```

**응답**:
```json
{
  "results": [
    { "toAddressId": 2, "txHash": "0x...", "amount": "0.078", "status": "confirmed" },
    { "toAddressId": 8, "txHash": "0x...", "amount": "0.078", "status": "confirmed" }
  ],
  "fromRemaining": "0.078",
  "symbol": "BNB"
}
```

---

## 구현

### 파일: `packages/blockchain-api/src/routes/system-admin.ts`

#### 1. import 추가

기존 import 블록의 `@cryptoments/common`에 추가:

```typescript
import {
  // ... 기존 import에 추가
  chainProviderFactory,
  blockchainNetworkRepo,  // native_currency 조회용
} from '@cryptoments/common';

import { ethers } from 'ethers';
```

> `blockchainNetworkRepo`가 아직 없다면, 직접 query로 `blockchain_networks` 테이블 조회.

#### 2. 헬퍼 함수 (라우터 위에 배치)

```typescript
// ─── 헬퍼: 지갑 주소 + 개인키 조회 ───

interface WalletWithKey {
  id: number;
  address: string;
  networkId: number;
  walletType: string;
  privateKey: string;       // 복호화된 평문
}

async function getWalletWithKey(addressId: number): Promise<WalletWithKey> {
  const wallet = await walletAddressRepo.findById(addressId);
  if (!wallet) throw new Error(`Wallet not found: addressId=${addressId}`);

  const encryptedKey = await walletKeyRepo.findByAddressId(addressId);
  if (!encryptedKey) throw new Error(`Key not found for addressId=${addressId}`);

  const privateKey = await keyManager.decrypt(encryptedKey);

  return {
    id: wallet.id,
    address: wallet.address,
    networkId: wallet.network_id,
    walletType: wallet.wallet_type,
    privateKey,
  };
}

// ─── 헬퍼: 네이티브 통화 심볼 + 소수점 조회 ───

async function getNativeCurrency(networkId: number): Promise<{ symbol: string; decimals: number }> {
  const [rows] = await query<any[]>(
    'SELECT native_currency FROM blockchain_networks WHERE id = ?',
    [networkId],
  );
  if (!rows || rows.length === 0) throw new Error(`Network not found: ${networkId}`);

  const symbol = rows[0].native_currency;
  // TRON은 6 decimals (sun), EVM은 18 decimals (wei)
  const decimals = symbol === 'TRX' ? 6 : 18;
  return { symbol, decimals };
}

// ─── 헬퍼: human-readable 수량 → bigint 변환 ───

function parseAmount(amount: string, decimals: number): bigint {
  if (decimals === 18) {
    return ethers.parseEther(amount);
  }
  // TRON (6 decimals): "100" → 100_000_000n
  const parts = amount.split('.');
  const whole = BigInt(parts[0]) * BigInt(10 ** decimals);
  if (parts.length === 1) return whole;
  const fracStr = parts[1].padEnd(decimals, '0').slice(0, decimals);
  return whole + BigInt(fracStr);
}

function formatAmount(amount: bigint, decimals: number): string {
  if (decimals === 18) {
    return ethers.formatEther(amount);
  }
  const divisor = BigInt(10 ** decimals);
  const whole = amount / divisor;
  const frac = amount % divisor;
  return frac === 0n
    ? whole.toString()
    : `${whole}.${frac.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
}
```

#### 3. `POST /wallet/transfer-native` 엔드포인트

`router.get('/contract/list', ...)` **위**에 추가 (컨트랙트 섹션 전):

```typescript
// ═══════════════════════════════════════════════════════════════════
//  1-3. 내부 지갑 간 네이티브 토큰 전송
// ═══════════════════════════════════════════════════════════════════

router.post('/wallet/transfer-native', async (req, res) => {
  const { fromAddressId, toAddressId, amount } = req.body;

  if (!fromAddressId || !toAddressId || !amount) {
    return res.status(400).json({
      error: 'fromAddressId, toAddressId, and amount are required',
    });
  }

  if (fromAddressId === toAddressId) {
    return res.status(400).json({ error: 'Cannot transfer to the same wallet' });
  }

  try {
    // 발신/수신 지갑 조회
    const fromWallet = await getWalletWithKey(fromAddressId);
    const toWallet = await walletAddressRepo.findById(toAddressId);
    if (!toWallet) {
      return res.status(404).json({ error: `Target wallet not found: ${toAddressId}` });
    }

    // 같은 네트워크인지 확인
    if (fromWallet.networkId !== toWallet.network_id) {
      return res.status(400).json({
        error: `Network mismatch: from=${fromWallet.networkId}, to=${toWallet.network_id}`,
      });
    }

    const { symbol, decimals } = await getNativeCurrency(fromWallet.networkId);
    const amountBigInt = parseAmount(amount, decimals);

    // 전송 실행
    const provider = chainProviderFactory.get(fromWallet.networkId);
    const txHash = await provider.sendNative({
      from: fromWallet.address,
      to: toWallet.address,
      amount: amountBigInt,
      privateKey: fromWallet.privateKey,
    });

    logger.info('Native transfer executed', {
      from: fromWallet.address,
      to: toWallet.address,
      amount,
      symbol,
      txHash,
    });

    res.json({
      txHash,
      from: fromWallet.address,
      to: toWallet.address,
      amount,
      symbol,
      networkId: fromWallet.networkId,
    });
  } catch (error) {
    const msg = error instanceof Error ? error.message : 'Unknown error';
    logger.error('Native transfer failed', { fromAddressId, toAddressId, amount, error: msg });
    res.status(500).json({ error: 'Native transfer failed', detail: msg });
  }
});
```

#### 4. `POST /wallet/distribute-native` 엔드포인트

```typescript
// ═══════════════════════════════════════════════════════════════════
//  1-4. 네이티브 토큰 균등 분배
// ═══════════════════════════════════════════════════════════════════

router.post('/wallet/distribute-native', async (req, res) => {
  const { fromAddressId, toAddressIds, mode } = req.body;

  if (!fromAddressId || !toAddressIds || !Array.isArray(toAddressIds) || toAddressIds.length === 0) {
    return res.status(400).json({
      error: 'fromAddressId and toAddressIds (non-empty array) are required',
    });
  }

  try {
    const fromWallet = await getWalletWithKey(fromAddressId);
    const { symbol, decimals } = await getNativeCurrency(fromWallet.networkId);
    const provider = chainProviderFactory.get(fromWallet.networkId);

    // 현재 잔액 조회
    const balance = await provider.getNativeBalance(fromWallet.address);

    // 분배 수량 계산: 잔액 / (대상 수 + 1) — 자신도 1/N 유지
    const portions = BigInt(toAddressIds.length + 1);
    const amountPerWallet = balance / portions;

    if (amountPerWallet === 0n) {
      return res.status(400).json({ error: 'Insufficient balance for distribution' });
    }

    logger.info('Native distribution started', {
      from: fromWallet.address,
      balance: formatAmount(balance, decimals),
      targets: toAddressIds.length,
      amountEach: formatAmount(amountPerWallet, decimals),
      symbol,
    });

    const results: Array<{
      toAddressId: number;
      address: string;
      txHash: string;
      amount: string;
      status: string;
    }> = [];

    for (const toId of toAddressIds) {
      const toWallet = await walletAddressRepo.findById(toId);
      if (!toWallet) {
        results.push({ toAddressId: toId, address: '', txHash: '', amount: '0', status: 'NOT_FOUND' });
        continue;
      }

      if (toWallet.network_id !== fromWallet.networkId) {
        results.push({
          toAddressId: toId,
          address: toWallet.address,
          txHash: '',
          amount: '0',
          status: 'NETWORK_MISMATCH',
        });
        continue;
      }

      try {
        const txHash = await provider.sendNative({
          from: fromWallet.address,
          to: toWallet.address,
          amount: amountPerWallet,
          privateKey: fromWallet.privateKey,
        });

        results.push({
          toAddressId: toId,
          address: toWallet.address,
          txHash,
          amount: formatAmount(amountPerWallet, decimals),
          status: 'confirmed',
        });

        logger.info('Distributed native', {
          to: toWallet.address,
          amount: formatAmount(amountPerWallet, decimals),
          txHash,
        });

        // TRON nonce 충돌 방지: TVM이면 3초 대기
        if (provider.chainSymbol === 'TRON') {
          await new Promise(r => setTimeout(r, 3000));
        }
      } catch (txError) {
        const txMsg = txError instanceof Error ? txError.message : 'Unknown';
        results.push({
          toAddressId: toId,
          address: toWallet.address,
          txHash: '',
          amount: '0',
          status: `FAILED: ${txMsg}`,
        });
      }
    }

    // 최종 잔액
    const remaining = await provider.getNativeBalance(fromWallet.address);

    res.json({
      results,
      fromRemaining: formatAmount(remaining, decimals),
      symbol,
    });
  } catch (error) {
    const msg = error instanceof Error ? error.message : 'Unknown error';
    logger.error('Native distribution failed', { fromAddressId, error: msg });
    res.status(500).json({ error: 'Native distribution failed', detail: msg });
  }
});
```

---

## 헤더 JSDoc 업데이트

파일 상단 JSDoc에 새 엔드포인트 2줄 추가:

```typescript
 *   POST /api/admin/wallet/create-admin     — ADMIN 지갑 생성 (네트워크당 1개)
 *   POST /api/admin/wallet/create-infra     — 범용 인프라 지갑 생성 (ADMIN/GAS/SETTLEMENT)
 *   POST /api/admin/wallet/transfer-native  — 내부 지갑 간 네이티브 전송    ← 추가
 *   POST /api/admin/wallet/distribute-native — 네이티브 균등 분배            ← 추가
 *   POST /api/admin/contract/register       — 수동 배포한 컨트랙트 등록
```

---

## 사용 예시

### 단일 전송: ADMIN → GAS 에 0.05 BNB 전송

```bash
curl -s -X POST http://localhost:3001/api/admin/wallet/transfer-native \
  -H "Content-Type: application/json" \
  -d '{"fromAddressId": 1, "toAddressId": 2, "amount": "0.05"}'
```

### 균등 분배: ADMIN 잔액의 1/3씩 GAS + RELAYER에

```bash
# BSC: ADMIN(1) → GAS(2) + RELAYER(8)
curl -s -X POST http://localhost:3001/api/admin/wallet/distribute-native \
  -H "Content-Type: application/json" \
  -d '{"fromAddressId": 1, "toAddressIds": [2, 8], "mode": "equal"}'

# Polygon: ADMIN(5) → GAS(6) + RELAYER(9)
curl -s -X POST http://localhost:3001/api/admin/wallet/distribute-native \
  -H "Content-Type: application/json" \
  -d '{"fromAddressId": 5, "toAddressIds": [6, 9], "mode": "equal"}'

# TRON: ADMIN(3) → GAS(4) + RELAYER(10)
curl -s -X POST http://localhost:3001/api/admin/wallet/distribute-native \
  -H "Content-Type: application/json" \
  -d '{"fromAddressId": 3, "toAddressIds": [4, 10], "mode": "equal"}'
```

---

## 주의사항

1. **같은 네트워크 검증 필수** — 크로스 체인 전송은 불가. `network_id` 일치 확인
2. **TRON 신규 계정 활성화** — 미활성 주소로 첫 전송 시 활성화 비용(1 TRX) 추가 소비. `sendNative`에서 알아서 처리됨
3. **TRON TX 간격** — TVM은 nonce 개념이 다르므로 연속 TX 사이에 3초 대기 필요
4. **가스비 미차감** — `amount`는 순수 전송 수량. EVM은 별도 가스비 차감, TRON은 대역폭/에너지 소비
5. **distribute-native 가스비** — 분배 시 여러 TX의 가스비가 소스 지갑에서 차감되므로 실제 잔여는 정확히 1/N보다 약간 적음

---

## 빌드 & 테스트

```bash
cd node-service
pnpm build
# blockchain-api 재시작

# 단일 전송 테스트
curl -s -X POST http://localhost:3001/api/admin/wallet/transfer-native \
  -H "Content-Type: application/json" \
  -d '{"fromAddressId": 1, "toAddressId": 2, "amount": "0.001"}'
```

---

## Spring Boot 연동 (Phase 2 이후)

admin-api에서 이 API를 호출하는 컨트롤러가 필요하면 별도 지침서로 추가 예정.
현재 Phase 0에서는 직접 blockchain-api를 호출하면 충분합니다.
