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.

6 min readUpdated 3 August 2026rewards, apr, accrual, fees, cron, distribution

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 adminFeePercentage of the gross — is credited to the Super Admin wallet through the platform fee collector, and only then recorded as a PLATFORM_FEE row 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 amount is then rewritten to the compounded total for history, but pool capacity is restored by the original principal, never the compounded figure.
  • earningFrequency stops 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.

APR accrual catch-up. Credits only the delta beyond what has already been distributed, for active positions in one pool. Supports dryRun.

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.

One-off payout of an explicit amount, split pro-rata across active positions. Always booked as BONUS.

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.