# Cryptoments v2 — 수량(Amount) 변환 통합 지침서

> **작성일**: 2026-03-22
> **심각도**: P0 — CollectionPoller·WithdrawalPoller 완전 동작불능
> **범위**: Node.js (relayer-api, wallet-activator, common), Spring Boot (참고)

---

## 1. 핵심 원칙: 두 세계의 수량 표현

```
┌─────────────────────┐          ┌─────────────────────┐
│    DB / 비즈니스      │          │    블록체인 / 컨트랙트  │
│  DECIMAL(36,18)      │  ←────→  │  BigInt (raw units)   │
│  "1.500000000000..."  │          │  1500000000000000000n │
│  (토큰 단위)          │          │  (wei / sun 단위)     │
└─────────────────────┘          └─────────────────────┘
```

| 구분 | 표현 | 예시 (1.5 USDT, 18 decimals) | 용도 |
|------|------|-----|------|
| **DB 형식** | DECIMAL(36,18) 문자열 | `"1.500000000000000000"` | 저장, API 응답, 정산 |
| **Raw 형식** | BigInt 정수 | `1500000000000000000n` | 스마트 컨트랙트 호출 |

**절대 규칙**: DB ↔ 블록체인 경계를 넘을 때 반드시 변환 함수를 거칠 것.

---

## 2. 변환 함수 (이미 구현됨)

`packages/blockchain-api/src/services/BalanceService.ts`에 이미 존재:

```typescript
/**
 * Raw bigint → DB DECIMAL(36,18) 문자열
 * 예: 1500000000000000000n, 18 → "1.500000000000000000"
 */
function formatBalance(raw: bigint, decimals: number): string {
  if (decimals === 0) return `${raw}.${'0'.repeat(18)}`;
  const str = raw.toString();
  const padded = str.padStart(decimals + 1, '0');
  const intPart = padded.slice(0, padded.length - decimals);
  const fracPart = padded.slice(padded.length - decimals);
  return `${intPart}.${fracPart.padEnd(18, '0').slice(0, 18)}`;
}

/**
 * DB DECIMAL(36,18) 문자열 → Raw bigint
 * 예: "1.500000000000000000", 18 → 1500000000000000000n
 */
function parseBalance(decimalStr: string | null, decimals: number): bigint {
  if (!decimalStr || decimalStr === '0' || decimalStr === '0.000000000000000000') return 0n;
  const [intPart, fracPart = ''] = decimalStr.split('.');
  const paddedFrac = fracPart.padEnd(decimals, '0').slice(0, decimals);
  return BigInt(intPart + paddedFrac);
}
```

---

## 3. 공유 라이브러리로 이동 (필수)

### 3-1. common에 export

현재 `formatBalance`/`parseBalance`는 `blockchain-api` 내부에만 있어서 `relayer-api`에서 사용 불가.
**`packages/common`으로 이동하여 전역 공유.**

**파일 생성**: `packages/common/src/utils/amount.ts`

```typescript
/**
 * 수량 변환 유틸리티
 *
 * DB DECIMAL(36,18) ↔ 블록체인 Raw BigInt 변환
 *
 * 용어 정의:
 *   - tokenAmount: 사람이 읽는 단위 (예: "1.5" USDT) — DB 저장 형식
 *   - rawAmount:   컨트랙트가 읽는 단위 (예: 1500000n) — 블록체인 형식
 *   - decimals:    토큰별 소수점 자릿수 (currency.decimals)
 */

/**
 * DB 토큰 단위 → 블록체인 raw 단위
 *
 * @param tokenAmount  DB DECIMAL(36,18) 문자열, 예: "1.500000000000000000"
 * @param decimals     토큰 소수점 (currency.decimals), 예: 18
 * @returns            BigInt raw 값, 예: 1500000000000000000n
 *
 * @example
 *   toRawAmount("1.5", 18)  → 1500000000000000000n   (USDT BSC)
 *   toRawAmount("1.5", 6)   → 1500000n               (USDT TRON/Polygon)
 */
export function toRawAmount(tokenAmount: string, decimals: number): bigint {
  if (!tokenAmount || tokenAmount === '0' || tokenAmount === '0.000000000000000000') {
    return 0n;
  }
  const [intPart, fracPart = ''] = tokenAmount.split('.');
  const paddedFrac = fracPart.padEnd(decimals, '0').slice(0, decimals);
  return BigInt(intPart + paddedFrac);
}

/**
 * 블록체인 raw 단위 → DB 토큰 단위
 *
 * @param rawAmount  BigInt raw 값, 예: 1500000000000000000n
 * @param decimals   토큰 소수점 (currency.decimals), 예: 18
 * @returns          DB DECIMAL(36,18) 문자열, 예: "1.500000000000000000"
 *
 * @example
 *   toTokenAmount(1500000000000000000n, 18)  → "1.500000000000000000"
 *   toTokenAmount(1500000n, 6)               → "1.500000000000000000"
 */
export function toTokenAmount(rawAmount: bigint, decimals: number): string {
  if (rawAmount === 0n) return '0.' + '0'.repeat(18);
  const str = rawAmount.toString();
  const padded = str.padStart(decimals + 1, '0');
  const intPart = padded.slice(0, padded.length - decimals);
  const fracPart = padded.slice(padded.length - decimals);
  return `${intPart}.${fracPart.padEnd(18, '0').slice(0, 18)}`;
}
```

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

