# Guide #44 — 정산 전체 흐름 구현 (SETTLEMENT 인프라 + Realization + 쉐어 출금)

> **작성일**: 2026-03-22
> **대상**: Spring Boot (core, admin-api, partner-api) + Node.js (relayer-api, common)
> **난이도**: 높음 (3 Phase, Spring ~10개 파일 + Node.js ~6개 파일)
> **DDL 변경**: 없음 (기존 DDL v1.8 구조 그대로)

---

## 0. 아키텍처 개요

### 정산 전체 흐름

```
[입금] → 수수료 계산 → settlement_daily_fees (UNREALIZED)
                                  ↓
[Phase B] Realization (스케줄러)
  - 각 파트너 MASTER → 시스템 SETTLEMENT 온체인 전송
  - settlement_daily_fees → REALIZED
  - settlement_balances.realizedBalance 증가
                                  ↓
[Phase C] SETTLEMENT_WITHDRAW (파트너 요청)
  - SETTLEMENT(인프라) → 외부 주소 온체인 전송
  - settlement_balances.realizedBalance 차감
```

### SETTLEMENT 지갑 = 인프라 지갑

| 속성 | 값 |
|------|---|
| wallet_type | `SETTLEMENT` |
| partner_id | **NULL** (시스템 소유) |
| 개수 | **네트워크당 1개** (ADMIN/GAS와 동일) |
| 토큰 보유 | ✅ USDT/USDC (모든 파트너의 실현 수익이 물리적으로 모임) |
| approve 필요 | ✅ (Relayer가 transferFrom으로 출금 실행) |
| 생성 방법 | Admin Console → HD 파생 + approve 등록 → wallet-activator 처리 |

### SETTLEMENT이 approve가 필요한 유일한 인프라 지갑인 이유

| 인프라 지갑 | 용도 | 토큰 보유 | approve |
|------------|------|----------|---------|
| ADMIN | 컨트랙트 Owner | ✗ | 불필요 |
| GAS | 네이티브 코인 관리 | ✗ | 불필요 |
| RELAYER | TX 실행자 (가스비) | ✗ | 불필요 |
| **SETTLEMENT** | **실현 수익 보관** | **✅ USDT/USDC** | **✅ 필요** |

---

## Phase A — SETTLEMENT 인프라 지갑 생성

### A-1. WalletService.createSettlementWallet() 리팩토링

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

현재 `createSettlementWallet(Long partnerId, Long networkId)` → 파트너별 생성.

**변경**: 인프라 지갑으로 전환 (partner_id = NULL, 네트워크당 1개).

```java
/**
 * SETTLEMENT 인프라 지갑을 생성한다 (네트워크당 1개).
 * SETTLEMENT은 모든 파트너의 실현 수익이 모이는 시스템 지갑.
 * USDT/USDC 토큰을 보유하므로 Relayer approve 등록 필요.
 *
 * @param networkId 블록체인 네트워크 ID
 * @return 생성된 (또는 기존) SETTLEMENT 지갑
 */
public WalletAddress createSettlementWallet(Long networkId) {
    // 1. 기존 SETTLEMENT 지갑 확인 (네트워크당 1개)
    List<WalletAddress> existing = walletAddressRepository
            .findByNetworkIdAndWalletType(networkId, WalletType.SETTLEMENT);
    if (!existing.isEmpty()) {
        log.info("SETTLEMENT wallet already exists: networkId={}, address={}",
                networkId, existing.get(0).getAddress());
        return existing.get(0);
    }

    // 2. HD 파생 (partner_id = NULL → 인프라 지갑)
    WalletDeriveResponse derived = deriveWallet(networkId, "SETTLEMENT", null, null);
    WalletAddress settlementWallet = walletAddressRepository.findOne(derived.getWalletAddressId());
    if (settlementWallet == null) {
        throw new NotFoundException(ErrorCodes.WALLET_ADDRESS_NOT_FOUND);
    }

    // 3. 잔액 초기화
    initWalletBalances(settlementWallet.getId(), networkId);

    // 4. Relayer approve 등록 (USDT/USDC 토큰별)
    int approvals = registerTokenApprovals(settlementWallet.getId(), networkId);

    log.info("SETTLEMENT infra wallet created: networkId={}, address={}, approvals={}",
            networkId, settlementWallet.getAddress(), approvals);
    return settlementWallet;
}
```

