# 집금(Collection) 배치 아키텍처 재설계

> **작성일**: 2026-03-22
> **범위**: DDL, Spring Boot (core/DepositService), Node.js (relayer-api/CollectionPoller)
> **버전**: DDL v1.8 예정

---

## 1. 현재 설계의 문제점

### 건별 집금 방식

```
입금 1.0 → collection_queue #1 (amount=1.0, IMMEDIATE) → TX #1
입금 0.5 → collection_queue #2 (amount=0.5, IMMEDIATE) → TX #2
입금 0.3 → collection_queue #3 (amount=0.3, IMMEDIATE) → TX #3
```

| 문제 | 설명 |
|------|------|
| **가스비 비효율** | 3건 × 가스비. 1건으로 동일 결과 가능 |
| **잔액 불일치** | DB amount와 온체인 잔액 불일치 시 TX revert |
| **즉시 집금 불필요** | 소액 입금마다 집금하면 가스비가 수익을 초과할 수 있음 |
| **collection_mode 무용** | IMMEDIATE/SCHEDULED 구분이 실질적 의미 없음 |
| **실패 처리 부적절** | 잔액 부족 = 일시적 상태인데 FAILED로 영구 처리 |

---

## 2. 재설계 핵심 원칙

### 2-1. 물리 TX와 논리 추적의 분리

```
논리 레이어 (추적)                     물리 레이어 (실행)
─────────────────                     ─────────────────
collection_queue #1 (1.0) ──┐
collection_queue #2 (0.5) ──┼──→  collection_batches #1
collection_queue #3 (0.3) ──┘       wallet: HOT-A
                                     collected: 1.8 USDT (온체인 전액)
                                     tx_hash: 0xabc...
                                     gas_cost: 0.002 USD
```

### 2-2. 온체인 잔액이 Source of Truth

- 집금 금액 = DB amount 합산이 **아님**
- 집금 금액 = **온체인 잔액 조회 결과** (실제 지갑에 있는 전액)
- 이유: 외부 직접 전송, 실패한 TX의 가스 차감 등으로 DB와 온체인은 항상 불일치 가능

### 2-3. 스케줄 기반 배치

- 즉시 집금이 아닌 **설정 가능한 주기** (1시간/6시간/12시간/24시간)
- 파트너별 또는 시스템 전역 설정
- 임계값 미달 지갑은 자동 SKIP

### 2-4. 가스비 임계값

- 집금 예상 가스비(USD) vs 집금 금액(USD) 비교
- 가스비가 집금 금액의 일정 비율(예: 10%) 이상이면 SKIP
- 설정: `system_settings` 또는 파트너별 설정

---

## 3. DDL 변경

### 3-1. collection_queue 변경

**역할 변경**: "집금 TX 실행 단위" → "입금 건의 집금 대기/완료 추적"

```sql
ALTER TABLE collection_queue
    -- 제거
    DROP COLUMN collection_mode,
    DROP COLUMN scheduled_at,
    DROP COLUMN tx_hash,
    DROP COLUMN relayer_address_id,
    DROP INDEX idx_collection_mode,

    -- 추가
    ADD COLUMN deposit_id BIGINT NOT NULL COMMENT 'deposits.id 참조' AFTER collection_code,
    ADD COLUMN batch_id BIGINT COMMENT 'collection_batches.id — 실제 집금 배치 참조' AFTER amount,

    -- 인덱스
    ADD KEY idx_deposit (deposit_id),
    ADD KEY idx_batch (batch_id);
```

**변경 후 컬럼 구조**:

| 컬럼 | 역할 | 비고 |
|------|------|------|
| id | PK | |
| collection_code | 고유 코드 | 유지 |
| **deposit_id** | deposits.id 참조 | **신규** — 어떤 입금 건인지 |
| wallet_address_id | HOT 지갑 | 유지 |
| partner_id | 파트너 | 유지 |
| network_id | 네트워크 | 유지 |
| currency_id | 통화 | 유지 |
| amount | 해당 입금의 집금 대상 금액 | 유지 (추적용, 실제 집금액 아님) |
| **batch_id** | collection_batches.id | **신규** — 어떤 배치로 집금됐는지 |
| status | QUEUED/COLLECTED/SKIPPED | 상태 단순화 |
| error_message | 에러 | 유지 |
| retry_count | 재시도 | 유지 |
| ~~collection_mode~~ | ~~제거~~ | 배치 스케줄이 결정 |
| ~~scheduled_at~~ | ~~제거~~ | |
| ~~tx_hash~~ | ~~제거~~ | batch로 이동 |
| ~~relayer_address_id~~ | ~~제거~~ | batch로 이동 |

