For the complete documentation index, see llms.txt. This page is also available as Markdown.

Stader Withdrawal Queue

A plain adapter that reports the ETH owed to an account from open requests in Stader's UserWithdrawalManager.

A plain adapter that reports the ETH owed to an account from open requests in Stader's UserWithdrawalManager. When a user requests an unstake, ETHx is burned and a sequential request id is recorded; until claimed, the value lives as native ETH owed by the manager (its amount fixed at finalization). The position is denominated in the configured underlying (the native-ETH sentinel / WETH on mainnet), not ETHx.

The account's open requests are enumerable on-chaingetRequestIdsByUser(account) returns the live set — so no assistant or cache is needed.


Positions returned

One Supplied leg per open request, all sharing one static positionId. A Stader request is binary — fully claimable OR fully locked — so each open request emits exactly one leg:

Leg
PositionKind
isDebt
isLocked
Description

Per finalized request

Supplied

false

false

ETH from a finalized request, withdrawable now

Per pending request

Supplied

false

true

ETH from a not-yet-finalized request, still pending

The payout token (ETH/WETH) is carried in balanceAsset; isLocked is set from that request's state. The per-request sequential id rides in positionInstanceId (= bytes32(requestId)), not in the label. A zero-amount leg is omitted; the position is dropped entirely if that asset is not registered in NAVCalculator, if there are no open requests, or if filtered out by assetFilter. The balance methods (getAdapterBalances) still report the summed total.


Balance calculation

Flowchart of the Stader Withdrawal Queue balance adapter: read calls derive position legs into PositionBalance entries.
Stader Withdrawal Queue adapter — how the underlying balances reported to the NAV Calculator are derived.

The adapter reads the account's live request ids in one call, getRequestIdsByUser(account), and values each with userWithdrawRequests(id):

Stader removes an id from that array on claim (swap-and-pop in deleteRequestId), so getRequestIdsByUser holds only un-claimed requests — no per-item liveness filter is needed. A request is claimable iff ethFinalized > 0; those use ethFinalized (exact) and emit a leg with isLocked=false. A pending request is valued conservatively as min(ethExpected, STAKE_POOL_MANAGER.previewWithdraw(ethXAmount)) and emitted with isLocked=true: ethExpected is frozen at request time, so a later ETHx/ETH rate drop (slashing) would overstate it until finalization — the live re-conversion of the request's burned ETHx caps that. The live read fails open to ethExpected if it reverts (never worse than the prior estimate). This mirrors the Lido adapter's capped-owed valuation.

There is no adapter-level cap on the read: the full request list is enumerated (Stader itself bounds it at maxNonRedeemedUserRequestCount, 1000 on mainnet). Reading the whole list is the honest, over-report-safe answer — a skip-to-0 guard would silently zero a real, claimable balance, which is the wrong direction. The only residual is gas: a griefed account (see below) makes its own NAV read expensive, which under a constrained gas budget can OOG the aggregate getAccountNav for that account only. That failure is liveness-only and safe — NAV is an off-chain eth_call oracle with no atomic on-chain consumer, so a reverting read just means the shares layer doesn't process that account, never that it consumes a wrong value, and no other account is affected.

Third-party injection is self-defeating. requestWithdraw(ethXAmount, _owner) pulls the caller's ETHx but records the request under an arbitrary _owner, claimable only by _owner. So anyone can plant a request owned by this account — but they burn their own ETHx to do it and cannot recover it, so the injected value is real, claimable, and self-funded (equivalent to an airdrop, which the wallet ERC-20 adapter likewise reports). The only residual is the gas cost of reading a spammed list, bounded by Stader's own 1000-request cap and confined to that account's own NAV read (see the griefing note).


Identity

  • positionId: abi.encode(userWithdrawalManager) (static; the Stader UserWithdrawalManager address)

  • positionKind: Supplied

  • positionInstanceId: bytes32(requestId) (the per-request sequential id; ephemeral — the storage entry is deleted on claim)

  • Labels: the adapter implements positionLabels(positionId), returning ["Withdrawal Queue", "ETHx"] (surfaced only on the verbose read path; full breadcrumb = ["Stader", "Withdrawal Queue", "ETHx"]). The request id is not in the label — it rides in positionInstanceId.

Every per-request leg shares this single static positionId; legs of the same lock state are told apart by positionInstanceId. The payout token (ETH/WETH) rides in balanceAsset. The reconciliation key is keccak256(abi.encode(chainId, protocolSubId, positionId, balanceAsset.asset, positionKind)) — it excludes both isLocked and positionInstanceId, so all of an account's open requests (claimable and pending alike) reconcile into one coordinate and sum to the full owed amount; read isLocked per leg for the claimable-vs-pending split.


Constructor

Parameter
Description

userWithdrawalManager_

Stader UserWithdrawalManager contract. Must be a contract

underlyingToken_

Asset positions are denominated in (native-ETH sentinel / WETH on mainnet)

navCalculator_

NAVCalculator address, used for asset-registry filtering. Must be a contract

The StaderStakePoolsManager — whose previewWithdraw provides the live-rate cap for pending requests — is derived on-chain at construction from userWithdrawalManager.staderConfig().getStakePoolManager(), so there is no extra constructor argument.


Registration

Registered as a plain adapter with addBalanceAdapters([adapter]) and removed with removeBalanceAdapters([adapter]).

Last updated