> **주의**: 기존 `createSettlementWallet(Long partnerId, Long networkId)` 시그니처를 사용하는 곳이 있으면 컴파일 에러 발생. `PartnerWalletController`의 "SETTLEMENT 지갑 생성" 엔드포인트도 삭제 또는 수정 필요.

### A-2. PartnerWalletController SETTLEMENT 엔드포인트 제거

**파일**: `partner-api/src/.../controller/PartnerWalletController.java` (line 57~)

SETTLEMENT은 파트너가 생성하는 지갑이 아니므로 **엔드포인트 삭제**.

```java
// 삭제 대상:
// @PostMapping(name = "SETTLEMENT 지갑 생성", value = "/settlement")
```

### A-3. InfraWalletService에 SETTLEMENT 생성 추가

**파일**: `admin-api/src/.../service/InfraWalletService.java`

GAS 생성과 유사하지만, **approve 등록이 추가**되는 점이 다름.
approve 등록은 `WalletService.registerTokenApprovals()`가 이미 처리하므로, `WalletService.createSettlementWallet(networkId)`를 호출하면 됨.

```java
/**
 * SETTLEMENT 인프라 지갑 생성 (네트워크당 1개).
 * ADMIN/GAS와 달리 토큰 보유 지갑이므로 approve 등록 포함.
 */
public WalletAddress createSettlementWallet(Long networkId, Long adminId) {
    WalletAddress wallet = walletService.createSettlementWallet(networkId);

    auditLogService.log(adminId, AuditAction.CREATE_SETTLEMENT_WALLET,
            wallet.getId(),
            Map.of("networkId", networkId,
                    "walletType", "SETTLEMENT",
                    "address", wallet.getAddress()));

    return wallet;
}
```

**DI 추가**: `WalletService walletService` 생성자 주입.

### A-4. InfraWalletController에 SETTLEMENT 엔드포인트 추가

**파일**: `admin-api/src/.../controller/InfraWalletController.java` (line 82~83 주석 대체)

```java
/**
 * SETTLEMENT 인프라 지갑 생성.
 * 네트워크당 1개. USDT/USDC 보유 지갑이므로 Relayer approve 자동 등록.
 * wallet-activator가 GAS 지원 → approve TX 실행.
 */
@PostMapping(name = "SETTLEMENT 지갑 생성", value = "/settlement")
@ResponseStatus(HttpStatus.CREATED)
public WalletAddress createSettlementWallet(@RequestParam Long networkId) {
    Long adminId = getSession().getAdminId();
    return infraWalletService.createSettlementWallet(networkId, adminId);
}
```

### A-5. InfraWalletSearchMapper에 SETTLEMENT 포함 확인

이미 `wallet_type IN ('GAS', 'SETTLEMENT')` — ✅ 변경 불필요.

### A-6. wallet-activator 처리

approve 등록 후 wallet-activator가 자동으로:
1. GAS 지갑에서 SETTLEMENT으로 가스비 전송
2. SETTLEMENT → Relayer approve TX 실행
3. wallet_approvals 상태: PENDING → GAS_SUPPORTING → APPROVING → APPROVED

**변경 불필요** — 기존 wallet-activator 로직 그대로 동작.

---

## Phase B — Realization (MASTER → SETTLEMENT 온체인 전송)

### 현재 상태

`SettlementService.realizeFees()`:
- ✅ settlement_realizations 레코드 생성 (PENDING)
- ✅ settlement_daily_fees → REALIZED 일괄 업데이트
- ✅ settlement_balances.unrealizedBalance → realizedBalance 이전
- ❌ **fromWalletId / toWalletId 미설정**
- ❌ **온체인 TX 미실행**

### 설계: CollectionBatchRunner 패턴 차용

```
Spring 스케줄러 → realizeFees() → settlement_realizations(PENDING)
    → Node.js RealizationPoller 폴링 → transferFrom(MASTER, SETTLEMENT, amount)
    → status: PENDING → PROCESSING → COMPLETED (직접 receipt 대기)
```

