# Phase 1B: 가스비 USD 환산 + TRON 비용 상세 기록

**작성일**: 2026-03-16
**DDL 버전**: v1.5 (currencies 네이티브 토큰 추가, gas_cost_records 컬럼 추가)
**대상**: `node-service/packages/` (VS Code에서 실행)
**예상 소요**: 2시간
**선행 작업**: DDL v1.5 migration SQL 실행

---

## 선행: DB Migration SQL

```sql
-- 1. currencies: 네이티브 토큰 추가 (시세 관리용)
INSERT INTO currencies (symbol, network_id, name, contract_address, currency_type, decimals, is_stablecoin) VALUES
('BNB', 2, 'BNB (BSC)',     NULL, 'NATIVE', 18, FALSE),
('POL', 3, 'POL (Polygon)', NULL, 'NATIVE', 18, FALSE),
('TRX', 4, 'TRX (TRON)',   NULL, 'NATIVE', 6,  FALSE);

-- 2. gas_cost_records: 환산 시세 + TRON 비용 상세 컬럼
ALTER TABLE gas_cost_records
  ADD COLUMN native_price_usd DECIMAL(20,8) COMMENT '환산 시점 네이티브 토큰 USD 시세 (currency_prices 스냅샷)' AFTER fee_native,
  ADD COLUMN bandwidth_fee_trx DECIMAL(36,18) COMMENT 'TRON bandwidth 비용 (TRX) — EVM 체인이면 NULL' AFTER fee_usd,
  ADD COLUMN energy_fee_trx DECIMAL(36,18) COMMENT 'TRON 에너지 렌탈 비용 (TronZap, TRX) — EVM 체인이면 NULL' AFTER bandwidth_fee_trx;
```

---

## 변경 배경

### 문제

`gas_cost_records.fee_usd`에 USD 환산 금액을 기록해야 하는데, 네이티브 토큰(BNB/TRX/POL)의 USD 시세를 가져올 소스가 없었음.
`currency_prices` 테이블은 스테이블코인(USDT/USDC)만 추적 중이었음.

### 해결

1. `currencies`에 BNB/POL/TRX 네이티브 토큰을 `NATIVE` 타입으로 추가
2. `currency_prices`에서 스테이블코인과 동일한 파이프라인으로 시세 관리
3. `gas_cost_records` INSERT 시점에 시세 조회 → `fee_usd` 계산 → 함께 저장

### 네트워크별 환산 흐름

**EVM (BSC, Polygon)**:
```
webhook txFee → fee_native (BNB/POL)
currency_prices에서 BNB/POL price_usd 조회
fee_usd = fee_native × price_usd
```

**TRON**:
```
bandwidth 비용 (TRX)     → bandwidth_fee_trx
TronZap 에너지 렌탈 (TRX) → energy_fee_trx
fee_native = bandwidth_fee_trx + energy_fee_trx
currency_prices에서 TRX price_usd 조회
fee_usd = fee_native × price_usd
```

---

## 작업 순서

```
B-0 (버그 수정) → B-1 (타입) → B-2 (Repo) → B-3 (Poller) → B-4 (Activator) → 빌드
```

---

## B-0. 잔존 버그 수정 (Phase 1 누락)

### `blockchain-api/scripts/check-wallets.ts` (line 23)

```typescript
// Before
'SELECT wk.id, wk.wallet_address_id, wk.encryption_algorithm, wk.key_version, ...'

// After
'SELECT wk.id, wk.wallet_address_id, wk.encryption_algorithm, wk.encryption_key_version, ...'
```

---

## B-1. 타입 정의 수정

### `packages/common/src/types/tx.ts`

**GasCostRecord 인터페이스** — 3개 필드 추가:

```typescript
// Before
export interface GasCostRecord {
  id: number;
  partner_id: number;
  network_id: number;
  tx_type: GasCostTxType;
  tx_hash: string | null;
  reference_type: GasCostReferenceType | null;
  reference_id: number | null;
  fee_native: string | null;
  fee_usd: string | null;
  billing_status: GasCostBillingStatus;
  invoice_id: number | null;
  created_at: Date;
  updated_at: Date;
}

// After
export interface GasCostRecord {
  id: number;
  partner_id: number;
  network_id: number;
  tx_type: GasCostTxType;
  tx_hash: string | null;
  reference_type: GasCostReferenceType | null;
  reference_id: number | null;
  fee_native: string | null;             // 네이티브 토큰 단위 (BNB/POL/TRX)
  native_price_usd: string | null;       // ★ 환산 시점 USD 시세
  fee_usd: string | null;                // fee_native × native_price_usd
  bandwidth_fee_trx: string | null;      // ★ TRON bandwidth 비용 (EVM이면 NULL)
  energy_fee_trx: string | null;         // ★ TRON 에너지 렌탈 비용 (EVM이면 NULL)
  billing_status: GasCostBillingStatus;
  invoice_id: number | null;
  created_at: Date;
  updated_at: Date;
}
```

