API and data model
Every staking endpoint with its method, path and permission key, the eighteen database tables and their columns, and the enums the whole product turns on.
Everything on this page is generated from the shipped route handlers and models. Permission keys are enforced server-side; a frontend screen that hides a button is convenience, the key on the endpoint is the control.
User endpoints
All under /api/staking. Authenticated unless marked public.
/api/staking/stats counts ACTIVE and PENDING_WITHDRAWAL principal only. A
position that has asked to leave but not yet settled is still locked capital;
one that has completed or been cancelled has already been paid back. Every
staking surface uses this definition, so the landing page, the user dashboard
and the admin console agree.
On-chain product
The stake door POST /api/staking/position takes an on-chain pool with
consent: { version, acknowledgements } and answers with the position and the
quote it was placed against. Every on-chain door dispatches on the pool's
venue — SOLANA_NATIVE or LIDO_STETH — so the same requests serve both
chains; a Lido quote additionally reports stakeLimitEth, stakingPaused
and bunkerMode. The claim door answers an on-chain position with
"unstake instead": rewards compound in the pool and are paid on exit.
Admin endpoints
All under /api/admin/staking.
Settings
Compliance
Chains, wallets and validators (on-chain product)
All under /api/admin/staking. See Chains for what each
screen does with them.
They apply to different states and neither substitutes for the other.
Force-unstake acts on a position that holds pool shares: it exits through the
protocol and pays the rewards accrued to the request. Abandon acts on a
position still in PENDING_DELEGATION whose gather confirmed — it is refused
if principalOnchain is zero (nothing reached the wallet, so the ordinary
refund path covers it) and refused again if the position already holds shares
("unstake it instead"). Abandon moves the row to WITHDRAWABLE for the amount
that actually reached the staking wallet and hands it to the same return pass
every settled exit uses, so there is one return path and one credit path.
It is the only way a holder whose coins sat undelegated in the staking wallet
gets them back.
Operations (on-chain product)
shares (omitted = all treasury shares). One open request per pool; refused while the wallet is frozen or the chain has no ecosystem master wallet to pay to.limit up to 500.amount (omitted = the whole remaining allowance), pro rata to the holders, from the Super Admin's ECO wallet, refused with the shortfall named when it cannot fund it.period YYYY-MM, omitted = the previous month. Idempotent per user and period.The pool routes understand the product: POST /api/admin/staking/pool in on-chain mode needs walletChain (SOL) with an ACTIVE activation, inherits the wallet, validator set and slashing policy, and refuses every fixed-rate term; PUT /api/admin/staking/pool/:id on an on-chain pool accepts name, description, icon, limits, status, intakeStatus and the commission — a decrease at once, an increase as pending with an effective date a notice period away, every holder notified.
Dashboard and reporting
Pools
Positions
Earnings and performance
/earning/distribute (singular) pays an explicit amount as a BONUS.
/earnings/distribute (plural) runs the APR accrual engine and writes REGULAR
rows. They are deliberately separate namespaces so they can never double-pay a
period. Calling the wrong one is the most likely way to overpay a pool.
Tables
Eighteen tables in all. The seven fixed-rate tables use UUID primary keys, timestamps and soft deletes. The on-chain product adds eleven more, listed after them; those are not soft-deleted, because a key, a consent or an observation is a record that must not vanish from a console.
staking_pools
| Column | Type | Notes |
|---|---|---|
name |
string(191) | 2–100 characters |
token · symbol |
string(50) · string(10) | Symbol drives all wallet routing |
icon · description |
string(191) · text | |
walletType |
enum | FIAT · SPOT · ECO |
walletChain |
string(191) | Required for ECO |
mode |
enum | SYNTHETIC · REAL. Set from stakingMode at creation, immutable afterwards; the engine that settles the pool's positions is chosen by this column. Every pool that existed before the column reads SYNTHETIC. |
apr |
decimal(10,8) | ≥ 0 |
lockPeriod |
integer | ≥ 1 day |
minStake · maxStake |
decimal(36,18) | Max is nullable and must exceed min |
availableToStake |
decimal(36,18) | Live capacity |
earlyWithdrawalFee · adminFeePercentage |
decimal(10,8) | 0–100 |
status |
enum | ACTIVE · INACTIVE · COMING_SOON |
isPromoted · order |
boolean · integer | Presentation |
earningFrequency |
enum | DAILY · WEEKLY · MONTHLY · END_OF_TERM |
autoCompound |
boolean | |
externalPoolUrl · profitSource · fundAllocation · risks · rewards |
url · text x4 | Disclosure copy, never read by logic |
Positions are RESTRICT on delete; admin earnings and performance records
cascade.
staking_durations
The lock terms one fixed-rate pool offers, each with its own advertised yield and payout schedule.
| Column | Type | Notes |
|---|---|---|
poolId |
uuid | CASCADE from the pool |
name |
string(100), nullable | Operator label, e.g. "1 Year". Null renders as "N days" |
lockPeriod |
integer | 1–36500 days. Drives the position's endDate |
apr |
decimal(10,8) | ≥ 0. This term's rate, not the pool's |
earningFrequency |
enum | DAILY · WEEKLY · MONTHLY · END_OF_TERM |
autoCompound |
boolean, nullable | Null falls back to the pool's flag |
minStake · maxStake · adminFeePercentage · earlyWithdrawalFee |
decimal, nullable | Per-tier overrides. Null means the pool value applies |
status |
enum | ACTIVE · INACTIVE. A tier that has ever been staked into is retired, never deleted |
isFeatured |
boolean | The term the pool advertises. False on every row is valid and is what a pre-tier pool has; resolution then falls to the shortest ACTIVE term |
order |
integer | Display sequence only, not the headline |
The pool's own apr, lockPeriod and earningFrequency are retained as the
fallback tier, so a pool with no rows here behaves exactly as it did before
the table existed. The admin CRUD mirrors the default tier back onto those
pool columns whenever tiers are supplied, so the two cannot disagree.
There is deliberately no unique index on (poolId, lockPeriod): the table is
soft-deleted, so including deletedAt would admit two live rows and excluding
it would let a retired tier block re-creating that term for ever.
One-term-per-pool and at-most-one-featured-tier are enforced in the admin CRUD
routes, which can tell the two cases apart. Positions are RESTRICT on delete.
staking_positions
| Column | Type | Notes |
|---|---|---|
userId · poolId |
uuid | |
durationId |
uuid, nullable | The tier staked into. Null on a pool with no tiers and on every pre-tier row. |
mode |
enum | SYNTHETIC · REAL, snapshotted from the pool at stake time. The cron, settlement and every exit door read this, never the live setting. |
amount |
decimal(36,18) | Must be > 0 |
startDate · endDate |
datetime | Start must precede end |
status |
enum | ACTIVE · COMPLETED · CANCELLED · PENDING_WITHDRAWAL |
withdrawalRequested · withdrawalRequestDate |
boolean · datetime | The date prices an early exit |
adminNotes · completedAt |
text · datetime | completedAt only valid on COMPLETED |
apr · adminFeePercentage · earlyWithdrawalFee |
decimal(16,8), nullable | Terms snapshotted at stake time. Null on legacy rows falls back to the live pool. |
lastDistributionDate |
datetime, nullable | The accrual watermark. Null is treated as startDate. |
staking_earning_records
| Column | Type | Notes |
|---|---|---|
positionId |
uuid | |
amount |
double | ≥ 0 |
type |
enum | REGULAR · BONUS · REFERRAL |
description |
string(191) | Truncated by writers to fit |
isClaimed · claimedAt |
boolean · datetime | |
periodBucket |
string(100), nullable | Distribution-cycle key |
Unique on (positionId, type, periodBucket). NULL buckets on legacy rows never
collide because MySQL treats NULLs as distinct. REGULAR rows written by the
accrual engine carry accrual_YYYY-MM-DD; bonus distributions carry
poolId:frequency:LABEL:cycle. Nothing currently writes REFERRAL.
staking_admin_earnings
| Column | Type | Notes |
|---|---|---|
poolId |
uuid | |
amount · currency |
double · string(10) | Currency mirrors the pool symbol |
isClaimed |
boolean | Bookkeeping acknowledgement only |
type |
enum | PLATFORM_FEE · EARLY_WITHDRAWAL_FEE · PERFORMANCE_FEE · OTHER |
periodBucket |
string(100), nullable | Unique with (poolId, type) |
staking_external_pool_performances
poolId, date (not in the future), apr, totalStaked, profit, notes.
Reference data — no engine reads it.
staking_admin_activities
userId (null for cron-driven actions), action
(create · update · delete · approve · reject · distribute), type
(pool · position · earnings · settings · withdrawal) and relatedId.
On-chain columns on staking_pools and staking_positions
staking_pools gains venue, activationId, stakingWalletId,
validatorSetId, the share ledger (totalShares, sharePrice,
onchainValue, unallocatedValue — holders' coins sitting undelegated in the
staking wallet until a sweep reaches the chain's minimum stake account —
treasuryShares — commission minted as shares and not yet realised by a
COMMISSION_EXIT — lastObservedAt, lastObservedEpoch,
trailingRewardRateBps),
the protocol timings (activationDelaySeconds, unbondingEstimateSeconds,
unbondingBoundSeconds), disclosureVersion, commissionEffectiveAt,
pendingAdminFeePercentage, slashingPolicy, slashingReimburseCap and
intakeStatus (OPEN · PAUSED). All nullable or defaulted; a fixed-rate pool
never writes them.
staking_positions gains the on-chain states — PENDING_DELEGATION,
UNSTAKE_REQUESTED, UNBONDING, WITHDRAWABLE, FAILED — beside the
fixed-rate ones, makes endDate nullable (an on-chain position has no term),
and adds consentId, shares, entrySharePrice, principalOnchain, the
gather and return hashes and fees, the exit fields (unstakeRequestedAt,
unstakeShares, unstakeSharePrice, unbondingEndsAt, unbondingBoundAt,
settledAmount, settledAt), failureReason, forceUnstakedBy,
forceUnstakeReason and the three batch foreign keys.
staking_earning_records gains settlement (CLAIMABLE · COMPOUNDED) and
observationId. staking_admin_earnings.type gains STAKING_COMMISSION.
The nine states in one column
Two products share staking_positions.status, and the two vocabularies never
mix.
| Product | Path |
|---|---|
| Fixed-rate | ACTIVE → PENDING_WITHDRAWAL → COMPLETED or CANCELLED |
| On-chain | PENDING_DELEGATION → ACTIVE → UNSTAKE_REQUESTED → UNBONDING → WITHDRAWABLE → COMPLETED, plus FAILED |
No on-chain row ever enters PENDING_WITHDRAWAL: that state means an exit a
human reviews, and no human reviews a protocol exit. Once coins are on a
chain, an exit failure is a retry and never a failure.
Both start at PENDING_DELEGATION, and they end differently. Reading FAILED
as a single outcome will make you look for the coins in the wrong place.
- The gather never landed. Nothing reached the staking wallet. The refund
pass credits the full
amountback to the holder's ECO wallet asECO_REFUNDunderstaking_refund_<positionId>, and the position goes straight toFAILEDwith the batch's error asfailureReason. No coins move on-chain. - An admin abandons it. The gather did land, so the coins are real and in
the platform's staking wallet. The position goes to
WITHDRAWABLEforprincipalOnchain, the ordinary return pass sends that on-chain to the holder's own deposit address, the balance is credited only once that transfer lands, and the row then finishesFAILEDcarrying the operator's reason.FAILEDis therefore also reachable fromWITHDRAWABLE.
failureReason is what decides the ending: a returned position with a reason
set finishes FAILED, one without finishes COMPLETED. A stake that never
happened must not appear in the holder's completed history or in any
completed-position count.
staking_chain_wallets
One per chain and network: chain, network, currency, address, data
(the AES-256-GCM envelope, never returned), role (STAKING), status
(ACTIVE · FROZEN), balance, gasReserveFloor, lastObservedAt, the
freeze fields and createdBy.
staking_chain_activations
The legal record per chain and network: venue, status (DRAFT · ACTIVE
· PAUSED · RETIRED), wallet and validator-set references,
defaultCommissionPercent, commissionNoticeDays, slashingPolicy,
slashingReimburseCap, the licensing declaration (licensed, regulator,
licenceReference, jurisdictionsServed), the acknowledgements
(ringFenceAcknowledged, noGuaranteeAcknowledged, sfcAttestation),
validatorDueDiligence, and the acceptance (disclosureVersion,
disclosureHash, disclosureText, acceptedBy, acceptedAt, acceptedIp,
acceptedUserAgent), plus the pause and retire stamps.
staking_validator_sets and staking_validators
A set: chain, network, name, status, policy (the thresholds as
JSON), lastEvaluatedAt, lastEvaluation, healthy. A member:
voteAccount, identity, name, weight, commissionPercent,
mevCommissionPercent, asn, status (ACTIVE · SUSPENDED · REMOVED),
lastHealth, lastHealthAt, breach.
staking_tranches
A unit of delegated principal: kind (SOLANA_STAKE_ACCOUNT ·
LIDO_SHARES), stakeAccount, seed, validatorId, status (CREATING ·
ACTIVATING · ACTIVE · DEACTIVATING · INACTIVE · WITHDRAWN ·
FAILED), amount, observedValue, the activation and deactivation epochs,
and the create, exit and withdraw batch references.
staking_batches
One on-chain operation: kind (GATHER · DELEGATE · EXIT · CLAIM ·
RETURN · REFUND · COMMISSION_EXIT · SWEEP), status (PENDING ·
BROADCAST · CONFIRMED · RETRYING · FAILED), stakingWalletId,
intentDigest, intent, txHash (unique), networkFee, amount,
attempts, lastError, broadcastAt, confirmedAt, createdBy.
staking_observations
What the network paid one pool for one window, unique on (poolId, window):
epoch, observedAt, valueBefore, valueAfter, grossReward (signed),
commissionAmount, commissionShares, netReward, sharePriceBefore,
sharePriceAfter, totalShares, positionsCredited, detail.
staking_consents
What a user accepted, verbatim: userId, poolId, activationId,
version, hash, text, acknowledgements, acceptedAt, ip,
userAgent.
staking_statements
One per user per period: period, periodStart, periodEnd, format
(CSV), content, hash, totalStaked, totalRewards, totalCommission,
summary.
staking_commission_exits
The platform's own exit: poolId, chain, network, status (QUEUED ·
UNBONDING · SETTLED · PAID · FAILED), shares, requestSharePrice,
requestedValue, settledAmount, settledAt, destination (the chain's
ecosystem master wallet, named at request time), exitBatchId,
payoutBatchId, txHash, networkFee, failureReason, requestedBy,
requestedAt, paidAt. Settled from staking_pools.treasuryShares after
every user exit ahead of it; the realised commission ledger.
staking_incidents
kind (SLASHING · DRIFT · LOW_GAS · VALIDATOR_BREACH · BATCH_STUCK
· OBSERVER_LAG · UNBONDING_OVERDUE · DELEGATION_STALE · COMMISSION ·
OTHER), severity, status (OPEN · ACKNOWLEDGED · RESOLVED),
title, detail, lossAmount, reimbursedAmount, dedupeKey,
occurrences, firstSeenAt, lastSeenAt, and the acknowledge and resolve
stamps.
Wallet and transaction types
| Type | Written when |
|---|---|
STAKING |
Principal debited at stake time, and principal returned at settlement |
STAKING_REWARD |
A user claims earnings |
Those two are the fixed-rate product. The on-chain product moves real coins
through the ecosystem wallet service instead, so its rows carry the ECO
operation types: ECO_WITHDRAW for the stake debit, ECO_REFUND for a gather
that never landed, and ECO_DEPOSIT for a return that did — the last of these
written against the transaction hash, because it is a chain transfer the
deposit watcher can also see.
Idempotency keys used by the money paths: staking_create_<positionId> for the
stake debit, staking_principal_return_<positionId> for the principal return
(shared across every transition), and a hash of the claimed row IDs for a claim.
The on-chain product reuses staking_create_<positionId> for its own debit and
adds two more: staking_refund_<positionId> credits the full amount back when a
gather never reached the staking wallet, and a return that did reach the chain is
credited by the deposit key eco_deposit_<txHash>_<walletId> — the same key the
deposit watcher would use, so a return cannot be credited twice by two paths.
Permission keys
| Key | Guards |
|---|---|
access.staking |
Admin overview, dashboard and analytics endpoints |
access.staking.pool · view.staking.pool |
Pool screens and reads |
create.staking.pool · edit.staking.pool · delete.staking.pool |
Pool writes |
access.staking.position · view.staking.position |
Position screens and reads |
create.staking.position · edit.staking.position · delete.staking.position |
Position writes, including withdrawal approval |
access.staking.earning · view.staking.earning |
Earnings screen and reads |
create.staking.earning · edit.staking.earning |
Both distribute endpoints, manual earnings, claiming |
view.staking.performance · create.staking.performance |
External performance records |
view.staking.activity |
Activity log |
access.staking.settings · view.staking.settings · edit.staking.settings |
Staking settings screen, its read and its write. The mode key additionally needs the Super Admin role, re-checked per request. The compliance records and exports reuse view.staking.settings. |
access.staking.chain · view.staking.chain · create.staking.chain · edit.staking.chain |
Chain activations. Activate and retire additionally need the Super Admin role. |
access.staking.wallet · view.staking.wallet · create.staking.wallet · edit.staking.wallet |
Staking wallets. Create, freeze and unfreeze additionally need the Super Admin role. |
access.staking.validator · view.staking.validator · create.staking.validator · edit.staking.validator |
Validator sets and the screened candidate list |
access.staking.batch · view.staking.batch · edit.staking.batch |
The batch ledger and retry |
access.staking.incident · view.staking.incident · edit.staking.incident |
Incidents and the reconciler |
Key derivation and the places a key must exist are covered in Permissions.