MMashDiv

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.

13 min readUpdated 15 September 2026positions, withdrawals, settlement, lifecycle, on-chain

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:

  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. 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 stakingMode touches money. From here the handler branches, and the remaining steps are the fixed-rate branch.

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

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

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

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

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

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

  10. 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:

  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.

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

  1. 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 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.
  • 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 → COMPLETED

plus 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.
  • endDate is null. There is no term. The only clock is unbondingEndsAt, 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:

  1. The gather never landed. Nothing reached the staking wallet, so the position fails with an exact refund.
  2. You abandoned it. The coins did reach the staking wallet and the delegation never will. The position is moved to WITHDRAWABLE for 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 finishes FAILED carrying 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.

POST/api/admin/staking/position/:id/abandonpermission: edit.staking.position
Abandons a stuck delegation and returns what reached the staking wallet. Requires a non-empty reason, shown to the holder and kept on the failed position.

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.