# 잔액 동기화 수정 지침서

> **목적**: `WalletService.syncOnchainBalance()` — 잘못된 엔드포인트 + 누락된 contractAddress 수정
> **날짜**: 2026-03-22
> **대상 모듈**: core
> **발견 계기**: Phase 3 E2E — "Webhook 잔액 동기화 실패: error=XRestException" 반복 발생

---

## 1. 문제

### 1-1. 존재하지 않는 엔드포인트 호출

```
WalletService.syncOnchainBalance() line 361:
  blockchainApiClient.queryBalance(new BalanceQueryRequest(...))
    → POST /api/wallet/balance   ← ❌ 404 Not Found
```

blockchain-api에 `POST /api/wallet/balance` 라우트가 **정의되어 있지 않음**.

### 1-2. contractAddress를 null로 전달

```java
new BalanceQueryRequest(address.getNetworkId(), address.getAddress(), null)
//                                                                    ^^^^ contractAddress = null
```

토큰(USDT/USDC) 잔액 조회에는 contractAddress가 필수.

### 1-3. 실제 존재하는 blockchain-api 라우트

| 라우트 | 메서드 | 용도 | BlockchainApiClient 선언 |
|--------|--------|------|--------------------------|
| `/api/balance/token/{address}?networkId=&tokenContract=` | GET | 토큰 잔액 | `getTokenBalance()` ✅ |
| `/api/balance/native/{address}?networkId=` | GET | 네이티브 잔액 | `getNativeBalance()` ✅ |
| `/api/balance/sync` | POST | 일괄 동기화 | `syncBalances()` ✅ |

→ **BlockchainApiClient에 이미 올바른 메서드가 선언돼 있음**. `syncOnchainBalance()`만 잘못된 메서드를 쓰고 있는 것.

---

## 2. 수정 사항

### 2-1. WalletService.syncOnchainBalance() — getTokenBalance() 사용

**파일**: `core/src/main/java/com/cryptoments/core/wallet/WalletService.java`

**import 추가**:
```java
import com.cryptoments.common.entity.Currency;
import com.cryptoments.common.client.dto.TokenBalanceResponse;
import com.cryptoments.common.client.dto.NativeBalanceResponse;
import com.cryptoments.common.enums.CurrencyType;
```

> CurrencyRepository는 이미 WalletService에 주입되어 있는지 확인. 없으면 추가 필요.

**Before** (line 355~378):
```java
public WalletBalance syncOnchainBalance(Long walletAddressId, Long currencyId) {
    WalletAddress address = walletAddressRepository.findOne(walletAddressId);
    if (address == null) {
        throw new NotFoundException(ErrorCodes.WALLET_ADDRESS_NOT_FOUND);
    }

    BalanceQueryResponse onchain = blockchainApiClient.queryBalance(
            new BalanceQueryRequest(address.getNetworkId(), address.getAddress(), null));

    WalletBalance balance = walletBalanceRepository
            .findByWalletAddressIdAndCurrencyId(walletAddressId, currencyId);
    if (balance == null) {
        balance = WalletBalance.builder()
                .walletAddressId(walletAddressId)
                .currencyId(currencyId)
                .balance(onchain.getBalance())
                .build();
        walletBalanceRepository.save(balance);
    } else {
        balance.setBalance(onchain.getBalance());
        walletBalanceRepository.modify(balance);
    }
    return balance;
}
```

**After**:
```java
public WalletBalance syncOnchainBalance(Long walletAddressId, Long currencyId) {
    WalletAddress walletAddr = walletAddressRepository.findOne(walletAddressId);
    if (walletAddr == null) {
        throw new NotFoundException(ErrorCodes.WALLET_ADDRESS_NOT_FOUND);
    }

    Currency currency = currencyRepository.findOne(currencyId);
    if (currency == null) {
        throw new NotFoundException(ErrorCodes.CURRENCY_NOT_FOUND);
    }

    // 온체인 잔액 조회 — 토큰 vs 네이티브 분기
    BigDecimal onchainBalance;
    int networkId = walletAddr.getNetworkId().intValue();

    if (currency.getContractAddress() != null) {
        // ERC-20 / TRC-20 토큰 잔액
        TokenBalanceResponse tokenResp = blockchainApiClient.getTokenBalance(
                walletAddr.getAddress(), networkId, currency.getContractAddress());
        // 응답 balance는 최소 단위(wei/sun) String → 토큰 단위 변환
        int decimals = (tokenResp.getDecimals() != null) ? tokenResp.getDecimals() : 18;
        onchainBalance = new BigDecimal(tokenResp.getBalance()).movePointLeft(decimals);
    } else {
        // 네이티브 코인 (BNB, TRX, ETH)
        NativeBalanceResponse nativeResp = blockchainApiClient.getNativeBalance(
                walletAddr.getAddress(), networkId);
        onchainBalance = new BigDecimal(nativeResp.getBalance()).movePointLeft(18);
    }

    // wallet_balances UPSERT
    WalletBalance balance = walletBalanceRepository
            .findByWalletAddressIdAndCurrencyId(walletAddressId, currencyId);
    if (balance == null) {
        balance = WalletBalance.builder()
                .walletAddressId(walletAddressId)
                .currencyId(currencyId)
                .balance(onchainBalance)
                .build();
        walletBalanceRepository.save(balance);
    } else {
        balance.setBalance(onchainBalance);
        walletBalanceRepository.modify(balance);
    }
    return balance;
}
```

