# GAS 비용 체계 구현 지침서

> **작성일**: 2026-03-21
> **설계 문서**: `CRYPTOMENTS_GAS_COST_DESIGN.md` (반드시 먼저 읽을 것)
> **대상**: Node.js `wallet-activator`, `relayer-api`, `common` (VS Code)
> **목적**: 크로스 체크 + 미완성 부분 구현

---

## 체크리스트 요약

| # | 파일 | 변경 유형 | 우선순위 |
|---|------|----------|---------|
| 1 | `common/src/chain/EvmProvider.ts` | gasLimit ×1.2 적용 | 🔴 |
| 2 | `wallet-activator/src/services/ApprovalProcessor.ts` | 배수 상수 분리 | 🟡 |
| 3 | `relayer-api/src/services/CollectionPoller.ts` | receipt 대기 + fee_native 즉시 기록 | 🔴 |
| 4 | `relayer-api/src/services/WithdrawalPoller.ts` | receipt 대기 + fee_native 즉시 기록 | 🔴 |
| 5 | `common/src/types/chain.ts` | 배수 상수 정의 | 🟡 |
| 6 | 크로스 체크 | 기존 코드 정합성 확인 | 🟢 |

---

## 1. 배수 상수 분리 및 중앙화

### 1-1. 상수 정의

**파일**: `node-service/packages/common/src/constants/gas.ts` (신규 생성)

```typescript
/**
 * GAS 비용 배수 상수
 *
 * GAS_PRICE_MULTIPLIER: native coin 필요량 사전 계산 시 적용 (보수적)
 *   - GAS Support 전송액 = estimatedGas × GAS_PRICE_MULTIPLIER
 *   - 목적: 네트워크 혼잡 시에도 approve TX가 실패하지 않도록 여유분 확보
 *
 * GAS_LIMIT_MULTIPLIER: 실제 TX 제출 시 gasLimit에 적용
 *   - gasLimit = estimateGas() × GAS_LIMIT_MULTIPLIER
 *   - 목적: 추정 대비 실제 gas 소모가 클 수 있으므로 안전 마진
 */

/** Gas Price 견적 배수 — native coin 필요량 계산 시 */
export const GAS_PRICE_MULTIPLIER = 1.5;

/** Gas Limit 실행 배수 — TX gasLimit 설정 시 */
export const GAS_LIMIT_MULTIPLIER = 1.2;

/** ERC-20 approve TX 기본 Gas Limit */
export const APPROVE_GAS_LIMIT = 60_000n;

/** ERC-20 transferFrom TX 기본 Gas Limit (estimateGas 실패 시 fallback) */
export const TRANSFER_FROM_GAS_LIMIT = 100_000n;

/** TRON 계정 활성화 TRX (sun 단위: 0.1 TRX) */
export const TRON_ACTIVATION_AMOUNT = 100_000n;

/** TRON approve 에너지 추정치 (최소) */
export const TRON_APPROVE_ENERGY = 65_000;

/** TRON transferFrom 에너지 추정치 (최소) */
export const TRON_TRANSFER_ENERGY = 65_000;
```

### 1-2. common/src/index.ts 에 export 추가

```typescript
export * from './constants/gas.js';
```

---

## 2. ApprovalProcessor.ts — 배수 상수 교체

**파일**: `node-service/packages/wallet-activator/src/services/ApprovalProcessor.ts`

### 2-1. import 변경

```typescript
// ─── BEFORE ───
// (파일 내부 상수)
const GAS_MULTIPLIER = 1.5;

// ─── AFTER ───
import {
  GAS_PRICE_MULTIPLIER,
  GAS_LIMIT_MULTIPLIER,
  APPROVE_GAS_LIMIT,
  TRON_ACTIVATION_AMOUNT as TRON_ACTIVATION_AMOUNT_CONST,
  TRON_APPROVE_ENERGY as TRON_APPROVE_ENERGY_CONST,
} from '@cryptoments/common';
```

### 2-2. EVM GAS Support 계산 수정

```typescript
// ─── BEFORE (line 164-165) ───
const estimatedGas = await provider.estimateApproveGas();
const gasAmount = BigInt(Math.ceil(Number(estimatedGas) * GAS_MULTIPLIER));

// ─── AFTER ───
// 견적: Gas Price × 1.5 (네트워크 혼잡 대비)
const gasPrice = await provider.getGasPrice();
const price = gasPrice.maxFeePerGas ?? gasPrice.gasPrice;
const estimatedNative = APPROVE_GAS_LIMIT * price;
const gasAmount = BigInt(Math.ceil(Number(estimatedNative) * GAS_PRICE_MULTIPLIER));
```