**상태 변경**:

```
기존: QUEUED → DEFERRED → COLLECTING → BROADCASTING → CONFIRMED / FAILED
신규: QUEUED → COLLECTED (batch_id 할당) / SKIPPED (임계값 미달)
```

### 3-2. collection_batches 신규 테이블

```sql
CREATE TABLE collection_batches (
    id BIGINT AUTO_INCREMENT PRIMARY KEY
        COMMENT 'PK',

    batch_code VARCHAR(50) NOT NULL
        COMMENT '배치 고유 코드 — cb_{YYMM}_{random8}',

    -- 집금 대상 (지갑 + 통화 단위)
    wallet_address_id BIGINT NOT NULL
        COMMENT 'wallet_addresses.id — 집금 원천 HOT 지갑',
    partner_id BIGINT NOT NULL
        COMMENT 'partners.id',
    network_id BIGINT NOT NULL
        COMMENT 'blockchain_networks.id',
    currency_id BIGINT NOT NULL
        COMMENT 'currencies.id',

    -- 집금 금액 (온체인 실제 잔액 기준)
    onchain_balance DECIMAL(36,18) NOT NULL
        COMMENT '집금 시점 온체인 잔액 조회 결과',
    collected_amount DECIMAL(36,18) NOT NULL
        COMMENT '실제 집금된 금액 (= 온체인 전액)',
    queue_count INT NOT NULL DEFAULT 0
        COMMENT '이 배치에 포함된 collection_queue 건수',

    -- 가스비 판단
    estimated_gas_usd DECIMAL(18,8)
        COMMENT '예상 가스비 (USD)',
    gas_threshold_met TINYINT(1) NOT NULL DEFAULT 1
        COMMENT '가스비 임계값 충족 여부',

    -- TX 정보
    tx_hash VARCHAR(255)
        COMMENT '집금 TX 해시',
    relayer_address_id BIGINT
        COMMENT '집금 실행 Relayer',

    -- 상태
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING'
        COMMENT 'PENDING → COLLECTING → BROADCASTING → CONFIRMED / FAILED / SKIPPED',
    error_message TEXT
        COMMENT '실패 시 에러',

    created_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6),
    updated_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),

    UNIQUE KEY uk_batch_code (batch_code),
    KEY idx_wallet_status (wallet_address_id, status),
    KEY idx_status (status, created_at),
    KEY idx_tx_hash (tx_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
  COMMENT='집금 배치 — 지갑별 물리 TX 단위';
```

### 3-3. system_settings에 집금 설정 추가

```sql
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
('collection.schedule_interval_hours', '1', '집금 배치 실행 주기 (시간)'),
('collection.gas_threshold_ratio', '0.1', '가스비/집금액 비율 임계값 (초과 시 SKIP)'),
('collection.min_amount_usd', '1.0', '최소 집금 금액 (USD, 미만 시 SKIP)');
```

---

## 4. 프로세스 재설계

### 4-1. 입금 시 (Spring Boot — 변경 최소)

```
입금 확정 (onTxConfirmed)
  → collection_queue INSERT (deposit_id, amount, status=QUEUED)
  → 즉시 집금 TX 없음 — 배치 스케줄 대기
```

**DepositService.enqueueCollection() 변경**:
- `collectionMode` 제거
- `depositId` 필드 추가
- TX 관련 로직 제거 (배치에서 처리)

### 4-2. 배치 집금 (Node.js CollectionBatchRunner)

