Skip to main content

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

GoalHow it's enforced
Bonus is never withdrawablerecall-for-withdraw flushes free bonus back to BonusPool before any vault withdraw() call
Bonus pays its fair share of trading costs50/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 boundedEach grant has its own per-user expiry; reconcile worker freezes accounts whose invariants violate
Zero blast radius if the worker diesPre-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)
└────────┘
StatusWhat the user can do
no_bonusNormal account; no bonus_account row exists
activeTrade normally; bonus participates in margin
expired_pendingBonus has expired but positions still hold it. User may close positions but cannot open new ones. Open orders get cancelled by the expiry sweeper.
recalledAll bonus returned to BonusPool. Account behaves as if it had never received bonus.
frozenWrite path is paused; ops must unfreeze after investigation.

Core Numbers

ConstantValueSource
Default per-user expiry7 days from grant momentbonus_accounts.expires_at
60% net-direction threshold0.60direction_check.rs
SunX cost split (bonus / principal)50 / 50 when both positiveattribution.rs
Idempotency windowUUID request_id per admin callbonus_admin_audit_log.request_id UNIQUE
Reconcile cadenceEvery 15 minutesreconcile.rs
Realized PnL poll cadence30 secondspnl_poll.rs
Funding settlement poll cadence5 minutesfunding_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 via Authorization: Bearer <token>. See User API.
  • Admin (/api/v1/bonus/admin/*) — header auth via X-Bonus-Admin-Key: <key> (do not send Authorization — 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:

TablePurpose
bonus_accountsOne row per wallet that has ever held bonus. UNIQUE(wallet).
bonus_grantsAppend-only ledger of every grant (admin or code redemption).
bonus_codesPre-generated redemption codes; UNIQUE(code_hash).
bonus_cost_attributionAppend-only: each trading cost event with its 50/50 split.
bonus_order_tagsPer-order snapshot of bonus_locked vs principal_locked at submit time.
bonus_recall_eventsAudit of every recall (withdraw / expiry / manual / freeze).
bonus_compensation_logManual compensation entries (e.g., post-incident make-good).
bonus_admin_audit_logEvery admin action keyed by request_id for idempotency.
bonus_worker_watermarksHigh-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.