### 2-3. EvmProvider.approveToken() 호출 시 gasLimit 명시

```typescript
// ─── BEFORE (EvmProvider.ts line 149-152) ───
const tx = await contract.approve(params.spenderAddress, params.amount, {
  nonce: params.nonce,
  gasLimit: params.gasLimit || undefined,
});

// ─── AFTER ───
// 실행: Gas Limit × 1.2 (안전 마진)
const appliedGasLimit = params.gasLimit
  || BigInt(Math.ceil(Number(APPROVE_GAS_LIMIT) * GAS_LIMIT_MULTIPLIER));
const tx = await contract.approve(params.spenderAddress, params.amount, {
  nonce: params.nonce,
  gasLimit: appliedGasLimit,
});
```

> **참고**: `APPROVE_GAS_LIMIT`을 import하거나, `executeApprove()`에서 `gasLimit`를 미리 계산하여 params에 전달.

---

## 3. CollectionPoller.ts — receipt 대기 + fee_native 즉시 기록 (🔴 핵심)

**파일**: `node-service/packages/relayer-api/src/services/CollectionPoller.ts`

### 3-1. EVM 브랜치 수정

```typescript
// ─── BEFORE (line 154-172) ───
// ── EVM: ethers.js ──
const rpcUrl = provider.getRpcUrl();
const jsonRpcProvider = new ethers.JsonRpcProvider(rpcUrl);
const signer = new ethers.Wallet(relayerPrivateKey, jsonRpcProvider);
const contract = new ethers.Contract(
  relayerContract.contract_address,
  CRYPTO_RELAYER_ABI,
  signer,
);

const tx = await contract.executeTransfer(
  tokenContract,
  fromWallet.address,
  masterWallet.address,
  BigInt(entry.amount),
  { nonce: acquired.nonce },
);
txHash = tx.hash;

// ─── AFTER ───
// ── EVM: ethers.js ──
const rpcUrl = provider.getRpcUrl();
const jsonRpcProvider = new ethers.JsonRpcProvider(rpcUrl);
const signer = new ethers.Wallet(relayerPrivateKey, jsonRpcProvider);
const contract = new ethers.Contract(
  relayerContract.contract_address,
  CRYPTO_RELAYER_ABI,
  signer,
);

// gasLimit 추정 + ×1.2 안전 마진
const estimatedGas = await contract.executeTransfer.estimateGas(
  tokenContract,
  fromWallet.address,
  masterWallet.address,
  BigInt(entry.amount),
);
const gasLimit = BigInt(Math.ceil(Number(estimatedGas) * GAS_LIMIT_MULTIPLIER));

const tx = await contract.executeTransfer(
  tokenContract,
  fromWallet.address,
  masterWallet.address,
  BigInt(entry.amount),
  { nonce: acquired.nonce, gasLimit },
);
txHash = tx.hash;
```

### 3-2. Receipt 대기 + gas_cost_records 완전한 INSERT

```typescript
// ─── BEFORE (line 175-193) ───
// 9. tx_hash 기록, 상태 → BROADCASTING
await collectionQueueRepo.updateTxHash(entry.id, txHash, relayer.id);
await collectionQueueRepo.updateStatus(entry.id, 'BROADCASTING');
logger.info('Collection TX broadcast', { collectionId: entry.id, txHash });

// 10. 논스 확인
await nonceManager.confirmNonce(relayer.id, acquired.nonce, acquired.lockId);

// 💰 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,
});

// ─── AFTER ───
// 9. tx_hash 기록, 상태 → BROADCASTING
await collectionQueueRepo.updateTxHash(entry.id, txHash, relayer.id);
await collectionQueueRepo.updateStatus(entry.id, 'BROADCASTING');
logger.info('Collection TX broadcast', { collectionId: entry.id, txHash });

// 10. 논스 확인
await nonceManager.confirmNonce(relayer.id, acquired.nonce, acquired.lockId);

// 11. Receipt 대기 → 실제 가스비 확인
const receipt = await provider.waitForConfirmation(txHash);
const feeNative = (receipt.gasUsed * receipt.effectiveGasPrice).toString();

// 12. 💰 COLLECTION 비용 기록 (fee_native 즉시 포함)
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,
  fee_native: feeNative,
  native_price_usd: priceUsd ?? undefined,
  fee_usd: priceUsd ? calcFeeUsd(feeNative, priceUsd) : undefined,
});

// 13. 상태 → CONFIRMED (receipt 확인 완료)
await collectionQueueRepo.updateStatus(entry.id, 'CONFIRMED');
logger.info('Collection confirmed', { collectionId: entry.id, txHash, feeNative });
```