```
[스케줄 시점 — 매 N시간]

1. QUEUED 건이 있는 지갑 목록 그루핑
   SELECT wallet_address_id, network_id, currency_id, partner_id,
          COUNT(*) as queue_count
   FROM collection_queue
   WHERE status = 'QUEUED'
   GROUP BY wallet_address_id, network_id, currency_id

2. 지갑별 처리 (BatchRunner — 브로드캐스트만):
   a. 온체인 잔액 조회 (blockchain-api)
   b. 잔액 = 0 → SKIP (아직 온체인 미도착 가능)
   c. 가스비 추정 + USD 환산
   d. 가스비 임계값 체크:
      - 잔액(USD) < min_amount_usd → SKIP
      - 가스비 / 잔액(USD) > threshold_ratio → SKIP
   e. approve 확인 (기존 로직 유지)
   f. collection_batches INSERT (PENDING)
   g. Relayer 선택 → nonce 획득 → executeTransfer TX
   h. batch 상태: BROADCASTING (★ 여기서 끝 — 확정은 Webhook이 처리)
   i. 즉시 다음 지갑 처리 (동기 대기 없음)

3. TX 확정 (Webhook 비동기):
   blockchain_monitor가 TX 감지
   → POST webhook → open-api
   → BusinessEventClassifier → COLLECTION_CONFIRM
   → batch CONFIRMED + gas_cost 기록
   → 해당 지갑의 QUEUED 건 전부 → COLLECTED (batch_id 할당)
```

### 4-3. TX 확정: Webhook 비동기 방식 (설계 확정)

**BatchRunner는 브로드캐스트만, 확정은 Webhook이 처리.**

```
CollectionBatchRunner              blockchain_monitor        open-api
        │                                  │                    │
        │ TX #1 broadcast (지갑A)          │                    │
        │ → batch BROADCASTING             │                    │
        │                                  │                    │
        │ TX #2 broadcast (지갑B)          │                    │
        │ → batch BROADCASTING             │                    │
        │                                  │                    │
        │ TX #3 broadcast (지갑C)          │  TX #1 확정        │
        │ → batch BROADCASTING             │ ──→ Webhook POST   │
        │                                  │                    │
        │ (배치 완료, 종료)                 │    COLLECTION_CONFIRM
        │                                  │    → batch #1 CONFIRMED
        │                                  │    → queue COLLECTED
        │                                  │    → gas_cost 기록
        │                                  │                    │
        │                                  │  TX #2 확정        │
        │                                  │ ──→ Webhook POST   │
        │                                  │    → batch #2 CONFIRMED
```

**장점**:
- BatchRunner가 지갑 N개를 빠르게 순차 브로드캐스트 (대기 없음)
- TX 확정은 블록체인 속도에 따라 비동기로 도착
- 기존 Webhook 파이프라인(BusinessEventClassifier) 재사용
- `waitForConfirmation()` 완전 제거

**Webhook 처리 추가 (open-api)**:
BusinessEventClassifier에 `COLLECTION_CONFIRM` 이벤트 분류 추가:
- `tx_hash`로 `collection_batches` 조회
- batch `BROADCASTING → CONFIRMED`
- `gas_cost_records` INSERT (receipt에서 가스비 추출)
- `collection_queue` WHERE wallet+currency+status='QUEUED' → `COLLECTED` (batch_id 할당)

### 4-4. 상태 흐름

```
collection_queue:
  QUEUED ──→ COLLECTED (batch_id 할당, Webhook 확정 시)

collection_batches:
  PENDING → BROADCASTING → CONFIRMED (Webhook)
                         → FAILED (Webhook: TX revert)
```

- collection_queue는 **QUEUED / COLLECTED** 두 상태면 충분
- SKIP은 상태가 아님 — QUEUED 유지한 채 다음 배치에서 재평가
- FAILED batch의 queue 건도 QUEUED 유지 — 다음 배치에서 재시도

---

## 5. CollectionPoller → CollectionBatchRunner 리팩토링

### 5-1. 기존 (3초 폴링, 건별)

```typescript
// 매 3초
const queued = await collectionQueueRepo.findByStatuses(['QUEUED', 'DEFERRED']);
for (const entry of queued) {
  await executeCollection(entry);  // 건별 TX
}
```

### 5-2. 신규 — CollectionBatchRunner (브로드캐스트 전담)