Realization은 스케줄러 기반이고 실시간 아니므로, Webhook 없이 **직접 TX receipt 대기** 후 COMPLETED 처리.

### B-1. SettlementService.realizeFees() 수정

**파일**: `core/src/.../settlement/SettlementService.java`

`realizeFees()` 메서드에서 fromWalletId / toWalletId 설정 추가.

**DI 추가**: `WalletAddressRepository walletAddressRepository`

**변경 부분** (line 362~ realization 생성 부분):

```java
// fromWalletId: 수수료를 발생시킨 파트너의 MASTER 지갑
List<WalletAddress> masterWallets = walletAddressRepository
        .findByPartnerIdAndWalletType(first.getSourcePartnerId(), WalletType.MASTER);
WalletAddress masterWallet = masterWallets.stream()
        .filter(w -> networkId.equals(w.getNetworkId()))
        .findFirst().orElse(null);

// toWalletId: 해당 네트워크의 SETTLEMENT 인프라 지갑
List<WalletAddress> settlementWallets = walletAddressRepository
        .findByNetworkIdAndWalletType(networkId, WalletType.SETTLEMENT);
WalletAddress settlementWallet = settlementWallets.isEmpty() ? null : settlementWallets.get(0);

if (masterWallet == null || settlementWallet == null) {
    log.error("Realization 불가: MASTER 또는 SETTLEMENT 지갑 없음. partnerId={}, networkId={}",
            first.getSourcePartnerId(), networkId);
    return null;
}

SettlementRealization realization = SettlementRealization.builder()
        .partnerId(first.getSourcePartnerId())
        .currencyId(currencyId)
        .networkId(networkId)
        .periodStart(periodStart)
        .periodEnd(periodEnd)
        .totalFeeAmount(totalFee)
        .totalShareAmount(totalShare)
        .fromWalletId(masterWallet.getId())       // ← 추가
        .toWalletId(settlementWallet.getId())      // ← 추가
        .status(RealizationStatus.PENDING)
        .build();
```

### B-2. Node.js — SettlementRealizationRepo (신규)

**파일**: `node-service/packages/common/src/repo/SettlementRealizationRepo.ts`

```typescript
import { query } from '../db';

export interface SettlementRealization {
    id: number;
    partner_id: number;
    currency_id: number;
    network_id: number;
    total_share_amount: string;
    from_wallet_id: number;
    to_wallet_id: number;
    tx_hash: string | null;
    status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
    failed_reason: string | null;
}

/** PENDING 상태의 realization 조회 */
export async function findPending(limit = 10): Promise<SettlementRealization[]> {
    return query<SettlementRealization[]>(
        `SELECT * FROM settlement_realizations
         WHERE status = 'PENDING'
         ORDER BY created_at ASC
         LIMIT ?`,
        [limit],
    );
}

/** 상태 → PROCESSING + tx_hash 기록 */
export async function markProcessing(id: number, txHash: string): Promise<void> {
    await query(
        `UPDATE settlement_realizations
         SET status = 'PROCESSING', tx_hash = ?
         WHERE id = ?`,
        [txHash, id],
    );
}

/** 상태 → COMPLETED */
export async function markCompleted(id: number): Promise<void> {
    await query(
        `UPDATE settlement_realizations
         SET status = 'COMPLETED', completed_at = NOW()
         WHERE id = ?`,
        [id],
    );
}

/** 상태 → FAILED */
export async function markFailed(id: number, reason: string): Promise<void> {
    await query(
        `UPDATE settlement_realizations
         SET status = 'FAILED', failed_reason = ?
         WHERE id = ?`,
        [reason, id],
    );
}
```

**common/src/repo/index.ts**에 export 추가.

### B-3. Node.js — RealizationPoller (신규)

**파일**: `node-service/packages/relayer-api/src/services/RealizationPoller.ts`

CollectionBatchRunner 패턴 차용. Webhook 없이 직접 receipt 대기.

