# ERC20/TRC20 토큰 전송 API 구현 지침서

> **작성일**: 2026-03-21  
> **대상**: `node-service` — common (ChainProvider) + blockchain-api (route)  
> **목적**: 내부 지갑 간 ERC20/TRC20 토큰 직접 전송 (입금 테스트, 잔액 이동 등)

---

## 1. ChainProvider interface — `sendToken` 메서드 추가

**파일**: `packages/common/src/types/chain.ts`

### 1-a. SendTokenParams 인터페이스 추가 (~82행, SendNativeParams 뒤)

```typescript
export interface SendTokenParams {
  from: string;
  to: string;
  amount: bigint;
  privateKey: string;
  tokenContract: string;
  nonce?: number;
  gasLimit?: bigint;
}
```

### 1-b. ChainProvider interface에 메서드 추가

**파일**: `packages/common/src/chain/ChainProvider.ts` (~44행, sendNative 뒤)

```typescript
  sendToken(params: SendTokenParams): Promise<string>;
```

import에 `SendTokenParams` 추가.

---

## 2. EvmProvider — sendToken 구현

**파일**: `packages/common/src/chain/EvmProvider.ts`
import에 `SendTokenParams` 추가.  
sendNative() 뒤에 추가 (~214행):

```typescript
  async sendToken(params: SendTokenParams): Promise<string> {
    const wallet = new ethers.Wallet(params.privateKey, this.provider);
    const contract = new ethers.Contract(params.tokenContract, ERC20_ABI, wallet);

    const tx = await contract.transfer(params.to, params.amount, {
      nonce: params.nonce,
      gasLimit: params.gasLimit || undefined,
    });

    logger.info(`ERC20 transfer TX sent: ${tx.hash}`, {
      from: params.from,
      to: params.to,
      token: params.tokenContract,
      amount: params.amount.toString(),
    });

    return tx.hash;
  }
```

---

## 3. TronProvider — sendToken 구현

**파일**: `packages/common/src/chain/TronProvider.ts`

import에 `SendTokenParams` 추가.  
sendNative() 뒤에 추가 (~249행):

```typescript
  async sendToken(params: SendTokenParams): Promise<string> {
    this.tronWeb.setPrivateKey(stripHexPrefix(params.privateKey));
    const contract = await this.tronWeb.contract().at(params.tokenContract);
    const result = await contract.transfer(params.to, params.amount.toString()).send();

    // TronWeb returns txid in different ways depending on version
    const txId = typeof result === 'string' ? result : result.txid || result;

    logger.info(`TRC20 transfer TX sent: ${txId}`, {
      from: params.from,
      to: params.to,
      token: params.tokenContract,
      amount: params.amount.toString(),
    });

    return txId;
  }
```

---

## 4. blockchain-api route — `POST /api/admin/wallet/transfer-token`

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

transfer-native 라우트 뒤에 추가:

```typescript
// ═══════════════════════════════════════════════════════════════════
//  토큰(ERC20/TRC20) 전송
// ═══════════════════════════════════════════════════════════════════
router.post('/wallet/transfer-token', async (req, res) => {
  const { fromAddressId, toAddressId, currencyId, amount } = req.body;

  if (!fromAddressId || !toAddressId || !currencyId || !amount) {
    return res.status(400).json({
      error: 'fromAddressId, toAddressId, currencyId, 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}`,
      });
    }

    // 통화 조회 → contractAddress, decimals
    const [currencyRows] = await query<any[]>(
      'SELECT symbol, contract_address, decimals FROM currencies WHERE id = ?',
      [currencyId],
    );
    if (!currencyRows || currencyRows.length === 0) {
      return res.status(404).json({ error: `Currency not found: ${currencyId}` });
    }
    const currency = currencyRows[0] ?? currencyRows;
    if (!currency.contract_address) {
      return res.status(400).json({
        error: 'Native currency — use transfer-native instead',
      });
    }
    const amountBigInt = parseAmount(amount, currency.decimals);

    const provider = chainProviderFactory.get(fromWallet.networkId);
    const txHash = await provider.sendToken({
      from: fromWallet.address,
      to: toWallet.address,
      amount: amountBigInt,
      privateKey: fromWallet.privateKey,
      tokenContract: currency.contract_address,
    });

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

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

**import 추가** (파일 상단에 query가 없다면):
```typescript
import { query } from '@cryptoments/common/db/mysql.js';
```

`parseAmount`는 기존 transfer-native에서 사용하는 헬퍼 함수 그대로 사용.
---

## 5. 사용 예시

```bash
# BSC USDT: ADMIN(id=1) → HOT(id=17), 5 USDT
curl -X POST http://localhost:3001/api/admin/wallet/transfer-token \
  -H "Content-Type: application/json" \
  -d '{
    "fromAddressId": 1,
    "toAddressId": 17,
    "currencyId": 2,
    "amount": "5"
  }'

# TRON USDT: ADMIN(id=3) → HOT(id=12), 5 USDT
curl -X POST http://localhost:3001/api/admin/wallet/transfer-token \
  -H "Content-Type: application/json" \
  -d '{
    "fromAddressId": 3,
    "toAddressId": 12,
    "currencyId": 4,
    "amount": "5"
  }'
```

---

## 6. 체크리스트

```bash
# 빌드 확인
cd node-service && npm run build

# blockchain-api 재기동 후 테스트
curl http://localhost:3001/api/admin/wallet/transfer-token \
  -X POST -H "Content-Type: application/json" \
  -d '{"fromAddressId":1,"toAddressId":17,"currencyId":2,"amount":"5"}'
```