```typescript
/**
 * 매 N시간 스케줄 트리거.
 * TX 브로드캐스트만 담당 — 확정(CONFIRMED)은 Webhook이 처리.
 */
async function runBatchCollection(): Promise<void> {
  const walletGroups = await collectionQueueRepo.findQueuedWalletGroups();
  // → [{ wallet_address_id, network_id, currency_id, partner_id, queue_count }]

  for (const group of walletGroups) {
    try {
      await executeBatchBroadcast(group);
    } catch (error) {
      logger.error('Batch broadcast failed', { walletAddressId: group.wallet_address_id, error });
      // 실패해도 다음 지갑 계속 처리
    }
  }
  logger.info(`Batch collection cycle complete: ${walletGroups.length} wallets processed`);
}

async function executeBatchBroadcast(group: WalletGroup): Promise<void> {
  // 1. 온체인 잔액 조회 (Source of Truth)
  const currency = await currencyRepo.findById(group.currency_id);
  const onchainBalance = await getOnchainTokenBalance(
    group.wallet_address_id, group.network_id, currency
  );
  if (onchainBalance === 0n) return;  // 아직 미도착 — 다음 배치

  // 2. 가스비 임계값 체크
  const balanceUsd = await calcUsdValue(onchainBalance, currency);
  const gasUsd = await estimateCollectionGasUsd(group.network_id);

  if (balanceUsd < settings.minAmountUsd) {
    logger.info('Skipped: below min amount', { balanceUsd });
    return;  // QUEUED 유지
  }
  if (gasUsd / balanceUsd > settings.gasThresholdRatio) {
    logger.info('Skipped: gas exceeds threshold', { gasUsd, balanceUsd });
    return;  // QUEUED 유지
  }

  // 3. approve 확인
  const approval = await walletApprovalRepo.findByWalletAndCurrency(
    group.wallet_address_id, group.currency_id
  );
  if (!approval || approval.status !== 'APPROVED') {
    logger.info('Skipped: approve not ready', { status: approval?.status });
    return;  // QUEUED 유지
  }

  // 4. batch 레코드 생성
  const batchId = await collectionBatchRepo.insert({
    walletAddressId: group.wallet_address_id,
    partnerId: group.partner_id,
    networkId: group.network_id,
    currencyId: group.currency_id,
    onchainBalance: toTokenAmount(onchainBalance, currency.decimals),
    collectedAmount: toTokenAmount(onchainBalance, currency.decimals),
    queueCount: group.queue_count,
    estimatedGasUsd: gasUsd,
    status: 'PENDING',
  });

  // 5. Relayer 선택 → nonce → TX 브로드캐스트
  const relayer = await walletAddressRepo.findAvailableRelayer(group.network_id);
  if (!relayer) {
    await collectionBatchRepo.updateStatus(batchId, 'FAILED', 'No available relayer');
    return;
  }

  const acquired = await nonceManager.acquireNonce(relayer.id);

  try {
    const rawAmount = onchainBalance;  // 전액 집금
    const txHash = await broadcastExecuteTransfer(
      group.network_id, relayer, currency, group.wallet_address_id, rawAmount, acquired.nonce
    );

    // 6. ★ BROADCASTING으로 설정하고 끝 — 확정은 Webhook이 처리
    await collectionBatchRepo.update(batchId, {
      txHash,
      relayerAddressId: relayer.id,
      status: 'BROADCASTING',
    });

    await nonceManager.confirmNonce(relayer.id, acquired.nonce, acquired.lockId);
    logger.info('Collection TX broadcast', { batchId, txHash });

    // ★ waitForConfirmation() 없음 — 즉시 다음 지갑으로

  } catch (error) {
    await nonceManager.releaseNonce(relayer.id, acquired.nonce, acquired.lockId);
    await collectionBatchRepo.updateStatus(batchId, 'FAILED', error.message);
    // collection_queue는 QUEUED 유지 — 다음 배치에서 재시도
  }
}
```

### 5-3. 신규 — Webhook 확정 처리 (open-api)

