The per-pool console

The sixth staking admin screen — the Details, Positions and Analytics tabs behind one pool, what its expected-versus-actual APR is really measured from, and the external performance log.

9 min readUpdated 6 August 2026admin, pools, analytics, performance, external

The admin screens lists five screens. There is a sixth, and it is not in the navigation: 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.

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

The status toggle enforces the one-active-pool rule — the status endpoint does not

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 is refused with

An ACTIVE staking pool already exists for USDT (SPOT). Only one ACTIVE pool is allowed per symbol and wallet type.

There is a second, dedicated per-pool status route. It validates only that the value is one of ACTIVE, INACTIVE, COMING_SOON and then writes it through the generic status helper — it does not run the one-active-pool guard. The same is true of the bulk PUT /api/admin/staking/pool/status.

Neither is wired to a screen: this console's toggle and the Pools table's edit form both submit the whole record through the guarded update route. But if you script a status change against a /status path, you can put two ACTIVE pools on the same symbol and wallet type — the state the create and update routes exist to prevent, and one the stake endpoint has no defence against. Send PUT /api/admin/staking/pool/{id} with {"status": "ACTIVE"} instead.

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

Positions in one pool, with computed pendingRewards, earningsToDate and lastEarningDate.

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 for 90d, monthly for 1y), 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 COMPLETED positions ÷ all positions, and "early withdrawal rate" is CANCELLED positions ÷ all positions. Cancellation is the punitive terminal state described in Positions, not an early exit — an approved early withdrawal ends as COMPLETED and lands in the retention figure. Treat that second box as a cancellation rate.

The endpoint no screen calls

Measured per-pool performance history — TVL, position and reward series, top stakers, flows.

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.totalValueLocked and metrics.utilizationRate use ACTIVE + PENDING_WITHDRAWAL principal — the correct basis, unlike the Details tab.
  • historicalData.tvlHistory, positionHistory and rewardsHistory are reconstructed by walking an opening balance through each day's real inflows and outflows, capped at 366 buckets.
  • CANCELLED positions 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 in historicalData.meta.excludedCancelledPositions, alongside from, to, granularity and truncated.
  • financialMetrics.effectiveAPY is 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.

Lists external pool performance records. Filter by poolId, startDate, endDate.
Records external pool performance for a date.

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:

Sets the display order from an ordered list of IDs; every other pool is re-sequenced after them.
Sets explicit order values from an array of poolId/order pairs.

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/new and /admin/staking/pool/{id}/edit, the Duration Tiers tab). See Duration tiers.
  • Capacity changes. The availableToStake input on the edit form is inert; capacity moves only through an explicit capacityDelta on PUT /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-off BONUS payout. 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/performance above. Call it with an API client. See Rewards.
  • The activity log. Written by this console's actions, read at GET /api/admin/staking/activity.