```typescript
import { chainProviderFactory } from '@cryptoments/common';
import * as settlementRealizationRepo from '@cryptoments/common/repo/SettlementRealizationRepo';
import * as walletAddressRepo from '@cryptoments/common/repo/WalletAddressRepo';
import * as walletKeyRepo from '@cryptoments/common/repo/WalletKeyRepo';
import * as currencyRepo from '@cryptoments/common/repo/CurrencyRepo';
import * as nonceManager from '../nonce/NonceManager';
import { toRawAmount } from '@cryptoments/common/utils/amount';
import { logger } from '@cryptoments/common/logger';
import { errorResponse, ErrorCodes } from '@cryptoments/common/errors';

const POLL_INTERVAL = 10_000; // 10초 (realization은 빈번하지 않음)

export class RealizationPoller {
    private running = false;

    async start(): Promise<void> {
        this.running = true;
        logger.info('RealizationPoller started');

        while (this.running) {
            try {
                await this.poll();
            } catch (error) {
                logger.error('RealizationPoller error', { error });
            }
            await new Promise((r) => setTimeout(r, POLL_INTERVAL));
        }
    }

    stop(): void {
        this.running = false;
    }

    private async poll(): Promise<void> {
        const pending = await settlementRealizationRepo.findPending(5);
        if (pending.length === 0) return;

        for (const realization of pending) {
            try {
                await this.execute(realization);
            } catch (error) {
                const msg = error instanceof Error ? error.message : String(error);
                logger.error('Realization execution failed', {
                    id: realization.id,
                    error: msg,
                });
                await settlementRealizationRepo.markFailed(realization.id, msg);
            }
        }
    }

    private async execute(realization: settlementRealizationRepo.SettlementRealization): Promise<void> {
        const provider = chainProviderFactory.get(realization.network_id);

        // 1. from = MASTER 지갑, to = SETTLEMENT 지갑
        const fromWallet = await walletAddressRepo.findById(realization.from_wallet_id);
        const toWallet = await walletAddressRepo.findById(realization.to_wallet_id);
        if (!fromWallet || !toWallet) {
            throw new Error(`Wallet not found: from=${realization.from_wallet_id}, to=${realization.to_wallet_id}`);
        }

        // 2. 통화 정보 → contract address, decimals
        const currency = await currencyRepo.findById(realization.currency_id);
        if (!currency || !currency.contract_address) {
            throw new Error(`Currency or contract not found: ${realization.currency_id}`);
        }

        // 3. Relayer 선택 (COLLECTION 역할 재사용)
        const relayer = await walletAddressRepo.findAvailableRelayer(realization.network_id);
        if (!relayer) {
            throw new Error(`No available relayer for network=${realization.network_id}`);
        }

        // 4. Nonce 획득
        const nonce = await nonceManager.acquire(relayer.id, realization.network_id);

        try {
            // 5. 금액 변환
            const rawAmount = toRawAmount(realization.total_share_amount, currency.decimals);

            // 6. Relayer 키 복호화
            const relayerKey = await walletKeyRepo.getDecryptedKey(relayer.id);

            // 7. CryptoRelayer 컨트랙트 조회
            const contract = await walletAddressRepo.findRelayerContract(realization.network_id);
            if (!contract) {
                throw new Error(`Relayer contract not found for network=${realization.network_id}`);
            }

            // 8. executeTransfer(token, MASTER, SETTLEMENT, amount)
            const txHash = await provider.executeRelayerTransfer(
                contract.contract_address,
                relayerKey,
                currency.contract_address,
                fromWallet.address,    // MASTER
                toWallet.address,      // SETTLEMENT
                rawAmount,
                nonce.nonce,
            );

            // 9. PROCESSING 상태 + txHash 기록
            await settlementRealizationRepo.markProcessing(realization.id, txHash);

            // 10. TX receipt 대기 (realization은 Webhook 없이 직접 확인)
            const receipt = await provider.waitForReceipt(txHash);

            if (receipt.success) {
                await settlementRealizationRepo.markCompleted(realization.id);
                logger.info('Realization completed', {
                    id: realization.id,
                    txHash,
                    amount: realization.total_share_amount,
                });
            } else {
                await settlementRealizationRepo.markFailed(realization.id, 'TX reverted');
            }

            // 11. Nonce 확인
            await nonceManager.confirm(nonce);

        } catch (error) {
            await nonceManager.release(nonce);
            throw error;
        }
    }
}
```

### B-4. relayer-api 앱에 RealizationPoller 등록