```java
/**
 * WebhookProcessingService — COLLECTION_CONFIRM 이벤트 처리
 * BusinessEventClassifier에서 tx_hash로 collection_batches 매칭.
 */
public void processCollectionConfirm(WebhookEventDto dto) {
    // 1. tx_hash로 batch 조회
    CollectionBatch batch = collectionBatchRepository.findByTxHash(dto.getTxHash());
    if (batch == null) return;  // 우리 TX가 아님
    if (batch.getStatus() != CollectionBatchStatus.BROADCASTING) return;  // 이미 처리됨

    // 2. batch CONFIRMED
    batch.setStatus(CollectionBatchStatus.CONFIRMED);
    collectionBatchRepository.modify(batch);

    // 3. gas_cost 기록 (receipt에서 가스비 추출)
    gasCostService.recordCollectionGas(batch, dto);

    // 4. ★ 해당 지갑의 QUEUED 건 전부 → COLLECTED
    collectionQueueRepository.markCollected(
        batch.getWalletAddressId(),
        batch.getCurrencyId(),
        batch.getId()
    );

    // 5. deposit 상태 업데이트 (COLLECTING → COLLECTED)
    List<CollectionQueue> collected = collectionQueueRepository.findByBatchId(batch.getId());
    for (CollectionQueue q : collected) {
        depositService.onCollectionConfirmed(q.getDepositId());
    }

    log.info("집금 확정: batchId={}, txHash={}, count={}",
            batch.getId(), dto.getTxHash(), collected.size());
}
```
```

---

## 6. 영향 범위 정리

### Spring Boot (core)

| 파일 | 변경 |
|------|------|
| `DepositService.enqueueCollection()` | deposit_id 추가, collectionMode 제거 |
| `CollectionQueue` entity | deposit_id/batch_id 추가, collection_mode/tx_hash/relayer 제거 |
| `CollectionQueueRepository` | findBy 메서드 조정 |
| `CollectionStatus` enum | QUEUED/COLLECTED (단순화) |
| admin-api 관련 | collection 조회 시 batch JOIN |

### Node.js (relayer-api)

| 파일 | 변경 |
|------|------|
| `CollectionPoller.ts` | → `CollectionBatchRunner.ts` 전면 리팩토링 |
| `CollectionQueueRepo.ts` | findQueuedWalletGroups(), markCollected() 추가 |
| **`CollectionBatchRepo.ts`** | **신규** — collection_batches CRUD |
| `app.ts` | 폴링 간격 변경 (3초 → cron 또는 긴 간격) |

### DDL

| 변경 | 내용 |
|------|------|
| `collection_queue` | ALTER — deposit_id/batch_id 추가, mode/tx/relayer 제거 |
| `collection_batches` | **CREATE** — 신규 테이블 |
| `system_settings` | INSERT — 집금 스케줄/임계값 설정 |

---

## 7. 추적 가능성 (감사 추적)

재설계 후 추적 경로:

```
"이 입금 건은 언제 어떤 TX로 집금됐는가?"

deposits #7
  → collection_queue #3 (deposit_id=7, batch_id=5)
    → collection_batches #5
      tx_hash: 0xabc...
      collected_amount: 1.8 USDT
      confirmed_at: 2026-03-22 14:00:00

"이 집금 TX는 어떤 입금들을 포함하는가?"

collection_batches #5
  → collection_queue WHERE batch_id = 5
    → #1 (deposit_id=5, amount=1.0)
    → #2 (deposit_id=6, amount=0.5)
    → #3 (deposit_id=7, amount=0.3)
  → 합계: 1.8 USDT (= onchain_balance)
```

양방향 추적 완벽 유지.

---

## 8. 적용 순서

```
Phase 1: DDL + Entity (선행)
  1. collection_batches CREATE
  2. collection_queue ALTER (deposit_id, batch_id 추가)
  3. collection_queue ALTER (mode, scheduled_at, tx_hash, relayer 제거)
  4. Spring Entity/Repository 동기화
  5. Node.js 타입/Repo 동기화

Phase 2: Node.js 리팩토링
  6. CollectionBatchRepo.ts 신규
  7. CollectionQueueRepo.ts 수정 (findQueuedWalletGroups, markCollected)
  8. CollectionBatchRunner.ts (기존 CollectionPoller 대체)
  9. 가스비 임계값 + 온체인 잔액 조회 통합

