Lido stETH Exchange Rate: How to Read It Correctly via API

How to read Lido's real stETH exchange rate via API: shares vs. balance, the wstETH rate, and why balanceOf alone isn't precise enough for accounting.

evmquery team··7 min read
Share
Lido stETH exchange rate — shares vs. balance and the wstETH conversion rate

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.tool

Run 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 getPooledEthByShares returns 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() and unwrap() are state-changing, not reads. evmquery only executes eth_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

Share

Read Lido's exchange rate in one request

Free tier, no credit card. Point the expression below at stETH or wstETH and get the current rate back in milliseconds.