# GoldenBall — 기술 개요 및 운영 인수인계

**제품:** GoldenBall — 캐셔 방식 포인트 지갑을 갖춘 인덱스 기반 소셜 게이밍 플랫폼
**대상 독자:** GoldenBall을 인수하여 소유·운영할 인수 측 엔지니어링 / 운영 팀
**개정:** 2026-07-17 (2026-07-15 개정판을 대체함)

GoldenBall은 체인 없는(chain-free) 게이밍 플랫폼이다. 플레이어의 게임 잔고는 **GBP**로 보유되며, 이는 전적으로 PostgreSQL 내부에 유지되는 오프체인 포인트 원장이다 — **블록체인 스마트 컨트랙트가 없으며** 플랫폼은 **어떤 개인 키도 보유하지 않는다**. 유일한 온체인 접점은 **TRON TRC20 상의 USDT 입금/출금**이며, 플랫폼은 이를 읽기 전용으로 관찰한다. 플레이어는 사용자명, 비밀번호, 레퍼럴 코드로 가입하고(이메일 없음, 소셜 로그인 없음), 두 개의 검증가능 공정성 게임(Lotto와 Powerball)에서 티켓을 구매하거나 베팅하며, 회사 USDT 수신 주소를 통해 입출금한다. 사업은 두 개의 운영자 콘솔로 운영된다: **Command**(회사, 전권)와 **Nexus**(영업자, 다운라인 전용).

> **동반 문서.** 모든 계정, 비밀번호, 키, 서버 로그인, 접근 절차는 이 파일에서 제외되어 별도의 기밀 문서인 **Credentials & Infrastructure Handover Annex**로 전달된다. 이 문서에서 실제 시크릿, 서버 로그인, 또는 DNS/에지 계정이 필요한 부분마다 해당 Annex를 가리킨다. 비밀이 아닌 정보(공개 IP, 포트, 도메인명)는 명확성을 위해 여기에 표시된다.

---

## 목차

1. 시스템 아키텍처 및 런타임
2. 게임 메커닉스 및 경제 구조
3. 인증 및 가입
4. 지갑, 입금, 출금 및 P2P
5. 레퍼럴 네트워크 및 롤링 커미션
6. 어드민 콘솔 — Command 및 Nexus
7. 데이터베이스 모델
8. 프론트엔드
9. 환경 변수
10. 배포 및 인프라
11. 운영 및 인수인계
12. 보안 요약 및 인수 前 체크리스트

---

## 1. 시스템 아키텍처 및 런타임

GoldenBall은 캐셔 방식 게이밍 플랫폼이다. 잔고는 PostgreSQL 내의 오프체인 포인트 원장(GBP)이며, 유일한 온체인 접점은 읽기 전용으로 관찰되는 TRON TRC20 USDT 입금이다. 런타임은 PM2로 관리되는 소규모 프로세스 집합 — 하나의 Node/Fastify API와 여러 개의 단일 목적 Python 서비스 — 로 구성되며, Nginx가 앞단에 위치하고 PostgreSQL(PgBouncer 경유)과 Redis가 뒷받침한다.

### 1.1 런타임 프로세스 (정확히 6개의 PM2 서비스)

전달되는 런타임은 **6개의 PM2 프로세스**이며, 모두 `server/ecosystem.config.cjs`에 정의되어 있다. 모든 프로세스는 기본 fork 모드(단일 인스턴스)로 실행된다 — 어디에도 cluster 모드는 없다.

| PM2 이름 | 런타임 | 포트 | 싱글턴 | 역할 |
|----------|---------|---------|-----------|------|
| `gb-server` | Node.js (Fastify, `tsx`로 실행) | 4000 (`PORT`) | yes | 핵심 REST API: GBP 지갑, 입출금, 사용자, 어드민(Command 및 Nexus), 레퍼럴/커미션, Powerball/Lotto/게시판/verify 라우트. **게임 베팅을 정산하지 않는다.** |
| `gb-auth` | Python (FastAPI + uvicorn) | 9003 (`AUTH_PORT`) | yes | 사용자명/비밀번호 + 레퍼럴 로그인; JWT 발급; 체인 없는 내부 지갑 id 파생. IP 레이트 리미팅에 Redis 사용. |
| `gb-index` | Python (websockets + aiohttp) | 8001 WS, 8002 HTTP | yes | Binance 선물 인덱스 가격 피드; 두 게임 엔진이 함께 소비하는 단일 공유 가격 소스. |
| `gb-powerball` | Python (websockets) | 8003 (`PB_PORT`) | **yes** | Powerball 라운드 엔진(베팅 50초 / 정산 10초). 승자를 PostgreSQL에 직접 정산한다. |
| `gb-lotto` | Python (websockets) | 8004 (`LOTTO_PORT`) | **yes** | Lotto 라운드 엔진(하루 두 번 추첨). 티켓을 PostgreSQL에 직접 정산한다. |
| `gb-deposit-poller` | Python (asyncpg worker) | — (포트 없음) | **yes** | TronGrid를 통해 회사 USDT TRC20 주소를 폴링하고(읽기 전용), 송신자 주소 매칭 시 GBP를 자동 적립하며, 매칭되지 않은 입금은 어드민 검토를 위해 큐에 넣는다. 폴링마다 하트비트를 기록한다. |

### 1.2 게임 엔진이 싱글턴인 이유

각 게임 엔진은 하나의 권위 있는 라운드 타임라인(베팅 오픈 → 잠금 → 추첨 → 정산)을 소유하며 정산을 GBP 원장에 직접 기록한다. 어떤 엔진(또는 입금 poller)의 두 번째 인스턴스를 실행하면 라운드가 두 번 진행되어 이중 지급이 발생한다. 따라서 이들은 절대 수평 확장되지 않는다. 동시성이 증가할 경우 로드 밸런서 뒤에서 복제할 수 있는 것은 무상태 계층(`gb-server` Node API + 정적 자산)뿐이다; 게임 싱글턴은 하나의 APP 박스에 남는다.

### 1.3 Nginx 라우팅

