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-chain — getRequestIdsByUser(account) returns the live set — so no assistant or cache is needed.
Type: Plain adapter
protocolSubId:
keccak256("stader-withdrawal")
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:
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
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 StaderUserWithdrawalManageraddress)positionKind:
SuppliedpositionInstanceId:
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 inpositionInstanceId.
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
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