How rewards work
The exact reward formula, what earning frequency and auto-compound change, why the accrual engine can never double-pay, how the platform fee is split, and the two manual distribution doors.
Rewards in this product are computed, not received. The platform runs the APR maths against each position, writes an unclaimed earning row, keeps the admin fee, and books the whole gross amount as money it has committed to pay. There is no external yield feed and nothing reconciles against one.
This page is the arithmetic and the schedule. If you have not read the funding warning on the overview, read it first — it changes how you should read every number below.
The formula
Two methods, chosen platform-wide by
stakingDefaultAprCalculationMethod. It is not a per-pool setting.
Simple (the default, and what you get if the setting is unreadable):
reward = amount x (apr / 100) x (daysStaked / 365)Compound:
reward = amount x ((1 + rate/n)^(n x daysStaked/365) - 1)n — the compounding periods per year — comes from the pool's earning
frequency:
| Earning frequency | n |
|---|---|
DAILY |
365 |
WEEKLY |
52 |
MONTHLY |
12 |
END_OF_TERM |
1 |
A zero or negative amount, APR or day count returns zero. daysStaked is
measured from the position's startDate, always — never from the last payout.
Worked example. 1,000 USDT at 12% APR for a 30-day lock:
| Method | Gross reward | Admin fee at 20% | Net to staker |
|---|---|---|---|
| Simple | 9.8630 USDT | 1.9726 | 7.8904 |
| Compound, daily | 9.9073 USDT | 1.9815 | 7.9259 |
The staker's realised rate on the simple pool is 9.6%, not 12%. That is by design — the admin fee comes out of the reward, not out of the advertised APR. The admin overview measures every pool against its net promised APR for exactly this reason, so a healthy pool does not report as underpaying by exactly your fee.
When a reward row is written
earningFrequency sets the accrual interval:
| Frequency | Interval |
|---|---|
DAILY |
24 hours |
WEEKLY |
7 days |
MONTHLY |
30 days, not a calendar month |
END_OF_TERM |
Never during the term — settled at maturity only |
The engine works from the position's startDate in whole intervals: it computes
the latest interval boundary that has fully elapsed, caps it at endDate, and
settles up to there. A position five hours into a DAILY pool is owed nothing
yet; a position 25 hours in is owed one day.
Each position carries a watermark, lastDistributionDate, recording the point
the engine has already settled to. END_OF_TERM pools and auto-compound pools
never advance it during the term.
Why the engine cannot double-pay
This matters because the cron runs hourly, you can trigger distribution by hand, and settlement runs its own final accrual. Three mechanisms keep those from overlapping:
Accrual is a running total, not an increment. For each period the engine computes the reward owed for the entire elapsed term at the position's snapshotted APR, subtracts everything it has already credited, and writes the difference. Missing a run costs the staker nothing — the next run pays the whole outstanding amount at once.
Every row is keyed to its period. Accrual rows carry a periodBucket of
accrual_YYYY-MM-DD, under a unique database index on
(positionId, type, periodBucket). A concurrent duplicate collides at the
database rather than being credited twice.
One-off payouts live in a different namespace. The "already paid" total only
counts rows this engine wrote. An admin bonus is always booked as type BONUS
with its own bucket, so it is additive money rather than an advance against the
APR curve — otherwise a goodwill payment would silently suppress the staker's
real accrual until the curve caught up.
The fee split
Each accrual is split at the moment it is written:
- The net amount goes to the staker as an unclaimed
stakingEarningRecord. - The fee — the position's snapshotted
adminFeePercentageof the gross — is credited to the Super Admin wallet through the platform fee collector, and only then recorded as aPLATFORM_FEErow on the earnings screen. - The gross is recorded as a platform loss: the liability the platform has just taken on.
Recording all three is what makes the profit figure honest. Booking only the fee reported a 20% fee as pure profit while the platform had actually committed to paying the whole accrual — plus 20 against a real net of minus 80.
Two edge cases you can configure yourself into:
At adminFeePercentage of 100 the entire period's gross is routed to the
platform, no staker earning row is written, and no notification is sent — the
code explicitly avoids telling a user "you earned 0". The position still
completes and still returns principal. This is a valid configuration and there
is no warning on the form.
The collector credits a real wallet. With no Super Admin role or no user in it,
the fee is logged as [CRITICAL] and dropped, and the bookkeeping row is
deliberately not written — so the earnings screen never shows revenue that
does not exist. When the actor performing the action is the Super Admin, the fee
is skipped entirely rather than credited back to themselves.
Auto-compound
autoCompound changes the payout shape completely:
- No claimable rewards during the term. The claim endpoint returns a 400: "Auto-compound rewards are returned with your principal at maturity and cannot be claimed separately."
- At maturity the engine computes the whole term's compounded reward in one pass, takes the admin fee, and credits principal plus net reward as a single wallet credit.
- The position's stored
amountis then rewritten to the compounded total for history, but pool capacity is restored by the original principal, never the compounded figure. earningFrequencystops controlling payout timing and instead sets the compounding periods per year.
Auto-compound pools are also rejected by both manual distribution endpoints — there is nothing intra-term to distribute.
The automatic schedule
The hourly cron processStakingPositions does two jobs, and they obey different
rules.
Maturity settlement always runs. Once a position passes its endDate, the
final outstanding reward is accrued, the principal is returned and the position
completes — regardless of your distribution settings. Holding someone's
principal because an admin toggle is off is a breach of the lock they agreed to,
not a configuration choice.
Intra-term accrual is gated by two settings:
| Setting | Absent | Effect |
|---|---|---|
stakingAutomaticEarningsDistribution |
Treated as on | Off means matured positions still settle, but no periodic rewards are credited until you distribute by hand. |
stakingEarningsDistributionTime |
Runs every hour | An HH:MM value restricts periodic accrual to runs in that hour, server-local. The minutes are ignored. A blank or malformed value degrades to every hour rather than halting distribution. |
Skipping an hour never costs a user anything — accrual is delta-based, so the next qualifying run credits the full outstanding amount.
Operationally the cron processes up to five positions at a time, retries a failed position three times with a five-second gap, and if any position still fails it raises a high-priority in-app notification to a Super Admin listing the position IDs.
Distributing by hand
There are two distribute endpoints and they do genuinely different things. Both
require create.staking.earning.
Use this when automatic distribution has been off, or the cron was down, and you
want to bring a pool's positions up to date. It routes every write through the
same authority the cron uses, so it can be run repeatedly and cannot collide
with the cron. Optional positionIds narrows it to a subset — maximum 100 per
call. dryRun: true computes and reports the amounts without writing anything.
Use this to pay a bonus, pass through an unusually good month, or make good on
an incident. You give it poolId, a total amount and a distributionType
label of regular or bonus. The pool's current admin fee percentage is
taken off the top and credited to the platform; the remainder is split across
active positions in proportion to their staked amount.
Both labels write BONUS earning rows — the label only stops a "regular" and a
"bonus" run in the same cycle from colliding. The whole distribution is atomic:
if any share computes to a non-finite number, nothing is written and no fee is
booked.
The guard buckets requests by the pool's earning frequency — daily pools get one
run per 24-hour window, weekly pools one per 7 days, and END_OF_TERM falls
back to daily. A second attempt in the same cycle is refused with the cycle
timestamp in the message. The window slides from the epoch rather than flipping
at midnight, so two requests a minute either side of midnight fall in the same
bucket instead of both slipping through.
Claiming
Rewards sit as unclaimed rows until the user claims them. A claim takes every
unclaimed row on that position at once and credits the total to the user's wallet
as a STAKING_REWARD transaction, rate-limited to ten claims per hour per user
and gated on the withdraw_staking KYC feature.
Two refusals:
- Auto-compound pools — rewards are not separately claimable.
- Cancelled positions — their unclaimed rewards were forfeited at cancellation and are never payable.
A COMPLETED position stays claimable. Rewards earned during the term do not
expire when the term ends, and the admin overview counts them as an outstanding
liability until they are claimed.
Next: the position lifecycle.