**GasCostRecordInsert 인터페이스** — 3개 필드 추가:

```typescript
// Before
export interface GasCostRecordInsert {
  partner_id: number;
  network_id: number;
  tx_type: GasCostTxType;
  tx_hash?: string;
  reference_type?: GasCostReferenceType;
  reference_id?: number;
  fee_native?: string;
  fee_usd?: string;
}

// After
export interface GasCostRecordInsert {
  partner_id: number;
  network_id: number;
  tx_type: GasCostTxType;
  tx_hash?: string;
  reference_type?: GasCostReferenceType;
  reference_id?: number;
  fee_native?: string;
  native_price_usd?: string;             // ★ 환산 시점 시세
  fee_usd?: string;
  bandwidth_fee_trx?: string;            // ★ TRON only
  energy_fee_trx?: string;               // ★ TRON only
}
```

---

## B-2. GasCostRecordRepo 수정

### `packages/common/src/db/repositories/GasCostRecordRepo.ts`

**변경 A**: `insert()` — 컬럼 3개 추가

```typescript
// Before
async insert(record: GasCostRecordInsert): Promise<number> {
  const result = await execute(
    `INSERT INTO gas_cost_records
      (partner_id, network_id, tx_type, tx_hash, reference_type, reference_id, fee_native, fee_usd)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
    [
      record.partner_id,
      record.network_id,
      record.tx_type,
      record.tx_hash ?? null,
      record.reference_type ?? null,
      record.reference_id ?? null,
      record.fee_native ?? null,
      record.fee_usd ?? null,
    ],
  );
  return result.insertId;
}

