Creating pools
Every field on the staking pool form, what locked and flexible mean in this product, the one-active-pool-per-asset rule, and the capacity field the edit form cannot change.
A pool is the product a user buys. It sets the asset, the APR, how long the money is locked, how often rewards land, what you keep, and how much of your float you are willing to expose.
Create one at Extensions → Staking Services → Pools → New Pool
(/admin/staking/pool/new). You need create.staking.pool.
Several fields are frozen the moment the pool has a live position, and three are snapshotted onto every position at stake time. Get those right before you go live.
Identity and presentation
| Field | Notes |
|---|---|
| Name | 2–100 characters. Shown everywhere. |
| Token | The asset's full name, e.g. Tether. Display only. |
| Symbol | 1–10 characters, e.g. USDT. This is the money field — it must match a currency the user's wallet actually holds, because the stake is debited from a wallet of exactly this symbol and type. |
| Icon | Optional image. |
| Description | Required, minimum 10 characters. |
| Order | Sort position in the public list. New pools are appended after the current highest. |
| Promoted | Highlights the pool on the landing page. Has no economic effect. |
Wallet routing
walletType decides which of the user's wallets is debited and credited.
| Type | Use when | Extra requirement |
|---|---|---|
SPOT |
The default. Exchange-backed spot balances. | None |
FIAT |
Fiat currencies. | None |
ECO |
Ecosystem custody wallets with on-chain addresses. | walletChain is required and must be non-empty. The currency must also exist as an active ecosystem token, or the wallet is created with no addresses. |
Staking does not create the source wallet. If a user has no USDT SPOT wallet,
POST /api/staking/position rejects the stake with
"You don't have a USDT wallet. Please create one first." rather than opening
one for them. The destination wallet for rewards and returned principal is
created on demand, but the source is not.
Economics
| Field | Constraint | What it does |
|---|---|---|
| APR | ≥ 0, stored to 8 decimals | The advertised annual rate. Snapshotted onto each position. |
| Lock period | Integer, minimum 1 day | endDate = startDate + lockPeriod days. |
| Minimum stake | ≥ 0 | Rejects smaller stakes. |
| Maximum stake | Nullable, must exceed minimum | Per-position ceiling. Leave empty for none. |
| Available to stake | ≥ 0 | Remaining capacity, in tokens. See below. |
| Admin fee % | 0–100 | Share of each reward the platform keeps. Snapshotted per position. |
| Early withdrawal fee % | 0–100 | Share of principal taken on a pre-maturity exit. Snapshotted per position. |
| Earning frequency | DAILY · WEEKLY · MONTHLY · END_OF_TERM |
How often a reward row is written. See Rewards. |
| Auto-compound | Boolean | Rewards fold into the principal and are paid at maturity instead of being claimable during the term. |
lockPeriod is validated at a minimum of 1. A zero-day pool would make
endDate equal startDate, which the position model's own validator rejects —
so the pool would save cleanly in the admin form and then fail every single user
stake with an opaque validation error.
One pool, several terms
A pool can offer many lock terms, each with its own advertised rate and its own payout schedule — "365 days at 10%, paid at the end of the term" alongside "90 days at 8%, paid weekly" — on one pool, sharing one capacity.
Before duration tiers, an operator who wanted two terms had to publish two whole pools. Those then competed for the same capacity and, worse, ran into the one-active-pool-per-asset rule, so the second one could not be published at all.
What a tier can set for itself
| Field | Per tier? |
|---|---|
| Lock period, APR, earning frequency | Always — this is what a tier is |
| Auto-compound | Optional. Unset means use the pool's setting |
| Minimum and maximum stake | Optional override |
| Admin fee %, early withdrawal fee % | Optional override |
The optional overrides exist because a longer lock is routinely priced differently on both sides: an operator offering 365 days at 10% usually also wants a larger minimum and a steeper exit penalty than on their 30-day tier.
The headline term is a separate decision from the order
Two fields, answering two different questions:
- Featured — the term the pool advertises. Its rate, length and schedule are mirrored onto the pool's own fields, it is preselected on the staking form, and it is what every surface that can print only one figure prints.
- Order — the sequence the terms read in, maintained shortest-to-longest.
One field answering both forces a bad trade. An operator promoting their 365-day headline rate would have had to list it above the 30-day term — and a ladder that does not run shortest-to-longest reads as a mistake to the customer.
If no tier is featured, the pool advertises the shortest active term. That is a valid state, and it is what every pool created before tiers existed has — which is exactly what those pools already advertised, so nothing had to be backfilled and no pool changed behaviour by being upgraded.
A pool with no tiers still works
The pool's own rate, lock period and frequency are retained as the fallback tier. A pool with no tier rows behaves exactly as it did before.
When you do supply tiers, the featured one is mirrored back onto those pool fields on every save, so the two can never disagree.
Retiring a tier
Positions snapshot their terms at stake time, but they still reference the tier row for history. Set it INACTIVE instead — it disappears from the staking form and keeps every existing position readable.
What a staker sees
The pool page lists the active terms with their own rates, and the staking form opens on the featured one. A staker picks a term before they pick an amount, because the minimum, the fee and the payout schedule can all change with it.
Their position snapshots the term they chose, so a later edit to that tier does not alter a lock somebody already agreed to.
Locked versus flexible
The product has exactly one lock model: every term is locked for at least one day. There is no "flexible" pool type and no partial withdrawal — a position exits whole or not at all.
What operators mean by "flexible" is assembled from three settings, not one switch. With duration tiers you can now offer all three shapes below on the same pool, as separate terms, rather than as competing pools:
A term product. A long lockPeriod (30, 90, 180 days), END_OF_TERM or
MONTHLY earnings, a meaningful earlyWithdrawalFee, and withdrawal approval
left on so an early exit needs your sign-off. The user commits; you know
your outflow date.
A flexible product. A short lockPeriod (1–7 days), DAILY earnings, a
zero or near-zero earlyWithdrawalFee, and
stakingRequireWithdrawalApproval turned off so a user can exit whenever
they like and the settlement happens in the same request. Functionally this is
demand deposit with a published rate.
A compounding product. autoCompound on. The user cannot claim during the
term at all — the claim endpoint returns a 400 telling them so — and at maturity
they receive principal plus the whole compounded reward in one credit. Choose
the compounding cadence with earningFrequency: it stops driving payout timing
and instead sets the compounding periods per year.
Note that the approval switch is platform-wide, not per pool. You cannot have a flexible pool and a term pool with different exit policies on the same install.
Capacity, and the field you cannot edit
availableToStake is your exposure limit expressed in tokens. It is live
accounting, not a description:
- Every stake decrements it by the staked amount, atomically and only if enough remains — which is what stops concurrent stakes overselling a pool.
- Every settlement increments it by the original principal, never by a compounded amount.
- A stake larger than what remains is rejected with "Insufficient available amount to stake in this pool".
Total pool size is therefore live principal + availableToStake, which is how
the admin overview computes utilisation.
The update endpoint deliberately strips availableToStake out of the
request body before saving. Writing back the value the form loaded would revert
every stake and settlement that happened since the page opened — re-minting
capacity that is still locked in active positions, and compounding the error on
every later settlement.
The consequence for you: the Available to Stake input on the pool edit form is inert. Typing a new number and saving changes nothing, and nothing on screen says so.
Capacity is changed only through an explicit signed delta, capacityDelta, sent
to PUT /api/admin/staking/pool/{id}. It is applied atomically so it composes
with concurrent stakes, and a negative delta is refused if the pool has less
than that available. No screen in the admin panel sends it today, so raising a
full pool's capacity is currently an API call.
Set capacity generously at creation time to avoid this. If you must adjust it later:
curl -X PUT "https://your-host/api/admin/staking/pool/POOL_ID" \
-H "Content-Type: application/json" \
-H "Cookie: accessToken=..." \
-d '{"capacityDelta": 50000}'Disclosure fields
Four required text fields, minimum 10 characters each, all shown to users on the pool detail page and never read by any logic:
- Profit source — where the yield actually comes from.
- Fund allocation — what you do with the principal.
- Risks — what can go wrong for the staker.
- Rewards — how and when they get paid.
There is also an optional External pool URL, validated as http:// or
https:// if provided.
The platform funds every reward from your own treasury. If profitSource says
"delegated to validators" and you are in fact paying out of float, nothing in
the product will contradict you — and nothing will stop the shortfall either.
Write what is true.
Status and the one-active-pool rule
status is ACTIVE, INACTIVE or COMING_SOON. Only ACTIVE pools accept
stakes and appear in the user pool list; COMING_SOON is a marketing state that
renders but cannot be staked into.
Creating or activating a second ACTIVE pool for the same symbol and wallet
type is rejected with a 400. Multiple INACTIVE or COMING_SOON tiers on the
same asset are allowed, so you can stage a replacement — but switching over is
a two-step operation: deactivate the old pool first, then activate the new one.
Existing positions in a deactivated pool keep accruing and still settle normally. Deactivating a pool stops new stakes; it does not touch the book.
Editing a live pool
Once a pool holds an ACTIVE or PENDING_WITHDRAWAL position:
| Change | Result |
|---|---|
symbol, walletType, walletChain |
Rejected with a 400. The principal was debited from a wallet of the original symbol and type; changing it would strand the return. |
apr, adminFeePercentage, earlyWithdrawalFee |
Allowed, but applies to new positions only. Each position carries a snapshot of the three taken at stake time, so an existing lock keeps the terms the user agreed to. |
lockPeriod |
Allowed, and applies to new positions only — existing positions already have a fixed endDate. |
earningFrequency, autoCompound |
Allowed and applies immediately to existing positions. These are read live from the pool by the accrual engine, not snapshotted. Changing them mid-term changes when live positions get paid. |
availableToStake |
Silently ignored. Use capacityDelta. |
status |
Allowed, subject to the one-active-pool rule. |
Deleting a pool
Deleting is soft by default; the row is kept and hidden.
A pre-delete guard refuses any deletion — soft or forced — while the pool
holds ACTIVE or PENDING_WITHDRAWAL positions, with the message "Settle
all positions first." The association is RESTRICT at the database level too,
so there is no path that quietly destroys a book of positions and the principal
they represent.
Rewards already earned survive a soft delete: the claim endpoint deliberately reads the pool with soft-deleted rows included, so archiving a wound-down pool does not strand a user's unclaimed earnings.