# NODEJS_TRON_ENERGY_VERIFY_GUIDE — TRON 에너지 렌탈 도착량 검증 + 잔여 재사용 + 직렬화

> 작성 2026-08-21 · 대상 `node-service/packages/relayer-api`, `node-service/packages/common`
> 배경: 지식 repo `runbooks/troubleshooting.md` **§68**(실측), §29 Phase 1/2, §32, §53
> 선행 문서: `NODEJS_TRON_ENERGY_RENTAL_FIX_GUIDE.md`(Phase 1), `NODEJS_TRON_ENERGY_RENTAL_PHASE2_GUIDE.md`(Phase 2)

---

## 1. 왜 고치나 (실측 근거)

2026-08-21 릴레이어 `TW3x6Mz3B7vNUua9MQAacNJFJ2hYNcPwiG` 5시간 창 실측(발신 50건, 실패 0건):

| 항목 | 값 |
|---|---|
| 총 소각 | 41.61 TRX (건당 평균 0.832) |
| 대역폭(net_fee) | 20.48 TRX (49%) — **본 지침 범위 밖**(스테이킹/대역폭 구매로 해결) |
| 에너지(energy_fee) | 21.13 TRX (51%) — **본 지침 범위** |
| 렌탈 전액 커버 | 44건 (88%) — 소각 0.411(대역폭)만 |
| **렌탈 부족분 소각** | **6건 (12%)** — 건당 3.4~4.6 TRX |

부족분 6건의 공통점: 요청 렌탈량은 **71,000**(로그 `TRON: Renting energy … energy:71000`)인데,
온체인 `energy_usage`(위임분 실사용)가 **24,137 / 35,731 / 35,857 / 54,864** 등 **절반 남짓**.
즉 **위임이 온전히 도착하지 않은 상태에서 브로드캐스트**됐고, 부족분 `(energy_usage_total − energy_usage) × 100 SUN` 이 그대로 소각됐다.

### 현재 코드의 3가지 결함 (`relayer-api/src/services/tronEnergyRental.ts`)

1. **도착량 미검증** — `tronZap.waitForCompletion()` 은 **TronZap 주문 상태**(`success`)만 확인한다.
   온체인에 실제로 몇 에너지가 위임됐는지 확인하지 않고 곧바로 호출자가 브로드캐스트한다.
2. **잔여 에너지 무시** — 릴레이어가 이미 보유한 `availableEnergy` 를 조회조차 하지 않고 **매번 전량 렌탈**한다.
   (렌탈비 ≈ 2.72 TRX/건 × 104건/일 ≈ **283 TRX/일** 이 TronZap 선불 잔액에서 나간다.)
3. **동시성 무방비** — 에너지는 **릴레이어 EOA 계정 단위 공유 자원**인데, 같은 프로세스의
   `WithdrawalPoller` · `CollectionBatchRunner` · `RealizationPoller` 가 각자 렌탈하고 각자 브로드캐스트한다.
   집금은 **10초 간격 연속 발사**(로그 실측: 17:51:09 / :19 / :26 / :38 / :49 / :57) → A가 렌탈한 에너지를 B가 먹는다.

추가로 **P2P 정산 경로(`relayer-api/src/routes/transfer.ts`)는 렌탈 자체가 없다**(§53에서 식별, 미수정).
실측 창에서도 이 경로의 USDT 직접 `transfer` 1건이 **4.96 TRX**(전체 소각의 12%)를 태웠다.

---

## 2. 목표 (완료 기준)

- 브로드캐스트 시점에 **온체인 `availableEnergy ≥ 필요 에너지`가 검증된 상태**여야 한다.
- 이미 충분한 에너지를 보유하면 **렌탈을 건너뛴다**.
- 렌탈~브로드캐스트 구간이 **릴레이어 주소 단위로 직렬화**되어 서로의 에너지를 먹지 않는다.
- P2P 정산 경로도 동일 렌탈/검증을 탄다.
- 배포 후 지표: **`energy_fee > 0` 인 tx 비율 12% → 0에 수렴**, 렌탈 호출 건수 감소.

---

## 3. 수정 대상 파일

| 파일 | 변경 |
|---|---|
| `packages/common/src/chain/ChainProvider.ts` | `getAccountResources?()` 옵셔널 메서드 추가 |
| `packages/common/src/chain/TronProvider.ts` | 기존 `getAccountResources()` 시그니처를 인터페이스와 일치시킴(구현 이미 존재, 79~95행) |
| `packages/relayer-api/src/services/tronEnergyRental.ts` | **핵심 — 아래 4장** |
| `packages/relayer-api/src/services/WithdrawalPoller.ts` | 호출부를 `withRentedEnergy()` 로 전환 |
| `packages/relayer-api/src/services/CollectionBatchRunner.ts` | 〃 |
| `packages/relayer-api/src/services/RealizationPoller.ts` | 〃 |
| `packages/relayer-api/src/routes/transfer.ts` | **TRON 분기에 `withRentedEnergy()` 적용(신규)** |

