The per-pool console
The staking screen that is in no navigation — the Details, Positions and Analytics tabs behind one pool, what its expected-versus-actual APR is really measured from, the external performance log, and what the same console shows for an on-chain pool.
The admin screens lists eleven screens, all of them in the
sidebar. This one is not: the per-pool console at
/admin/staking/pool/{id}, reached by opening the View action on any row of
the Pools table.
It is where per-pool analytics live, where the external performance log is recorded, and where the Activate / Deactivate button for a single pool sits. It is worth knowing well, because two of its headline figures are computed differently from the same-named figures on the Overview screen.
Everything from Details tab onwards describes a fixed-rate pool. The same URL renders a different set of tabs for an on-chain one — see In on-chain mode at the foot of this page.
frontend/middlewares/permissions.json lists /admin/staking/pool as
access.staking.pool and /admin/staking/pool/new as create.staking.pool.
Neither /admin/staking/pool/{id} nor /admin/staking/pool/{id}/edit is
listed, and the matcher fails through to the base access.admin for any admin
path it does not recognise. Any role holding access.admin can therefore open
this console and the pool edit form.
What it can see and do there is still gated server-side, action by action:
the Details and Analytics tabs read view.staking.pool, the Positions tab
reads view.staking.position, the status toggle needs edit.staking.pool, the
hero's Delete button needs delete.staking.pool, and the external
performance card needs view.staking.performance to list rows and
create.staking.performance to record one. A role holding none of them gets an
empty console, not a working one. Grade those six permissions individually —
the screen itself is not a boundary.
The header strip
The hero shows the pool's name, symbol, APR, lock period and its count of
ACTIVE positions, with four actions:
| Action | What it calls | Notes |
|---|---|---|
| Refresh | GET /api/admin/staking/pool/{id} |
Re-reads the pool with its positions, admin earnings, external performance rows and duration tiers |
| Activate / Deactivate | PUT /api/admin/staking/pool/{id} with {status} |
Toggles between ACTIVE and INACTIVE only. COMING_SOON is not reachable from this button — set it on the pool form |
| Edit | Navigates to /admin/staking/pool/{id}/edit |
The full pool form, including the duration tiers |
| Delete | DELETE /api/admin/staking/pool/{id} |
Refused while the pool holds ACTIVE or PENDING_WITHDRAWAL positions |
All four doors now enforce the one-active-pool rule
The button goes through the full pool update endpoint, and that endpoint
re-checks the rule before saving: activating this pool while another ACTIVE
pool exists for the same symbol and walletType in the same product is
refused with
An ACTIVE staking pool already exists for USDT (SPOT). Only one ACTIVE pool is allowed per symbol and wallet type.
The two dedicated status routes — PUT /api/admin/staking/pool/{id}/status and
the bulk PUT /api/admin/staking/pool/status — used to validate only that the
value was one of ACTIVE, INACTIVE, COMING_SOON and then write it through
the generic status helper, so a scripted status change could put two ACTIVE
pools on one asset. Both now run the shared guard before writing, with a
different sentence:
An ACTIVE pool for USDT already exists ("Tether 30-day"). Deactivate it first, or edit this pool instead of activating it.
The bulk form also checks the batch against itself, so promoting two pools for one asset in a single call is refused rather than letting the second slip past a check that only looked at the database.
The key is (symbol, walletType, mode). A fixed-rate USDT pool that is closed
to new stakes does not block the on-chain USDT pool for the same asset, and
vice versa — which is what makes switching products possible without deleting
the book you already have.
Deactivating a pool stops new stakes and nothing else. Existing positions keep accruing and still settle on schedule — see Creating pools.
Details tab
A read-only panel of the pool's own columns: APR, admin fee, earning frequency, lock period, early-withdrawal fee, auto-compound, minimum and maximum stake, capacity, description, timeline, the risk boilerplate and the pool terms list.
A pool that publishes duration tiers mirrors its featured tier's rate, lock period, frequency and auto-compound flag onto the pool row. This tab reads those mirrored columns and nothing else, so a pool offering 30 / 90 / 365-day terms shows exactly one of them here, with no indication that the other two exist.
The tier list lives on the pool form (/admin/staking/pool/{id}/edit, the
Duration Tiers tab). Do not read this tab as the pool's full offer.
The capacity meter counts settled positions
The Pool Capacity card divides totalStaked by
totalStaked + availableToStake. totalStaked here is a raw
SUM(amount) over every position row in the pool — no status filter and no
soft-delete filter. Completed and cancelled positions are in it.
That is not the same number as anywhere else:
| Surface | Filled part of the bar |
|---|---|
This console, and the Pools table's totalStaked |
Every position ever opened in the pool |
| The Overview screen's pool pressure rows | ACTIVE + PENDING_WITHDRAWAL principal only |
GET /api/admin/staking/pool/{id}/analytics/performance |
ACTIVE + PENDING_WITHDRAWAL principal only |
The bar cannot read over 100%: both halves of the sum are non-negative —
staking is refused above availableToStake, and a negative capacityDelta is
refused unless there is that much headroom to take — so the ratio is bounded at
one. What it does instead is drift toward 100% as a pool churns, whatever room
is genuinely left, because a settled position stays in the numerator forever
while its principal is handed back to the headroom in the denominator. On a
pool that has been running long enough for positions to mature, the reading
stops meaning anything.
Use the Overview screen to judge whether a pool is out of room. The
availableToStake figure printed beside it is live and correct; it is the
percentage and the "total staked" figure that are cumulative.
Positions tab
The same position table as /admin/staking/position, scoped to this pool: same
columns, same analytics strip, same view dialog, and the same
Approve withdrawal / Reject withdrawal row actions on
PENDING_WITHDRAWAL rows, gated on edit.staking.position. Create and inline
edit are switched off here; delete and view are not.
The view dialog adds what the shared details panel does not carry — the full position UUID, when it was opened and closed, unclaimed rewards, the date of the last earning row, and the pool APR.
The pool-scoped list endpoint
This route differs from the global /api/admin/staking/position in exactly one
way that matters: the pool scope is forced from the path, and any poolId
the caller puts in the query filters is stripped before the query runs. A client
cannot widen it to another pool or to the whole book.
The tab on screen reads the global endpoint with a seeded poolId filter
instead, so the two produce the same rows for an operator; the forced-scope
route is what you want when you are handing an API key to something that must
only ever see one pool.
Both include the position's duration tier (id, name, lockPeriod, apr,
earningFrequency) on every row, read with soft-deleted tiers included so a
retired term still resolves rather than coming back null.
Analytics tab
Driven by GET /api/admin/staking/pool/{id}/analytics, with a range selector
(7d · 30d · 90d · 1y) and four sub-tabs — Overview, Earnings, Users and
Performance. The Export Data button is present on this tab.
The selector reads Last 7 days, Last 30 days, Last 90 days, Last year.
The endpoint computes, respectively: the current week (Monday-based), the
current calendar month, the start of the month two months back to the end
of this one, and the current calendar year. Everything is UTC, and daily
buckets are keyed off MySQL's DATE() on UTC-stored timestamps.
The response says which it measured, in range.periodType
(current_week · current_month · last_3_months · current_year) — but no
part of the screen prints it. On the 2nd of the month, "Last 30 days" is two
days of data.
Expected versus actual APR is not the Overview's promised-versus-realised
This is the single most important thing on the page to get right. The Pool Performance card and the Performance metric tile compare two numbers:
| Figure | Where it comes from |
|---|---|
| Expected APR | The pool's own apr column — i.e. the featured tier's rate |
| Actual APR | The mean apr of the last 10 staking_external_pool_performances rows for this pool, ordered by date descending |
| Efficiency | Actual ÷ Expected, badged as Exceeding (≥ 100%), Meeting (≥ 95%) or Below expectations |
Those external rows are the ones you type in by hand on this same tab (see below). Nothing about credited rewards, accruals or the wallet ledger enters this calculation.
That is the arithmetic doing exactly what it was told — the average of no rows is treated as zero — and not a solvency signal. Every internal pool, which is most of them, will show a red Below expectations badge here forever.
The figure that actually answers "am I paying what I advertise" is realised APR on the Overview screen, which is computed from rewards actually credited, annualised over principal-days and compared against the net promised rate. Read that one for solvency. Read this one only for external pools, and only as "what did the venue report versus what did I advertise".
The APR Performance chart on the Performance sub-tab has the same basis: one point per day in the window, carrying the external row's APR for that day or 0 where none was recorded, against a flat line at the pool's APR.
The rest of the tab
- Overview — staked amount, earnings and distinct stakers per bucket
(daily for
7d/30d, weekly for90d, monthly for1y), plus an earnings distribution donut counting earning rows into 0–10 / 10–50 / 50–100 / 100–500 / 500+ amount bands. - Earnings — earnings over time, and earnings split by row
type. - Users — activity, retention by position count (1 / 2 / 3+ positions), and
two derived boxes. "Retention rate" is
COMPLETEDpositions ÷ all positions, and "early withdrawal rate" isCANCELLEDpositions ÷ all positions. Cancellation is the punitive terminal state described in Positions, not an early exit — an approved early withdrawal ends asCOMPLETEDand lands in the retention figure. Treat that second box as a cancellation rate.
The endpoint no screen calls
Accepts timeframe of 24h, 7d, 30d, 90d or all. No admin screen
renders it today; it is there for reporting and integrations, and it is the
better source of the two because it is measured from real rows rather than
typed in:
metrics.totalValueLockedandmetrics.utilizationRateuseACTIVE+PENDING_WITHDRAWALprincipal — the correct basis, unlike the Details tab.historicalData.tvlHistory,positionHistoryandrewardsHistoryare reconstructed by walking an opening balance through each day's real inflows and outflows, capped at 366 buckets.CANCELLEDpositions carry no exit timestamp, so they cannot be placed on a timeline and are excluded from those three series. The count that was left out is reported inhistoricalData.meta.excludedCancelledPositions, alongsidefrom,to,granularityandtruncated.financialMetrics.effectiveAPYis a modelled figure — the pool's APR compounded 365 times a year — not a measurement of anything.
The external performance card
On the Performance sub-tab, and only when the pool has an
externalPoolUrl set. Internal pools do not get this form, deliberately: the
platform computes their performance itself, and a manual entry form would let an
admin record numbers that contradict the ledger.
It lists what has been logged for this pool — date, APR, total staked, profit, notes — newest first, and a Record button opens a dialog with those five fields.
poolId, date, apr, totalStaked and profit are all required; notes is
optional and an empty string is dropped rather than saved. apr and
totalStaked must be ≥ 0; profit may be negative. A date in the future is
rejected by the model with "date: Cannot be in the future", which the route
returns as a 400 validation error.
They are reference data. They do not fund a payout, do not change a position's APR, and do not appear in any user-facing surface. Their only consumers are the efficiency figures on this tab.
They exist so you can put what your real yield source produced next to what you promised stakers — the comparison this product cannot make for you, because the platform funds every reward out of your own float. See the warning on the product overview.
Recording a row also writes an admin notification, and its link is dead: it
points at a staking/performance path that no page.tsx serves. Clicking it
404s. The rows are only visible on this card.
Display order is not edited here
Two endpoints set the sequence pools appear in:
The POST form takes {"poolIds": [...]}, de-duplicates while preserving your
sequence, refuses unknown IDs, and then globally re-numbers all non-deleted
pools from 1 — the IDs you sent lead, everything else follows in its current
order. The PUT form takes {"poolOrders": [{poolId, order}, ...]} and refuses
duplicate IDs or duplicate order values, writing exactly what you send and
touching nothing else. It also writes one activity-log row per pool.
A store action for the POST form exists in the frontend but nothing calls it.
In practice you set a pool's position with the Display order field on the
pool form's Basic Info tab, or through the Pools table's inline edit form.
Either way it is presentation only. Order decides the sequence of the public pool list and nothing economic — not capacity, not priority for accrual, not which pool a stake lands in.
What is not on this console
- Duration tiers. Added, edited, retired and featured on the pool form
(
/admin/staking/pool/newand/admin/staking/pool/{id}/edit, the Duration Tiers tab). See Duration tiers. - Capacity changes. The
availableToStakeinput on the edit form is inert; capacity moves only through an explicitcapacityDeltaonPUT /api/admin/staking/pool/{id}. See Creating pools. - Reward distribution. The Earnings screen carries one of the two doors —
its Distribute dialog posts to
/api/admin/staking/earning/distribute(singular), the one-offBONUSpayout. The APR accrual catch-up,POST /api/admin/staking/earnings/distribute(plural), is on no screen at all: it is an API-only route, like/pool/{id}/analytics/performanceabove. Call it with an API client. See Rewards. - The activity log. Written by this console's actions, read at
GET /api/admin/staking/activity.
In on-chain mode
The URL is the same and the console is not. A pool created in REAL mode has
apr, lockPeriod, availableToStake, earlyWithdrawalFee,
earningFrequency, autoCompound, externalPoolUrl, profitSource and
fundAllocation all null by construction — not defaulted to zero — because
none of them is a thing an on-chain pool has. What that changes here:
| Surface | On an on-chain pool |
|---|---|
| Details tab | Gone. It read the three null columns, so it rendered "null%", "null days" and a paragraph saying funds are locked for null days at a null% exit fee. Its place is taken by an On-chain tab, and the console opens on that tab instead. |
| On-chain tab | Where the coins are, what the network paid, and the commission ledger with its two doors — covered under Operating on-chain staking. |
| Positions tab | Present, with the row menu forked per row as described on The admin screens. |
| Analytics tab | Present, and its expected-versus-actual APR is meaningless: Expected APR reads pool.apr, which is null. |
| External performance card | Never appears. It renders only for a pool with an externalPoolUrl, and an on-chain pool has none. |
| Capacity meter | Not applicable. availableToStake is null; the capacity of a delegation is the network's. |
The APR and lock-period tiles at the top of the console are rendered
unconditionally, so on an on-chain pool they read null% and
null days. The Active positions tile beside them is correct. Read the
On-chain tab for this pool's real terms, not the hero.
The form behind the Edit button is a different form too — an activated chain it inherits, two numbers that are its only terms, a per-pool intake switch, a commission that opens a notice period, and a list of fixed-rate fields it refuses by name rather than ignoring. See Creating on-chain pools.