# GAS Cost — partner_id 없는 시스템 지갑 비용 기록 스킵

> **날짜**: 2026-03-21
> **대상 파일**: `wallet-activator/src/services/ApprovalProcessor.ts`
> **우선도**: 🔴 필수 (partner_id=0 INSERT 시 DDL NOT NULL 제약 위반 또는 무의미 레코드 생성)

---

## 1. 배경

`gas_cost_records.partner_id`는 DDL에서 `NOT NULL`이다.
시스템 지갑(ADMIN, GAS 등)은 파트너 소유가 아니므로 `partner_id`가 존재하지 않는다.
현재 코드는 `getPartnerId()`가 파트너 없으면 `0`을 반환하고 그대로 INSERT하는데,
**시스템 비용은 레코딩할 필요가 없다** — 따라서 INSERT 자체를 스킵해야 한다.

## 2. 영향 범위

`gasCostRecordRepo.insert()` 호출 위치 (전체 7곳):

| # | 파일 | 라인 | 호출 방식 | partner_id 보장 | 수정 필요 |
|---|------|------|-----------|----------------|-----------|
| 1 | ApprovalProcessor.ts | ~100 | `recordGasCost()` 헬퍼 | ❌ 시스템 지갑 가능 | ✅ |
| 2 | ApprovalProcessor.ts | ~187 | 직접 `.insert()` | ❌ getPartnerId()=0 가능 | ✅ |
| 3 | ApprovalProcessor.ts | ~390 | 직접 `.insert()` | ❌ partnerId ?? 0 가능 | ✅ |
| 4 | CollectionPoller.ts | ~202 | 직접 `.insert()` | ✅ 파트너 HOT 지갑 | 불필요 |
| 5 | CollectionPoller.ts | ~265 | 직접 `.insert()` | ✅ 파트너 HOT 지갑 | 불필요 |
| 6 | WithdrawalPoller.ts | ~193 | 직접 `.insert()` | ✅ 파트너 MASTER 지갑 | 불필요 |
| 7 | WithdrawalPoller.ts | ~254 | 직접 `.insert()` | ✅ 파트너 MASTER 지갑 | 불필요 |

> **CollectionPoller / WithdrawalPoller는 수정 불필요** — 항상 파트너 소유 지갑(HOT/MASTER)에서 발생하므로 partner_id가 반드시 존재한다.

## 3. 수정 전략

**`getPartnerId()` 반환을 `number | null`로 변경하고, null이면 기록 스킵.**

이유: `recordGasCost()` 헬퍼만 가드해도 line 100은 해결되지만, line 187/390은 직접 `.insert()`를 호출하므로 근본적으로 `getPartnerId()`부터 null을 반환해야 모든 호출부에서 일관된 처리가 가능하다.

---

## 4. 수정 내용

### 4-1. `getPartnerId()` — 반환타입 변경

```typescript
// ── BEFORE ──
async function getPartnerId(walletAddress: WalletAddress): Promise<number> {
  if (walletAddress.partner_id) {
    return walletAddress.partner_id;
  }
  return 0;
}

// ── AFTER ──
async function getPartnerId(walletAddress: WalletAddress): Promise<number | null> {
  return walletAddress.partner_id ?? null;
}
```

### 4-2. `recordGasCost()` 헬퍼 — partner_id null 가드 추가

```typescript
// ── BEFORE ──
async function recordGasCost(record: GasCostRecordInsert): Promise<void> {
  try {
    await gasCostRecordRepo.insert(record);
    logger.debug('Gas cost recorded', {
      txType: record.tx_type,
      txHash: record.tx_hash,
      referenceId: record.reference_id,
      feeNative: record.fee_native,
    });
  } catch (error) {
    logger.error('Failed to record gas cost (non-fatal)', {
      record,
      error: (error as Error).message,
    });
  }
}

// ── AFTER ──
async function recordGasCost(record: GasCostRecordInsert): Promise<void> {
  // 시스템 지갑 (partner_id 없음) → 비용 기록 스킵
  if (!record.partner_id) {
    logger.debug('Gas cost record skipped: no partner_id (system wallet)', {
      txType: record.tx_type,
      txHash: record.tx_hash,
    });
    return;
  }
  try {
    await gasCostRecordRepo.insert(record);
    logger.debug('Gas cost recorded', {
      txType: record.tx_type,
      txHash: record.tx_hash,
      referenceId: record.reference_id,
      feeNative: record.fee_native,
    });
  } catch (error) {
    logger.error('Failed to record gas cost (non-fatal)', {
      record,
      error: (error as Error).message,
    });
  }
}
```

> `!record.partner_id`는 `0`, `null`, `undefined` 모두 falsy로 처리하므로 안전하다.

### 4-3. `processEvmApproval()` — 직접 `.insert()` 호출부 가드

```typescript
// ── BEFORE (line ~187) ──
const partnerId = await getPartnerId(walletAddress);
// ... (GAS Support TX 실행) ...
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,
});

// ── AFTER ──
const partnerId = await getPartnerId(walletAddress);
// ... (GAS Support TX 실행) ...
let gasCostId: number | null = null;
if (partnerId) {
  const priceUsd = await getNativePriceUsd(approval.network_id);
  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,
  });
} else {
  logger.debug('Gas cost record skipped: system wallet', { approvalId: approval.id });
}
```

