# 수수료 실현 중복 브로드캐스트 수정 지침서 (2026-09-03)

> **작성**: Cowork 오케스트레이터 / **대상**: 구현 서브에이전트
> **배포 목표**: 2026-09-04 00:00 (01:00 실현 배치 이전) — `node:deploy-production` + `spring:deploy-production`

---

## 0. 배경 — 무슨 일이 있었나

파트너 63(팝콘) 출금이 `ONCHAIN_INSUFFICIENT`로 막혀 원장을 전수 대사한 결과, **원장은 무결**했고
(`balance_after` 체인 166건 재계산 일치) **온체인 쪽에서 근거 없는 유출**이 발견됐다.

MASTER `0x3559…46bc` USDT Transfer 로그 전수(154건) 대사:

```
IN 28,425.674708 − OUT 28,179.195396 = 246.479311654559775198  (실제 잔액과 일치)
```

이 중 **DB 어디에도 없는 유출 2건**:

| 블록 | tx | 금액 | 기록 |
|---|---|---|---|
| 119563518 | `0x62c78469…3bef38` | 56.804304741960551771 | **없음** |
| 119564109 | `0x2b9b8be5…4220a0` | 56.804304741960551771 | `settlement_realizations#79` |

같은 실현 스윕이 **두 번 브로드캐스트**됐고 한 건만 기록됐다.

### 전수 조사 결과 (blast radius)

BSC는 정산지갑 유입 로그, TRON은 `TD78ej…dn7` TRC20 유입 82건을 `settlement_realizations` 62건과 대조.

| 파트너 | 중복 | 초과 유출 USDT |
|---|---|---|
| 44 (TRON) | 6건 | 289.150214 |
| 45 (TRON) | 4건 | 98.028605 |
| 63 (BSC) | 1건 | 56.804305 |
| 39 (TRON) | 3건 | 46.061677 |
| 54 (BSC) | 1건 | 18.509437 |
| **계** | **15건** | **508.554238** |

2026-08-30 배치부터 발생. 자금은 전액 정산지갑에 남아 있다(BSC 331.435542955302861540 =
기록분 256.121800962756556394 + 중복분 75.313741992546305146, 원 단위까지 일치).

### 원인 — `RealizationPoller`에 선점(claim)이 없다

```ts
this.timer = setInterval(async () => {         // ← 10초마다, 이전 틱 완료를 기다리지 않음
    await this.poll();
}, this.intervalMs);

const pending = await settlementRealizationRepo.findPending(this.batchSize);  // status='PENDING'
for (const r of pending) await this.execute(r);   // 직렬, 건당 20초~수 분 (TRON 에너지 렌탈 + receipt 대기)
   ...
   txHash = await 브로드캐스트;
   await markProcessing(id, txHash);   // ← 상태 변경이 브로드캐스트 "성공 후"
```