```typescript
export { toRawAmount, toTokenAmount } from './utils/amount.js';
```

### 3-3. blockchain-api의 BalanceService 리팩토링

기존 `formatBalance`/`parseBalance`를 common의 `toTokenAmount`/`toRawAmount`로 교체:

```typescript
// ❌ 기존 (blockchain-api 내부 함수)
import { formatBalance, parseBalance } from './local-utils';

// ✅ 수정 (common 공유 함수)
import { toRawAmount, toTokenAmount } from '@cryptoments/common';
```

---

## 4. 수정 대상 — CollectionPoller (P0)

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

### 4-1. import 추가

```typescript
import {
  // ... 기존 import ...
  toRawAmount,       // ← 추가
} from '@cryptoments/common';
```

### 4-2. EVM 분기 수정 (lines 186~203)

```typescript
// currency 조회 직후 raw 변환 (약 line 143 이후)
const rawAmount = toRawAmount(entry.amount.toString(), currency.decimals);

// EVM 분기
const estimatedGas = await contract.executeTransfer.estimateGas(
    tokenContract,
    fromWallet.address,
    masterWallet.address,
    rawAmount,              // ❌ 기존: BigInt(entry.amount)
);
const gasLimit = BigInt(Math.ceil(Number(estimatedGas) * GAS_LIMIT_MULTIPLIER));

const tx = await contract.executeTransfer(
    tokenContract,
    fromWallet.address,
    masterWallet.address,
    rawAmount,              // ❌ 기존: BigInt(entry.amount)
    { nonce: acquired.nonce, gasLimit },
);
```

### 4-3. TRON 분기 수정 (line 174)

TRON도 컨트랙트 함수에 raw amount(sun 단위 정수)를 전달해야 함:

```typescript
// TRON 분기
txHash = await contract.executeTransfer(
    tokenContract,
    fromWallet.address,
    masterWallet.address,
    rawAmount.toString(),   // ❌ 기존: entry.amount (DECIMAL 문자열)
                            // TronWeb은 string으로 받되 raw 단위여야 함
).send({ feeLimit: 100_000_000 });
```

---

## 5. 수정 대상 — WithdrawalPoller (P0)

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

CollectionPoller와 **동일한 패턴** 적용:

### 5-1. import 추가

```typescript
import { toRawAmount } from '@cryptoments/common';
```

### 5-2. raw 변환 + EVM/TRON 분기

```typescript
// currency 조회 후
const rawAmount = toRawAmount(withdrawal.amount.toString(), currency.decimals);

// EVM (lines 164, 172)
const estimatedGas = await contract.executeTransfer.estimateGas(
    tokenContract, fromAddress, toAddress, rawAmount,    // ❌ 기존: BigInt(withdrawal.amount)
);
const tx = await contract.executeTransfer(
    tokenContract, fromAddress, toAddress, rawAmount,    // ❌ 기존: BigInt(withdrawal.amount)
    { nonce: acquired.nonce, gasLimit },
);

// TRON (line 146)
txHash = await contract.executeTransfer(
    tokenContract, fromAddress, toAddress,
    rawAmount.toString(),                                // ❌ 기존: withdrawal.amount
).send({ feeLimit: 100_000_000 });
```

---

## 6. 토큰별 decimals 참조표

| currency_id | 심볼 | 네트워크 | decimals | 1.0 토큰의 raw 값 |
|-------------|------|---------|----------|-------------------|
| 2 | USDT | BSC | 18 | 1000000000000000000 |
| 3 | USDC | BSC | 18 | 1000000000000000000 |
| 4 | USDT | Polygon | 6 | 1000000 |
| 5 | USDC | Polygon | 6 | 1000000 |
| 6 | USDT | TRON | 6 | 1000000 |