**핵심 변경**:

| 항목 | Before | After |
|------|--------|-------|
| 호출 API | `queryBalance()` → `POST /api/wallet/balance` ❌ | `getTokenBalance()` → `GET /api/balance/token/{addr}` ✅ |
| contractAddress | null (하드코딩) | `currency.getContractAddress()` |
| 네이티브 코인 지원 | 미지원 | `getNativeBalance()` 분기 |
| 잔액 단위 변환 | 없음 (그대로 저장) | `movePointLeft(decimals)` — wei→토큰 변환 |

---

### 2-2. NativeBalanceResponse 확인

**파일**: `common/src/main/java/com/cryptoments/common/client/dto/NativeBalanceResponse.java`

이미 존재하는지 확인하고, balance 필드가 **String** 타입인지 확인 필요:

```java
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public class NativeBalanceResponse {
    /** 네이티브 잔액 (최소 단위, String) */
    private String balance;
}
```

> blockchain-api 응답 형식: `{"balance":"1000000000000000000","decimals":18}`
> balance가 String이어야 BigDecimal 변환 가능.

---

### 2-3. CurrencyRepository 주입 확인

WalletService에 `CurrencyRepository`가 이미 주입되어 있는지 확인.
없으면 생성자에 추가:

```java
private final CurrencyRepository currencyRepository;
```

> CORE_WALLET_SERVICE_REFACTOR_GUIDE에서 추가했을 가능성 높음 — 확인 필요.

---

### 2-4. ErrorCodes.CURRENCY_NOT_FOUND 확인

ErrorCodes에 `CURRENCY_NOT_FOUND`가 없으면 추가:

```java
public static final ErrorCode CURRENCY_NOT_FOUND = new ErrorCode("306", "통화를 찾을 수 없습니다.");
```

---

## 3. 적용 체크리스트

| # | 작업 | 파일 |
|---|------|------|
| 1 | WalletService에 CurrencyRepository 주입 확인 (없으면 추가) | `WalletService.java` |
| 2 | ErrorCodes.CURRENCY_NOT_FOUND 확인 (없으면 추가) | `ErrorCodes.java` |
| 3 | NativeBalanceResponse.java 확인 (balance 필드 String 타입) | `NativeBalanceResponse.java` |
| 4 | syncOnchainBalance() 메서드 교체 (섹션 2-1) | `WalletService.java` |
| 5 | `./gradlew :core:compileJava` | - |
| 6 | open-api 재기동 | - |

---

## 4. 검증

### 4-1. blockchain-api 직접 호출 (이미 확인됨)

```bash
# 토큰 잔액 — 정상 응답
curl "http://localhost:3001/api/balance/token/0x66059E8C81D5E400D546B07a356922d1F0D158E5?networkId=2&tokenContract=0x55d398326f99059fF775485246999027B3197955"
# → {"balance":"1000000000000000000","decimals":18}
```

### 4-2. Webhook 후 잔액 동기화

```bash
curl -X POST http://localhost:8082/api/v2/webhooks/blockchain-monitor \
  -H "Content-Type: application/json" \
  -d '{
    "eventType":"TRANSFER","chainId":"56","status":"CONFIRMED",
    "txHash":"0xtest_balance_sync_001",
    "from":"0xF9cbB86D5fae82183AA8e0E4fFf05a4C917414FE",
    "to":"0x66059E8C81D5E400D546B07a356922d1F0D158E5",
    "amount":"1000000000000000000",
    "tokenSymbol":"USDT",
    "contractAddress":"0x55d398326f99059fF775485246999027B3197955",
    "tokenDecimals":18
  }'
```

**로그 확인**:
```
# Before (실패):
Webhook 잔액 동기화 실패: address=..., error=XRestException

# After (성공):
Webhook 잔액 동기화 완료: address=0x66059E8C81D5E400D546B07a356922d1F0D158E5, networkId=2, currencyId=2
```

**DB 확인**:
```sql
SELECT wb.id, wb.wallet_address_id, wb.currency_id, wb.balance,
       wa.address, wa.wallet_type
FROM wallet_balances wb
JOIN wallet_addresses wa ON wa.id = wb.wallet_address_id
WHERE wa.address IN (
    '0x66059E8C81D5E400D546B07a356922d1F0D158E5',
    '0xF9cbB86D5fae82183AA8e0E4fFf05a4C917414FE'
);
-- 기대: balance가 온체인 실제 잔액 (토큰 단위, 예: 1.000000000000000000)
```
