Call balanceOf on a wallet’s stETH and you’ll get a number that grows on its own, a little more every day, with no incoming transfer to explain it. That’s not a bug and it’s not yield showing up as a transaction, it’s a rebasing token doing exactly what it’s designed to do. The trouble starts when you try to build anything more precise than “show the user their balance” on top of it: compute a position’s P&L, batch a wallet scan, or convert between stETH and wstETH, and balanceOf alone stops being enough. The actual exchange rate lives in two other methods, and most integration guides don’t mention either.
TL;DR
stETH balances rebase daily, so balanceOf already reflects accrued
rewards, it just isn’t a precise, reusable rate. For that, call
steth.getPooledEthByShares(shares) (or the inverse,
getSharesByPooledEth(ethAmount)), or read wsteth.stEthPerToken() if
you’re starting from wstETH. All three track the same underlying number:
Lido’s internal shares-to-ETH rate.
stETH is a rebasing token — here’s what that changes
Most ERC-20s store a balance per address and move fixed amounts on transfer. stETH stores shares per address instead, and balanceOf(account) computes shares[account] * totalPooledEther / totalShares on every call. totalPooledEther increases roughly once a day, when Lido’s oracle reports the validator rewards earned since the last report, so every holder’s balanceOf increases too, without a transfer, without the holder doing anything.
That’s convenient for a wallet UI: whatever balance you show the user is always correct, with no extra math. It’s the wrong number for anything that needs to reason about a holding over time, because the exchange rate between “a share of the pool” and “ETH” is exactly the piece of information balanceOf throws away. You get the output of the conversion, not the rate itself.
The exchange rate: getPooledEthByShares and getSharesByPooledEth
stETH’s contract exposes the rate directly through two view methods:
| Method | Input | Returns |
|---|---|---|
getPooledEthByShares(sharesAmount) |
an amount of shares | the ETH value of those shares at the current rate |
getSharesByPooledEth(ethAmount) |
an amount of ETH | the number of shares worth that much ETH right now |
getTotalShares() |
— | total shares outstanding across all holders |
getTotalPooledEther() |
— | total ETH the protocol controls (stake + rewards) |
sharesOf(account) |
an address | the account’s raw share count (not ETH-denominated) |
The rate itself is getTotalPooledEther() / getTotalShares(), and getPooledEthByShares(1e18) is just that ratio applied to exactly one share, denominated the same way parseUnits("1", 18) denominates one token.
Reading the live rate via API
stETH is deployed at 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84 on Ethereum mainnet. One request gets the current rate and the two totals behind it:
curl -s -X POST https://api.evmquery.com/api/v1/query \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "chain": "evm_ethereum", "schema": { "contracts": { "steth": { "address": "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84" } } }, "expression": "cel.bind(oneShare, 1000000000000000000, {\"eth_per_share\": dyn(formatUnits(steth.getPooledEthByShares(oneShare), 18)), \"total_shares\": dyn(formatUnits(steth.getTotalShares(), 18)), \"total_pooled_ether\": dyn(formatUnits(steth.getTotalPooledEther(), 18))})" }' | python3 -m json.toolRun live against mainnet, this returns:
{ "result": { "value": { "eth_per_share": 1.245550746098711, "total_shares": 7902750.442772411, "total_pooled_ether": 9843276.710227095 }, "type": "map<string, dyn>" }, "meta": { "blockNumber": "26123805" }}One share is worth roughly 1.2456 ETH as of block 26123805, up from 1.0 at Lido’s launch, entirely from accumulated validator rewards. 9843276.71 / 7902750.44 lands on the same 1.2456, confirming the two totals and the direct method agree. The dyn() wrapper around each field is required here: a plain map literal infers its value type from the first entry and rejects the rest, since this one mixes a computed rate with raw totals under one key set.
balanceOf vs. sharesOf: two different questions
balanceOf(account) and sharesOf(account) both describe a holder, but they answer different questions, and conflating them is the actual trap in stETH integrations, not the rebasing itself:
balanceOf(account)returns the account’s current ETH-denominated balance. It already includes every rebase up to the current block. It’s the right number to show a user.sharesOf(account)returns the account’s raw, unchanging share count. It’s the right number if you’re tracking a position’s cost basis, computing a user’s percentage of the pool, or need a value that doesn’t drift between the moment you read it and the moment you use it in a calculation two blocks later.
Reading balanceOf twice, a day apart, and treating the difference as “yield earned” happens to work for stETH specifically, because the rebase is itself the reward. It breaks the moment you need the same account’s position in wstETH-equivalent terms, because wstETH doesn’t rebase at all, which is the next trap.
wstETH isn’t a separate asset, it’s a tokenized share
wstETH wraps stETH to make it compatible with protocols that don’t expect a rebasing balance (most DeFi doesn’t). Wrapping deposits stETH and mints wstETH shares; the wstETH balance then stays fixed while its value per token rises. That description makes it sound like wstETH tracks its own independent rate, but it doesn’t; it exposes Lido’s shares directly.
Confirmed live, in the same request, at the same block as the stETH call above:
{ "steth_per_1_share": 1.245550746098711, "wsteth_stEthPerToken": 1.245550746098711}wsteth.stEthPerToken() and steth.getPooledEthByShares(1e18) return the identical number, because 1 wstETH is 1 Lido share, just issued as its own ERC-20 instead of living in the sharesOf mapping on the stETH contract. There’s no second rate to track, no separate oracle, and no reason to expect drift between the two: they’re two views onto the same internal accounting.
Converting between ETH, stETH, and wstETH in one request
Because wstETH exposes the inverse rate too (tokensPerStEth()), a single batched request answers “how much is this stETH position worth in wstETH, and vice versa” without a second round trip:
curl -s -X POST https://api.evmquery.com/api/v1/query \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_API_KEY" \ -d '{ "chain": "evm_ethereum", "schema": { "contracts": { "steth": { "address": "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84" }, "wsteth": { "address": "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0" } } }, "expression": "{\"steth_to_wsteth_rate\": dyn(formatUnits(wsteth.tokensPerStEth(), 18)), \"wsteth_to_steth_rate\": dyn(formatUnits(wsteth.stEthPerToken(), 18)), \"wsteth_supply\": dyn(formatUnits(wsteth.totalSupply(), 18))}" }' | python3 -m json.tool{ "result": { "value": { "steth_to_wsteth_rate": 0.8028576941824168, "wsteth_to_steth_rate": 1.245550746098711, "wsteth_supply": 3665503.2814686145 }, "type": "map<string, dyn>" }, "meta": { "blockNumber": "26123805" }}Both contracts resolve in one HTTP round trip: 3 eth_calls batched through Multicall3, one execution round. See Multicall3: batch EVM contract reads for the batching mechanics underneath this, which is the same mechanism the ERC-4626 vault-price guide uses to price multiple vaults in a single request.
Pitfalls worth knowing before you ship this
Verify the checksum independently
A single wrong character in stETH’s address, lowercase where it should be
uppercase, was silently accepted by a plain method call during research
for this post, then rejected by the identical address inside a cel.bind
expression with a clear viem checksum error. The two call shapes don’t
validate consistently, so a bad checksum can pass for a while before it
doesn’t. Recompute any address you didn’t type yourself from a source you
trust rather than assuming a search result or a memory got the casing
right.
- This is Ethereum mainnet only. stETH and wstETH are native to Ethereum; bridged representations exist on other chains, but they’re minted by third-party bridges with their own addresses and their own trust assumptions, not Lido’s canonical contracts. Don’t reuse these two addresses on Base, Polygon, or BNB Chain.
- The rate updates once a day, not continuously. Lido’s oracle reports validator rewards in a daily batch, so
getPooledEthBySharesreturns the same value across many consecutive blocks, then jumps at the next report. Don’t mistake a flat rate across blocks for a stalled oracle. wrap()andunwrap()are state-changing, not reads. evmquery only executeseth_call, so these two conversions above tell you the current rate; converting an actual position still requires a signed transaction through a wallet or a script with a private key.
If you want to explore stETH’s or wstETH’s full method set before writing an expression against either, evmquery’s Contract Inspector resolves any address’s ABI and lists every callable view, which is also how the exact method names and signatures in this post were confirmed before publishing.
Next steps
- Contract Inspector: paste any address and see its full resolved read methods before writing a query
- ERC-4626 Price: Read Vault Share Price With convertToAssets: the same shares-vs-assets distinction, in the standard that most other yield vaults use instead of stETH’s custom rebasing model
- Multicall3: batch EVM contract reads: the batching mechanics behind the stETH/wstETH example above
- evmquery for developers: what else the API can read besides staking rates



