# Balance 소수점 변환 저장 지침서

**날짜**: 2026-03-20
**대상**: `blockchain-api` — `BalanceService.ts`
**목적**: 온체인 raw 값(wei/sun bigint)을 사람이 읽을 수 있는 소수점 형식으로 변환하여 `DECIMAL(36,18)`에 저장

---

## 배경

- DB 컬럼: `wallet_balances.balance DECIMAL(36,18)` — 정수부 최대 18자리, 소수부 18자리
- 온체인 raw: `17064656000000000000` (wei) → 이대로 저장 불가 (정수부 20자리 초과)
- 올바른 저장: `17.064656000000000000` (USDT, 18 decimals 기준)

### 예시

| 통화 | decimals | 온체인 raw (bigint) | DB 저장값 |
|------|----------|-------------------|----------|
| BSC USDT | 18 | 17064656000000000000 | 17.064656000000000000 |
| TRON USDT | 6 | 27747875 | 27.747875000000000000 |
| Polygon USDT | 6 | 5038910 | 5.038910000000000000 |
| BNB | 18 | 78363343733333334 | 0.078363343733333334 |
| TRX | 6 | 211950768 | 211.950768000000000000 |
| POL | 18 | 49901684055373839174 | 49.901684055373839174 |

---

## 수정 내용

### 1. 유틸 함수 추가 (BalanceService.ts 상단)

```typescript
/**
 * 온체인 raw bigint → DB DECIMAL(36,18) 문자열 변환
 * 예: formatBalance(17064656000000000000n, 18) → "17.064656000000000000"
 */
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);

  // DECIMAL(36,18)에 맞춰 소수부를 18자리로 정규화
  return `${intPart}.${fracPart.padEnd(18, '0').slice(0, 18)}`;
}

/**
 * DB DECIMAL(36,18) 문자열 → 온체인 raw bigint 역변환
 * 예: parseBalance("17.064656000000000000", 18) → 17064656000000000000n
 */
function parseBalance(decimalStr: string | null, decimals: number): bigint {
  if (!decimalStr || decimalStr === '0' || decimalStr === '0.000000000000000000') return 0n;

  const [intPart, fracPart = ''] = decimalStr.split('.');
  // 소수부에서 통화의 decimals만큼만 사용 (나머지는 패딩)
  const paddedFrac = fracPart.padEnd(decimals, '0').slice(0, decimals);
  return BigInt(intPart + paddedFrac);
}
```

### 2. syncBalances 함수 수정

`syncBalances()` 내부 for 루프에서 currency.decimals를 활용:

```typescript
// 기존 (98~105행 부근):
const onchainBalance = onchainRaw.toString();
const previousRaw = bal.balance ? BigInt(bal.balance.split('.')[0]) : 0n;
const diff = (onchainRaw - previousRaw).toString();
await walletBalanceRepo.updateOnchainBalance(id, bal.currency_id, onchainBalance);

// 수정 후:
const onchainDecimal = formatBalance(onchainRaw, currency.decimals);
const previousRaw = parseBalance(bal.balance, currency.decimals);
const diff = (onchainRaw - previousRaw).toString();
await walletBalanceRepo.updateOnchainBalance(id, bal.currency_id, onchainDecimal);
```

그리고 results.push도 수정:

```typescript
// 기존:
results.push({ walletAddressId: id, currencyId: bal.currency_id, onchainBalance, diff });

// 수정:
results.push({ walletAddressId: id, currencyId: bal.currency_id, onchainBalance: onchainDecimal, diff });
```

로그 부분도:

```typescript
if (diff !== '0') {
  logger.info('Balance changed', {
    walletAddressId: id,
    address: wallet.address,
    currency: currency.symbol,
    previous: previousRaw.toString(),  // raw 단위 diff 로그
    current: onchainRaw.toString(),
    humanReadable: onchainDecimal,     // 추가: 사람이 읽을 수 있는 값
    diff,
  });
}
```

---

## 전체 수정 후 BalanceService.ts (syncBalances 핵심 부분)

```typescript
// ── 유틸 함수 (파일 상단, export 아래) ──

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)}`;
}

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);
}

// ── syncBalances 내부 (for bal of balances 루프) ──

for (const bal of balances) {
  try {
    const currency = currencyMap.get(Number(bal.currency_id));
    if (!currency) {
      logger.warn(`Currency not found: currencyId=${bal.currency_id}, walletAddressId=${id}`);
      continue;
    }

    // 온체인 잔액 조회 — 네이티브 vs 토큰 분기
    let onchainRaw: bigint;
    if (!currency.contract_address) {
      onchainRaw = await provider.getNativeBalance(wallet.address);
    } else {
      onchainRaw = await provider.getTokenBalance(wallet.address, currency.contract_address);
    }

    // raw → decimal 변환 (DB 저장용)
    const onchainDecimal = formatBalance(onchainRaw, currency.decimals);

    // DB 이전 값 → raw로 역변환하여 diff 계산
    const previousRaw = parseBalance(bal.balance, currency.decimals);
    const diff = (onchainRaw - previousRaw).toString();

    // DB 업데이트 (decimal 형식)
    await walletBalanceRepo.updateOnchainBalance(id, bal.currency_id, onchainDecimal);

    if (diff !== '0') {
      logger.info('Balance changed', {
        walletAddressId: id,
        address: wallet.address,
        currency: currency.symbol,
        previous: previousRaw.toString(),
        current: onchainRaw.toString(),
        humanReadable: onchainDecimal,
        diff,
      });
    }

    results.push({ walletAddressId: id, currencyId: bal.currency_id, onchainBalance: onchainDecimal, diff });
  } catch (innerError) {
    logger.error(`Balance sync failed for currency: walletAddressId=${id}, currencyId=${bal.currency_id}`, innerError);
  }
}
```

---

## 검증

```bash
# sync 실행
curl -s -X POST http://localhost:3001/api/balance/sync \
  -H "Content-Type: application/json" \
  -d '{"walletAddressIds": [1, 3, 5]}' | jq '.results[] | {walletAddressId, onchainBalance}'

# 기대:
# BSC ADMIN(1): USDT "17.064656000000000000", BNB "0.078363..."
# TRON ADMIN(3): USDT "27.747875000000000000", TRX "211.950768..."
# Polygon ADMIN(5): USDT "5.038910000000000000", POL "49.901684..."
```

```sql
-- DB 확인
SELECT wb.wallet_address_id, wa.wallet_type, c.symbol, c.decimals, wb.balance
FROM wallet_balances wb
JOIN currencies c ON c.id = wb.currency_id
JOIN wallet_addresses wa ON wa.id = wb.wallet_address_id
WHERE wb.wallet_address_id IN (1, 3, 5)
ORDER BY wb.wallet_address_id, wb.currency_id;
-- balance 컬럼에 소수점 형식으로 저장되어야 함
```

---

## 주의사항

- `currency.decimals` 값이 정확해야 합니다 (currencies 테이블에 이미 정의됨)
- BSC USDT/USDC: 18 decimals, TRON/Polygon USDT/USDC: 6 decimals
- Native: BNB/POL 18 decimals, TRX 6 decimals
- `DECIMAL(36,18)`의 정수부 한도 = 18자리 → 최대 약 10^18 토큰 단위 → 충분
