Positions and withdrawals

The four position states, what happens when a user stakes, how withdrawal requests are approved or rejected, and the difference between completing and cancelling a position.

7 min readUpdated 3 August 2026positions, withdrawals, settlement, lifecycle

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.

The four 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.

Opening a position

The user picks a pool and an amount. POST /api/staking/position is rate limited to five calls a minute per user and gated on the invest_staking KYC feature. It refuses, in order:

  1. A non-numeric, zero or negative amount — 400.

  2. A pool that is not ACTIVE"Staking pool is not active". INACTIVE and COMING_SOON pools cannot be staked into.

  3. Too many decimal places for the symbol — the amount is validated against the currency's precision and then rounded canonically.

  4. Below the pool's minimum or above its maximum — the message names the limit.

  5. More than the pool has left"Insufficient available amount to stake in this pool".

  6. 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.

  7. Insufficient balance — the message states the balance and the requirement.

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 pool's apr, adminFeePercentage and earlyWithdrawalFee at that instant.

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. Only earningFrequency and autoCompound are read live from the pool by the accrual engine — change those and live positions change with them.

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. 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).

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 — the maturity cron, an admin action, a user's own exit — runs through one settlement routine. In order:

  1. 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.

  2. Lock the position and re-check for a terminal status. If it is already COMPLETED or CANCELLED, stop — this is what makes a retry safe.

  3. Price the early-withdrawal fee from the request date, capped at the principal, using the position's snapshotted percentage.

  4. 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.

  5. Flip the status and advance the accrual watermark.

  6. Credit the principal, net of any fee, under a single idempotency key shared by every transition — a second return is impossible.

  7. Book the fee as an EARLY_WITHDRAWAL_FEE platform earning, but only if the collector actually credited a wallet.

  8. Restore pool capacity by the original principal consumed, never a compounded amount.

  9. 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.

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 that is still ACTIVE or PENDING_WITHDRAWAL cannot be deleted, soft or forced, because its principal has not been returned. Settle it first. Deleting a settled position is a soft delete — the row is retained and hidden.

What the user sees

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 ACTIVE pools appear here; COMING_SOON pools show on the public landing page instead.
  • My Positions — every position with its status, term, accrued earnings and the claim and withdraw actions.
  • 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.

Next: the admin screens.