Positions and withdrawals
The four fixed-rate position states, what happens when a user stakes, how withdrawal requests are approved or rejected, the difference between completing and cancelling, and how the same table behaves for an on-chain position.
A position is one user's stake in one pool. It holds the principal, the term, and a frozen copy of the economic terms that were in force when it opened.
Positions are never partially withdrawn and never topped up. A user who wants to stake more opens another position; a user who wants out exits the whole thing.
staking_positions holds both products in one table and one status column, and
a row's own mode decides which lifecycle it follows. Everything down to
In on-chain mode describes a fixed-rate row; an on-chain row never reaches
PENDING_WITHDRAWAL at all.
The four fixed-rate states
| Status | Meaning | Principal |
|---|---|---|
ACTIVE |
Running normally. Rewards accrue on schedule. | Held |
PENDING_WITHDRAWAL |
The user asked to exit early and is waiting for your decision. Accrual continues. | Held |
COMPLETED |
Term ran out, or you completed it, or an approved exit settled. | Returned |
CANCELLED |
You cancelled it. Unclaimed rewards were forfeited. | Returned |
COMPLETED and CANCELLED are terminal. Every settlement path re-checks the
status under a row lock before moving money, so a second attempt is a no-op
rather than a second payout.
The column carries nine values in total — the four above, plus the five the
on-chain lifecycle uses. A fixed-rate row never enters one of those and an
on-chain row never enters PENDING_WITHDRAWAL.
Opening a position
The user picks a pool and an amount. POST /api/staking/position is the one
door for both products; it is rate limited to five calls a minute per user and
gated on the invest_staking KYC feature. It refuses, in order:
-
A non-numeric, zero or negative amount — 400.
-
A pool that is not
ACTIVE— "Staking pool is not active".INACTIVEandCOMING_SOONpools cannot be staked into. -
A pool belonging to the other product — "This pool belongs to the fixed-rate product, which is closed to new deposits on this platform. Positions already open continue to their term." This is the only place the global
stakingModetouches money. From here the handler branches, and the remaining steps are the fixed-rate branch. -
A resident of a blocked territory — the fixed-rate product ships blocked in the territories listed on Settings. It is checked here, at the only door that commits money; an exit is never gated.
-
Too many decimal places for the symbol — the amount is validated against the currency's precision and then rounded canonically.
-
Below the resolved term's minimum or above its maximum — the message names the limit. The bounds come from the resolved terms, not the pool, so a tier that raises the minimum rejects a stake the pool's own minimum would allow.
-
More than the pool has left — "Insufficient available amount to stake in this pool".
-
No wallet in that symbol and wallet type — "You don't have a USDT wallet. Please create one first." Staking does not create the source wallet.
-
Insufficient balance — the message states the balance and the requirement.
-
A treasury that cannot cover the promise — the Super Admin's wallet in the pool's currency must cover what the live fixed-rate book in that currency and wallet type already promises plus what this stake would add. The refusal names the shortfall.
Everything after that is one transaction: the position row is created, the
pool's capacity is decremented atomically (and only if enough remains, so
concurrent stakes cannot oversell), and the wallet is debited as a STAKING
operation under an idempotency key derived from the new position ID.
The position stores a snapshot of the resolved terms at that instant —
apr, adminFeePercentage, earlyWithdrawalFee, earningFrequency,
autoCompound and lockPeriod, plus the durationId it was opened on. See
Duration tiers for how those are resolved.
Raising the early-withdrawal fee tomorrow does not make yesterday's stakers pay more to leave, and cutting the APR does not reprice a lock somebody already committed to. The accrual engine reads the position's snapshot first and the pool only as a fallback — never through to the tier, which is mutable and shared.
The exception is a legacy position created before the snapshot columns existed. It carries nulls, falls through to the live pool values, and is pinned to whatever is in force the first time the engine touches it. If a pool still holds pre-snapshot positions, run the accrual catch-up for real before you edit its APR or its schedule.
The affiliate STAKING commission is processed after the transaction commits.
A failure there is logged and never rolls back the stake.
Exiting
POST /api/staking/position/{id}/withdraw is the only user-facing exit for a
fixed-rate position. It is gated on the withdraw_staking KYC feature and
refuses anything the caller does not own (403) or that is not ACTIVE (400 — a
position already in PENDING_WITHDRAWAL is told the request is in progress). It
also refuses an on-chain position by product, naming which product the row
belongs to; the on-chain exit is a different door,
POST /api/staking/position/{id}/unstake.
stakingMinimumWithdrawalAmount is checked here, and it is checked against the
whole position, because there is no partial exit. Setting it high does not
create a minimum withdrawal — it makes small positions permanently unexitable by
their owner.
What happens next depends on two things:
| Lock state | stakingRequireWithdrawalApproval |
Result |
|---|---|---|
Expired (now >= endDate) |
Either | Settles immediately. Principal returned in the same request, no fee. |
| Still locked | Off | Settles immediately, charging the early-withdrawal fee. |
| Still locked | On (the default when the key has never been saved) | Moves to PENDING_WITHDRAWAL and waits for an admin. |
A request filed inside the lock period keeps its early-exit price no matter how
long it sits in your queue. Earlier behaviour priced from settlement time, which
meant the longer an admin took the cheaper the exit became — and once endDate
passed in the review queue it became free.
The consequence for the queue: an old request may show "term complete" and still deduct the full fee when you approve it, because the fee was fixed the day it was filed. The admin overview computes and displays that fee for you on each queued row.
Approving or rejecting a request
Withdrawal requests appear in the Positions table with a status of
PENDING_WITHDRAWAL, and in the queue on the Overview screen ordered oldest
first. Both actions need edit.staking.position.
Approve. Set the position to COMPLETED. Because the source status is
PENDING_WITHDRAWAL, this settles as a withdrawal: the final outstanding reward
is accrued, the early-withdrawal fee is deducted from the principal and booked
as platform revenue, the remainder is credited to the user's wallet, and pool
capacity is restored. Unclaimed rewards survive and stay claimable.
Reject. Set the position back to ACTIVE. No money moves. The withdrawal
flags are cleared by the server and the position resumes its original lock. The
user is notified, with your reason if you supply one.
CANCELLED returns the principal and destroys every unclaimed earning row on
the position. It is the punitive exit, meant for a position that should never
have existed — not for approving a withdrawal.
Before this was separated out, approving an early exit and confiscating the
staker's earned rewards were the same action. They are now distinct: approve
with COMPLETED, reject with ACTIVE, and reserve CANCELLED for cases where
forfeiting rewards is the intent.
Cancellation also books a reversal against the platform's ledger, because the forfeited rewards had already been recorded as money committed. That keeps your profit figures right; it does not give the user anything back.
What settlement actually does
Every terminal transition of a fixed-rate position — the maturity cron, an admin action, a user's own exit — runs through one settlement routine. An on-chain position never touches it: its exit is the protocol's, and the batch runner owns it. In order:
-
Resolve the destination wallet, creating it if the user does not have one yet. This happens before the position row is locked, so generating ECO chain addresses never blocks other positions in the same pool.
-
Lock the position and re-check for a terminal status. If it is already
COMPLETEDorCANCELLED, stop — this is what makes a retry safe. -
Price the early-withdrawal fee from the request date, capped at the principal, using the position's snapshotted percentage.
-
Accrue the final outstanding reward — skipped for cancellations and for auto-compound pools. It runs in a savepoint, so a half-written accrual cannot commit alongside the principal. If it fails outright it is logged and the principal is returned anyway; stranding someone's capital to protect a rounding of reward is strictly worse.
-
Flip the status and advance the accrual watermark.
-
Credit the principal, net of any fee, under a single idempotency key shared by every transition — a second return is impossible.
-
Book the fee as an
EARLY_WITHDRAWAL_FEEplatform earning, but only if the collector actually credited a wallet. -
Restore pool capacity by the original principal consumed, never a compounded amount.
-
Forfeit unclaimed rewards — cancellations only.
Bulk actions
PUT /api/admin/staking/position/bulk handles up to 100 positions in one call
with an action of COMPLETE, CANCEL or WITHDRAW. Eligible source states
differ per action: COMPLETE only from ACTIVE, WITHDRAW only from
PENDING_WITHDRAWAL, CANCEL from either.
Each position settles in its own transaction, so one failure cannot commit a partial credit for another. The response lists what was updated and, per position, why anything else was skipped.
An on-chain row in the selection is one of those skips: it is refused per row, never for the whole batch, with "On-chain positions are not settled by an admin; their principal returns from the network." The single-position status door refuses the same way, and so does the position edit endpoint.
Editing and deleting
The position edit endpoint refuses to mass-assign anything economic. Sending
amount, startDate, endDate, apr, adminFeePercentage,
lastDistributionDate, userId, poolId or completedAt returns a 400 naming
the offending fields. What you can change is adminNotes, a reason, and the
status transitions above.
Deletion is guarded the same way pools are: a position in any state that still
holds or owes money cannot be deleted, soft or forced, because its principal
has not been returned. That is six states — ACTIVE and PENDING_WITHDRAWAL for
the fixed-rate product, and PENDING_DELEGATION, UNSTAKE_REQUESTED,
UNBONDING and WITHDRAWABLE for the on-chain one. Settle or unstake it first.
Only COMPLETED, CANCELLED and FAILED rows are historical, and deleting one
of those is a soft delete — the row is retained and hidden.
What the user sees
- Only an ACTIVE position can be withdrawn from
Under /staking:
- Dashboard — their staking summary, live positions and pending rewards.
- Staking Pools — the browsable pool list, filterable by token, APR range
and minimum lock period. Only
ACTIVEpools appear here;COMING_SOONpools show on the public landing page instead. - My Positions — every position with its status, term, accrued earnings and the claim and withdraw actions.
- Statements — the monthly statements written by the on-chain product. Empty on a fixed-rate-only install.
- Staking Guide — the built-in explainer page.
The pool detail page includes a rewards calculator that runs the same formula
the engine uses, so a quoted figure and a paid figure reconcile. It is
fixed-rate only: in REAL mode the projection endpoint answers 503 with "This
platform runs on-chain staking. There is no rate to project: what a pool pays is
whatever the network pays, observed after the fact."
In on-chain mode
An on-chain row lives in the same table and the same status column, and follows a different path through it:
PENDING_DELEGATION → ACTIVE → UNSTAKE_REQUESTED → UNBONDING → WITHDRAWABLE → COMPLETEDplus FAILED. Four differences change how you read the table:
- No
PENDING_WITHDRAWAL, ever. There is no human decision to make: the protocol decides when the coins come back, so there is no queue, no approval and no early-withdrawal fee. endDateis null. There is no term. The only clock isunbondingEndsAt, and it starts when the exit is requested — not when the position opened.- No claim. The claim door refuses an on-chain position in words before it refuses it by product: "On-chain rewards compound in the pool and are paid when you unstake. To take rewards out, unstake the shares you want to withdraw; there is no separate claim."
- No affiliate commission. The fixed-rate path pays one after its commit; the on-chain path pays none at all, deliberately.
Four on-chain states that did not exist before hold money — PENDING_DELEGATION
(the stake was debited and the coins are being gathered or delegated),
UNSTAKE_REQUESTED (the shares are still the holder's), UNBONDING (the coins
are with the protocol) and WITHDRAWABLE (the exit settled and the return has
not been paid yet). The delete doors treat all four exactly as they treat
ACTIVE and PENDING_WITHDRAWAL: a position in any of them cannot be deleted,
soft or forced, and neither can a pool holding one. Deleting such a row destroys
the debt, not the record of it.
The two roads to FAILED
The model's comment says FAILED is reachable only from PENDING_DELEGATION.
That is half of it. Both roads start there:
- The gather never landed. Nothing reached the staking wallet, so the position fails with an exact refund.
- You abandoned it. The coins did reach the staking wallet and the
delegation never will. The position is moved to
WITHDRAWABLEfor the amount that actually arrived, the ordinary return pass sends that to the holder's own deposit address, the ledger is credited only once the transfer has landed, and the position then finishesFAILEDcarrying the reason you gave.
So FAILED is also reachable from WITHDRAWABLE, and a row sitting in
WITHDRAWABLE may be on either the ordinary exit path or the abandon path.
An empty reason is a 400: "Give the holder a reason; it is shown to them and
kept on the failed position." The door's own header calls it the only way a
holder whose coins sat in the staking wallet ever gets them back — so if you are
looking at a PENDING_DELEGATION row that will never delegate, this is the
control, and it is on the Positions table's row menu. See
The admin screens for where it appears and
Operating on-chain staking for the rest of the
operator's doors.
Next: the admin screens.