**파일**: `node-service/packages/relayer-api/src/app.ts`

기존 CollectionBatchRunner, WithdrawalPoller와 동일하게 시작:

```typescript
import { RealizationPoller } from './services/RealizationPoller';

// ... 기존 poller 시작 코드 아래에 추가
const realizationPoller = new RealizationPoller();
realizationPoller.start();
```

### B-5. Spring 스케줄러에 Realization 배치 추가

**파일**: `scheduler/src/.../job/SettlementRealizationJob.java` (신규)

```java
/**
 * 수수료 실현 배치 (매일 01:00).
 * UNREALIZED settlement_daily_fees를 파트너별로 실현 처리.
 * 실현 레코드(PENDING) 생성 → Node.js RealizationPoller가 온체인 실행.
 */
@Scheduled(cron = "0 0 1 * * *")
public void realizeDaily() {
    LocalDate yesterday = LocalDate.now().minusDays(1);
    // 모든 참여자(SYSTEM + 상위 파트너)에 대해 실현 실행
    // 구체적 구현은 SettlementService.realizeFees() 호출
}
```

> 상세 구현은 기존 `realizeFees()` 활용. 파트너 목록을 조회하여 각각 호출.

---

## Phase C — SETTLEMENT_WITHDRAW (쉐어 출금)

### C-1. core/WithdrawalService — requestSettlementWithdrawal()