Phase 3: Spring Boot 조정
  10. DepositService.enqueueCollection() 수정
  11. Admin API collection 조회 batch JOIN
  12. CollectionStatus enum 단순화

Phase 4: 설정 + 테스트
  13. system_settings INSERT (스케줄 주기, 임계값, stale TX 설정)
  14. E2E 테스트: 다건 입금 → 배치 집금 → 추적 확인

Phase 5: 안전망 스케줄러
  15. StaleTxMonitorJob (Spring scheduler 모듈)
  16. blockchain-api TX receipt 조회 API 확인/추가
  17. STALE 상태 enum 추가 (CollectionBatchStatus, WithdrawalStatus)
  18. findAvailableRelayer — STALE Relayer 제외 조건 추가
  19. Telegram 알림 연동 (Stuck TX 감지 시)
  20. E2E 테스트: BROADCASTING 1시간 방치 → STALE 마킹 확인
```

---

## 9. Nonce 확정 타이밍

### 9-1. 원칙: Nonce 확정 = 네트워크 제출 시점

```
acquireNonce()   → nonce 번호 예약 (LOCKED)
confirmNonce()   → nonce 번호 사용 확정 (AVAILABLE, next_nonce 유지)
releaseNonce()   → nonce 번호 롤백 (AVAILABLE, next_nonce 원복)
```

| 시점 | 동작 | 이유 |
|------|------|------|
| TX broadcast 성공 | `confirmNonce()` | 네트워크에 제출된 nonce는 되돌릴 수 없음 |
| TX broadcast 실패 | `releaseNonce()` | 네트워크에 도달하지 않았으므로 롤백 |
| TX onchain revert | 추가 동작 없음 | revert된 TX도 nonce는 소모됨 (블록체인 규칙) |

**중요**: `confirmNonce()`는 "온체인 confirm"이 아니라 **"이 nonce 번호의 사용을 확정"**하는 것.
TX 확정(CONFIRMED)은 Webhook에서 별도로 처리.

### 9-2. Stuck TX 위험 — Nonce 막힘

```
TX #5 broadcast 성공 → nonce #5 확정 → TX가 mempool에서 안 빠짐
→ nonce #6, #7 TX는 정상 broadcast 가능
→ 그러나 #5가 포함(include)되기 전까지 #6, #7도 블록에 포함 안 됨
→ 해당 Relayer의 모든 TX 정지 (nonce ordering 규칙)
```

이것이 **안전망 스케줄러**가 필요한 핵심 이유.

---

## 10. 안전망 스케줄러 — StaleTxMonitor

### 10-1. 대상

집금뿐 아니라 **출금(Withdrawal)도 동일하게 적용**:

| 테이블 | 감시 조건 | TX 주체 |
|--------|----------|---------|
| `collection_batches` | status='BROADCASTING', updated_at < NOW()-1h | Relayer (집금) |
| `withdrawals` | status='BROADCASTING', updated_at < NOW()-1h | Relayer (출금) |

### 10-2. 프로세스

```
[StaleTxMonitor — 매 1시간 (system_settings.stale_tx.check_interval_minutes)]

1. BROADCASTING + 1시간 이상 경과 건 조회:
   SELECT * FROM collection_batches
   WHERE status = 'BROADCASTING'
     AND updated_at < NOW() - INTERVAL {threshold} MINUTE;

   SELECT * FROM withdrawals
   WHERE status = 'BROADCASTING'
     AND updated_at < NOW() - INTERVAL {threshold} MINUTE;

2. 각 건별 온체인 TX receipt 조회:
   GET /api/v1/tx/{networkId}/{txHash}/receipt