**주의**: BSC의 USDT/USDC는 decimals=18이지만, Polygon/TRON의 USDT는 decimals=6.
**반드시 `currency.decimals`를 사용**하고, 하드코딩 금지.

---

## 7. 변환 방향 총정리

### 7-1. 데이터 흐름별 변환

```
📥 입금 (블록체인 → DB)
   Webhook raw amount → movePointLeft(decimals) [Spring] → DB DECIMAL(36,18)
   ✅ WebhookProcessingService.processDeposit() — 정상

📤 출금 (DB → 블록체인)
   DB DECIMAL(36,18) → toRawAmount(amount, decimals) [Node.js] → 컨트랙트
   ❌ WithdrawalPoller — 수정 필요

🔄 집금 (DB → 블록체인)
   DB DECIMAL(36,18) → toRawAmount(amount, decimals) [Node.js] → 컨트랙트
   ❌ CollectionPoller — 수정 필요

💰 잔액 동기화 (블록체인 → DB)
   Raw BigInt → toTokenAmount(raw, decimals) [Node.js BalanceService] → DB
   또는: movePointLeft(decimals) [Spring WalletService]
   ✅ 양쪽 모두 정상

💸 수수료 계산 (DB 내부)
   amount * feeRate → DB DECIMAL(36,18)
   ✅ SettlementService — 정상 (변환 불필요, DB 단위끼리 연산)

⛽ 가스비 기록 (블록체인 → DB)
   receipt.fee (raw string) → DB gas_cost_records.fee_native (raw string 그대로)
   fee_usd = feeNative / 10^decimals * priceUsd
   ✅ 정상
```

### 7-2. 경계별 규칙

| 경계 | 방향 | 변환 함수 | 담당 |
|------|------|----------|------|
| Webhook → DB | raw → token | `movePointLeft(decimals)` | Spring (open-api) |
| DB → 컨트랙트 TX | token → raw | `toRawAmount(amount, decimals)` | Node.js (relayer-api) |
| 온체인 조회 → DB | raw → token | `toTokenAmount(raw, decimals)` | Node.js (blockchain-api) |
| DB → DB | 변환 없음 | — | Spring (core) |
| DB → API 응답 | 변환 없음 | — | Spring (admin/partner-api) |

---

## 8. Spring Boot 현황 (참고 — 수정 불필요)

Spring Boot의 모든 변환은 정상:

| 위치 | 변환 | 상태 |
|------|------|------|
| `WebhookProcessingService.processDeposit()` | raw → token (`movePointLeft`) | ✅ |
| `WalletService.syncOnchainBalance()` | raw → token (`movePointLeft`) | ✅ |
| `WalletService.monitorGasWallets()` | raw → token (`movePointLeft`) | ✅ |
| `SettlementService.*` | token → token (DB 내부 연산) | ✅ |
| `PriceService.*` | token → KRW/USD (곱셈) | ✅ |

---

## 9. 적용 순서

```
1. packages/common/src/utils/amount.ts 생성 (toRawAmount, toTokenAmount)
2. packages/common/src/index.ts에 export 추가
3. packages/common 빌드: cd packages/common && pnpm run build
4. CollectionPoller.ts 수정 (§4)
5. WithdrawalPoller.ts 수정 (§5)
6. packages/relayer-api 빌드: cd packages/relayer-api && pnpm run build
7. (선택) blockchain-api BalanceService 리팩토링 (§3-3)
8. DB 정리:
   UPDATE collection_queue SET status = 'QUEUED', error_message = NULL WHERE status = 'FAILED';
   UPDATE nonce_tracker SET next_nonce = 0, last_confirmed_nonce = -1,
          status = 'AVAILABLE', locked_by = NULL, locked_at = NULL
          WHERE wallet_address_id = 8;
9. relayer-api 재시작
10. 로그 확인: tail -f /tmp/relayer-api.log
```

---

## 10. 검증 체크리스트

- [ ] `toRawAmount("1.500000000000000000", 18)` === `1500000000000000000n`
- [ ] `toRawAmount("1.500000000000000000", 6)` === `1500000n`
- [ ] `toTokenAmount(1500000000000000000n, 18)` === `"1.500000000000000000"`
- [ ] `toTokenAmount(1500000n, 6)` === `"1.500000000000000000"`
- [ ] CollectionPoller: DEFERRED → COLLECTING → BROADCASTING → CONFIRMED
- [ ] WithdrawalPoller: 동일 변환 적용 확인
- [ ] nonce_tracker: acquire → increment → confirm 정상 순환