// After
async insert(record: GasCostRecordInsert): Promise<number> {
  const result = await execute(
    `INSERT INTO gas_cost_records
      (partner_id, network_id, tx_type, tx_hash, reference_type, reference_id,
       fee_native, native_price_usd, fee_usd, bandwidth_fee_trx, energy_fee_trx)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
    [
      record.partner_id,
      record.network_id,
      record.tx_type,
      record.tx_hash ?? null,
      record.reference_type ?? null,
      record.reference_id ?? null,
      record.fee_native ?? null,
      record.native_price_usd ?? null,
      record.fee_usd ?? null,
      record.bandwidth_fee_trx ?? null,
      record.energy_fee_trx ?? null,
    ],
  );
  return result.insertId;
}
```

**변경 B**: `updateFee()` — `native_price_usd` 파라미터 추가

```typescript
// Before
async updateFee(
  id: number,
  feeNative: string,
  feeUsd?: string,
): Promise<void> {
  if (feeUsd) {
    await execute(
      `UPDATE gas_cost_records
       SET fee_native = ?, fee_usd = ?, updated_at = NOW()
       WHERE id = ?`,
      [feeNative, feeUsd, id],
    );
  } else {
    await execute(
      `UPDATE gas_cost_records
       SET fee_native = ?, updated_at = NOW()
       WHERE id = ?`,
      [feeNative, id],
    );
  }
}

// After
async updateFee(
  id: number,
  feeNative: string,
  nativePriceUsd?: string,
  feeUsd?: string,
): Promise<void> {
  await execute(
    `UPDATE gas_cost_records
     SET fee_native = ?,
         native_price_usd = COALESCE(?, native_price_usd),
         fee_usd = COALESCE(?, fee_usd),
         updated_at = NOW()
     WHERE id = ?`,
    [feeNative, nativePriceUsd ?? null, feeUsd ?? null, id],
  );
}
```

---

## B-3. 가격 조회 유틸리티 추가

### 새 파일: `packages/common/src/price/NativePriceService.ts`

네이티브 토큰의 USD 시세를 `currency_prices`에서 조회하는 서비스.

```typescript
/**
 * 네이티브 토큰 USD 시세 조회
 * currencies (NATIVE 타입) + currency_prices 조인
 */

import { query } from '../db/mysql.js';
import type { RowDataPacket } from 'mysql2/promise';

interface NativePrice {
  currency_id: number;
  symbol: string;
  price_usd: string;
  fetched_at: Date;
}

/**
 * 네트워크의 네이티브 토큰 USD 시세 조회
 * @returns price_usd (string) 또는 null (시세 없음)
 */
export async function getNativePriceUsd(networkId: number): Promise<string | null> {
  const rows = await query<(NativePrice & RowDataPacket)[]>(
    `SELECT c.id AS currency_id, c.symbol, cp.price_usd, cp.fetched_at
     FROM currencies c
     JOIN currency_prices cp ON cp.currency_id = c.id
     WHERE c.network_id = ?
       AND c.currency_type = 'NATIVE'
     LIMIT 1`,
    [networkId],
  );
  return rows[0]?.price_usd ?? null;
}

/**
 * fee_native × price_usd = fee_usd 계산
 */
export function calcFeeUsd(feeNative: string, priceUsd: string): string {
  // 부동소수점 정밀도: 8자리
  const result = parseFloat(feeNative) * parseFloat(priceUsd);
  return result.toFixed(8);
}
```

### `packages/common/src/price/index.ts` (새 파일)

```typescript
export { getNativePriceUsd, calcFeeUsd } from './NativePriceService.js';
```

### `packages/common/src/index.ts` — export 추가

```typescript
// 기존 export 아래에 추가
export { getNativePriceUsd, calcFeeUsd } from './price/index.js';
```

---

## B-4. CollectionPoller 수정

### `packages/relayer-api/src/services/CollectionPoller.ts`

**변경 A**: import 추가

```typescript
import { getNativePriceUsd, calcFeeUsd } from '@cryptoments/common';
```

**변경 B**: COLLECTION 비용 기록 (현재 line ~161) — USD 환산 추가

```typescript
// Before
await gasCostRecordRepo.insert({
  partner_id: entry.partner_id,
  network_id: entry.network_id,
  tx_type: 'COLLECTION',
  tx_hash: txHash,
  reference_type: 'COLLECTION_QUEUE',
  reference_id: entry.id,
});

// After
// 💰 COLLECTION 비용 기록 (fee_native는 TX 확인 후 Spring Boot webhook에서 업데이트)
const priceUsd = await getNativePriceUsd(entry.network_id);
await gasCostRecordRepo.insert({
  partner_id: entry.partner_id,
  network_id: entry.network_id,
  tx_type: 'COLLECTION',
  tx_hash: txHash,
  reference_type: 'COLLECTION_QUEUE',
  reference_id: entry.id,
  native_price_usd: priceUsd ?? undefined,
});
```

> **참고**: COLLECTION/WITHDRAWAL TX의 `fee_native`는 TX broadcast 시점에는 모름.
> webhook으로 TX 확인 후 Spring Boot가 `updateFee()`로 실제 비용 반영.
> 이때 `native_price_usd`가 이미 있으므로 `fee_usd = fee_native × native_price_usd` 계산 가능.

**변경 C**: ENERGY_RENTAL 비용 기록 (현재 line ~218) — TRON 비용 상세 + USD 환산

```typescript
// Before
await gasCostRecordRepo.insert({
  partner_id: entry.partner_id,
  network_id: entry.network_id,
  tx_type: 'ENERGY_RENTAL',
  tx_hash: energyTx.id,
  reference_type: 'COLLECTION_QUEUE',
  reference_id: entry.id,
  fee_native: energyTx.total.toString(),
});

// After
const trxPriceUsd = await getNativePriceUsd(entry.network_id);
const energyFeeNative = energyTx.total.toString();
await gasCostRecordRepo.insert({
  partner_id: entry.partner_id,
  network_id: entry.network_id,
  tx_type: 'ENERGY_RENTAL',
  tx_hash: energyTx.id,
  reference_type: 'COLLECTION_QUEUE',
  reference_id: entry.id,
  fee_native: energyFeeNative,
  native_price_usd: trxPriceUsd ?? undefined,
  fee_usd: trxPriceUsd ? calcFeeUsd(energyFeeNative, trxPriceUsd) : undefined,
  energy_fee_trx: energyFeeNative,
});
```

---

## B-5. WithdrawalPoller 수정

### `packages/relayer-api/src/services/WithdrawalPoller.ts`

CollectionPoller와 동일 패턴. 2곳 수정:

**변경 A**: WITHDRAWAL 비용 기록 (현재 line ~155)

```typescript
// After
const priceUsd = await getNativePriceUsd(withdrawal.network_id);
await gasCostRecordRepo.insert({
  partner_id: partnerId,
  network_id: withdrawal.network_id,
  tx_type: 'WITHDRAWAL',
  tx_hash: txHash,
  reference_type: 'WITHDRAWAL',
  reference_id: withdrawal.id,
  native_price_usd: priceUsd ?? undefined,
});
```

**변경 B**: ENERGY_RENTAL 비용 기록 (현재 line ~212)

```typescript
// After
const trxPriceUsd = await getNativePriceUsd(withdrawal.network_id);
const energyFeeNative = energyTx.total.toString();
await gasCostRecordRepo.insert({
  partner_id: withdrawal.partner_id!,
  network_id: withdrawal.network_id,
  tx_type: 'ENERGY_RENTAL',
  tx_hash: energyTx.id,
  reference_type: 'WITHDRAWAL',
  reference_id: withdrawal.id,
  fee_native: energyFeeNative,
  native_price_usd: trxPriceUsd ?? undefined,
  fee_usd: trxPriceUsd ? calcFeeUsd(energyFeeNative, trxPriceUsd) : undefined,
  energy_fee_trx: energyFeeNative,
});
```

---

## B-6. ApprovalProcessor 수정

### `packages/wallet-activator/src/services/ApprovalProcessor.ts`

**변경 A**: import 추가

```typescript
import { getNativePriceUsd, calcFeeUsd } from '@cryptoments/common';
```

**변경 B**: `recordActualFee()` — USD 환산 추가 (현재 line ~114)

```typescript
// Before
async function recordActualFee(
  costRecordId: number,
  txHash: string,
  networkId: number,
): Promise<void> {
  try {
    const provider = chainProviderFactory.get(networkId);
    const receipt = await provider.getTransactionReceipt(txHash);
    if (receipt) {
      const feeNative = (receipt.gasUsed * receipt.effectiveGasPrice).toString();
      await gasCostRecordRepo.updateFee(costRecordId, feeNative);
    }
  } catch { ... }
}

// After
async function recordActualFee(
  costRecordId: number,
  txHash: string,
  networkId: number,
): Promise<void> {
  try {
    const provider = chainProviderFactory.get(networkId);
    const receipt = await provider.getTransactionReceipt(txHash);
    if (receipt) {
      const feeNative = (receipt.gasUsed * receipt.effectiveGasPrice).toString();
      const priceUsd = await getNativePriceUsd(networkId);
      const feeUsd = priceUsd ? calcFeeUsd(feeNative, priceUsd) : undefined;
      await gasCostRecordRepo.updateFee(costRecordId, feeNative, priceUsd ?? undefined, feeUsd);
    }
  } catch { ... }
}
```

**변경 C**: GAS_SUPPORT 비용 기록 (현재 line ~177) — INSERT 시점에도 시세 기록

```typescript
// Before
const gasCostId = await gasCostRecordRepo.insert({
  partner_id: partnerId,
  network_id: approval.network_id,
  tx_type: 'GAS_SUPPORT',
  tx_hash: gasTxHash,
  reference_type: 'WALLET_APPROVAL',
  reference_id: approval.id,
  fee_native: gasAmount.toString(),
});

// After
const gasNative = gasAmount.toString();
const priceUsd = await getNativePriceUsd(approval.network_id);
const gasCostId = await gasCostRecordRepo.insert({
  partner_id: partnerId,
  network_id: approval.network_id,
  tx_type: 'GAS_SUPPORT',
  tx_hash: gasTxHash,
  reference_type: 'WALLET_APPROVAL',
  reference_id: approval.id,
  fee_native: gasNative,
  native_price_usd: priceUsd ?? undefined,
  fee_usd: priceUsd ? calcFeeUsd(gasNative, priceUsd) : undefined,
});
```

**변경 D**: APPROVE 비용 기록 (현재 line ~369) — 시세 기록

```typescript
// Before
const approveCostId = await gasCostRecordRepo.insert({
  partner_id: resolvedPartnerId,
  network_id: approval.network_id,
  tx_type: 'APPROVE',
  tx_hash: approveTxHash,
  reference_type: 'WALLET_APPROVAL',
  reference_id: approval.id,
});

// After
const approvePriceUsd = await getNativePriceUsd(approval.network_id);
const approveCostId = await gasCostRecordRepo.insert({
  partner_id: resolvedPartnerId,
  network_id: approval.network_id,
  tx_type: 'APPROVE',
  tx_hash: approveTxHash,
  reference_type: 'WALLET_APPROVAL',
  reference_id: approval.id,
  native_price_usd: approvePriceUsd ?? undefined,
});
```

> **참고**: APPROVE TX의 fee_native도 broadcast 시점에는 모름. `recordActualFee()`에서 확인 후 업데이트.

---

## TRON TX의 bandwidth_fee_trx 기록

TRON 집금/출금 TX의 경우 `fee_native`에는 **bandwidth 비용만** 들어감 (에너지는 TronZap 별도).
webhook에서 TX 확인 시 Spring Boot가 `updateFee()`를 호출할 때, TRON이면 `bandwidth_fee_trx`도 함께 업데이트해야 함.

이를 위해 `GasCostRecordRepo`에 TRON 전용 업데이트 메서드 추가:

```typescript
/** TRON TX의 bandwidth 비용 + USD 환산 업데이트 */
async updateTronFee(
  id: number,
  bandwidthFeeTrx: string,
  nativePriceUsd?: string,
): Promise<void> {
  const feeUsd = nativePriceUsd
    ? (parseFloat(bandwidthFeeTrx) * parseFloat(nativePriceUsd)).toFixed(8)
    : null;
  await execute(
    `UPDATE gas_cost_records
     SET bandwidth_fee_trx = ?,
         fee_native = COALESCE(energy_fee_trx, 0) + ?,
         native_price_usd = COALESCE(?, native_price_usd),
         fee_usd = CASE
           WHEN ? IS NOT NULL THEN (COALESCE(energy_fee_trx, 0) + ?) * ?
           ELSE fee_usd
         END,
         updated_at = NOW()
     WHERE id = ?`,
    [bandwidthFeeTrx, bandwidthFeeTrx, nativePriceUsd, nativePriceUsd, bandwidthFeeTrx, nativePriceUsd, id],
  );
}
```

> **이 메서드는 Spring Boot webhook 서비스에서 호출하는 것이 더 자연스러움.**
> Node.js에 미리 준비해두되, 실제 호출은 Spring Boot 구현 시 결정.

---

## 빌드 검증

```bash
cd node-service
pnpm run build
```

---

## 체크리스트

- [ ] DB migration SQL 실행 (currencies INSERT + gas_cost_records ALTER)
- [ ] B-0: `check-wallets.ts` — `key_version` → `encryption_key_version`
- [ ] B-1: `types/tx.ts` — GasCostRecord, GasCostRecordInsert에 3개 필드 추가
- [ ] B-2: `GasCostRecordRepo.ts` — insert() 컬럼 추가, updateFee() 시세 파라미터 추가
- [ ] B-3: `NativePriceService.ts` 새 파일 생성 + common index export
- [ ] B-4: `CollectionPoller.ts` — COLLECTION + ENERGY_RENTAL 비용 기록에 USD 환산 추가
- [ ] B-5: `WithdrawalPoller.ts` — WITHDRAWAL + ENERGY_RENTAL 비용 기록에 USD 환산 추가
- [ ] B-6: `ApprovalProcessor.ts` — recordActualFee() USD 환산, GAS_SUPPORT/APPROVE 시세 기록
- [ ] (선택) `GasCostRecordRepo.ts` — `updateTronFee()` TRON 전용 업데이트 메서드 추가
- [ ] `pnpm run build` — 5개 패키지 SUCCESS

---

## 주의사항

1. **`currency_prices`에 네이티브 토큰 시세가 없으면** `getNativePriceUsd()`는 null 반환. 이 경우 `fee_usd`와 `native_price_usd`가 NULL로 저장됨. **시세 수집 배치가 먼저 가동되어야** USD 환산이 동작.

2. **fee_usd 계산 정밀도**: `parseFloat` 기반이므로 극단적으로 큰 금액에서는 오차 가능. 현실적으로 가스비는 소액이라 문제 없음. 향후 필요시 `decimal.js` 도입 검토.

3. **TRON fee_native = bandwidth_fee_trx + energy_fee_trx**: 두 비용의 합산. ENERGY_RENTAL 레코드에는 `energy_fee_trx`만, COLLECTION/WITHDRAWAL 레코드에는 TX 확인 후 `bandwidth_fee_trx`가 채워짐.