3. 분기 처리:

   ┌─ receipt 존재 + status=1 (success)
   │    → Webhook 누락 보상: CONFIRMED 처리
   │    → gas_cost 기록
   │    → (집금) collection_queue → COLLECTED
   │    → (출금) deposit 상태 업데이트
   │    ★ Webhook 누락 원인 조사 알림 발송
   │
   ├─ receipt 존재 + status=0 (revert)
   │    → FAILED 처리
   │    → (집금) collection_queue QUEUED 유지 — 다음 배치 재시도
   │    → (출금) 관리자 알림 — 수동 판단 필요
   │    ★ nonce는 정상 소모됨 (revert도 nonce 사용)
   │
   └─ receipt 없음 (TX not found / still pending)
        → STALE 상태로 마킹
        → 관리자 알림 (Telegram + Admin Console)
        → 해당 Relayer TX 발송 일시 차단 (추가 stuck 방지)
        ★ 관리자가 판단:
          - gas price 올려서 replace TX 제출?
          - mempool drop 확인 후 FAILED 처리?
          - nonce 수동 리셋?
```

### 10-3. STALE 상태 추가

```
collection_batches:
  PENDING → BROADCASTING → CONFIRMED (Webhook 정상)
                         → FAILED (Webhook: TX revert / broadcast 실패)
                         → STALE (StaleTxMonitor: 1시간+ 미확정)

withdrawals:
  ... → BROADCASTING → CONFIRMED
                     → FAILED
                     → STALE (신규)
```

STALE에서의 전이:
```
STALE → CONFIRMED (receipt 발견 시 자동 복구)
STALE → FAILED (관리자 수동 처리)
```

### 10-4. STALE 발생 시 Relayer 보호

STALE TX가 있는 Relayer는 **추가 TX 발송 차단**:

```sql
-- findAvailableRelayer 쿼리 수정 조건:
-- 해당 Relayer에 STALE batch/withdrawal이 없을 것
WHERE NOT EXISTS (
    SELECT 1 FROM collection_batches cb
    WHERE cb.relayer_address_id = wa.id AND cb.status = 'STALE'
)
AND NOT EXISTS (
    SELECT 1 FROM withdrawals w
    WHERE w.relayer_address_id = wa.id AND w.status = 'STALE'
)
```

이렇게 하면 stuck Relayer에 새 TX를 쌓아서 상황을 악화시키는 걸 방지.

### 10-5. system_settings 추가

```sql
INSERT INTO system_settings (setting_key, setting_value, description, value_type) VALUES
('stale_tx.check_interval_minutes', '60', 'Stale TX 점검 주기 (분)', 'NUMBER'),
('stale_tx.threshold_minutes', '60', 'BROADCASTING 후 미확정 임계 시간 (분)', 'NUMBER');
```

### 10-6. 배치 위치

| 옵션 | 위치 | 특성 |
|------|------|------|
| **A. Spring Boot scheduler** | scheduler 모듈 | DB 직접 접근 + blockchain-api REST 호출 |
| **B. Node.js relayer-api** | CollectionBatchRunner 옆 | 이미 체인 연동 로직 존재 |

**권장: A (Spring Boot scheduler)**
- DB 업데이트(CONFIRMED/FAILED 처리) + 알림 발송 → Spring 쪽이 더 자연스러움
- blockchain-api에 receipt 조회만 REST 호출
- open-api의 Webhook 처리 로직 재사용 가능 (processCollectionConfirm)
- scheduler 모듈이 아직 비어있으므로 첫 비즈니스 로직으로 적합

### 10-7. 시퀀스

```
[매 1시간]
Spring Scheduler (StaleTxMonitorJob)
    │
    ├─ SELECT stale batches/withdrawals
    │
    ├─ for each stale TX:
    │     │
    │     ├─ GET blockchain-api /tx/{networkId}/{txHash}/receipt
    │     │
    │     ├─ receipt found (success)?
    │     │     → 동일 로직: processCollectionConfirm() / processWithdrawalConfirm()
    │     │     → Telegram: "⚠️ Webhook 누락 복구: batch #{id}, txHash={hash}"
    │     │
    │     ├─ receipt found (revert)?
    │     │     → FAILED 처리
    │     │     → Telegram: "❌ TX Revert 감지: batch #{id}"
    │     │
    │     └─ no receipt?
    │           → STALE 마킹
    │           → Telegram: "🚨 Stuck TX: batch #{id}, relayer={addr}, 수동 확인 필요"
    │           → Relayer 차단 (findAvailableRelayer에서 제외)
    │
    └─ 완료
```