### 3-3. TRON 브랜치에서도 동일 패턴

TRON TX의 경우 `feeLimit` 내에서 실행되며, receipt에서 `fee`를 가져옴:

```typescript
// TRON receipt 대기 후
const tronReceipt = await provider.waitForConfirmation(txHash);
// TRON receipt에서 fee 추출 (sun 단위)
const feeNative = tronReceipt.fee?.toString() ?? '0';
const bandwidthFee = tronReceipt.bandwidthFee?.toString();

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,
  fee_native: feeNative,
  native_price_usd: priceUsd ?? undefined,
  fee_usd: priceUsd ? calcFeeUsd(feeNative, priceUsd) : undefined,
  bandwidth_fee_trx: bandwidthFee,
});
```

> **주의**: TronProvider의 `waitForConfirmation()`이 fee/bandwidthFee를 반환하는지 확인 필요. 없으면 TxReceipt 타입에 추가.

---

## 4. WithdrawalPoller.ts — 동일 패턴 적용 (🔴 핵심)

**파일**: `node-service/packages/relayer-api/src/services/WithdrawalPoller.ts`

CollectionPoller와 동일한 변경 적용:

1. EVM: `estimateGas() × 1.2` → `gasLimit` 명시
2. TX 제출 후 `receipt` 대기
3. `receipt.gasUsed × receipt.effectiveGasPrice` → `fee_native`
4. `gas_cost_records INSERT` 시 fee_native, fee_usd 포함
5. TRON: receipt에서 fee 추출

```typescript
// 핵심 변경 부분 (EVM)
const receipt = await provider.waitForConfirmation(txHash);
const feeNative = (receipt.gasUsed * receipt.effectiveGasPrice).toString();
const priceUsd = await getNativePriceUsd(entry.network_id);

await gasCostRecordRepo.insert({
  partner_id: entry.partner_id,
  network_id: entry.network_id,
  tx_type: 'WITHDRAWAL',
  tx_hash: txHash,
  reference_type: 'WITHDRAWAL',
  reference_id: entry.id,
  fee_native: feeNative,
  native_price_usd: priceUsd ?? undefined,
  fee_usd: priceUsd ? calcFeeUsd(feeNative, priceUsd) : undefined,
});
```

---

## 5. EvmProvider — transferFrom/approveToken gasLimit 개선

**파일**: `node-service/packages/common/src/chain/EvmProvider.ts`

### 5-1. transferFrom에 gasLimit 전달 지원 확인

현재 `TransferFromParams`에 `gasLimit?: bigint` 이 있는지 확인.
없으면 추가:

```typescript
// types/chain.ts
export interface TransferFromParams {
  tokenContract: string;
  from: string;
  to: string;
  amount: bigint;
  relayerPrivateKey: string;
  nonce?: number;
  gasLimit?: bigint;    // ← 추가 (없으면)
}
```

### 5-2. estimateApproveGas() 반환값 변경 검토

현재 `estimateApproveGas()`가 native coin 금액(gasLimit × gasPrice × 1.5)을 반환.
이것은 GAS Support 전송액 계산용이므로 유지하되, 상수 이름을 명확히:

```typescript
/**
 * GAS Support 전송액 추정
 * = APPROVE_GAS_LIMIT × gasPrice × GAS_PRICE_MULTIPLIER
 * 목적: approve TX 실행에 필요한 native coin을 GAS 지갑에서 전송할 때 사용
 */
async estimateGasSupportAmount(): Promise<bigint> {
  const gasPrice = await this.getGasPrice();
  const price = gasPrice.maxFeePerGas ?? gasPrice.gasPrice;
  return (APPROVE_GAS_LIMIT * price * 3n) / 2n;  // ×1.5
}
```

> `estimateApproveGas()` → `estimateGasSupportAmount()` 이름 변경 권장 (의미 명확화)

---

## 6. 크로스 체크 항목

개발 AI가 확인해야 할 정합성 항목:

### 6-1. wallet-activator/ApprovalProcessor.ts