> `gasCostId`가 이후 `recordActualFee(gasCostId, ...)` 에서 사용되므로, null 체크 필요:

```typescript
// recordActualFee 호출부도 가드
if (gasCostId !== null) {
  await recordActualFee(gasCostId, gasTxHash, approval.network_id);
}
```

### 4-4. `executeApprove()` — 직접 `.insert()` 호출부 가드

```typescript
// ── BEFORE (line ~388-396) ──
async function executeApprove(
  approval: WalletApproval,
  walletAddr: string,
  partnerId?: number,
): Promise<void> {
  // ... (approve TX 실행) ...
  const resolvedPartnerId = partnerId ?? 0;
  const approvePriceUsd = await getNativePriceUsd(approval.network_id);
  const approveCostId = await gasCostRecordRepo.insert({
    partner_id: resolvedPartnerId,
    // ...
  });
  // ...
  await recordActualFee(approveCostId, approveTxHash, approval.network_id);
}

// ── AFTER ──
async function executeApprove(
  approval: WalletApproval,
  walletAddr: string,
  partnerId?: number | null,
): Promise<void> {
  // ... (approve TX 실행) ...

  // 💰 APPROVE 비용 기록 — 시스템 지갑이면 스킵
  let approveCostId: number | null = null;
  if (partnerId) {
    const approvePriceUsd = await getNativePriceUsd(approval.network_id);
    approveCostId = await gasCostRecordRepo.insert({
      partner_id: partnerId,
      network_id: approval.network_id,
      tx_type: 'APPROVE',
      tx_hash: approveTxHash,
      reference_type: 'WALLET_APPROVAL',
      reference_id: approval.id,
      native_price_usd: approvePriceUsd ?? undefined,
    });
  } else {
    logger.debug('Approve cost record skipped: system wallet', { approvalId: approval.id });
  }

  // Approve TX 확인 대기
  await provider.waitForConfirmation(approveTxHash);
  await walletApprovalRepo.updateStatus(approval.id, 'APPROVED');
  logger.info('Approve TX confirmed — APPROVED', { approvalId: approval.id });

  // 실제 가스비로 업데이트 — 비용 기록이 있을 때만
  if (approveCostId !== null) {
    await recordActualFee(approveCostId, approveTxHash, approval.network_id);
  }
}
```

### 4-5. `processTronApproval()` — `recordGasCost()` 헬퍼 사용부

TRON 쪽은 이미 `recordGasCost()` 헬퍼를 사용하고 있으므로 **4-2의 헬퍼 수정만으로 자동 해결**된다.
`partnerId`가 null이면 `recordGasCost()` 내부에서 스킵된다.

단, `recordGasCost()`에 전달되는 `partner_id` 타입이 `number | null`이므로
`GasCostRecordInsert` 타입의 `partner_id` 필드도 `number | null`을 허용해야 한다:

```typescript
// types/tx.ts — GasCostRecordInsert
export interface GasCostRecordInsert {
  partner_id: number | null;   // null이면 recordGasCost()에서 스킵
  network_id: number;
  tx_type: string;
  tx_hash?: string;
  reference_type?: string;
  reference_id?: number;
  fee_native?: string;
  native_price_usd?: string;
  fee_usd?: string;
  bandwidth_fee_trx?: string;
  energy_fee_trx?: string;
}
```

---

## 5. 수정 체크리스트

| # | 파일 | 수정 내용 | 확인 |
|---|------|-----------|------|
| 1 | ApprovalProcessor.ts | `getPartnerId()` 반환 `number \| null`, 0 제거 | ☐ |
| 2 | ApprovalProcessor.ts | `recordGasCost()` — `!record.partner_id` 가드 추가 | ☐ |
| 3 | ApprovalProcessor.ts | `processEvmApproval()` — gasCostId null 가드 | ☐ |
| 4 | ApprovalProcessor.ts | `executeApprove()` — partnerId 타입 `number \| null`, 가드 | ☐ |
| 5 | ApprovalProcessor.ts | `recordActualFee()` 호출 2곳 — `gasCostId !== null` 가드 | ☐ |
| 6 | types/tx.ts | `GasCostRecordInsert.partner_id` → `number \| null` | ☐ |

## 6. 수정하지 않는 파일

| 파일 | 이유 |
|------|------|
| CollectionPoller.ts | 항상 파트너 HOT 지갑 → partner_id 보장 |
| WithdrawalPoller.ts | 항상 파트너 MASTER 지갑 → partner_id 보장 |
| GasCostRecordRepo.ts | INSERT 로직 변경 불필요 (호출부에서 스킵) |
| gas.ts | 상수 파일, 무관 |
| EvmProvider.ts | 가스비 계산만, 기록 안 함 |

## 7. 검증 방법

수정 후 아래 시나리오로 확인:

1. **시스템 지갑 approve** (ADMIN/GAS 지갑 — partner_id 없음)
   → `gas_cost_records` INSERT가 발생하지 않아야 함
   → 로그에 `"Gas cost record skipped: system wallet"` 출력 확인

2. **파트너 지갑 approve** (HOT/MASTER/POOL — partner_id 있음)
   → `gas_cost_records` INSERT 정상, fee_native 포함
   → `recordActualFee()` 호출되어 실제 가스비 업데이트

3. **Collection/Withdrawal** (기존과 동일)
   → partner_id 항상 존재, 변경 없음 확인