앞 건이 실행되는 동안 뒤 건은 여전히 `PENDING`이고, 10초 뒤 **겹쳐 도는 다음 틱**이 같은 행을
다시 집어 두 번째 전송을 날린다. 소요시간이 긴 건(#79 = 294초, #69 = 89초)에서만 중복이 난 것이
이 구조의 직접 증거다.

> 참고: `WithdrawalPoller`는 `while (this.running) { … await sleep }` 구조라 틱이 겹치지 않고,
> 브로드캐스트 **전에** `PROCESSING`으로 바꾼다 — 같은 사고가 없다. 이 폴러가 정답 형태다.

---

## 작업 A — RealizationPoller 중복 브로드캐스트 차단 (node-service)

### A-1. 원자적 선점 메서드 추가

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

`findPending`은 그대로 두고, **선점 전용 메서드**를 추가한다.

```ts
/**
 * PENDING → PROCESSING 원자적 선점.
 *
 * ☠️ 이 메서드 없이 findPending 결과를 바로 execute 하면, 겹쳐 도는 폴 틱이 같은 행을
 *    다시 집어 온체인 전송을 두 번 날린다 (2026-08-30~09-03, 15건 508.55 USDT 유출).
 *    조건절의 status='PENDING' 이 유일한 방어선이므로 절대 제거하지 말 것.
 *
 * @returns 이 호출이 선점에 성공했으면 true. false = 다른 실행 흐름이 이미 가져감 → 건너뛸 것.
 */
async claim(id: number): Promise<boolean> {
    const result = await execute(
        `UPDATE settlement_realizations
         SET status = 'PROCESSING', updated_at = NOW()
         WHERE id = ? AND status = 'PENDING'`,
        [id],
    );
    return (result as { affectedRows?: number }).affectedRows === 1;
}

/**
 * 선점 해제 (PROCESSING → PENDING).
 *
 * ⚠️ <b>브로드캐스트를 시도하지 않았다고 확신할 때만</b> 호출한다. 릴레이어 미확보처럼
 *    전송 이전에 빠져나오는 경로 전용. 전송 이후 실패는 markFailed 로 간다.
 */
async release(id: number): Promise<void> {
    await execute(
        `UPDATE settlement_realizations
         SET status = 'PENDING', updated_at = NOW()
         WHERE id = ? AND status = 'PROCESSING'`,
        [id],
    );
}

/** tx_hash 기록 — 상태는 이미 PROCESSING 이므로 건드리지 않는다. */
async recordTxHash(id: number, txHash: string): Promise<void> {
    await execute(
        `UPDATE settlement_realizations SET tx_hash = ?, updated_at = NOW() WHERE id = ?`,
        [txHash, id],
    );
}

/** PROCESSING 장기 체류 조회 — 감시 알림용 (자동 복구 아님) */
async findStaleProcessing(minutes: number): Promise<SettlementRealization[]> {
    return query<RealizationRow[]>(
        `SELECT * FROM settlement_realizations
         WHERE status = 'PROCESSING'
           AND updated_at < DATE_SUB(NOW(), INTERVAL ? MINUTE)`,
        [minutes],
    );
}
```

기존 `markProcessing(id, txHash)`는 **더 이상 호출하지 않는다.** 다른 호출부가 없으면 제거하고,
있으면 남기되 `@deprecated` 주석에 "선점은 claim(), 해시 기록은 recordTxHash()" 를 명시할 것.

### A-2. 폴 재진입 가드 + 선점 적용

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

1. 클래스에 `private polling = false;` 필드 추가. `poll()` 진입 시:

```ts
private async poll(): Promise<void> {
    if (this.polling) {
        logger.warn('이전 폴 사이클이 아직 진행 중 — 이번 틱 건너뜀');
        return;
    }
    this.polling = true;
    try {
        ... 기존 본문 ...
    } finally {
        this.polling = false;
    }
}
```

> 재진입 가드만으로도 이번 사고는 막히지만, **가드와 선점 둘 다 넣는다.** 가드는 프로세스 내부
> 한정이고 선점은 인스턴스가 늘어나도 성립한다. 하나만 넣으면 다음에 relayer-api 를 2대로
> 늘리는 순간 같은 사고가 재발한다.

2. `poll()` 루프에서 `execute` 호출 **전에** 선점:

```ts
for (const realization of pending) {
    const claimed = await settlementRealizationRepo.claim(realization.id);
    if (!claimed) {
        logger.warn('선점 실패 — 다른 흐름이 처리 중, 건너뜀', { id: realization.id });
        continue;
    }
    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);
    }
}
```

3. `execute()` 내부 수정:

- **릴레이어 미확보 경로** — 지금은 `return`으로 PENDING 유지를 노렸지만 이제 선점 상태이므로
  반드시 되돌린다:

```ts
const relayer = await walletAddressRepo.findAvailableRelayer(networkId);
if (!relayer) {
    noteRelayerUnavailable(`realization:${realization.id}`, { realizationId: realization.id, networkId });
    await settlementRealizationRepo.release(realization.id);   // ★ 선점 해제 — 빠뜨리면 영구 정체
    return;
}
```

- **nonce 획득 실패**(`acquireNonce` false)도 브로드캐스트 이전이므로 `release` 후 `throw` 대신
  `return` 으로 다음 틱에 넘긴다. (지금처럼 throw 하면 markFailed 로 굳는다)

- **상태 전이** — 10번 블록에서 `markProcessing` 호출 제거, `recordTxHash` 로 교체:

```ts
await settlementRealizationRepo.recordTxHash(realization.id, txHash);
await settlementRealizationRepo.markCompleted(realization.id);
```

- **TVM 경로 주의**: `withRentedEnergy` 콜백 안에서 브로드캐스트가 일어난다. 선점은 콜백 진입
  이전에 이미 끝나 있어야 하므로 A-2의 2번(루프에서 선점) 위치를 지킬 것. 콜백 안으로 옮기지 말 것.

### A-3. PROCESSING 장기 체류 알림 (자동 복구 없음)

**결정(2026-09-03, 오너)**: 갇힌 `PROCESSING` 행을 **자동으로 되돌리지 않는다.** 자동 복구는
이미 체인에 나간 tx 를 다시 보내는 이번 사고를 그대로 재현할 수 있다. 사람이 온체인을 확인하고
판정한다.

`poll()` 말미에 감시만 추가:

```ts
// PROCESSING 30분 초과 = 브로드캐스트 도중 프로세스가 죽었을 가능성.
//   ☠️ 자동으로 PENDING 되돌리지 말 것 — 이미 나간 tx 를 재전송해 중복 유출이 된다(2026-09-03).
//      온체인 확인 후 사람이 판정한다.
const stale = await settlementRealizationRepo.findStaleProcessing(30);
for (const r of stale) {
    logger.error('실현 PROCESSING 장기 체류 — 온체인 확인 필요(자동 복구 안 함)', {
        id: r.id, partnerId: r.partner_id, amount: r.total_share_amount, txHash: r.tx_hash,
    });
}
```

동일 건이 매 틱 로그를 도배하지 않도록, 이미 경고한 id 는 `Set` 에 담아 프로세스 생애 동안
1회만 남긴다.

### A-4. CollectionBatchRunner 재진입 가드 (같은 구조 예방)

`CollectionBatchRunner` 도 `setInterval(async …)` 구조라 사이클이 인터벌(1시간)을 넘기면 같은
겹침이 난다. `runCycle()` 에 A-2의 1번과 동일한 `polling` 가드만 추가한다. **선점 로직은
이번 범위 밖** — 큐/배치 선점은 별도 검토 사항이다.

---

## 작업 B — 정산지갑 발신을 조용히 처리 (open-api)

### 왜 필요한가

중복분 508.55 를 정산지갑에서 각 파트너 MASTER 로 되돌리면, 그 전송이 webhook 으로 들어와
`BusinessEventClassifier.classify()` 의 **2번 분기(양쪽 다 우리 주소)** 를 탄다. 정산지갑은
`wallet_addresses` 에 등록돼 있고(id 525/526/527) `SETTLEMENT` 타입은 `INFRA_WALLET_TYPES`
에 없으므로, 2-4 분기에서 `toWalletType == MASTER` → **`DEPOSIT` 으로 분류되어 원장에
크레딧이 쌓인다.** 반환인데 입금으로 잡히면 파트너 잔액이 부풀고 대사가 다시 깨진다.

### 수정

**파일**: `open-api/src/main/java/com/cryptoments/webhook/service/BusinessEventClassifier.java`

```java
/**
 * 인프라 지갑 타입 — 이 지갑에서 보낸 TX는 비즈니스 입금이 아닌 인프라 자금 이동.
 *
 * <p>SETTLEMENT 포함(2026-09-03): 정산지갑 → 파트너 MASTER 반환 전송이 DEPOSIT 으로 분류되면
 * 원장에 크레딧이 쌓여 "반환"이 "입금"이 된다. 정산지갑은 시스템 소유이므로 여기서 나가는
 * 자금 이동은 어떤 수취 지갑이든 비즈니스 입금이 아니다.
 */
private static final Set<WalletType> INFRA_WALLET_TYPES = Set.of(
        WalletType.GAS, WalletType.ADMIN, WalletType.RELAYER, WalletType.SETTLEMENT);
```

**범위 결정(2026-09-03, 오너)**: `SETTLEMENT → MASTER` 조합만이 아니라 **SETTLEMENT 발신 전부**를
FUNDING 으로 본다. 조건 분기를 늘리지 않아 예외가 생기지 않는다.

클래스 상단 버전 주석에 `v2.1: SETTLEMENT 발신 TX를 FUNDING으로 분류(반환 전송이 입금으로
잡히는 것 방지, 2026-09-03)` 한 줄 추가.

### 확인 사항 (수정 불필요, 검증만)

- `WebhookProcessingService.process()` 의 **3단계 잔액 동기화는 businessType 과 무관하게 항상
  실행**된다. FUNDING 으로 빠져도 `wallet_balances` 는 정상 갱신된다 — 별도 처리 불필요.
- 2번 분기의 앞선 조건들(`collection_batches` / `withdrawals` / `p2p_settlements` txHash 매칭)이
  먼저 걸리는 케이스는 영향 없음. SETTLEMENT 발신은 이 셋 어디에도 tx 가 없다.
- 3번 분기(외부 → SETTLEMENT)는 `default → UNKNOWN_INBOUND` 라 원래도 입금이 아니다. 변경 없음.

---

## 완료 기준

1. `RealizationPoller` 가 같은 `settlement_realizations` 행에 대해 두 번 `executeTransfer` 를
   호출할 수 없다 — 선점 실패 시 로그 남기고 건너뛴다.
2. 릴레이어 미확보 / nonce 실패 경로에서 행이 `PENDING` 으로 돌아간다(영구 `PROCESSING` 정체 없음).
3. `PROCESSING` 장기 체류는 **로그 경고만** 남고 상태를 자동 변경하지 않는다.
4. `CollectionBatchRunner.runCycle()` 에 재진입 가드가 있다.
5. `BusinessEventClassifier` 가 SETTLEMENT 발신 TX를 `FUNDING` 으로 분류한다.
6. 빌드 통과:
   - `cd node-service && npx tsc --noEmit -p packages/common && npx tsc --noEmit -p packages/relayer-api`
     (또는 레포 표준 빌드 명령)
   - `./gradlew :open-api:compileJava`

## 코딩 규칙 (레포 표준)

- Java 17, Lombok `@Getter/@Setter/@Builder(toBuilder=true)/@NoArgsConstructor/@AllArgsConstructor` (`@Data` 금지)
- TS: 기존 파일 스타일(4-space indent, named export, `createLogger`) 유지
- **DDL 변경 없음** — 이번 수정은 스키마를 건드리지 않는다
- 주석은 한국어. "왜 이렇게 했는지"와 "건드리면 무엇이 깨지는지"를 적을 것 (레포 관행)
- push 하지 말 것. 커밋까지만 하고 오너 지시를 기다린다

## 손대지 말 것

- `WithdrawalPoller` — 구조상 안전하다. 이번 수정 범위 아님
- `settlement_realizations` 기존 데이터 — 보정은 별도 절차
- 실현 금액 계산 로직(`SettlementService.realizeFees`) — 계산은 정상이었다

---

## 검증·판정 절차 (2026-09-03 추가)

이 버그는 **하루 한 번 01:00 배치에서만 재현**된다. "배포하고 내일 확인"은 판정이 아니므로
3단계로 나눈다.

### 1단계 — 배포 전: 선점 술어 자체의 정합성 (완료)

로컬 MySQL 9.6 에 동일 스키마·동일 술어로 재현. 결과 **통과**.

```
선점 테스트: 30라운드 × 동시 8흐름 → 라운드당 평균 승자 1, 위반 0건
release 경로: 해제 true, 재선점 true
COMPLETED 행 선점 시도: false
```

- 같은 행을 8개 흐름이 동시에 노려도 `affectedRows === 1` 은 정확히 하나뿐 —
  InnoDB 행 잠금이 직렬화하고, 진 쪽은 `status='PENDING'` 술어에서 0행이 된다.
- **COMPLETED 행은 선점되지 않는다** = 이미 끝난 건이 어떤 경로로도 재전송되지 않는다.
  이 단언이 사고의 반대 명제다.

배포 산출물(`dist/services/RealizationPoller.js`)에서 호출 순서도 확인:
`claim` → `execute` / 릴레이어 미확보·nonce 실패 → `release` / 종료 → `recordTxHash` + `markCompleted`.

### 2단계 — 배포 직후 (01:00 전)

```bash
# 폴러가 살아서 돌고 있는가 (대기건 0에서도 감시가 돌아야 한다)
ssh cryptoments-bastion "ssh node-02 'tail -50 /opt/cryptoments/node-service/logs/relayer-api-out.log | grep -i realization'"

# PROCESSING 잔여 행이 없어야 한다 (있으면 배포 전 상태가 남은 것)
SELECT id, partner_id, status, tx_hash, updated_at FROM settlement_realizations WHERE status='PROCESSING';
```

### 3단계 — 01:00 배치 실전 판정 (01:10경)

**판정 기준 3개를 모두 만족해야 통과.**

```bash
# (a) 겹침이 실제로 발생했고 막혔는가 — 이 경고가 핵심 증거
ssh cryptoments-bastion "ssh node-02 'grep \"$(date +%Y-%m-%d) 01:0\" /opt/cryptoments/node-service/logs/relayer-api-out.log | grep -c \"선점 실패\"'"

# (b) Realization completed 가 id 당 정확히 1회인가 (2회 이상이면 실패)
ssh cryptoments-bastion "ssh node-02 'grep \"$(date +%Y-%m-%d) 01:0\" /opt/cryptoments/node-service/logs/relayer-api-out.log \
  | grep \"Realization completed\" | grep -o \"\\\"id\\\":[0-9]*\" | sort | uniq -c'"
```

```sql
-- (c) 그날 실현 건수·합계
SELECT network_id, COUNT(*) n, SUM(total_share_amount) amt
FROM settlement_realizations WHERE DATE(created_at)=CURDATE() GROUP BY network_id;
```

온체인 대조 — **정산지갑 유입 건수가 DB 건수와 같아야 한다.**

```bash
# TRON: 정산지갑 유입 (USDT 컨트랙트만 필터)
ssh cryptoments-bastion "ssh node-01 'curl -s \"https://api.trongrid.io/v1/accounts/TD78ejcTggMDMS5xdTs6TVHsyGp29Ngdn7/transactions/trc20?limit=50&only_to=true\"'"
# BSC: eth_getLogs(USDT, topic2=정산지갑) 을 해당 블록 구간에 대해 조회
```

> ⚠️ **(a) 가 0이면 판정 보류다.** 겹침이 아예 없었다는 뜻이라 선점이 작동했는지 확인되지 않는다.
> 현재 배치는 에너지 도착 60초 타임아웃 때문에 건당 ~70초라 겹침이 확실히 발생한다 —
> 역설적으로 **느린 지금이 검증에 유리**하다. 나중에 렌탈을 최적화해 배치가 빨라지면
> (a) 는 자연히 0이 되고, 그때는 (b)(c) 만으로 판정한다.

### ⚠️ 배포와 함께 처리해야 할 것 — TRON 릴레이어 TRX

`TKwpJiV2wK1ZFvMsmHmwPAi6yppVof2Ni9` 의 유동 TRX 가 하루 만에 42.6 → **0.254 TRX** 로 말랐다
(2026-09-03 01:00 로그 `trxBalanceSun:42587xxx` → 20:00 실측 `254009`). 에너지는 렌탈로 채워지지만
**대역폭은 자기 TRX 로 태워야 해서** 이 릴레이어로 배정된 건은 `Account resource insufficient error`
로 실패한다 — 오늘 출금 1638·1633 과 집금 배치 1847·1849·1851 이 그렇게 죽었다.

선점을 넣으면 브로드캐스트 단계 실패는 `markFailed`(터미널)로 간다. **TRX 를 채우지 않으면
내일 01:00 배치는 "중복" 대신 "FAILED"를 만든다.** 배포 전에 TRX 충전을 같이 할 것
(TW3x 도 59.96 TRX 로 임계 100 미만).