---

## 4. 설계 — `tronEnergyRental.ts`

### 4.1 신규 공개 API: `withRentedEnergy(params, broadcastFn)`

기존 `rentTronEnergy()` 는 "렌탈만" 하고 반환했기 때문에 **브로드캐스트가 임계구역 밖**에 있었다.
브로드캐스트까지 감싸는 형태로 바꾼다.

```ts
export async function withRentedEnergy<T>(
    params: RentEnergyParams,
    broadcast: (ctx: { energyAmount: number; rented: boolean; availableEnergy: number }) => Promise<T>,
): Promise<T>
```

- 반환값은 `broadcast` 의 반환값을 그대로 통과(호출부의 tx hash 등).
- `rentTronEnergy()` 는 **내부 함수로 강등**하거나 deprecated 주석을 달고 남겨둔다(외부 호출 없어야 함).

### 4.2 실행 순서 (임계구역 내부)

```
[ relayerAddress 단위 뮤텍스 획득 ]
 1. requiredEnergy 산정            ← 기존 로직 그대로 (estimate + fresh/funded floor + 오버헤드 + transferCount)
 2. before = getAccountResources(relayer).availableEnergy
 3. if (before >= requiredEnergy * SKIP_MARGIN) → 렌탈 스킵, rented=false 로 5번으로
 4. 렌탈 실행
    4-1. deficit = requiredEnergy - before
    4-2. rentAmount = max(deficit, TRONZAP_MIN_ENERGY)      ← TronZap 최소 60,000 제약
    4-3. createEnergyTransaction → onEnergyTxCreated 훅(기존 gas_cost_records 기록 유지) → waitForCompletion
    4-4. ★ 온체인 도착 검증: availableEnergy >= requiredEnergy 가 될 때까지 폴링
         (간격 1s, 최대 VERIFY_TIMEOUT_MS=30_000)
    4-5. 타임아웃 시 → 1회에 한해 부족분 재렌탈 후 4-4 재검증
    4-6. 그래도 미달 → logger.error(소각 예상) 남기고 **그대로 진행**
         (자금 이동 지연보다 소각을 감수 — 브로드캐스트 자체는 성공하므로 자금 안전에는 영향 없음)
 5. 릴레이어 liquid TRX 경보 (기존 로직, 임계값만 상향 — 4.4)
 6. broadcast(ctx) 실행
[ 뮤텍스 해제 ]
```

### 4.3 뮤텍스

- **in-process, 릴레이어 주소 키 단위**의 단순 promise chain 뮤텍스(외부 라이브러리 추가 금지).
  `relayer-api` 는 node-02 pm2 단일 인스턴스이므로 in-process로 충분하다.
- 데드락 방지: 반드시 `try/finally` 로 해제. `broadcast` 가 throw해도 해제된다.
- 대기 로그: 뮤텍스 대기 시간이 5초를 넘으면 `logger.warn` (직렬화 병목 관측용).
- ⚠️ **`broadcast` 안에서 다시 `withRentedEnergy` 를 호출하지 말 것**(재진입 = 데드락).

### 4.4 상수

```ts
export const SKIP_MARGIN = 1.1;              // 잔여 에너지가 필요량의 110% 이상이면 렌탈 스킵
export const TRONZAP_MIN_ENERGY = 60_000;    // TronZap 최소 주문량
export const VERIFY_POLL_MS = 1_000;
export const VERIFY_TIMEOUT_MS = 30_000;
export const RELAYER_MIN_TRX_SUN = 100_000_000n;  // 50 → 100 TRX (§68: 소비 ~100 TRX/일 = 1일치)
```

기존 `TRON_TRANSFER_ENERGY_FUNDED(71_000)` · `TRON_TRANSFER_ENERGY_FRESH(140_000)` ·
`CONTRACT_OVERHEAD_ENERGY(6_000)` 는 **실측 근거가 있는 값이므로 변경 금지**.

### 4.5 로깅 (배포 후 검증에 필요 — 반드시 포함)

`TRON: Renting energy` 로그에 다음 필드를 **추가**한다(기존 필드 유지):

