Duration tiers, end to end
The staking_durations table, how the featured term is resolved, what a stake snapshots, the durationId a multi-term pool now requires, and how to migrate a single-term pool.
A pool can offer many lock terms. Each term carries its own advertised rate and its own payout schedule — "365 days at 10%, paid at the end" alongside "90 days at 8%, paid weekly" — on one pool, sharing one capacity budget.
Creating pools covers the authoring side: the form, the fields, the tier editor. This page is the rest of the contract — what the table holds, what the platform does with it at stake time, what a position freezes, and how to move an existing single-term pool onto tiers without disturbing the book.
The table
staking_durations, one row per term, soft-deleted like every other staking
table.
| Column | Type | What it does |
|---|---|---|
poolId |
uuid | The owning pool. Cascades on pool delete |
name |
string(100), nullable | Operator-facing label, e.g. "1 Year". Null falls back to "N days" |
lockPeriod |
integer | The term, in days. Minimum 1, maximum 36500. Drives the position's endDate |
apr |
decimal(10,8) | The advertised annual rate for this term. ≥ 0 |
earningFrequency |
enum | DAILY · WEEKLY · MONTHLY · END_OF_TERM. Defaults to DAILY |
autoCompound |
boolean, nullable | Null means inherit the pool's flag. false is an explicit opt-out, not the same thing |
minStake · maxStake |
decimal(36,18), nullable | Optional overrides. Null inherits the pool's |
adminFeePercentage |
decimal(10,8), nullable | Optional override, 0–100 |
earlyWithdrawalFee |
decimal(10,8), nullable | Optional override, 0–100 |
status |
enum | ACTIVE or INACTIVE. INACTIVE retires a term without destroying it |
isFeatured |
boolean | The term the pool advertises. See below |
order |
integer | Display sequence. Presentation and tiebreak only |
Three constraints are enforced in the admin routes rather than in the schema, and it is worth knowing which:
- One term per lock period, per pool. Two 90-day tiers make "the 90-day
rate" ambiguous everywhere downstream. There is a non-unique index on
(poolId, lockPeriod)but no unique one, because the table is soft-deleted and MySQL treats NULLdeletedAtvalues as distinct — a unique index would either admit two live rows or permanently block re-creating a retired term. - At most one featured ACTIVE tier. MySQL has no partial index, so a
UNIQUE (poolId, isFeatured)would allow exactly one non-featured tier per pool, which is the opposite of the rule. - At most 24 tiers per pool. A guard against an unbounded payload.
The foreign key from staking_positions.durationId is RESTRICT: a tier
holding user principal cannot be destroyed out from under its positions.
Which term the pool advertises
Resolution runs on read, in this order:
-
The ACTIVE tier marked featured. One only.
-
Otherwise the shortest ACTIVE tier, with ties broken by
order.
Rule 2 is the whole backward-compatibility story rather than a safety net. Every
tier created before the flag existed has isFeatured false, so those pools
resolve to their shortest term — which is exactly what they already advertised.
Nothing needed backfilling and no pool changed behaviour by being upgraded.
Resolving on read rather than persisting the choice also means that retiring the featured term moves the headline to the next shortest by itself, with no write to go wrong and no window in which the pool advertises a term nobody can buy. The retired row keeps its flag, so reactivating it restores your original choice instead of silently discarding it.
Saving a pool with more than one featured ACTIVE tier returns a 400:
Only one term can be featured, and 2 are: 90 days, 365 days. The featured term is the rate the pool advertises.
The tier editor uses a radio-style star button rather than a checkbox precisely so you cannot construct that payload from the form. If it ever does reach the resolver — a direct API call, say — the shortest of the featured tiers wins, so the headline is at least deterministic.
A featured tier that is closed is deliberately not refused. "I am featuring a closed term" and "I am closing my featured term" arrive identically in a stateless payload, and the second is an ordinary thing to do. Resolution passes over it and the next shortest open term becomes the headline.
The featured tier's apr, lockPeriod, earningFrequency and — when the tier
sets one — autoCompound are mirrored onto the pool's own columns on every
save. That is why the Pools table, the per-pool console, the landing page and
any report reading pool.apr all show a rate the pool actually offers rather
than one frozen at whatever preceded the tiers.
What terms a stake is opened under
resolveStakeTerms(pool, duration) is the single authority, and both the stake
endpoint and the rewards calculator go through it.
With no tier (a pool that publishes none) it returns the pool's own
lockPeriod, apr, earningFrequency, autoCompound, minStake, maxStake,
adminFeePercentage and earlyWithdrawalFee. That is the pre-tier contract,
unchanged.
With a tier:
| Field | Resolution |
|---|---|
lockPeriod · apr |
The tier always wins. This is what a tier is |
earningFrequency |
Tier, else pool, else DAILY |
autoCompound |
tier ?? pool ?? false |
minStake |
tier ?? pool |
maxStake |
tier ?? pool — null on both means no ceiling |
adminFeePercentage |
tier ?? pool, clamped to 0–100 |
earlyWithdrawalFee |
tier ?? pool, clamped to 0–100 |
Every optional override falls back with the nullish operator. 0 is a
legitimate value for a minimum stake and for both fees, and || would treat a
deliberately fee-free tier as "unset" and silently restore the pool's fee.
A 0% early-withdrawal fee on your flexible term stays 0% because of that
distinction. The frontend's own resolveTerms mirrors the same rule so the
quoted figure and the applied figure agree.
The minimum and maximum a stake is checked against come from the resolved terms, not from the pool. A tier that raises the minimum to 5,000 rejects a 1,000 stake even though the pool's own minimum is 100.
durationId is required once a pool publishes tiers
POST /api/staking/position resolves the requested term before it does anything
else, and it refuses to guess. Opening a 365-day lock for someone who meant to
pick 30 days is a money decision made on the user's behalf.
| Situation | Result |
|---|---|
Pool has no ACTIVE tiers, no durationId sent |
The pool's own terms apply |
Pool has no ACTIVE tiers, a durationId sent |
400 — "This pool does not offer selectable durations" |
Pool has one ACTIVE tier, no durationId sent |
That tier is used. One option is not a choice, so older clients keep working |
Pool has two or more ACTIVE tiers, no durationId sent |
400 — see below |
durationId names a tier of this pool that is retired |
400 — "That staking duration is no longer available" |
durationId is unknown, or belongs to another pool |
400 — "Invalid durationId for this staking pool" |
The refusal on a multi-term pool, verbatim:
This pool offers 3 durations — select one to stake (durationId is required)The count is the number of ACTIVE tiers. If an integration of yours posts stakes directly, this is the change that will break it the moment you add a second term to a pool.
There is one further check after resolution: a resolved term with a lock period below 1 day is refused with "This staking option has no valid lock period configured". Both the tier and the pool validate a minimum of 1, so this only fires on data that got in another way.
What a position freezes
On creation the position stores a full snapshot of the resolved terms:
Column on staking_positions |
From |
|---|---|
durationId |
The tier chosen, or null for a tier-less pool |
apr |
Resolved APR |
adminFeePercentage |
Resolved fee |
earlyWithdrawalFee |
Resolved exit fee |
earningFrequency |
Resolved schedule |
autoCompound |
Resolved flag |
lockPeriod |
Resolved term in days |
The accrual engine reads position snapshot first, pool second — never through to the tier. That is deliberate: the tier is mutable and shared, so reading through to it would let you re-price a term and retroactively change every in-flight position opened under it, which is exactly the drift the snapshots prevent.
Before tiers existed, earningFrequency and autoCompound were read live from
the pool. They are snapshotted now because a single pool can hold positions on
different cadences, so "how often does this position pay" is no longer
answerable from the pool at all.
A position created before the snapshot columns existed has nulls in them and falls through to the live pool values. The first time the accrual engine touches such a position it pins every null column from the values in force at that moment — APR, admin fee, early-withdrawal fee, earning frequency, auto-compound and lock period — and writes them onto the row.
Those are the best recoverable approximation of the originals, not the
originals. What pinning stops is any further drift, and one failure in
particular: because an accrual credits target − already paid, cutting a pool's
APR could make the target lower than what a legacy position had already been
credited, freezing that staker's rewards for good.
A dry run does not pin anything. If you are about to change a pool's APR or
schedule and it still holds pre-snapshot positions, run the accrual catch-up
(POST /api/admin/staking/earnings/distribute) for real first, then edit — see
Rewards.
A pool with zero tiers is a supported state
It is not a migration gap and there is nothing to fix. The pool's own
lockPeriod, apr, earningFrequency and autoCompound are retained
precisely so that a pool with no staking_durations rows behaves exactly as it
did before the table existed: resolveStakeTerms falls back to them, the stake
endpoint asks for no durationId, and every read surface renders one coherent
headline.
The tier editor says as much when the list is empty — "This pool offers one term, from Staking Details".
What a staker is shown
Both user-facing pool endpoints attach the ACTIVE tiers and a summary:
GET /api/staking/pool— every ACTIVE pool, each withdurations(ACTIVE only, serialised) anddurationSummary.GET /api/staking/pool/{id}— the same, plus the caller's own positions.
durationSummary is summarizeDurations over the ACTIVE tiers and carries
count, aprMin, aprMax, lockPeriodMin and lockPeriodMax. It is null
when the pool publishes no tiers, which is the signal for a client to fall back
to the pool's single APR and lock period rather than print an empty range. It is
what turns a five-tier pool into one line on a card:
8% – 10% APR over 90 – 365 daysThe reward calculator quotes one row per (pool, tier) pair rather than one
per pool, so a user typing an amount sees a figure for each term they could
actually get it on. Each quote carries durationId — null for a tier-less
pool — which is what the client sends straight back as the stake payload. A term
longer than the horizon the user asked about is omitted from the quote entirely;
a 365-day term cannot pay a 90-day reward.
The staking form opens on the featured term, and a staker picks a term before an amount, because the minimum, the fees and the payout schedule can all change with it.
Retiring a tier versus deleting one
The tier editor's trash icon simply drops the row from the payload you save. The server then decides which of two things that means:
| Condition | What happens |
|---|---|
| Nothing has ever been staked into the tier | The row is deleted |
| Any position references it — including settled and soft-deleted ones | The row is retired (status set to INACTIVE) and kept |
The count is taken with soft-deleted positions included, so a tier that was ever
used is never destroyed. Positions snapshot their own terms, but the row still
backs their history, and the FK is RESTRICT — deleting it would either fail
the whole save or orphan a user's record of what they agreed to. The response
reports the retained tiers with their lock period and position count.
Retiring on purpose is the Open to new stakes checkbox under a tier's
Advanced panel. Unticking it sets INACTIVE: the term disappears from the
staking form, drops out of durationSummary and the pool's advertised range,
and stops being stakeable — while every position already opened under it keeps
running to maturity untouched.
A payload whose tiers are all INACTIVE is refused:
At least one duration tier must be ACTIVE, otherwise the pool cannot be staked into
The form checks this before it submits and drops you back on the Duration Tiers
tab with the reason. To take a pool off sale, deactivate the pool instead —
that is what the INACTIVE pool status is for.
Tiers are matched by id, never by position in the array or by lock period.
Sending an id that belongs to a different pool is refused with
durations: tier <id> does not belong to this pool rather than quietly moving
that pool's tier under this one.
One more distinction worth knowing if you script against the pool update
endpoint: omitting durations entirely leaves the tiers alone; sending an
explicit empty array (or null) clears them. Those cannot be collapsed, which
is why the payload cannot simply default to [].
Migrating an existing single-term pool
The pool keeps working throughout. Nothing here touches live positions, because every one of them carries its own snapshot.
-
Open the pool form —
/admin/staking/pool/{id}/edit, the Duration Tiers tab. An untiered pool shows an empty state and the quick-add row. -
Add your existing term first. Use the preset buttons (7, 14, 30, 60, 90, 180, 270 and 365 days) or the custom-days box, then set that tier's APR and Rewards Paid to match what the pool already advertises on the Staking Details tab. Getting this one right is what keeps the pool's headline unchanged.
-
Add the new terms. Each needs a distinct lock period — a duplicate is refused with "duplicate duration of 90 days — each tier must have a distinct lock period". Use Advanced on a tier only where it should differ from the pool: minimum stake, maximum stake, admin fee, early-withdrawal fee and auto-compound are overrides, and the placeholder in each box shows the pool value that applies if you leave it blank.
-
Mark one term featured with the star button. If you skip this the pool advertises its shortest ACTIVE term, which for a straight migration is usually right — but set it explicitly if your headline rate is not on the shortest term. The star is disabled on a closed term.
-
Save, then re-open the pool and confirm the mirrored scalars. The featured tier's APR, lock period and earning frequency are now written onto the pool's own columns, so the Staking Details tab and the per-pool console's Details tab should read back as that tier. If they show something else, the tier you meant to feature is not the one that resolved.
Before tiers, offering two terms on USDT meant publishing two pools — which then competed for the same capacity and, worse, hit the one-ACTIVE-pool-per (symbol, wallet type) rule, so the second could not be published at all.
If you worked around that with a second INACTIVE or COMING_SOON pool, fold
its term in here as a tier and retire the spare pool once it holds no ACTIVE
or PENDING_WITHDRAWAL positions.
Where tiers show up in the admin
| Surface | Shows |
|---|---|
| Pool form, Duration Tiers tab | The editor. All statuses, so a retired tier can be seen and reactivated |
GET /api/admin/staking/pool/{id} |
durations, all statuses, ordered by order then lockPeriod |
| Positions API — global and pool-scoped | Each row's duration (id, name, lockPeriod, apr, earningFrequency), read with soft-deleted tiers included so a retired term still resolves |
| The per-pool console | Nothing. Its Details tab reads the pool's mirrored scalars, so it shows the featured term only |
The tiers are not currently a column on the admin Positions table; they are in the payload. A position's real terms are its own snapshot in any case — the tier row tells you which term it was bought on, not what it pays.