Bonus System Overview
The Primit Bonus system (体验金 / "experience credit") lets the platform grant USDT margin to selected users — KOLs, community partners, waitlist users, or campaign participants — without giving them withdrawable principal. Users can trade U-margined perpetuals with their bonus, and profits are theirs to keep; the original bonus principal is recallable by the platform and never withdrawable.
The implementation follows the SunX 50/50 cost-attribution model and runs as a sidecar — zero modifications to the existing matching / settlement / wallet code path. Bonus and principal are commingled in the existing balances table; a shadow bonus_* ledger tracks how much of each user's available is bonus-origin.
Design Goals
| Goal | How it's enforced |
|---|---|
| Bonus is never withdrawable | recall-for-withdraw flushes free bonus back to BonusPool before any vault withdraw() call |
| Bonus pays its fair share of trading costs | 50/50 attribution split across realized loss, fees, funding while bonus and principal are both positive |
| Users can't park bonus as directional hedge | "60% trip-wire": when bonus_balance / total_available > 60%, opening orders must match the current net position direction |
| Issuance is bounded | Each grant has its own per-user expiry; reconcile worker freezes accounts whose invariants violate |
| Zero blast radius if the worker dies | Pre-checks fail open; post-event listeners (order / balance) catch violations after the fact |
Lifecycle
┌──────────┐ redeem_code ┌────────┐
│ no_bonus │ ─────────────▶│ active │
└──────────┘ grant_batch └────┬───┘
▲ │
│ all bonus recalled │ expires_at hit
│ ▼
┌────┴────────┐ ┌─────────────────┐
│ recalled │◀────────│ expired_pending │
└─────────────┘ sweep └─────────────────┘
▲
│ admin manual_recall
│
┌────┴───┐
│ frozen │ (reconcile detected invariant break)
└────────┘
| Status | What the user can do |
|---|---|
no_bonus | Normal account; no bonus_account row exists |
active | Trade normally; bonus participates in margin |
expired_pending | Bonus has expired but positions still hold it. User may close positions but cannot open new ones. Open orders get cancelled by the expiry sweeper. |
recalled | All bonus returned to BonusPool. Account behaves as if it had never received bonus. |
frozen | Write path is paused; ops must unfreeze after investigation. |
Core Numbers
| Constant | Value | Source |
|---|---|---|
| Default per-user expiry | 7 days from grant moment | bonus_accounts.expires_at |
| 60% net-direction threshold | 0.60 | direction_check.rs |
| SunX cost split (bonus / principal) | 50 / 50 when both positive | attribution.rs |
| Idempotency window | UUID request_id per admin call | bonus_admin_audit_log.request_id UNIQUE |
| Reconcile cadence | Every 15 minutes | reconcile.rs |
| Realized PnL poll cadence | 30 seconds | pnl_poll.rs |
| Funding settlement poll cadence | 5 minutes | funding_poll.rs |
Component Map
hyper_strcution/backend/src/services/bonus/
├── mod.rs spawn() — wires all 7 background tasks into app.workers
├── attribution.rs pure 50/50 algorithm (no I/O)
├── db.rs typed query layer
├── direction_check.rs 60% rule evaluator
├── expiry.rs per-user 7d sweep + position cleanup
├── funding_poll.rs 5min watermark-driven funding settlement poll
├── pnl_poll.rs 30s watermark-driven realized PnL poll
├── reconcile.rs 15min pass/warn/freeze invariants
├── metrics.rs 6 prometheus counters + 1 histogram
├── activate.rs shared activation flow (grant_batch + redeem_code)
└── listeners/ trade_listener / order_listener / balance_listener
(NOTIFY-driven, post-event safety net)
API Surface
Two route groups, both mounted on the public API gateway:
- User (
/api/v1/bonus/v1/*) — JWT auth viaAuthorization: Bearer <token>. See User API. - Admin (
/api/v1/bonus/admin/*) — header auth viaX-Bonus-Admin-Key: <key>(do not sendAuthorization— that hits the global JWT middleware). See Admin API.
For a complete end-to-end ops runbook for the live campaign, see Spring 2026 Campaign.
Data Model
The migration ships 8 tables + 1 watermark table:
| Table | Purpose |
|---|---|
bonus_accounts | One row per wallet that has ever held bonus. UNIQUE(wallet). |
bonus_grants | Append-only ledger of every grant (admin or code redemption). |
bonus_codes | Pre-generated redemption codes; UNIQUE(code_hash). |
bonus_cost_attribution | Append-only: each trading cost event with its 50/50 split. |
bonus_order_tags | Per-order snapshot of bonus_locked vs principal_locked at submit time. |
bonus_recall_events | Audit of every recall (withdraw / expiry / manual / freeze). |
bonus_compensation_log | Manual compensation entries (e.g., post-incident make-good). |
bonus_admin_audit_log | Every admin action keyed by request_id for idempotency. |
bonus_worker_watermarks | High-water marks for funding_poll / pnl_poll resumption. |
Money Precision
All money fields are stored as PostgreSQL numeric(38, 18) (Rust rust_decimal::Decimal). JSON serialisation:
- Small values fit
f64→ emitted as JSON number. - Large or odd-precision values → emitted as JSON string.
Frontend treats every money field as string and parses with Decimal.js / BigInt to dodge precision loss.