| 필드 | 의미 |
|---|---|
| `availableEnergyBefore` | 렌탈 전 잔여 |
| `requiredEnergy` | 산정된 필요량 |
| `rented` | 실제 렌탈 여부(스킵이면 false) |
| `rentAmount` | 실제 주문량(스킵이면 0) |
| `verifiedEnergy` | 검증 통과 시점의 availableEnergy |
| `verifyWaitMs` | 도착 대기에 걸린 ms |
| `mutexWaitMs` | 뮤텍스 대기 ms |

검증 미달로 진행한 경우:
`logger.error('TRON: energy shortfall — broadcasting anyway (burn expected)', { required, available, deficit })`

---

## 5. 호출부 전환

3개 폴러는 현재 이런 형태다:

```ts
const rental = await rentTronEnergy({ ... });
// ... 이어서 브로드캐스트
```

이를 다음으로 바꾼다:

```ts
const txHash = await withRentedEnergy({ ...동일 params... }, async () => {
    return await /* 기존 브로드캐스트 호출 그대로 */;
});
```

- **기존 params 구성 로직·`onEnergyTxCreated` 훅(gas_cost_records 기록)은 그대로 유지**한다.
- 브로드캐스트 이후의 DB 상태 전이(`BROADCASTING` 마킹 등)는 **콜백 밖**에 두어 임계구역을 짧게 유지한다.
  단, tx hash 획득까지는 콜백 안이어야 한다.

### 5.1 `transfer.ts` (P2P 정산, 신규 적용)

- TRON(`network_id=3`) 분기에서만 적용. EVM 분기는 건드리지 않는다.
- params 매핑: `usdtHolderAddress` = 송신 MASTER, `toAddress` = 수신 MASTER,
  `relayerAddress` = 브로드캐스트 릴레이어 EOA, `externalId` = `p2p-transfer-{p2p_transfer_requests.id}`.
- `onEnergyTxCreated` 훅으로 `gas_cost_records` 기록(`reference_type` 은 기존 `ENERGY_RENTAL` 관례를 따르고,
  DDL enum에 없는 값은 넣지 말 것 — `external_id` 로 추적. §29 Phase 2와 동일 처리).
- ⚠️ **기존 멱등성 분기(`if (existing.tx_hash)` → 기존 해시 반환)를 절대 건드리지 말 것**(§53 복구 절차가 이 분기에 의존).

---

## 6. 하지 말 것 (가드레일)

- ❌ 대역폭 관련 코드 추가(스테이킹/대역폭 렌탈) — **이번 범위 아님**.
- ❌ `TRON_TRANSFER_ENERGY_FUNDED/FRESH`, `CONTRACT_OVERHEAD_ENERGY` 값 변경.
- ❌ 릴레이어 TRX 부족 시 브로드캐스트 **차단/defer** — 기존 정책대로 **경보만**(§29). 출금이 멈추면 안 된다.
- ❌ `p2p_transfer_requests` 의 `tx_hash` 존재 분기 등 기존 멱등성 로직 변경.
- ❌ XML 매퍼·DDL 변경(이번 작업은 Node.js 전용, DDL 변경 없음).
- ❌ 외부 뮤텍스/큐 라이브러리 추가.

---

## 7. 완료 기준 (리뷰 체크리스트)

1. `pnpm -C node-service build` (또는 각 패키지 `tsc --noEmit`) **통과**.
2. `rentTronEnergy` 직접 호출이 코드베이스에 **0건**(3개 폴러 + transfer.ts 모두 `withRentedEnergy` 사용).
3. 임계구역이 `try/finally` 로 해제됨 — 브로드캐스트 예외 시에도 뮤텍스가 남지 않음.
4. 스킵 경로(`rented:false`)와 재렌탈 경로(4-5)가 **로그로 구분 가능**.
5. `transfer.ts` TRON 분기에 렌탈이 붙었고, EVM 분기·멱등성 분기는 diff에 **변화 없음**.
6. 기존 `gas_cost_records` 기록 시점(= `createEnergyTransaction` 직후, `waitForCompletion` 전)이 유지됨.
7. 변경 파일 외 불필요한 리팩토링 없음.

## 8. 배포 후 검증 (오케스트레이터 수행)

1. node-02 `logs/relayer-api-out.log` — `rented:false`(스킵) 건이 나타나는지, `verifyWaitMs` 분포.
2. tronscan `api/transaction` 으로 24시간 뒤 `energy_fee > 0` 비율 재측정 (기준: 12% → 0 목표).
3. 릴레이어 일 소각량 재측정 (기대: 에너지분 ~50% 제거 → 대역폭분만 잔존).
4. 결과를 지식 repo `runbooks/troubleshooting.md` §68 에 추기.