Nginx는 단일 공개 진입점이다. 포트 80은 HTTPS로 리다이렉트(301)하고; 포트 443(Let's Encrypt TLS)은 `/var/www/goldenball`에서 React SPA를 SPA 폴백과 함께 서빙하며 API 및 WebSocket 트래픽을 리버스 프록시한다. 매칭 순서가 중요하다 — `/api/auth/`가 `/api/`보다 먼저 매칭된다.

| Location | 업스트림 | 목적 |
|----------|----------|---------|
| `/` | static `/var/www/goldenball` | 플레이어 SPA (`try_files … /index.html`) |
| `/api/auth/` | `gb-auth` 127.0.0.1:9003 | 인증 서버 (가장 먼저 매칭) |
| `/api/` | `gb-server` 127.0.0.1:4000 | 그 외 모든 REST API (`/api/admin/*` 및 `/api/nexus/*` 포함) |
| `/ws/powerball` | `gb-powerball` 8003 | Powerball WebSocket |
| `/ws/lotto` | `gb-lotto` 8004 | Lotto WebSocket |
| `/ws/index`, `/ws/bb-index` | `gb-index` 8001 | 가격 피드 WebSocket (`/ws/bb-index`는 레거시 클라이언트 별칭) |
| `/ws/game` | (없음) | **Stale** — :9001의 제거된 게임 엔진을 가리킨다. 설정 시 이 업스트림/location을 제거하라(§11 참조). |

WebSocket location들은 `Upgrade`/`Connection` 헤더와 긴 `proxy_read_timeout`(3600초)을 설정하는 공유 스니펫(`/etc/nginx/goldenball_ws.inc`)을 가져온다. `client_max_body_size`는 10 MB이다. 두 운영자 콘솔(Command, Nexus)은 각자의 호스트에서 서빙되는 별도의 정적 빌드이다(§6 및 §10 참조); 둘 다 `/api/`를 4000의 동일한 `gb-server`로 프록시한다.

### 1.4 Redis 사용

Redis(6.0.16, Ubuntu 22.04 기본 패키지)는 APP 박스에서 실행되지만, 정확히 한 곳에서만 사용된다: IP별 레이트 리미팅을 위한 `gb-auth`이며, 이는 **fail-open**이다 — Redis에 도달할 수 없거나 `REDIS_URL`이 설정되지 않은 경우, 레이트 리미팅은 조용히 비활성화되고 요청은 통과한다. Redis에는 공유 캐시나 공유 세션 상태가 없다; Node 측은 `ioredis`를 의존성으로 나열하지만 Redis 클라이언트를 import하지는 않는다. Redis는 인증 강화 컴포넌트로만 취급하라.

### 1.5 요청 / 데이터 흐름

1. 브라우저가 Nginx에서 SPA를 로드한다(정적). `fetch`/XHR 호출은 `/api/*`에 도달하며; Nginx는 `/api/auth/*`를 `gb-auth`로, 그 외 모든 것을 `gb-server`로 라우팅한다. `gb-server`는 JWT(공유 `JWT_SECRET`)를 검증하고 PostgreSQL을 읽고 쓴다.
2. 게임은 `/ws/{powerball|lotto|index}`로 WebSocket을 연다. 각 게임 엔진은 `ws://127.0.0.1:8001`의 공유 피드(스냅샷용 `http://127.0.0.1:8002/current` 추가)를 구독하며, 이는 Binance 인덱스 가격을 스트리밍한다.
3. **정산은 엔진 직접 방식이다.** 라운드 종료 시 각 게임 엔진은 자체 커넥션 풀을 통해 PostgreSQL에 SQL을 직접 실행하여 `gbp_balances`를 차감/적립한다. `gb-server`를 호출하지 **않는다**.
4. **입금**은 `gb-deposit-poller`가 처리하며, TronGrid에서 회사 TRC20 주소를 읽어 송신자 주소 매칭 시 GBP를 적립하거나(PostgreSQL에 직접 기록), 매칭되지 않은 이체는 어드민 해결을 위해 큐에 넣는다. 모니터가 추적할 수 있도록 폴링에 성공할 때마다 `poller_heartbeat`를 기록한다.

### 1.6 아키텍처 다이어그램

```mermaid
graph TD
    subgraph Client
        SPA[Player SPA<br/>React 18 static build]
        CMD[Command console<br/>static build]
        NEX[Nexus console<br/>static build]
    end

    subgraph Edge
        DNS[Cloudflare<br/>DNS / edge]
        NGINX[Nginx :80 -> :443<br/>Let's Encrypt TLS<br/>static SPA + reverse proxy]
    end

    subgraph AppBox["APP box (<APP_SERVER_IP>)"]
        NODE[gb-server<br/>Fastify Node :4000<br/>wallet / deposits / admin / referral]
        AUTH[gb-auth<br/>Python :9003<br/>id + password + referral, JWT]
        INDEX[gb-index<br/>Python WS :8001 / HTTP :8002<br/>Binance index feed]
        PB[gb-powerball<br/>Python WS :8003<br/>SINGLETON]
        LOTTO[gb-lotto<br/>Python WS :8004<br/>SINGLETON]
        POLLER[gb-deposit-poller<br/>Python worker<br/>SINGLETON]
        REDIS[(Redis 6.0.16<br/>auth rate-limit only)]
    end

    subgraph DBBox["DB box (<DB_SERVER_IP>)"]
        PGB[PgBouncer :6432]
        PG[(PostgreSQL 16<br/>GBP ledger + all state)]
    end

    subgraph External
        TRON[TronGrid<br/>TRC20 read-only]
        BINANCE[Binance Futures<br/>index price]
    end

    SPA --> DNS
    CMD --> DNS
    NEX --> DNS
    DNS --> NGINX
    NGINX -->|/api/auth/*| AUTH
    NGINX -->|/api/*| NODE
    NGINX -->|/ws/powerball| PB
    NGINX -->|/ws/lotto| LOTTO
    NGINX -->|/ws/index| INDEX

    BINANCE --> INDEX
    INDEX -->|ws 8001 / http 8002| PB
    INDEX --> LOTTO

    AUTH --> REDIS
    AUTH --> PGB
    NODE --> PGB
    PB -->|direct SQL settle| PGB
    LOTTO -->|direct SQL settle| PGB
    POLLER -->|read transfers| TRON
    POLLER -->|credit GBP direct SQL| PGB
    PGB --> PG
```

### 1.7 PM2 외부의 작업

두 개의 운영 작업이 PM2 ecosystem 파일 외부에서 실행된다. 이들의 정확한 스케줄과 현재 라이브 상태는 §11.1에 상세히 기술되어 있다.

- **`server/gbp_monitor.py`** — 크래시에 안전한 1분 주기 헬스 워치독(6개의 PM2 프로세스, poller 하트비트 신선도, `/api/health`, 새로운 매칭되지 않은 입금을 점검; `gbp_settings.system_health`에 스냅샷; 선택적 Telegram 알림). APP 박스에서 root crontab으로 **이미 설치되어 실행 중이다**.
- **`server/src/workers/settlement-worker.ts`** — 일일 커미션 정산 + 사기 자동 홀드 배치. 어디에도(PM2에도 cron에도) 스케줄되어 있지 **않다**. 베팅별 커미션 적립은 여전히 작동하지만, 운영자가 스케줄할 때까지 일일 정산/지급 배치는 실행되지 않는다(§11 참조).

---
## 2. 게임 메커니즘 & 이코노미

GoldenBall은 **두 개의 게임: Lotto와 Powerball**을 제공한다. 두 게임 모두 결과를 Binance Futures BTCUSDT 인덱스 가격에서 도출하므로, 모든 결과는 결정론적이며 공개적으로 검증 가능하다 — 결코 무작위가 아니다. 모든 자금은 GBP(오프체인 포인트)이며, 게임 엔진이 직접 정산한다.

> **실시간 롱/숏 "battle" 게임은 이 납품에 포함되지 않는다 — 이는 원본 BB 소스에서 배송되는 별도 제품이다.** 일부 레거시 테이블, 오래된 Nginx `/ws/game` 업스트림, 그리고 어드민 "Live" 탭은 여전히 그 은퇴한 엔진을 참조한다. 이들은 비활성 상태이며 §7과 §11에서 정리 대상으로 표시되어 있다.

> ### CRITICAL RULE — 상금 풀 비율(RTP)은 내부 전용
> 상금 풀 지급 비율(예: Lotto의 70%)은 **내부 회계 값**이다. 이는 어떠한 플레이어 대상 화면, 툴팁, 도움말 텍스트, 마케팅 문구에도 **절대** 노출되어서는 안 된다. 납품된 UI는 이미 이를 준수한다: "RTP", "70%", "returned as prizes" 같은 문구는 플레이어에게 표시되지 않는다. Lotto 상금 표에는 "Pool 60% / 30% / 10%" 분배가 표시되는데 — 이는 **당첨 등급 1/2/3 간의** 패리뮤추얼 분배로, RTP와는 다른 숫자이며 허용된다. 향후 UI 작업에서도 이 구분을 유지한다.

### 2.1 Lotto — 하루 두 번, 7자리 위치 일치 복권

Lotto는 하루 두 번(오전/오후) 진행되는 7자리 위치 일치 복권이다. 티켓은 7개의 정수 0–9(**메인 6개 + 보너스 1개**)이며, 위치에 맞는 숫자를 일치시키면 상금이 지급된다. 티켓과 상금은 실제 GBP이며, 무료 포인트 티켓은 제거되었다.

**티켓 & 구매**

- **티켓 가격 = $1 = 1 GBP.**
- 티켓 = 7자리 숫자(메인 6개 + 보너스 1개), 서버 측에서 검증된다.
- 구매(`POST /api/lotto/buy`)는 원자적이다: 판매 중인 라운드가 행 잠금(row-lock)되고, 조건부 `UPDATE … WHERE balance >= price`로 GBP가 차감되며, 내부 `LOTTO_POOL` 원장 지갑에 적립되고, 티켓이 삽입되며, 감사 행이 기록되고, 롤링 커미션 적립이 실행된다.
- 라운드당 최대 티켓 수는 사실상 무제한(100,000)이다.

**일정(한국 시간, KST / UTC+9).** 모든 유저 대상 시각은 KST로 표시된다.

| Round | Buy window (KST) | Draw (KST) | Result / payout (KST) |
|-------|------------------|------------|-----------------------|
| AM | 00:00 – 10:00 | 11:31 – 11:37 | 12:00 |
| PM | 12:00 – 22:00 | 23:31 – 23:37 | 00:00 |

추첨은 분당 한 자리씩 해소한다 — 메인 6자리는 :31–:36에, 보너스는 :37에. 라운드 수명주기 상태는 `selling → drawing → finalized`(또는 `void`)로 흐른다.

**추첨 소스(검증가능 공정성, 결정론적).** 각 당첨 숫자는 Binance `indexPriceKlines`(`pair=BTCUSDT`, `interval=1m`) 분 **open** 가격의 소수 둘째 자리 숫자이다 — `int((price × 100) % 10)`으로 계산된다. 매 분의 open은 공개 kline 히스토리에서 영구적으로 재조회 가능하므로, 각 숫자는 해소되는 대로 영속화되며 중단된 라운드는 재시작 후 멱등하게 재완료될 수 있다. 재시도 후에도 가격을 가져올 수 없으면 해당 라운드는 **무효화되고 모든 티켓이 환불된다**.

**상금 등급**(배타적 — 티켓은 자신의 단일 최상위 등급만 지급받는다):

| Tier | Match | Prize |
|------|-------|-------|
| 1st | 6 main | Pool **60%** share, minimum guarantee **$100,000** |
| 2nd | 5 main + bonus | Pool **30%** share, minimum guarantee **$20,000** |
| 3rd | 5 main | Pool **10%** share, minimum guarantee **$5,000** |
| 4th | 4 main | **$50** fixed |
| 5th | 3 main | **$10** fixed |
| 6th | 2 main | **$2** fixed |

완벽한 메인 6개 + 보너스 티켓은 프로그레시브 잭팟에 매핑되는데, 이는 **기본적으로 OFF**이며 따라서 1등 당첨으로 지급된다. 잭팟이 활성화되더라도 자체 적립된 풀만 지급하므로, 초과 지급은 구조적으로 불가능하다.

**정산 이코노미.** 정산은 단일 멱등 DB 트랜잭션이다. `sales`는 실제 티켓 비용의 합계이고, `pool_amt = sales × POOL_RATE`이며 `house = sales − pool_amt`이다. **`POOL_RATE`(RTP)는 `LOTTO_POOL_RATE`를 통해 기본값 0.70(70%)** 이며 — 내부 전용이다(위의 critical rule 참조). 고정 상금(4/5/6등)이 먼저 지급되며, 풀을 초과할 경우 비례적으로 축소된다. 남은 풀에 이전 이월분을 더한 금액이 1/2/3등에 걸쳐 패리뮤추얼로(60/30/10) 분배되며, 분배 나머지는 1등에 접혀 들어가 아무것도 손실되지 않는다. 1/2/3등 최소 보장 부족분은 회사 자금으로 조성된 `LOTTO_RESERVE` 원장 지갑에서 보충된다. 리저브가 비어 있으면 보장은 단순히 발동되지 않는다(그리고 critical 로그가 기록된다). 분배되지 않은 나머지는 다음 라운드로 이월된다.

**당첨금에 대한 레퍼럴.** 각 상금의 10%는 당첨자의 1레벨 업라인(`parentId`)에게 지급되며, 당첨자는 90%를 갖는다(업라인이 없으면 100%). 이는 동일한 상금의 내부 분배일 뿐 추가 자금이 아니므로, 풀 회계는 변하지 않는다.

**내부 원장 지갑.** `LOTTO_POOL`, `LOTTO_HOUSE`, `LOTTO_RESERVE`는 `gbp_balances` 내부의 키이다 — 회계 버킷이며, 온체인 주소가 **아니다**.

### 2.2 Powerball — 1분 인덱스 숫자 게임

Powerball은 1분 단위, 라운드 기반의 "인덱스 숫자 맞히기" 게임이다. 권위 있는 라운드 수명주기와 정산은 `gb-powerball` 싱글턴에서 실행되며, Fastify 라우트는 베팅 배치와 REST 쿼리만 처리한다.

**지급 배수**(엔진, 라우트 폴백, 클라이언트 전반에서 일관됨):

| Market | Choices | Multiplier |
|--------|---------|------------|
| Under / Over | `under` (n < 50) / `over` (n ≥ 50) | **1.95×** |
| Odd / Even | `odd` / `even` (by n mod 2) | **1.95×** |
| Range low/mid/high | `low` (0–29) / `mid` (30–59) / `high` (60–89) | **3.20×** |
| Range ultra | `ultra` (90–99) | **9.60×** |

이븐머니 마켓은 2.5% 하우스 엣지(1.95×)를 가진다. 어드민은 `GameConfig`를 통해 배수를 오버라이드할 수 있다(1.0–20.0으로 제한되며, 표시된 배당이 지급 배당과 같도록 라운드 시작 시 스냅샷된다).

> 참고: 라이브 블랙 스킨은 **Odd/Even과 Under/Over(1.95×) 마켓만** 렌더링한다. `range` 마켓(3.20× / 9.60×)에는 UI 버튼이 없으므로 플레이어는 앱에서 베팅할 수 없지만, REST API는 여전히 `range` 베팅을 프로그래밍 방식으로 수락한다.

**숫자 소스(결코 무작위가 아님).** 당첨 숫자는 Binance Futures BTCUSDT **인덱스 가격**의 소수 마지막 두 자리(센트)이며, 정산 분 경계에서의 1분 인덱스 가격 kline `open`에서 취한다 — `int(round(price × 100)) % 100`. 그 open을 사용할 수 없으면 엔진은 분 close로, 다음으로 응답 순간에 취한 라이브 피드 스냅샷으로 폴백하고, 마지막으로 라운드를 무효화하고 전액 환불한다. 10초 정산 창 동안 클라이언트의 회전하는 숫자는 애니메이션일 뿐이며, 지급되는 결과는 서버의 값이다.

**라운드 타이밍.** 60초 라운드로, 벽시계에 정렬되어 베팅은 정확히 분 `:00`에 마감된다. 베팅 창 **50초**(`PB_BETTING_SEC`), 정산/추첨 세리머니 **10초**(`PB_SETTLING_SEC`). 정산은 비동기로 실행되므로 다음 라운드를 결코 막지 않는다. 클라이언트는 정확한 라운드별 타이밍을 REST가 아닌 WebSocket 메시지에서 취한다.

**베팅 한도.** **최소 베팅 1 GBP, 최대 베팅 라운드당 3,000 GBP, 유저별 누적**(라운드 행 잠금(row-lock) 하에 **베팅 배치 트랜잭션** 내부에서 재검증된다. 정산 경로는 한도 재검증을 수행하지 않는다). 최소/최대는 환경 변수(`PB_MIN_BET`/`PB_MAX_BET`)에서 읽어오며 — 어드민 GameConfig UI에서 변경해도 Powerball 한도에는 아무 영향이 없다(GameConfig로 구동되는 것은 지급 배수뿐이다).

**자금 흐름.** 베팅은 `gbp_balances`를 차감하고(조건부 원자적 `balance >= amount`) 설정된 경우 `PRIZE_POOL_WALLET`에 적립한다. 정산은 `FOR UPDATE` 잠금과 `settledAt` 멱등 가드로 상금 풀에서 당첨자에게 `amount × multiplier`를 지급한다. 고아/무효 라운드는 정산되지 않은 모든 베팅을 환불하며, 시작 시 스윕이 재시작으로 인해 진행 중에 남겨진 라운드를 무효화한다. 이는 오프체인 GBP 전용이다.

---
## 3. 인증 & 회원가입

GoldenBall은 **ID + 비밀번호 + 레퍼럴 코드** 인증 모델만 제공한다. Google/Facebook 로그인과 이메일 인증은 제거되었으며, 회원가입에는 이메일이 필요 없고 인증 단계도 없다. **블록체인 지갑은 존재하지 않는다** — "지갑 주소"는 결정론적 내부 식별자다.

**토폴로지.** `gb-auth`(Python FastAPI, port 9003)는 JWT를 발급·갱신하고 register/login/username-check/logout을 처리한다. `gb-server`(Node Fastify, port 4000)는 인증이 필요한 모든 지갑/게임 API에서 `authGuard`를 통해 동일한 JWT를 검증한다. Python 게임 엔진은 자체적으로 JWT를 검증하지 않는다 — 베팅은 `authGuard` 뒤에 있는 Fastify 라우트를 통과하며, 이 라우트가 토큰에서 지갑을 해석한다.

### 3.1 등록 규칙 (`POST /api/auth/id-register`)

| Field | Rule | Rejection |
|-------|------|-----------|
| `username` | `^[a-zA-Z0-9_]{6,20}$` — 6–20자, 영문/숫자/언더스코어만, 대소문자 구분 없이 유일. `@`는 문자셋 밖이라 이메일은 username이 될 수 없다. | `400` 형식 오류 / `409` 사용 중 |
| `password` | 최소 8자 | `400` 너무 짧음 |
| `inviteCode` | **필수** — 유저의 개인 레퍼럴 코드나 활성 `InviteLink`로 해석되어야 함 | 비어 있으면 `400 INVITE_REQUIRED` / 유효하지 않으면 `400` |

`nickname`은 선택 항목이다(기본값은 username). 중복 username은 네 개 계층에서 차단된다: 가용성 확인 엔드포인트, 등록 중 조회(409), 경합 안전 유니크 위반 캐치(409), 그리고 `lower(username)`에 대한 대소문자 무시 DB 유니크 인덱스다. 성공 시 계정이 생성되고 동기적으로 로그인되므로, 탈취할 수 있는 반쯤 생성된 "pending" 상태가 존재하지 않는다.

### 3.2 비밀번호 해싱

비밀번호는 **bcrypt, cost factor 12**로 해싱되며, 해시만 저장된다. 존재하지 않는 계정에 대해서는 상수 시간 더미 해시 검사가 수행되어 "그런 유저 없음"과 "잘못된 비밀번호"가 동일한 시간이 걸리고 동일한 일반 메시지를 반환한다.

### 3.3 JWT (HS256, 공유 시크릿)

- **알고리즘:** `HS256`. 클레임: `{ wallet, provider:"id", iat0, exp }`, 여기서 `iat0`은 절대 수명 및 비밀번호 재설정 후 무효화에 사용되는 최초 로그인 타임스탬프다.
- **공유 시크릿.** `gb-auth`(Python)와 `gb-server`(Node)가 동일한 `JWT_SECRET`을 읽으므로, 인증 서비스가 발행한 토큰은 서비스 전반에서 소지자를 인증한다. 둘 다 fail-closed다: 인증 서비스는 `JWT_SECRET`이 없거나 32자보다 짧으면 부팅을 거부한다.
- **만료 / 세션.** 액세스 토큰은 **30일** 동안 유효하다. `POST /api/auth/refresh`는 앱을 열 때마다 세션을 슬라이딩하며, `iat0`로부터 **180일 절대 수명**으로 제한되고, 계정의 `password_changed_at` 이전에 발급된 토큰은 거부한다. (Node 서명자의 7일 기본값은 레거시/어드민 경로에만 적용된다; 실제 유저 토큰은 Python이 발행한 30일 토큰이다.)

### 3.4 내부 지갑 파생 (블록체인 없음)

각 계정은 결정론적 내부 식별자를 받는다 — 온체인 키가 아니다:

```
synthetic = "{username_lowercase}@id.local"
wallet    = "0x" + sha256("goldenball_wallet_" + synthetic).hexdigest()[:40]
```

파생은 실제 이메일이 아닌 **synthetic** `@id.local` 주소를 사용한다. 유저가 제공하는 실제 이메일은 `recovery_email`에만 저장되며 지갑 파생이나 로그인 조회 키로는 결코 사용되지 않는다 — 계정 탈취 방지 설계다. 이 지갑 id는 서버 내부 식별자이며, 유저의 실제 온체인 USDT 출금 주소는 별도로 등록되는 TRON TRC20 주소다(`users.usdt_address`, §4 참조).

### 3.5 레퍼럴 연결 → 유니레벨 트리

초대 코드는 `validate_invite_code`로 해석된다: 먼저 유저의 개인 레퍼럴 코드와 대조하고(해당 유저가 부모가 되며, `childRate = 0`), 그렇지 않으면 `InviteLink` 테이블과 대조한다(`isActive`, `expiresAt`, `maxUses`를 준수). 회원가입 시 신규 유저의 `parentId`는 초대 생성자로 설정되고 `shareRate`는 초대의 `childRate`로 설정된다; 역할은 `{user, agent, dist, super_dist}`로 화이트리스트되며(그 외는 모두 `user`로 강등되어 초대 링크를 통한 권한 상승을 차단한다), 초대 소비는 동시성 안전 가드로 `usedCount`를 증가시킨다. `User.parentId` 체인은 커미션 엔진이 순회하는 유니레벨 트리다(§5 참조).

### 3.6 로그인, 잠금, 레이트 리미팅

- **로그인**(`POST /api/auth/id-login`)은 username 또는 recovery 이메일 + 비밀번호를 받으며, 실패 시 일반 401을 반환한다.
- **잠금:** **5**회 실패하면 계정이 **15분** 동안 잠긴다(ID 유저는 이메일이 없을 수 있으므로 `wallet_address`를 키로 사용); 잠긴 계정은 **429**를 반환한다.
- **레이트 리미트:** Redis 기반, **20 requests / IP / 60 s**, **fail-open**(Redis가 다운되거나 `REDIS_URL`이 설정되지 않으면 비활성화). 활성화하려면 Redis를 프로비저닝하고 `REDIS_URL`을 설정한다.

### 3.7 엔드포인트 레퍼런스 (인증 서비스, port 9003)

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| POST | `/api/auth/id-register` | none | 계정 생성(username + password + inviteCode), 자동 로그인 |
| POST | `/api/auth/id-login` | none | username 또는 recovery 이메일 + 비밀번호로 로그인 |
| GET | `/api/auth/check-username` | none | 회원가입 버튼용 중복/형식 검사 |
| POST | `/api/auth/refresh` | Bearer JWT | 슬라이딩 30일 재발급, 180일 제한 |
| GET | `/api/auth/me` | Bearer JWT | 본인 계정(recovery 이메일 / verified 플래그 포함) |
| GET | `/api/auth/user/{wallet}` | none | 공개 유저 조회 + 잔고 |
| GET | `/api/auth/balance/{wallet}` | none | GBP 잔고 |
| GET / POST | `/api/auth/logout` | none | 클라이언트 토큰 / 쿠키 삭제 |
| GET | `/health` | none | 라이브니스 |

모든 유저 대상 인증 문자열은 영어다. 응답에서 `bbpBalance`는 GBP 잔고이고 `provider`는 `"id"`다 — 클라이언트 호환성을 위해 유지된 레거시 이름이다.

---

## 4. 지갑, 입금, 출금 & P2P

GoldenBall은 체인 없는 캐셔 방식 지갑이다. GBP는 `gbp_balances`의 오프체인 포인트이며, 유저 자금을 보유하는 핫 월렛이 없다. 실제로 접촉하는 유일한 체인은 **TRON TRC20 USDT**이며, 입금 감지와 (수동) 출금 전송을 위해 읽기 전용으로 사용된다. 모든 잔고 변경은 하나의 감사된 원장 함수(`adjustGbpTx`)를 통해 흐르며, 이 함수는 잔고를 `FOR UPDATE`로 행 잠금(row-lock)하고, 원자적 증분 변경을 적용하고, 음수 결과를 차단하고, 변경 전/후 잔고가 담긴 감사 행을 강제한다.

### 4.1 핵심 모델

- **GBP = 오프체인 포인트**, `gbp_balances.balance NUMERIC(36,18) CHECK (balance >= 0)`, 내부 `wallet_address`를 키로 함.
- **USDT ⇄ GBP = 1:1** 기본값(`gbp_settings.usdt_to_gbp_rate = '1'`, 실시간 재조회 및 구성 가능).
- **체인 = TRON TRC20 USDT만**(기본 컨트랙트 `TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`, `USDT_CONTRACT`로 재정의 가능). ETH/BSC/Polygon 없음.
- **키 없음 / 온체인 읽기 전용.** 플랫폼은 들어오는 USDT를 관찰할 수 있으나 프로그램적으로 자금을 이동시킬 수는 없다. 모든 출금 전송은 어드민이 수동으로 실행하고 트랜잭션 해시로 다시 기록한다. 앱 서버가 침해되어도 자금을 빼낼 수 없다 — 훔칠 서명 능력이 존재하지 않는다.

### 4.2 입금 (poller + 수동 매칭)

라이브 경로는 `gb-deposit-poller`이며, **30 s**(`GBP_POLL_SEC`)마다 폴링한다.

- **읽기 전용 TronGrid.** 회사 주소의 최근 TRC20 전송을 가져와 코드에서 수신자를 필터링한다(빈 결과를 반환하는 TronGrid의 `only_to=true`는 의도적으로 피한다). 키는 사용되지 않는다.
- **회사 주소 소스:** `gbp_settings.company_usdt_address`, 없으면 `COMPANY_USDT_ADDRESS` env로 폴백; 둘 다 비어 있으면 폴링은 건너뛴다.
- **TXID 멱등성.** `gbp_deposits.txid`는 UNIQUE이고 삽입은 `ON CONFLICT (txid) DO NOTHING`을 사용하므로, 동일한 온체인 트랜잭션이 두 번 적립될 수 없다.
- **from 주소 매칭 + pending 누적.** 발신자가 유저의 등록된 `usdt_address`와 일치하는 전송은 `status='pending'`으로 삽입되며 즉시 적립되지 **않는다**. 유저의 합산된 pending USDT가 계산되고, 그 합이 **`min_deposit` (10)**에 도달했을 때에만 전체 합이 `gbp_balances`에 적립되고, 해당 유저의 모든 pending 행이 `credited`로 전환되며, `deposit_auto` 감사 행이 기록된다. (예: 9 전송 → 보류; 1 추가 전송 → 합 10 → 적립.)
- **미매칭 큐.** 등록되지 않은 주소에서 온 전송은 어드민 수동 매칭을 위해 `status='unmatched'`(빈 지갑)로 저장된다.
- **어드민 수동 매칭.** Command에서 어드민이 TXID를 검증하고(`POST /api/admin/deposits/verify`) 선택한 지갑에 적립한다(`POST /api/admin/deposits/credit`). 수동 경로는 멱등이지만(이미 적립된 행은 무동작) **전체 금액을 즉시** 적립한다 — `min_deposit` 누적은 poller에만 적용되는 규칙이다.
- **하트비트.** 폴링이 성공할 때마다 모니터를 위해 `gbp_settings.poller_heartbeat = now()`를 기록한다.

### 4.3 출금 (선차감 → 어드민 수동 전송)

시스템은 키를 보유하지 않는다 — 어드민이 USDT를 수동으로 전송하고 tx 해시를 기록한다.

1. **요청 = 즉시 선차감.** 유저는 등록된 `usdt_address`가 있어야 하고 최소 `min_withdraw` (10) 이상을 요청해야 한다. GBP가 선차감되고(`withdraw_debit`, 잔고 부족 시 예외 발생), `status='requested'`이며 `dest_addr`이 유저의 등록 주소로부터 스냅샷된 `gbp_withdrawals` 행이 삽입된다. 상태는 정확히 `requested | completed | rejected`이며 — `processing` 상태는 없다.
2. **어드민 완료 (온체인 검증 포함).** `POST /api/admin/withdrawals/:id/complete {txHash}`는 TronGrid에 대해 수신자가 스냅샷된 `dest_addr`와 같은지, 온체인 금액이 요구되는 USDT 이상인지(0.01 USDT 허용 오차), 그리고 tx 해시가 이미 사용되지 않았는지를 검증한다. 부분 유니크 인덱스(`status='completed'`)는 하나의 온체인 전송이 두 개의 출금을 종료할 수 없다는 DB 수준의 보장이다. 잔고 변경은 발생하지 않는다(이미 차감됨).
3. **어드민 거부 = 환불.** `POST /api/admin/withdrawals/:id/reject {reason}`는 선차감된 GBP를 환불하고(`withdraw_refund`) `status='rejected'`로 설정한다.

선차감 설계는 대기 중인 출금의 GBP가 두 번 사용될 수 없고, 거부/실패한 전송은 항상 전액 환불됨을 의미한다.

### 4.4 쓰기 1회(write-once) USDT 주소

- **유저 등록은 쓰기 1회(write-once)다.** `POST /api/wallet/usdt-address {address}`는 TRON base58check 주소를 검증하고, 다른 계정이 이미 소유한 주소를 거부한 뒤(409), `usdt_address IS NULL OR ''`로 가드된 조건부 원자적 업데이트를 수행한다. 그것이 0개 행에 영향을 주면 주소는 이미 잠긴 것이다 → **409 "Your USDT address is locked and cannot be changed."**
- **이중 역할:** 이 하나의 주소가 입금 발신자 매칭 키이자 고정된 출금 목적지다 — 그래서 잠금이 필요하다.
- **소유자 전용 리셋.** `POST /api/admin/members/:wallet/usdt-address`(운영자 등급 `owner`, 사유 필수)는 주소를 덮어쓰거나 지울 수 있다; 지우면 재등록이 잠금 해제된다. 모든 설정/리셋은 감사된다.

### 4.5 P2P GBP 전송

`POST /api/wallet/transfer {toUsername, amount, memo?}`는 수신자를 **username 핸들**로 해석하고(대소문자 무시, 앞의 `@` 제거), 하나의 트랜잭션에서 발신자를 차감(`transfer_out`)하고 수신자에 적립(`transfer_in`)한다 — 원자적, **수수료 = 0**. 오류: 알 수 없는 ID → 404, 자기 자신 전송 → 400, 잔고 부족 → 400. 이력은 감사 원장에서 읽는다; 오타 방지 자동완성 엔드포인트는 최대 10개의 일치하는 username을 반환한다.

### 4.6 자금 흐름 다이어그램

```mermaid
graph LR
  subgraph OnChain["TRON TRC20 (on-chain)"]
    UserWallet["User USDT address<br/>(registered, write-once)"]
    CompanyWallet["Company USDT<br/>receiving address"]
  end

  subgraph Platform["GoldenBall (off-chain, key-less)"]
    Poller["gb-deposit-poller<br/>(read-only, 30s)"]
    Ledger["GBP balances<br/>(gbp_balances)"]
    Admin["Admin<br/>(manual USDT send)"]
  end

  UserWallet -- "1. send USDT (TRC20)" --> CompanyWallet
  CompanyWallet -. "2. TronGrid read (TXID)" .-> Poller
  Poller -- "3a. from-match -> pending; credit when sum >= min_deposit" --> Ledger
  Poller -. "3b. unmatched -> admin manual match" .-> Admin
  Admin -- "manual credit (full amount)" --> Ledger

  Ledger -- "4. withdraw request: pre-debit GBP (requested)" --> Admin
  Admin -- "5. manual USDT send to dest_addr" --> UserWallet
  Admin -. "6. verify on-chain + record tx_hash -> completed" .-> Ledger

  Ledger == "P2P: transfer_out / transfer_in, fee 0" ==> Ledger
```

### 4.7 모니터링

`gbp_monitor.py` 워치독(APP 박스에서 1분 root 크론탭으로 이미 라이브 — §11.1 참조)은 6개 PM2 프로세스와 크래시 루프, poller 하트비트 신선도(> 180 s면 stale), `gb-server /api/health`, 그리고 신규 미매칭 입금을 점검한다. 어드민 대시보드용 `system_health` 스냅샷을 기록하고, 봇 토큰/chat id가 구성되어 있으면 Telegram으로 경보한다. 스크립트 전체가 try/except로 감싸여 있고 항상 0으로 종료하므로, 박스를 다운시킬 일이 없다.

---
## 5. 레퍼럴 네트워크 & 롤링 커미션

레퍼럴은 `User.parentId`(가입 시 설정, §3.5)를 통해 **유니레벨 트리**를 이룬다. 그 위에서 **차등("망원경식") 롤링 커미션** 엔진이 동작한다.

### 5.1 차등 모델

- **유저별·게임별 요율.** 각 `(wallet_address, game)`는 `user_rolling_rate`에 롤링 요율을 가지며, **퍼센트(0–100)**로 저장된다. 엔진은 캡과 비교하기 전에 100으로 나눠 분수로 변환한다.
- **차등 지급.** **베팅자 본인**(레벨 0)에서 시작해 `parentId` 체인을 거슬러 올라가며, 각 노드는 `(min(own_rate, cap) − rate_of_the_node_below) × betAmount`를 번다. 체인 합계는 최상위 실효 요율 × 베팅과 같으며, `cap × bet`로 상한이 걸린다 — 따라서 업라인 전체가 게임별 캡을 넘겨 집합적으로 벌 수 없다. 베팅자는 자신의 베팅에 대해 자신의 요율만큼도 번다(셀프 리베이트).
- **게임별 캡.** Powerball **2%**, Lotto **3.5%**(납품 게임). 캡은 `GameConfig.rolling_rate`(어드민 편집 가능, 30초 캐시) 또는 동일 값의 코드 폴백에서 온다.
- **부정 방지 게이트.** 유저는 해당 게임에 **최소 5건의 기록된 베팅**이 있어야 하며, 없으면 그 베팅에 대한 분배 전체가 건너뛰어진다.

### 5.2 적립 → 정산 → 지급 라이프사이클

- **적립.** 각 적격 베팅은 `Commission` 행을 `status='accrued'`로 기록하며, 현재 열려 있는 일일 `SettlementPeriod`에 묶는다(열린 것이 없으면 필요 시 생성).
- **정산.** `runSettlementBatch`는 `accrued → settled`를 원자적으로 뒤집고 적립자별로 `CommissionPayout`(pending)을 생성/증가시킨다.
- **지급.** `approvePayout`는 잔고가 증가하는 **유일한** 지점이다 — 조건부 원자 상태 전이(`requested/approved/pending → paid`)로 파트너 커미션 원장(`usdt_balances`)에 적립하며, 이중 지급으로부터 보호된다. 어드민 승인 없이는 어떤 것도 유저 잔고에 도달하지 못한다.
- **부정 자동 홀드.** 정산 워커는 베팅 건수 < 5, 평균 베팅 ≤ $2, 또는 ≥ 5개 가입이 하나의 IP를 공유할 때 요청된 지급을 홀드한다.

> **운영자 셋업 공백.** 일일 배치는 `settlement-worker.ts`에 있으나, 납품 상태에서는 PM2에 등록되어 있**지 않다**. 베팅별 적립은 여전히 작동하지만, `accrued → settled → CommissionPayout`(및 부정 자동 홀드)은 운영자가 워커를 스케줄링하기 전까지 실행되지 않는다(문서화된 스케줄 `0 15 * * *` UTC = KST 00:00). §11 참조.

### 5.3 레거시 정액 수수료(별개)

Powerball은 회계용으로 베팅별 정액 `feeAmount = amount × (COMPANY_FEE_RATE + REFERRAL_FEE_RATE)`도 기록한다(코드 기본값 0.03 + 0.02 = 5%). 이는 롤링 엔진과 독립적이다. 납품된 `.env.example`가 0.05/0.03을 담고 있음에 유의하며, 런칭 전에 의도한 프로덕션 값을 확인하라.

---

## 6. 어드민 콘솔 — Command & Nexus

GoldenBall은 하나의 백엔드(`gb-server`의 `/api/admin/*` 및 `/api/nexus/*`)와 하나의 운영자 계정 시스템을 공유하는 **두 개의 분리된 정적 어드민 표면**을 납품한다.

| Console | Audience | Entry build | Host |
|---------|----------|-------------|------|
| **Command** | 회사 관리자(전권) | `client/src/admin-main.tsx` → `pages/AdminPanel.tsx` | `command.goldenballgames.com` |
| **Nexus** | 영업자 / 파트너(다운라인 전용) | `client/src/nexus-main.tsx` → `features/nexus/NexusDashboard.tsx` | `nexus.goldenballgames.com` |

### 6.1 Command — 인증

모든 `/api/admin/*` 라우트의 `requireAdmin` 프리핸들러는 **이중 모드**다:

1. **운영자 세션 토큰** — `POST /api/admin/login`에서 발급된 `Authorization: Bearer <jwt>`. 이것이 정상 경로다.
2. **Break-glass** — `x-hq-secret: <ADMIN_SECRET>` 헤더로, 최초 셋업 및 복구 전용으로 합성된 `{loginId:"root", grade:"owner"}`를 부여한다.

운영자 계정은 `operators` 테이블에 세 등급으로 존재하며, `viewer(0) < manager(1) < owner(2)` 순으로 서열이 매겨진다. 로그인은 알 수 없는 계정에 대해서도 상수 시간 bcrypt 비교를 수행한다. 세션 토큰은 JWT이며(시크릿은 `ADMIN_JWT_SECRET` → `JWT_SECRET` → `ADMIN_SECRET` 순으로 해석), TTL은 **12시간**이다. `mustChangePassword` 플래그는 최초 로그인 시 비밀번호 변경을 강제한다; break-glass "root" 세션은 운영자 비밀번호를 변경할 수 없다. 최초 owner 부트스트랩: break-glass 헤더를 한 번 사용해 `POST /api/admin/operators`로 최초의 실제 owner를 생성한다.

### 6.2 Command — 탭과 백킹 엔드포인트

사이드바 그룹: **Operations**(Deposits, Withdrawals, Members, Ledger, Winners, Live), **Governance**(Operators, Notice, Settings, Audit), **Network**(Unilevel).

- **Overview / Audit** — `GET /api/admin/overview`(통계 + 풀) 및 `GET /api/admin/audit`(보안 감사 로그).
- **Deposits** — `GET /api/admin/deposits/unmatched`(미매칭 큐); `POST /api/admin/deposits/verify` 이후 `POST /api/admin/deposits/credit`로 수동 매칭.
- **Withdrawals** — `GET /api/admin/withdrawals?status=requested`; 완료 `POST /api/admin/withdrawals/:id/complete {txHash}`; 거부+환불 `POST /api/admin/withdrawals/:id/reject {reason}`.
- **Members** — 검색 `GET /api/admin/members?q=`(지갑, username/loginId, email, nickname 전반); 상세 `GET /api/admin/members/:wallet`(유저 + GBP 잔고 + 최근 50건 입금/출금 + 게임별 롤링, 민감 컬럼 제외); 타임라인 `GET /api/admin/members/:wallet/audit`. 멤버 액션(모두 감사 로그 기록): 잔고 조정(`grant|deduct|confiscate|bonus`), 동결, 비밀번호 리셋(일회용 임시 비밀번호를 반환하고 세션을 무효화), 롤링 요율 설정, 로그인 아이디 변경, 그리고 owner 전용 USDT 주소 리셋/잠금 해제.
- **Ledger** — `GET /api/admin/ledger?type=cash|game`. `cash`는 입금(IN), 출금(OUT), 조정(ADJUST)을 합치고; `game`은 Lotto 트랜잭션과 Powerball 베팅을 순액(지급 − 스테이크)으로 합친다.
- **Winners** — `GET /api/admin/winners`(Lotto 등급 당첨 + Powerball 양수 지급).
- **Live** — `GET /api/admin/live`(자동 새로고침). 이 탭은 폐기된 게임의 레거시 라운드 테이블을 대상으로 하며 이번 납품에서는 영구적으로 비어 있다; 숨기거나 무시하라.
- **Operators** — 목록(manager+), 생성/등급 변경/비활성화(owner 전용). 하드 삭제는 없으며; 비활성화는 `is_active`를 통한다.
- **Notice** — 회사 공지사항 게시판. 목록/상세는 공개 게시판 엔드포인트를 재사용하고; 생성/수정/삭제는 `POST/PUT/DELETE /api/admin/board/posts[/:id]`를 통한다. 게시글은 색상, 이모지 문자, 고정 플래그를 가지며; 작성자는 "GoldenBall"로 고정되고; 플레이어는 엄격히 읽기 전용이다(댓글 없음).
- **Settings** — `GET/POST /api/admin/settings`로 회사 USDT 주소, 자동 크레딧 플래그, telegram URL, 최소 입금/출금, USDT↔GBP 요율을 관리한다. 회사 주소는 base58 검증 엔드포인트 또는 owner 전용 TronLink 서명 플로우(지갑 통제권 증명, 10분 리플레이 윈도우)로도 설정할 수 있다. 설정 변경은 `gbp_settings_audit`에 기록된다. 또한 `GET/POST /api/admin/game-config`(Powerball & Lotto의 롤링 요율, 최소/최대 베팅, 라운드 일시정지, 베팅/정산 초).
- **Unilevel** — `GET /api/admin/tree`는 HQ를 루트로 하는 레퍼럴 트리를 노드별 GBP, 공유/자식 요율, 다운라인 집계와 함께 구성한다.

### 6.3 Nexus — 영업자 콘솔(다운라인 전용)

Nexus는 **파트너 자신의 유저 Bearer 토큰**(플레이어 앱과 동일한 토큰)으로 인증하며 — 결코 `x-hq-secret`을 쓰지 않고, `/api/admin/*` 호출을 **하지 않는다**. 네 개의 탭(Overview, Network, Commissions, Withdrawal)은 다음으로 백킹된다:

- **다운라인 + 요율 할당**(`/api/nexus/*`): `GET /api/nexus/downline`는 호출자의 **직속** 자식만, 회사 캡이 포함된 게임 카탈로그, 그리고 호출자 본인의 요율을 반환한다. `POST /api/nexus/allocate-rate {targetUserId, game, rate}`는 대상이 직속 다운라인임을(아니면 403) 그리고 단조 상한 `rate ≤ min(호출자의 해당 게임 요율, 회사 캡)`을 강제한다. 게임은 Powerball과 Lotto뿐이다.
- **레퍼럴 / 커미션**(`/referral/*`, 모두 유저 인증): summary, tree, commissions, commission balance, 출금 요청(최소 **$50**) 및 이력, invite, 그리고 다운라인 게이팅된 서브 통계. 셀프 서비스 권한 경로(`/referral/promote`, `/referral/commission-toggle`)는 비활성화되어 있다(403) — 요율과 등급 할당은 회사 어드민 전용이다.

### 6.4 엔드포인트 레퍼런스

**Command**(표시된 등급은 요구되는 최소값)

| Method | Path | Grade |
|--------|------|-------|
| POST | `/api/admin/login` | public |
| POST | `/api/admin/change-password` | any operator |
| GET | `/api/admin/operators` | manager+ |
| POST | `/api/admin/operators` | owner |
| POST | `/api/admin/operators/:id` | owner |
| GET | `/api/admin/overview` | admin |
| GET | `/api/admin/audit` | admin |
| GET | `/api/admin/members` | admin |
| GET | `/api/admin/members/:wallet` | admin |
| POST | `/api/admin/members/:wallet/adjust` | admin |
| POST | `/api/admin/members/:wallet/freeze` | admin |
| POST | `/api/admin/members/:wallet/reset-password` | admin |
| POST | `/api/admin/members/:wallet/rolling` | admin |
| POST | `/api/admin/members/:wallet/login-id` | admin |
| POST | `/api/admin/members/:wallet/usdt-address` | owner |
| GET | `/api/admin/deposits/unmatched` | admin |
| POST | `/api/admin/deposits/credit` | admin |
| GET | `/api/admin/withdrawals` | admin |
| POST | `/api/admin/withdrawals/:id/complete` | admin |
| POST | `/api/admin/withdrawals/:id/reject` | admin |
| GET/POST | `/api/admin/settings` | admin |
| POST | `/api/admin/settings/company-address-tronlink` | owner |
| GET/POST | `/api/admin/game-config` | admin |
| GET | `/api/admin/ledger` | admin |
| GET | `/api/admin/winners` | admin |
| GET | `/api/admin/live` | admin |
| GET | `/api/admin/tree` | admin |
| POST/PUT/DELETE | `/api/admin/board/posts[/:id]` | admin |

**Nexus**(모두 유저 Bearer 토큰)

| Method | Path |
|--------|------|
| GET | `/api/nexus/downline` |
| POST | `/api/nexus/allocate-rate` |
| GET | `/referral/me · summary · tree · commissions` |
| GET | `/referral/commission/balance · withdraw-history` |
| POST | `/referral/invite · commission/withdraw-request` |
| POST | `/referral/promote · commission-toggle` → 403 (disabled) |

> 운영자 UX 참고: Command "Operators" 폼은 "operator" 역할 옵션을 제공하지만, 서버는 `owner|manager|viewer`만 허용하고 그 외 값은 조용히 `viewer`로 강제 변환한다. 운영자는 명시적으로 `manager` 또는 `owner`로 생성하라.

---
## 7. 데이터베이스 모델

데이터베이스는 DB 박스에서 **PgBouncer**가 앞단에 위치한 **PostgreSQL 16**이다. 세 개의 writer가 이를 공유한다: Prisma(Fastify TS 서버, PascalCase 따옴표 처리 테이블), Python 게임/유틸 서버(`CREATE TABLE IF NOT EXISTS`로 생성한 snake_case 테이블), 수작업으로 적용한 SQL 마이그레이션(GBP 캐셔, operators, 롤링 rate, fraud, materialized view). 금액과 포인트는 `NUMERIC`을 사용한다(float 절대 금지): GBP 잔고는 `NUMERIC(36,18)`, 파트너 커미션 원장은 `NUMERIC(36,6)`, 롤링 rate는 `NUMERIC(6,3)`(퍼센트).

### 7.1 두 개의 user 테이블

- **`users`** (raw, Python 소유) — `wallet_address`를 키로 하는 **권위 있는 인증/신원 테이블**. 등록된 모든 유저가 여기에 존재하며, `authGuard`가 이를 읽는다. 주요 컬럼: `username`(`lower(username)`에 unique), `password_hash`(bcrypt), `provider`, `recovery_email`, `usdt_address`(쓰기 1회(write-once) 출금 목적지, unique partial index), `is_frozen`(베팅 차단 게이트), `password_changed_at`, `failed_login_count`/`locked_until`, 그리고 로그인 텔레메트리.
- **`"User"`** (Prisma) — uuid `id`를 키로 하고 `walletAddr`가 unique인 **레퍼럴/커미션 레이어**. `role`, `referralCode`, `parentId`(자기 참조 다운라인 트리), `shareRate`/`childRate`, `commissionEnabled`를 보유한다. 이는 **지연(lazy)** 생성되며, 레퍼럴 체인이 필요할 때만 만들어진다 — 그래서 일부 유저는 `"User"` 행이 없고, 이것이 바로 캐셔가 raw `users`를 키로 삼는 이유다.

### 7.2 GBP 원장 / 캐셔 (`gbp_*`) — 실제 지갑

모든 잔고 변경은 `adjustGbpTx`(행 잠금(row-lock) + 강제 감사 행)를 통해 흘러간다.

- **`gbp_balances`** — GBP 잔고의 단일 진실 소스(`wallet_address` PK, `balance NUMERIC(36,18) CHECK (balance >= 0)`, `locked_balance`).
- **`gbp_deposits`** — USDT→GBP 입금 원장; `txid` UNIQUE(멱등), `from_addr`(매칭 기준), `matched_type`(`auto|manual`), `status`(`pending|credited|unmatched`).
- **`gbp_withdrawals`** — 출금 요청; `dest_addr`(스냅샷), `status`(`requested|completed|rejected`), `tx_hash`, 그리고 완료된 `tx_hash`에 대한 partial-unique index.
- **`gbp_admin_adjustments`** — 감사의 척추; 모든 delta가 before/after와 함께 여기에 기록된다. `type`은 `grant, deduct, confiscate, bonus, deposit_auto, deposit_manual, withdraw_debit, withdraw_refund, bet, payout, transfer_out, transfer_in`을 열거한다.
- **`gbp_settings`** / **`gbp_settings_audit`** — KV 설정(rate, 회사 주소, 최소 입금/출금, poller heartbeat, telegram, 시스템 헬스)과 설정 변경 감사 추적.

### 7.3 게임 테이블

- **Powerball (납품됨):** `"PowerballRound"` + `"PowerballBet"`(Prisma 소유, Python 엔진이 raw SQL로 기록).
- **Lotto (납품됨):** `lotto_rounds`, `lotto_tickets`, `lotto_state`(`rollover`와 `jackpot`을 보유하는 KV 테이블), Lotto 엔진이 생성; 티켓은 `gbp_balances`에 대해 직접 정산된다. Prisma에서는 `LottoRound`/`LottoTicket`으로 읽기 전용 매핑된다.
- **레거시 round/bet 테이블 (미사용):** `game_rounds` + `bets`(그리고 별개의 Prisma `"Bet"`)는 은퇴한 실시간 게임에 속하며 이번 납품에서 새 행을 받지 않는다. 이들은 materialized view와 몇몇 어드민 라우트가 여전히 이들을 읽기 때문에만 살아남아 있다(그 수치들은 항상 0이다).
- **`GameConfig`** — 게임별 동적 파라미터(`gameKey`/`paramKey`/`paramValue`), 60초 캐시로 폴링된다. Powerball 최소/최대 베팅은 env 전용이며 여기서 읽지 않는다는 점에 유의한다.

### 7.4 레퍼럴 / 커미션 / 설정

- **`Commission`**, **`SettlementPeriod`**, **`CommissionPayout`** — 적립 → 정산 → 지급 파이프라인(§5).
- **`InviteLink`** — 레퍼럴 초대 코드(`code` unique, `childRate`, `defaultRole`, `maxUses`/`usedCount`, `expiresAt`).
- **`user_rolling_rate`** + **`user_rolling_rate_audit`** — `(wallet_address, game)`별 롤링 rate(퍼센트)와 변경 감사.
- **`usdt_balances`** — **파트너 커미션 잔고 원장**(`approvePayout`이 여기에 적립하고, HQ/Nexus 대시보드가 이를 읽는다).
- **`Wallet`** + **`Transaction`** (Prisma) — 레거시 표시용 미러일 뿐; 실제 잔고는 `Wallet.balance`가 아니라 `gbp_balances`다.

### 7.5 어드민, 감사, 게시판, 뷰

- **`operators`** — Command/Nexus 운영자 계정(`login_id` unique, bcrypt `password_hash`, `grade`, `is_active`, `must_change_pw`).
- **`security_audit_log`** (그리고 병행하는 Prisma `SecurityAuditLog`) — 보안 이벤트.
- **`FraudRule`** (R1–R7 시드, 슈퍼 어드민 라우트가 읽음) + **`FraudAlert`** — **fraud 스캐너는 납품되지 않으므로** `FraudAlert`는 결코 채워지지 않는다는 점에 유의한다; 규칙은 렌더링되지만 알림을 생성하지 않는다.
- **`board_posts`** — 회사 공지 게시판(유저에게 읽기 전용, 어드민 작성, soft-delete). 댓글 **기능**은 없다(BB에서 상속된 빈 레거시 `board_comments` 테이블이 DB에 남아 있으나 미사용 — GoldenBall 소스는 이를 생성하거나 참조하지 않는다).
- **`mv_daily_game_revenue`** 와 **`mv_referral_rollup`** — 슈퍼 어드민 materialized view(`CONCURRENTLY`로 갱신).

### 7.6 레거시 / 미사용 테이블

`deposits`, `withdrawals`, `swap_orders`, `deposit_addresses`, `price_ticks`(snake_case)는 GoldenBall 이전 온체인 지갑에서 상속된 레거시 테이블로 **라이브 DB에 존재하지만** 이번 납품에서 쓰기를 받지 않는다 — 파괴적 마이그레이션을 피하기 위해서만 유지된다; 일부 HQ 대시보드 통계가 이들 중 몇 개를 읽으며 따라서 비어 있거나 stale하다. 이전의 Prisma 멀티체인 지갑 모델(`DepositAddress`, `ChainDeposit`, `SwapOrder`, `WithdrawalRequest`)은 현재 `schema.prisma`나 라이브 DB에 **존재하지 않는다**(그들의 마이그레이션은 GoldenBall DB에 적용된 적이 없으며 Prisma 모델은 제거되었다) — 참고용으로만 언급한다.

### 7.7 ER 다이어그램

```mermaid
erDiagram
    User ||--o{ User : "parentId (self-ref, unilevel)"
    User ||--o{ Commission : "userId (earner)"
    User ||--o{ InviteLink : "creatorId"
    User ||--o{ PowerballBet : "userId"

    PowerballRound ||--o{ PowerballBet : "powerballRoundId"
    LottoRound ||--o{ LottoTicket : "round_id"

    users ||--|| gbp_balances : "wallet_address"
    users ||--o{ gbp_deposits : "wallet_address"
    users ||--o{ gbp_withdrawals : "wallet_address"
    users ||--o{ gbp_admin_adjustments : "wallet_address"
    users ||--o{ lotto_tickets : "wallet"
    users ||--o{ user_rolling_rate : "wallet_address"

    users {
        bigint id PK "bigserial"
        string wallet_address "internal id (link key)"
        string username UK "lower(username) unique"
        string password_hash "bcrypt"
        string usdt_address "write-once withdraw dest (unique)"
        boolean is_frozen "authGuard gate"
    }
    gbp_balances {
        string wallet_address PK
        decimal balance "NUMERIC(36,18) CHECK >= 0"
        decimal locked_balance
    }
    gbp_deposits {
        bigint id PK
        string wallet_address FK
        string txid UK "idempotency"
        string from_addr "auto-match"
        string matched_type "auto | manual"
        string status "pending | credited | unmatched"
    }
    gbp_withdrawals {
        bigint id PK
        string wallet_address FK
        decimal gbp_amount "pre-debited"
        string dest_addr "snapshot"
        string status "requested | completed | rejected"
        string tx_hash
    }
    gbp_admin_adjustments {
        bigint id PK
        string wallet_address FK
        decimal delta
        string type "grant|deduct|bet|payout|transfer_*|..."
        decimal before_balance
        decimal after_balance
    }
    user_rolling_rate {
        string wallet_address PK
        string game PK "powerball | lotto"
        decimal rate "percent 0-100"
    }
    User {
        string id PK "uuid"
        string walletAddr UK "join to users.wallet_address"
        string parentId FK "self-ref referral parent"
        string role
        string referralCode UK
        decimal shareRate
        decimal childRate
    }
    Commission {
        string id PK
        string userId FK "earner"
        string fromUserId "source bettor"
        decimal amount
        int level
        string status "accrued|settled|paid"
    }
    InviteLink {
        string id PK
        string creatorId FK
        string code UK
        decimal childRate
        int maxUses
        boolean isActive
    }
    PowerballRound {
        string id PK "PBR-{seq}"
        int roundNumber UK
        string status "betting|settling|finalized"
        int bbNumber
    }
    PowerballBet {
        string id PK
        string userId FK
        string powerballRoundId FK
        string game "under_over|range|odd_even"
        decimal amount
        decimal payout
    }
    LottoRound {
        bigint id PK
        int round_no UK
        string session "AM | PM"
        string status "selling|drawing|finalized|void"
        json digits
    }
    LottoTicket {
        bigint id PK
        bigint round_id FK
        string wallet
        json digits "6 main + 1 bonus"
        int tier "1-6"
        decimal prize
    }
```

---
## 8. 프론트엔드

플레이어 클라이언트는 **React 18 + TypeScript** 로 만든 단일 페이지 애플리케이션이며 **Vite** 로 번들링한다. Nginx 가 서빙하는 정적 `dist/` 로 컴파일되며 서버 사이드 렌더링은 없다. 두 운영자 콘솔은 별도의 Vite 빌드 엔트리(`admin-main.tsx`, `nexus-main.tsx`)이며 공개 번들에서 제외한다.

### 8.1 핵심 스택

| 항목 | 라이브러리 |
|---------|---------|
| UI 런타임 | React 18 / React-DOM 18 (함수형 컴포넌트 + 훅) |
| 언어 | TypeScript |
| 번들러 | Vite (`@vitejs/plugin-react`) |
| 라우팅 | react-router-dom 6 |
| 클라이언트 상태 | zustand |
| 서버 상태 | @tanstack/react-query |
| i18n | i18next + react-i18next |
| 차트 | lightweight-charts, recharts |
| 아이콘 / QR | lucide-react, qrcode.react |

### 8.2 국제화

세 개의 언어가 등록되어 있고 선택 가능하다: **`en`, `ja`, `zh`**. `zh` 는 **간체 중국어**이며 지갑/캐셔와 게임 UI 에 대해 완전히 번역되어 있다. 영어가 폴백이며 선택한 언어는 `localStorage` 에 유지된다. `ko.json` 파일이 디스크에 존재하지만 i18n 리소스에 연결되어 있지 않다(한국어 활성화는 한 줄 등록이다).

### 8.3 플레이어 라우트

로그인은 게이트로 막혀 있다: 앱은 먼저 인트로/로그인 플로우를 렌더링하고 인증 후에만 라우팅된 셸을 마운트한다.

| 영역 | 라우트 | 비고 |
|------|----------|-------|
| 로그인 / 가입 | 인증 전 스플래시 | ID + 비밀번호 + 레퍼럴 폼, 사용자명 중복 확인 포함 |
| 앱 잠금 (PIN) | 셸 이전 게이트 | 앱이 마운트되기 전 PIN 설정/입력 게이트 |
| 홈 | `/home`, `/` → `/lotto` | |
| 지갑 | `/wallet` | 잔고, 입금 QR, 출금, P2P 이체 |
| 로또 | `/lotto` | |
| 파워볼 | `/arena/powerball` | |
| 결과 / 프로필 | `/result`, `/profile` | 랭킹 페이지/라우트 없음(프로필에 통합; `/ranking` → 404) |

### 8.4 게임 UI

- **로또**는 단일 **"Treasure Vault"** 레이아웃을 렌더링하며 루트 hue-rotate 필터를 통해 **4개의 순환 색상 팔레트**(Royal Gold, Emerald, Sapphire, Ruby)를 적용한다. 세션의 첫 방문은 Royal Gold 이고, 이후 방문마다 팔레트가 하나씩 진행된다. 구조는 항상 동일하다.
- **파워볼**은 단일 통합 **블랙 스킨**을 렌더링한다(연기 낀 유리 항아리에 골드 받침대; 큰 중앙 볼이 숫자를 표시하고 작은 색상 볼들이 정산 중에 돌아다닌다).

### 8.5 제거된 의존성

GoldenBall 은 오프체인이고 ID/비밀번호 전용이므로, 온체인 지갑 연결 스택(`wagmi`, `viem`, `ethers`, `@web3modal/wagmi`)과 Google OAuth(`@react-oauth/google`)는 사용되지 않으며(임포트 0개) 제거할 수 있다. 인증 유틸리티는 ID/비밀번호 + 레퍼럴 전용이다.

---

## 9. 환경 변수

박스의 각 서비스는 서버 디렉토리에서 단일 `.env` 를 로드한다 — Node 는 `dotenv` 로, Python 은 `python-dotenv` 로. 중앙 시크릿 저장소는 없다. PM2 ecosystem 파일은 **비시크릿** 값(포트, 루프백 URL, 타이밍 상수)만 주입하고 시크릿은 `process.env` 로부터 전달한다; 파일 자체에는 시크릿이 없다. 박스를 구성하려면: 서버 디렉토리에 `.env` 하나를 놓고 `pm2 start ecosystem.config.cjs` 를 실행한다.

### 9.1 필수 변수

플레이스홀더만 표시한다 — 실제 값은 절대 커밋하지 않는다. **두 개**의 시크릿만 부팅 시 크래시를 일으킨다 — `JWT_SECRET` 과 `DATABASE_URL` — 프로세스는 이들 없이는 시작을 거부한다. `ADMIN_SECRET` 과 `INTERNAL_API_SECRET` 은 부팅을 막지 **않지만**(표 참조) 운영을 위해 설정해두어야 한다.

| 변수 | 용도 | 시크릿 |
|----------|---------|--------|
| `DATABASE_URL` | PostgreSQL 연결 문자열(6432의 PgBouncer 를 경유) | **예 — 부팅 시 크래시** |
| `JWT_SECRET` | 인증 토큰용 HMAC 서명 키. `gb-server` 와 `gb-auth` 에서 **반드시 동일**해야 하며; ≥ 32자여야 한다. | **예 — 부팅 시 크래시** |
| `ADMIN_SECRET` | Break-glass 어드민 헤더(`x-hq-secret`) 값; 어드민 JWT 폴백 시크릿이기도 하다 | 운영에 필요(미설정 시 `x-hq-secret` 헤더 인증이 경고 로그와 함께 비활성화됨; 어드민 로그인은 `JWT_SECRET` 폴백을 통해 여전히 작동 — 부팅 시 크래시 **아님**) |
| `INTERNAL_API_SECRET` | (현재 코드 어디에서도 참조되지 않음 — 예약됨) | 아니오(미사용) |
| `COMPANY_USDT_ADDRESS` | 회사 TRON TRC20 수신 주소(또는 `gbp_settings` 에 설정) | 운영상 민감 |
| `TRONGRID_API_KEY` | 입금 폴러 / 온체인 읽기용 TronGrid 키 | **예** |
| `NODE_ENV` | `production` | 아니오 |
| `PORT` | Node/Fastify API 포트 — 프로덕션에서 **4000** | 아니오 |
| `REDIS_URL` | Redis 연결; 인증 IP 레이트 리미팅을 활성화하려면 설정(그렇지 않으면 fail-open) | 아니오(엔드포인트) |
| `ADMIN_JWT_SECRET` | 선택적 전용 운영자 세션 시크릿(`JWT_SECRET` 로 폴백) | 예(선택) |
| `PRIZE_POOL_WALLET` | 파워볼 지급용 내부 상금 풀 원장 키 | 아니오 |
| `GBP_POLL_SEC` | 입금 폴러 간격(기본 30) | 아니오 |
| `LOTTO_POOL_RATE` | 로또 RTP(기본 0.70) — 내부 전용 | 아니오 |
| `PB_MIN_BET` / `PB_MAX_BET` | 파워볼 한도(1 / 3000) | 아니오 |
| `USDT_CONTRACT` | TRC20 USDT 컨트랙트(기본값은 메인넷 USDT) | 아니오 |
| `COMPANY_FEE_RATE` / `REFERRAL_FEE_RATE` | 파워볼 정액 수수료 회계(기본 0.03 / 0.02) | 아니오 |
| `PUBLIC_BASE_URL` | 초대/레퍼럴 링크용 베이스 URL | 아니오 |
| `WALLET_API_URL`, `INDEX_WS_URL`, `INDEX_HTTP_URL` | 내부 루프백 URL | 아니오 |

ecosystem 파일이 주입하는 포트/타이밍(`.env` 아님): `AUTH_PORT` 9003, `INDEX_PORT` 8001, `INDEX_HTTP_PORT` 8002, `PB_PORT` 8003, `LOTTO_PORT` 8004, `PB_BETTING_SEC` 50, `PB_SETTLING_SEC` 10.

**불필요:** SMTP/메일 릴레이 없음, OAuth 클라이언트 없음 — GoldenBall 은 이메일도 소셜 로그인도 없다. RPC/컨트랙트/운영자 키 설정 없음 — 온체인 정산이 없다.

**클라이언트 빌드타임(`VITE_*`):** `VITE_API_BASE`(동일 출처를 위해 비워둠 — 권장), `VITE_BASE`, `VITE_GAME_WS_URL`. 이들은 빌드 시점에 번들로 컴파일된다.

### 9.2 시크릿 생성 및 로테이션

- `JWT_SECRET` — 길고 높은 엔트로피의 랜덤 문자열(`openssl rand -hex 32`), `gb-server` 와 `gb-auth` 에서 동일. 불일치는 로그인을 깨뜨린다. 박스 전체를 한 번에 로테이션한다.
- `ADMIN_SECRET`, `INTERNAL_API_SECRET` — 독립적인 랜덤 문자열(`openssl rand -base64 32`), `JWT_SECRET` 을 절대 재사용하지 않는다.
- `DATABASE_URL` — 강한 비밀번호를 가진 전용 DB 롤, PgBouncer 를 경유.
- `TRONGRID_API_KEY` — TronGrid 계정에서 발급; `server/.env` 에만 저장한다.
- `server/.env` 를 편집하고 영향받는 PM2 프로세스를 재시작하여 로테이션한다.

실제 `.env` 는 git-ignore 되며 절대 커밋하거나 배포해서는 안 된다; 값이 없는 `.env.example` 템플릿만 배포한다.

> **배포 노트:** 개발 값(예: `PORT=4100`, `localhost:5433` DB, 플레이스홀더 시크릿)이 담긴 `server/.env` 가 작업 트리에 존재한다. 이는 git-ignore 되며 배포 동기화에서 제외된다 — 배포하지 말 것. 프로덕션 값(`PORT=4000`, PgBouncer 를 경유한 DB 박스)을 주입하고 라이브 전에 모든 시크릿을 로테이션한다.

---
## 10. 배포 및 인프라

### 10.1 토폴로지 — 두 대의 BlueVPS 박스(Singapore, Ubuntu 22)

| Box | IP | Role |
|-----|-----|------|
| **APP** | `<APP_SERVER_IP>` | Node 20 (`gb-server`) + system python3 (3.10.12) 게임 싱글턴 + Redis 6 (6.0.16) + Nginx (80/443) + 입금 poller + monitor/settlement/backup cron |
| **DB** | `<DB_SERVER_IP>` | PostgreSQL 16 + PgBouncer (6432); 인바운드 PostgreSQL은 APP 박스 IP로만 제한 |

SSH는 포트 **56777**(22 아님), 키 기반이다. 게임 엔진이 싱글턴(이중 정산 위험)이기 때문에 게임 프로세스를 실행하는 APP 박스는 항상 하나만 존재한다; 무상태 Node/API 계층은 필요 시 복제할 수 있다. 서버 로그인, root 패스워드, SSH 키, BlueVPS 컨트롤 패널 절차는 **Credentials & Infrastructure Handover Annex**에 있다.

문서화된 참조 사양: APP 박스 8 vCPU / 16 GB / 150 GB NVMe; DB 박스 10 vCPU / 32 GB / 200 GB NVMe. 단일 박스(colocated) 배포는 스테이징이나 저트래픽 런칭에 유효하다.

### 10.2 네트워크 경계

PostgreSQL 인바운드는 세 개 계층에서 APP 박스 IP로만 제한된다: DB 박스의 `ufw`(APP IP에서만 5432/6432), `pg_hba.conf`(`host goldenball goldenball <APP_IP>/32 scram-sha-256`), 그리고 방화벽 뒤에 바인딩된 PgBouncer. DB는 노트북에서 Postgres로 접속하는 것이 아니라 DB 박스로 SSH하여 관리한다.

**열린 포트(`ufw status` / `ss -tlnp`로 실시간 실측).** 이것들이 각 박스에서 인터넷으로부터 실제로 도달 가능한 유일한 포트다 — 그 외 모든 서비스 포트는 `0.0.0.0`에 바인딩되어 있지만 `ufw`가 인바운드를 차단하며, Nginx 리버스 프록시를 통해서만 도달 가능하다.

| Box | Port | Protocol | Firewall allows | Purpose |
|-----|------|----------|-----------------|---------|
| APP (`<APP_SERVER_IP>`) | **56777** | TCP | Anywhere | SSH |
| APP | **80** | TCP | Anywhere | HTTP → 443 리다이렉트 |
| APP | **443** | TCP | Anywhere | HTTPS (Nginx, 세 도메인 전부) |
| DB (`<DB_SERVER_IP>`) | **56777** | TCP | Anywhere | SSH |
| DB | **5432** | TCP | **APP 박스 IP만** (`<APP_SERVER_IP>`) | PostgreSQL |
| DB | **6432** | TCP | **APP 박스 IP만** | PgBouncer |

**나머지 포트(인터넷 도달 불가).** APP 박스에서 `gb-server`(4000), `gb-auth`(9003), `gb-index` WS(8001), `gb-powerball`(8003), `gb-lotto`(8004)는 `0.0.0.0`(모든 인터페이스)에 바인딩되지만 `ufw`가 56777/80/443을 제외한 모든 인바운드를 거부하므로 그중 어느 것도 인터넷에서 직접 도달할 수 없다 — `ufw`는 항상 `active` 상태를 유지해야 한다(운영 점검 항목; 만약 비활성화된다면 이것들이 노출된다). `gb-index` HTTP 스냅샷 엔드포인트(8002)와 Redis(6379)는 `127.0.0.1`에만 바인딩되어 있어, 같은 박스의 다른 프로세스를 제외하면 로컬에서조차 도달할 수 없다.

### 10.3 Nginx + TLS(세 도메인)

| Domain | Serves |
|--------|--------|
| `goldenballgames.com` | Player SPA |
| `command.goldenballgames.com` | Command(회사) 콘솔 |
| `nexus.goldenballgames.com` | Nexus(영업자) 콘솔 |

세 도메인 모두 유효한 **Let's Encrypt** 인증서를 가진다(certbot으로 발급, 현재 만료 ~2026-10-13/14; 만료 전 갱신). Nginx는 80→443 리다이렉트와 함께 443에서 TLS를 종료하고, 각 콘솔/SPA를 자체 root에서 서빙하며, `/api/`를 `gb-server`(4000)로, `/api/auth/`를 `gb-auth`(9003)로, 그리고 `/ws/*` 게임 소켓을 리버스 프록시한다. DNS/edge는 Annex에 문서화된 계정으로 관리한다. WebSocket location은 `/etc/nginx/goldenball_ws.inc`(upgrade 헤더 + 긴 read timeout)를 include한다 — 운영자는 그 snippet 파일이 존재하는지 확인해야 한다.

### 10.4 커넥션 풀링

PgBouncer는 트랜잭션 풀링 모드로 6432에서 PostgreSQL 앞단에 놓여(`default_pool_size=40`, `reserve_pool_size=10`, `max_client_conn=2000`, SCRAM 인증) 게임 싱글턴, Node 서버, poller가 `max_connections`(라이브 100)를 소진하지 않게 한다. 앱 `DATABASE_URL`은 DB 호스트의 6432를 가리킨다.

### 10.5 배포 시퀀스

1. **호스트 하드닝** — 56777에서 키 기반 SSH, 가능한 곳에서 root 패스워드 로그인 비활성화, `ufw`(DB 박스: APP IP에서만 5432/6432), `fail2ban`.
2. **DB 박스** — `install-stack.sh --role db`(PostgreSQL 16 + PgBouncer), 데이터베이스 + 앱 role 생성, `pg_hba.conf`에 APP IP 추가.
3. **APP 박스** — `install-stack.sh --role app`(Node 20, system python3 3.10.12 [별도 venv 없음], Redis 6 [6.0.16], Nginx, OS 튜닝).
4. **코드 배포** — `/opt/goldenball/`로, `.env`, `test_*.py`, `node_modules` 제외. 그다음 `npm ci`, `npx prisma generate`, `npx prisma db push`, `pip install -r requirements.txt`, 그리고 SQL 마이그레이션(`gbp_init.sql`, `gbp_super_mv.sql`, 그리고 rolling-rate/operators 마이그레이션) 적용. materialized-view SQL은 필수이며, 없으면 super-admin 통계 엔드포인트가 "relation does not exist"로 실패한다.
5. **환경** — 템플릿으로부터 `server/.env` 생성; `DATABASE_URL`은 PgBouncer를 통해 DB 박스를 대상으로 한다.
6. **PM2** — `pm2 start ecosystem.config.cjs && pm2 save && pm2 startup`(리부트 후 여섯 프로세스가 되살아나도록 — 강제 요건).
7. **Nginx** — 세 개의 server block 설치, `goldenball_ws.inc` 생성, `nginx -t`, reload. 클라이언트 빌드(`npm run build`)하여 `/var/www/goldenball`와 두 콘솔 root에 배포.
8. **DNS + TLS** — 세 개의 A 레코드를 APP 박스 IP로 지정, Let's Encrypt 인증서 발급.
9. **스케줄 작업** — `gbp_monitor.py` watchdog과 certbot TLS 갱신은 이미 라이브다; 운영자는 커미션 정산 worker와 (권장) 오프박스 `pg_dump` 백업을 스케줄해야 한다(§11.1 참조).
10. **검증** — HTTPS 헬스 체크, 로그인 E2E, 완전한 Lotto 및 Powerball 라운드 정산, 입금 poll, 그리고 출금 완료/거부.

### 10.6 백업 및 스케일링

데이터베이스 내구성은 현재 **BlueVPS 프로바이더 수준 백업**(APP 박스 무료 "Auto backup"; DB 박스 유료 "Enhanced backup")에 의존한다. 오늘날 두 박스 어디에도 실행 중인 **애플리케이션 수준 `pg_dump` 백업 cron은 없다**. 소스는 `deploy/backup-db.sh`(일일 `pg_dump --no-owner --no-privileges | gzip -9`, 무결성 체크, 14일 로컬 보존, `OFFSITE_REMOTE`를 통한 선택적 `rclone` 오프사이트)를 제공한다; 운영자가 프로바이더 외부 또는 시점(point-in-time) 논리 백업을 원한다면 이를 스케줄해야 한다(강력 권장). 스케일링: 게임 엔진은 하나의 인스턴스에 고정되어 있어 복제할 수 없다; 무상태 Node/API 계층만 스케일하고, 데이터베이스는 수직 업그레이드 + 읽기 복제본으로 한다.

---
## 11. 운영 및 인수인계

이 절은 납품 시점의 설정 미비 항목을 운영자가 취해야 할 건설적 조치로 제시하고, 여기에 헬스 모니터링, 런북, 서비스 오픈 체크리스트를 더한다.

### 11.1 예약 작업 (cron & systemd 타이머)

**이미 구성되어 실행 중인 애플리케이션 작업** (양쪽 박스에서 라이브 검증 완료 — 재생성하지 말 것):

- **헬스 워치독 (APP 박스, root crontab, 라이브):** `* * * * * cd /opt/goldenball/server && /usr/bin/python3 gbp_monitor.py >> /var/log/gbp_monitor.log 2>&1` — 크래시에 안전한 `gbp_monitor.py`가 **매분** 실행되며 `/var/log/gbp_monitor.log`에 로깅한다. 6개의 PM2 프로세스와 크래시 루프, poller 하트비트 신선도(> 180 s = stale), `gb-server /api/health`, 신규 미매칭 입금을 점검하고, `gbp_settings.system_health`를 기록하며, 크래시에 안전하다(항상 0으로 종료). 이미 자리 잡고 있으므로 운영자는 이를 유지하기만 하면 되고, 선택적으로 `gbp_settings`에 Telegram 알림 봇 토큰 / chat id를 채워 알림을 받을 수 있다(그전까지는 로그 전용이다).
- **TLS 자동 갱신 (라이브):** certbot이 `/etc/cron.d/certbot`(12시간마다)과 `snap.certbot.renew.timer` systemd 타이머 **양쪽** 모두를 통해 자동으로 갱신한다. 세 도메인 모두 자동 갱신되며 운영자의 조치가 필요 없다.

**예약되지 않음 — 운영자가 처리해야 할 실제 미비 항목:**

- **커미션 정산 워커**(`server/src/workers/settlement-worker.ts`)는 어디에도 **예약되어 있지 않다**(PM2에도, cron에도 없다). 운영자는 이를 반드시 예약해야 한다 — 헤더가 매일 KST 00:00(UTC 15:00)을 권하므로, 예컨대 root cron `0 15 * * * cd /opt/goldenball/server && npx tsx src/workers/settlement-worker.ts`나 PM2 `cron_restart` 항목을 쓴다 — 그렇지 않으면 롤링 커미션의 `accrued → settled → paid` 라이프사이클(및 사기 auto-hold)이 결코 돌지 않는다.
- **박스 외부 데이터베이스 백업:** 어느 박스에도 애플리케이션 수준의 `pg_dump` 백업 cron이 **없다**. 현재 내구성은 BlueVPS 제공자 백업(APP = 무료 "Auto backup", DB = 유료 "Enhanced backup")에 의존한다. 운영자는 제공자 외부 / 특정 시점 백업을 위해 박스 외부 논리 `pg_dump` cron(동봉된 `deploy/backup-db.sh`)을 추가해야 한다.

**OS 기본 cron & 타이머 (양쪽 박스, 표준 Ubuntu 유지보수 — 앱 고유가 아니라 식별 가능하도록 나열함):** certbot, logrotate, man-db, fstrim, e2scrub_all, apt/dpkg-db-backup, fwupd, motd-news. DB 박스는 추가로 sysstat를 실행한다. DB 박스에는 애플리케이션 고유 cron 작업이 **없다** — DB 측 예약은 전부 제공자 수준 백업이다.

### 11.2 헬스 신호

- **Poller 하트비트** — `gbp_settings.poller_heartbeat`는 매 폴링(~30 s)마다 갱신되어야 한다. 이것이 stale(> 180 s)이 되면 입금이 감지되지 않는 것이며, 워치독이 이를 플래그한다.
- **`gb-server`의 `/api/health`** — API 계층의 liveness 프로브.
- **`gbp_settings.system_health`** — 워치독의 최신 JSON 스냅샷으로, 어드민 대시보드에 표출된다.

### 11.3 런북

- **클라이언트 배포** — `client/`에서 `npm ci && npm run build`를 실행한 뒤 `dist`를 `/var/www/goldenball`(및 콘솔 루트들)에 게시한다. 아직 업로드되지 않은 자산에 대한 404를 CDN이 캐싱하지 않도록 정적 자산을 `index.html`보다 먼저 게시한다. 캐시가 오염되면 새 자산 해시로 재빌드한다.
- **서버 파일 배포 (백업 우선)** — 항상 라이브 파일을 먼저 `file.bak.YYYYMMDD`로 복사한 뒤 교체하고, 그다음 백업본과 새 파일을 `diff`로 비교해 변경을 확인하고, 그다음 영향을 받는 서비스 하나만 재시작하여 로그를 확인한다. 한 번에 둘 이상의 서비스를 편집하지 말 것.
- **서비스 재시작** — 정확히 프로세스 하나에 대해 `pm2 restart <name> --update-env`; 정상 복귀를 확인하고 로그를 tail한다. 전부를 한꺼번에 재시작하지 말 것.
- **미매칭 입금 해소** — Command → Deposits에서 TXID를 검증하고(회사 주소로의 실제 USDT 전송 여부와 이미 크레딧되었는지를 확인), 올바른 회원 지갑에 크레딧한다.
- **출금 완료 / 거절** — `requested` 행에 대해 스냅샷된 `dest_addr`로 USDT를 플랫폼 외부로 전송한 뒤 tx 해시로 `complete`한다(서버가 수신자 + 금액 + 온체인 단일 사용을 재검증한다); 또는 사유와 함께 `reject`하여 미리 차감된 GBP를 환불한다.
- **회사 USDT 주소 변경** — Command → Settings(또는 소유자 전용 TronLink 서명 흐름). 한 폴링 주기 이내에 반영되며 재시작이 필요 없다.
- **시크릿 로테이션** — `server/.env`를 편집하고 영향을 받는 프로세스를 재시작한다; `JWT_SECRET`은 `gb-server`와 `gb-auth`에서 동기 상태를 유지하도록 함께 로테이션한다.

### 11.4 서비스 오픈 체크리스트

1. **실제 회사 USDT 수신 주소 설정** — DB는 현재 테스트/플레이스홀더 값을 담고 있다. 오직 바이어의 실제 TRC20 주소만 자금을 보유해야 한다.
2. **커미션 정산 워커 예약** — `settlement-worker.ts`(root cron `0 15 * * *` UTC = KST 00:00, 또는 PM2 `cron_restart` 항목). 이것이 없으면 매일의 accrue→settle→payout 배치와 사기 auto-hold가 결코 돌지 않는다. (`gbp_monitor.py` 워치독과 certbot TLS 갱신은 이미 예약되어 있다 — §11.1 참조.)
3. **박스 외부 데이터베이스 백업 추가** — 박스들은 BlueVPS 제공자 백업에만 의존한다; 제공자 외부 / 특정 시점 논리 백업을 위해 동봉된 `deploy/backup-db.sh`(`rclone` `OFFSITE_REMOTE` 포함)를 예약한다.
4. **stale된 `/ws/game` 업스트림/location을 Nginx 설정에서 제거** — 제거된 엔진을 가리키므로 접근 시 502가 난다.
5. **Redis + `REDIS_URL` 프로비저닝**으로 인증 IP 레이트 리밋을 활성화한다(그전까지는 fail-open).
6. **프로덕션 env 주입** — `PORT=4000`, PgBouncer 경유 DB; 개발용 `server/.env`를 납품하지 말 것.
7. **`/etc/nginx/goldenball_ws.inc` 생성** — 없을 경우(이것이 없으면 WS 프록시가 깨진다).
8. **모든 자격증명 로테이션** — `JWT_SECRET`, `ADMIN_SECRET`, `INTERNAL_API_SECRET`, DB 비밀번호, TronGrid 키, Command 마스터/운영자 비밀번호, BlueVPS root + SSH 키, Cloudflare, 레지스트라. **Credentials & Infrastructure Handover Annex**의 로테이션 체크리스트를 따른다.
9. **선택적 정리** — 어드민 "auto-credit" 설정 토글은 현재 무효다(poller가 항상 매칭된 입금을 자동 크레딧한다); 일부 REST 설정 엔드포인트는 stale된 Powerball 타이밍을 보고한다(클라이언트가 WebSocket을 통해 스스로 보정한다). 원한다면 런칭 전에 이들을 연결하거나 제거한다.

> **접근.** BlueVPS 웹 패널과 SSH 접근(포트 56777), root 비밀번호, 호스트 키 지문, 데이터베이스 자격증명, Cloudflare, TronGrid, Command/HQ 어드민 계정은 모두 **Credentials & Infrastructure Handover Annex**에 문서화되어 있다.

---

## 12. 보안 요약

- **키 없는 읽기 전용 온체인.** 플랫폼은 어떤 TRON 개인키도 보유하지 않는다. 입금을 관측하고 어드민이 실행한 출금을 기록한다; 서버가 침해되어도 자금을 옮길 수 없다 — 서명 표면이 존재하지 않는다.
- **멱등 입금.** `gbp_deposits.txid`는 `ON CONFLICT DO NOTHING`과 함께 UNIQUE이다; 동일한 온체인 트랜잭션이 두 번 크레딧될 수 없다.
- **온체인 검증을 동반한 선차감 출금.** GBP는 요청 시점에 차감된다; 완료 시 수신자, 금액(0.01 USDT 허용 오차), tx 해시의 단일 사용을 검증하며, 부분 유니크 DB 인덱스로 뒷받침된다. 거절은 전액 환불한다.
- **쓰기 1회(write-once) 출금 주소.** 사용자의 USDT 주소는 한 번 설정되고(조건 원자적, 변경 시 409) 소유자 운영자만이 감사 추적과 함께 재설정한다. 이는 입금 발신자 매칭 키를 겸한다.
- **fail-closed 인증.** `gb-auth`는 ≥ 32자 `JWT_SECRET` 없이는 부팅을 거부한다; 공유 시크릿이 서비스 전반에 걸쳐 토큰을 인증한다. bcrypt(cost 12), 계정 잠금(5회 실패 / 15분), 상수 시간 로그인 경로가 무차별 대입과 타이밍 오라클을 무력화한다. IP 레이트 리밋은 Redis가 프로비저닝되면 활성화된다.
- **등급화된 어드민 인증.** 운영자 계정은 `owner|manager|viewer`이며 12시간 세션 토큰과 강제 최초 로그인 비밀번호 변경을 갖는다; `x-hq-secret` break-glass는 오직 설정/복구용으로만 존재한다. Nexus는 엄격히 다운라인 범위로 한정되며 회사 전역 제어에는 손댈 수 없다.
- **완전한 감사 추적.** 모든 GBP 이동은 변경 전/후 잔고와 함께 `gbp_admin_adjustments` 행을 기록한다; 설정 변경, 회원 조치, 롤링 레이트 변경은 별도로 감사된다.
- **검증가능 공정성을 갖춘 결정론적 게임.** 두 게임 모두 공개 Binance 인덱스 가격에서 결과를 도출하며 kline 히스토리로부터 재검증 가능하다; 결과는 결코 랜덤이 아니다.

### 12.1 인수 前 보안 체크리스트 (실서비스·실자금 전환 시)

위 보안 태세의 핵심 조치(무인증 잔고 열람 차단, 어드민 로그인 잠금, 설정 변경 등급 게이트, 출금 완료 온체인 발신자 검증, 잔고 음수 금지·입금 멱등성)는 **이미 적용·라이브**이며 판매·데모 등급으로 충분하다. 아래는 인수 측이 **실제 유저와 실제 USDT로 전환하기 전에** 확인·조치할 항목이다. 인수 시점에는 실유저 자금·개인정보가 존재하지 않는다(테스트 데이터).

**A. 필수 — 실자금 전환 前**

- **A1. 모든 시크릿 재발급.** `JWT_SECRET`, `ADMIN_JWT_SECRET`(반드시 `JWT_SECRET`과 **다른** 값), `ADMIN_SECRET`, DB 비밀번호, TronGrid API 키를 인수 측 전용 새 값으로 교체한다. 실제 값과 절차는 **Credentials & Infrastructure Handover Annex**를 참조한다.
- **A2. 회사 USDT 수신 주소 교체.** Command → 설정 → 회사 수신 주소(owner 권한, TronLink 서명 등록 지원)에서 인수 측이 통제하는 TRON TRC20 주소로 변경한다. 이 주소는 유저 입금 목적지이자 출금 완료 검증(발신자 매칭)의 기준값이다.
- **A3. 어드민 owner 계정 비밀번호를 강한 값으로 재설정한다.**

> A1~A3 완료 시 실운영·실자금 시작이 가능하다.

**B. 조건부 — 이메일/소셜 로그인을 활성화할 경우에만**

- 현재 가입은 사용자명 기반이며 이메일·소셜 로그인은 설계상 비활성이다(§3). 이 상태에서는 유저 프로필 조회 라우트(`GET /api/auth/user/{wallet}`)가 프로필을 인증 없이 반환하더라도 저장된 이메일이 합성값(`사용자명@id.local`)이라 실제 개인정보가 아니다. **인수 측이 실제 이메일 또는 소셜 로그인을 활성화하는 경우에 한해**, 이 라우트를 본인 전용으로 제한하거나 이메일·provider 필드를 응답에서 제거한다.

**C. 권장 하드닝 — 규모 확대 前 (선택)**

| 항목 | 내용 | 목적 |
|---|------|------|
| C1 | 게임 회차 VOID(무효) 시 해당 회차의 롤링 커미션도 무효 처리 | 환불된 베팅에 커미션이 잔존하는 소액 회계 누수 방지 |
| C2 | `ADMIN_JWT_SECRET` 미설정 시 `JWT_SECRET`로의 폴백 제거(미설정이면 부팅 거부) | 설정 실수 시 유저 토큰이 어드민으로 통과하는 잠복 위험 차단(A1 완수 시 현재도 안전) |
| C3 | Fastify 앱 계층 요청 레이트 리밋 추가 | 대량요청(DoS) 가용성 방어 |
| C4 | 환율 0 입력 거부, 미사용 인증·배당표 dead code 제거 | 오조작 방어 및 코드 위생 |

> 요약: **판매·데모는 현 상태로 가능**하다. A(시크릿·수신주소·비밀번호)는 인수 측이 실자금 전환 前 반드시 수행하고, B는 이메일/소셜 로그인 활성화 시에만, C는 규모 확대에 따라 순차 적용한다.

*문서 끝.*