**이미 적용됨** (Guide #44 v1). 다만 확인 사항:

- ✅ `settlementService.withdrawSettlement()` 호출로 realizedBalance 차감
- ✅ 항상 APPROVED 상태로 생성 (자동 승인)
- ✅ `cancel()` / `cancelByPartner()` / `expireStaleWithdrawals()`에 SETTLEMENT_WITHDRAW 분기

### C-2. partner-api — 이미 적용됨

- ✅ `PartnerSettlementService.settlementWithdraw()` 구현 완료
- ✅ `PartnerSettlementController` 스텁 채움, 반환 타입 `WithdrawalResponse`

### C-3. Node.js WithdrawalPoller — SETTLEMENT 소스 지갑 분기

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

`executeWithdrawal()` 메서드에서 from_wallet 결정 부분 수정:

**기존:**
```typescript
const masterWallet = await walletAddressRepo.findMasterWallet(partnerId, withdrawal.network_id);
if (!masterWallet) {
    throw new Error(`Master wallet not found: partner=${partnerId}, network=${withdrawal.network_id}`);
}
```

**변경:**
```typescript
let fromWallet;

if (withdrawal.withdrawal_type === 'SETTLEMENT_WITHDRAW') {
    // 정산 출금: 인프라 SETTLEMENT 지갑에서 송금 (네트워크당 1개, partner_id=NULL)
    fromWallet = await walletAddressRepo.findSettlementWallet(withdrawal.network_id);
    if (!fromWallet) {
        throw new Error(`Settlement wallet not found: network=${withdrawal.network_id}`);
    }
} else {
    // 일반 출금: 파트너의 MASTER 지갑에서 송금
    if (withdrawal.partner_id === null) {
        throw new Error(`Withdrawal has no partner_id: ${withdrawal.id}`);
    }
    fromWallet = await walletAddressRepo.findMasterWallet(withdrawal.partner_id, withdrawal.network_id);
    if (!fromWallet) {
        throw new Error(`Master wallet not found: partner=${withdrawal.partner_id}, network=${withdrawal.network_id}`);
    }
}
```

이하 `masterWallet` → `fromWallet`로 변수명 변경 (executeTransfer 호출부 등).

### C-4. Node.js WalletAddressRepo — findSettlementWallet() 추가

**파일**: `node-service/packages/common/src/repo/WalletAddressRepo.ts`

```typescript
/** 네트워크의 SETTLEMENT 인프라 지갑 조회 (네트워크당 1개) */
export async function findSettlementWallet(networkId: number): Promise<WalletAddress | null> {
    const rows = await query<WalletAddressRow[]>(
        `SELECT * FROM wallet_addresses
         WHERE network_id = ? AND wallet_type = 'SETTLEMENT' AND status = 'ACTIVE'
         LIMIT 1`,
        [networkId],
    );
    return rows[0] ?? null;
}
```

---

## 4. 선택적 정리

### Dead DTO 삭제

| 파일 | 이유 |
|------|------|
| `common/.../client/BalanceQueryRequest.java` | `queryBalance()` 삭제로 미사용 |
| `common/.../client/BalanceQueryResponse.java` | 동일 |

> 삭제 전 `grep -r "BalanceQuery" --include="*.java"` 로 참조 0건 확인.

---

## 5. 구현 순서

```
Phase A (SETTLEMENT 인프라 지갑)
  A-1. WalletService.createSettlementWallet() 리팩토링 (partnerId 제거)
  A-2. PartnerWalletController SETTLEMENT 엔드포인트 제거
  A-3. InfraWalletService에 createSettlementWallet() 추가
  A-4. InfraWalletController에 엔드포인트 추가
  → 빌드 확인: :core:compileJava + :admin-api:compileJava + :partner-api:compileJava
  → Admin Console에서 SETTLEMENT 지갑 생성 (BSC/TRON/Polygon 각 1개)
  → wallet-activator가 approve 처리 확인

Phase B (Realization 온체인)
  B-1. SettlementService.realizeFees() 수정 (fromWalletId/toWalletId 설정)
  B-2. Node.js SettlementRealizationRepo 신규
  B-3. Node.js RealizationPoller 신규
  B-4. relayer-api에 RealizationPoller 등록
  B-5. Spring 스케줄러에 SettlementRealizationJob 추가
  → 빌드 확인: Spring + Node.js (pnpm -r build)

Phase C (SETTLEMENT_WITHDRAW)
  C-1. core WithdrawalService — requestSettlementWithdrawal() [이미 적용됨]
  C-2. partner-api — [이미 적용됨]
  C-3. Node.js WithdrawalPoller — SETTLEMENT 소스 분기
  C-4. Node.js WalletAddressRepo — findSettlementWallet() 추가
  → 빌드 확인: Node.js (pnpm -r build)
```

---

## 6. 검증 체크리스트

| # | Phase | 항목 | 확인 |
|---|-------|------|------|
| 1 | A | SETTLEMENT 지갑 생성 (네트워크당 1개, partner_id=NULL) | |
| 2 | A | wallet_approvals에 SETTLEMENT approve PENDING 등록 확인 | |
| 3 | A | wallet-activator → SETTLEMENT approve APPROVED 확인 | |
| 4 | A | PartnerWalletController SETTLEMENT 엔드포인트 제거 확인 | |
| 5 | B | realizeFees() — fromWalletId/toWalletId 설정 확인 | |
| 6 | B | RealizationPoller — MASTER→SETTLEMENT transferFrom 실행 확인 | |
| 7 | B | settlement_realizations 상태: PENDING→PROCESSING→COMPLETED 확인 | |
| 8 | B | settlement_balances.realizedBalance 증가 확인 | |
| 9 | C | SETTLEMENT_WITHDRAW — settlement_balances.realizedBalance 차감 확인 | |
| 10 | C | WithdrawalPoller — SETTLEMENT 인프라 지갑에서 송금 확인 | |
| 11 | C | SETTLEMENT_WITHDRAW 취소 시 realizedBalance 복원 확인 | |
| 12 | 전체 | `:core:compileJava` + `:admin-api:compileJava` + `:partner-api:compileJava` 성공 | |
| 13 | 전체 | `pnpm -r build` (Node.js 5개 패키지) 성공 | |

---

## 7. 관련 문서

| 문서 | 참고 내용 |
|------|----------|
| `CRYPTOMENTS_SETTLEMENT_DESIGN.md` | 정산 전체 설계 |
| `CRYPTOMENTS_V2_DDL.sql` | settlement_realizations, settlement_balances, settlement_daily_fees 테이블 |
| `COLLECTION_BATCH_IMPL_GUIDE.md` | CollectionBatchRunner 패턴 (RealizationPoller 참고) |
| `WITHDRAWAL_WEBHOOK_PATTERN_GUIDE.md` | WithdrawalPoller 패턴 |
| `INFRA_WALLET_API_GUIDE.md` | ADMIN/GAS 인프라 지갑 생성 패턴 |