| # | 확인 항목 | 기대 | 현재 코드 |
|---|----------|------|----------|
| ✅ | GAS_SUPPORT INSERT 시 fee_native 포함 | 전송액 기록 | line 182-194 ✅ |
| ✅ | APPROVE INSERT 후 receipt에서 실제 fee 업데이트 | recordActualFee() | line 387-403 ✅ |
| ✅ | ENERGY_RENTAL INSERT 시 energy_fee_trx 포함 | TronZap 비용 | line 275-286 ✅ |
| ✅ | partner_id 조회 | walletAddress.partner_id ?? 0 | line 84-91 ✅ |
| ⚠️ | GAS_MULTIPLIER 배수 분리 | ×1.5 견적 / ×1.2 실행 | **현재: ×1.5 하나만** |
| ⚠️ | approveToken 호출 시 gasLimit | APPROVE_GAS_LIMIT × 1.2 | **현재: 미지정** |

### 6-2. relayer-api/CollectionPoller.ts

| # | 확인 항목 | 기대 | 현재 코드 |
|---|----------|------|----------|
| ❌ | COLLECTION INSERT 시 fee_native 포함 | receipt에서 계산 | **현재: NULL** |
| ❌ | receipt 대기 | await provider.waitForConfirmation() | **현재: 미대기** |
| ⚠️ | EVM executeTransfer 시 gasLimit | estimateGas × 1.2 | **현재: 미지정** |
| ✅ | TRON ENERGY_RENTAL INSERT | energy_fee_trx 포함 | line 242-253 ✅ |
| ✅ | partner_id | entry.partner_id | ✅ |

### 6-3. relayer-api/WithdrawalPoller.ts

CollectionPoller와 동일한 체크 항목 (fee_native, receipt 대기, gasLimit).

### 6-4. TxReceipt 타입

| # | 확인 항목 | 기대 |
|---|----------|------|
| ✅ | gasUsed | bigint 타입 |
| ✅ | effectiveGasPrice | bigint 타입 |
| ⚠️ | fee (TRON) | sun 단위 — TronProvider.waitForConfirmation() 반환 확인 |
| ⚠️ | bandwidthFee (TRON) | sun 단위 — TronProvider 반환 확인 |

---

## 7. 구현 순서

| 단계 | 파일 | 작업 | 예상 시간 |
|------|------|------|----------|
| **1** | `common/src/constants/gas.ts` | 상수 파일 생성 + export | 5분 |
| **2** | `common/src/chain/EvmProvider.ts` | estimateApproveGas 이름 변경, gasLimit 개선 | 10분 |
| **3** | `wallet-activator/ApprovalProcessor.ts` | import 교체, 배수 분리, gasLimit 전달 | 15분 |
| **4** | `relayer-api/CollectionPoller.ts` | receipt 대기 + fee_native 즉시 기록 + gasLimit ×1.2 | 20분 |
| **5** | `relayer-api/WithdrawalPoller.ts` | 4번과 동일 패턴 | 15분 |
| **6** | `common/src/types/tx.ts` / `chain.ts` | TxReceipt TRON fee 필드 확인/추가 | 5분 |
| **7** | 테스트 | pnpm build + 단위 확인 | 10분 |

---

## 8. 테스트 검증

### 8-1. 빌드 확인

```bash
cd /Users/dudgh/git/cryptoments/node-service
source ~/.nvm/nvm.sh && nvm use 20
pnpm build
```

### 8-2. DB 검증 (구현 후 실제 TX 발생 시)

```sql
-- gas_cost_records에 fee_native가 NULL인 레코드가 없어야 함
SELECT id, tx_type, tx_hash, fee_native, fee_usd
FROM gas_cost_records
WHERE fee_native IS NULL;
-- 기대: 0 rows (TRON ENERGY_RENTAL 제외하면 모두 fee_native 있어야 함)

-- 파트너별 가스비 합계
SELECT
  p.partner_code,
  gcr.tx_type,
  COUNT(*) AS cnt,
  SUM(gcr.fee_usd) AS total_usd
FROM gas_cost_records gcr
JOIN partners p ON p.id = gcr.partner_id
GROUP BY p.partner_code, gcr.tx_type
ORDER BY p.partner_code;
```

### 8-3. 배수 적용 확인

```sql
-- GAS_SUPPORT 레코드: fee_native ≈ APPROVE_GAS_LIMIT × gasPrice × 1.5
-- APPROVE 레코드: fee_native < GAS_SUPPORT의 fee_native (실제는 견적보다 적음)
SELECT
  gcr.id,
  gcr.tx_type,
  gcr.fee_native,
  gcr.fee_usd,
  gcr.reference_id
FROM gas_cost_records gcr
WHERE gcr.reference_type = 'WALLET_APPROVAL'
ORDER BY gcr.reference_id, gcr.tx_type;
```
