ERC-4626 Vault Share Price: convertToAssets Across Any Vault, No SDK

Read any ERC-4626 vault share price in a single REST call using convertToAssets — the vault-vs-asset decimals trap, and batching several vaults together.

evmquery team··6 min read
Share
ERC-4626 vault share price — convertToAssets across Yearn, Morpho, and sDAI-style vaults

Every yield vault, whether it’s Yearn, Morpho, or a savings wrapper like sDAI, answers the same question: how much of the underlying asset is one share worth right now. Before ERC-4626, answering that meant a different SDK, a different ABI, and a different rounding convention per protocol. After ERC-4626, it’s one method signature, convertToAssets(shares), implemented identically on every conforming vault. The catch nobody’s ABI file warns you about: that method returns a number scaled to the vault’s underlying asset, not to the vault’s own decimals() — and those two are frequently different.

TL;DR

convertToAssets(shares) is the ERC-4626 method that returns a vault’s share price. Format the result with the underlying asset’s decimals(), not the vault’s own decimals() — they diverge on vaults like Morpho’s MetaMorpho line, which pad share decimals with a virtual offset to defend against inflation attacks.

What ERC-4626 actually standardizes

ERC-4626 is an extension of ERC-20: an ERC-4626 vault is itself an ERC-20 token, and its balance represents a claim on a pool of some other asset. What the standard adds on top of plain ERC-20 is five view methods every conforming vault must implement identically:

Method Returns Meaning
asset() address The single underlying ERC-20 token the vault holds
totalAssets() uint256 Total underlying assets under management
totalSupply() uint256 Total shares outstanding (inherited from ERC-20)
convertToShares(assets) uint256 How many shares a given amount of assets is worth
convertToAssets(shares) uint256 How many assets a given amount of shares is worth

The full spec (deposit/mint/withdraw/redeem and their preview variants) is worth reading at eips.ethereum.org/EIPS/eip-4626 if you’re integrating write paths. For a read-only price feed, the five methods above are the entire surface area — and because every conforming vault implements them the same way, the exact same expression works against Yearn’s vaults, Morpho’s MetaMorpho vaults, and MakerDAO’s sDAI without touching anything but the contract address.

Reading a vault’s share price with convertToAssets

sDAI, MakerDAO’s Dai Savings Rate wrapper, is the simplest possible ERC-4626 example: one share converts to slightly more than one DAI, and the ratio grows as savings interest accrues.

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": { "sdai": { "address": "0x83F20F44975D03b1b09e64809B757c47f942BEeA" } }
},
"expression": "cel.bind(oneShare, parseUnits(\"1\", sdai.decimals()), formatUnits(sdai.convertToAssets(oneShare), sdai.decimals()))"
}' | python3 -m json.tool

Run live against the current mainnet deployment, this returns:

{ "result": { "value": 1.179379598357122, "type": "double" }, "meta": { "blockNumber": "25772359" } }

One sDAI share is worth roughly 1.1794 DAI as of block 25772359. cel.bind computes parseUnits("1", sdai.decimals()) once and reuses it, so the call resolves to a single eth_call rather than a round trip per intermediate value. This particular expression is deceptively easy to get right, because sDAI and DAI both use 18 decimals — format by either one and you get the same answer. That coincidence is exactly what breaks on the next vault.

The decimals trap: vault decimals vs. asset decimals

Swap in a USDC vault and the identical expression, formatted the identical way, produces a number that’s off by a factor of a trillion. Here’s Re7 Labs’ curated USDC vault on Morpho, on Base:

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_base",
"schema": {
"contracts": { "vault": { "address": "0x12AFDeFb2237a5963e7BAb3e2D46ad0eee70406e" } }
},
"expression": "vault.decimals()"
}'

That returns 18 — but the vault holds USDC, a 6-decimal token. Format convertToAssets()’s raw return value with formatUnits(value, 18) (the vault’s own decimals) and one share prices out at 0.000000000000205186 — off by 10^12, because you scaled a 6-decimal-denominated integer as if it were 18-decimal. Format it correctly, with the asset’s decimals, and it’s 0.205186 USDC per share:

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_base",
"schema": {
"contracts": { "vault": { "address": "0x12AFDeFb2237a5963e7BAb3e2D46ad0eee70406e" } }
},
"expression": "cel.bind(oneShare, parseUnits(\"1\", vault.decimals()), formatUnits(vault.convertToAssets(oneShare), 6))"
}' | python3 -m json.tool
{ "result": { "value": 0.205186, "type": "double" }, "meta": { "blockNumber": "50075482" } }

This isn’t a bug in the vault, it’s a deliberate defense. MetaMorpho vaults (Morpho’s ERC-4626 wrapper) expose a DECIMALS_OFFSET() method — 12 on this vault — and pad decimals() to underlying_decimals + offset (6 + 12 = 18) using virtual shares. That padding makes early-depositor inflation attacks (donating assets to a near-empty vault to skew the share price before the next depositor’s rounding) prohibitively expensive, at the cost of making vault.decimals() useless as a formatting hint. sDAI happens to use 18 decimals for both the vault and DAI, so the two numbers looked interchangeable. They aren’t, in general.

Always fetch asset() and its decimals separately

Never assume a vault’s decimals() matches its underlying asset’s. Call asset() once, resolve that address’s own decimals(), and format convertToAssets()’s result with the asset’s decimal count — every time, for every vault, regardless of what the vault’s own decimals() returns.

Batching multiple vault share prices in one request

Because every ERC-4626 vault exposes the same method signatures, comparing share prices across vaults on the same chain is one Multicall3 round trip, not one request per vault. Here’s sDAI next to Steakhouse USDC, a flagship MetaMorpho vault, both on Ethereum:

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": {
"sdai": { "address": "0x83F20F44975D03b1b09e64809B757c47f942BEeA" },
"steakusdc": { "address": "0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB" }
}
},
"expression": "{\"sdai_per_dai\": formatUnits(sdai.convertToAssets(parseUnits(\"1\", sdai.decimals())), sdai.decimals()), \"steakusdc_per_usdc\": formatUnits(steakusdc.convertToAssets(parseUnits(\"1\", steakusdc.decimals())), 6)}"
}' | python3 -m json.tool
{
"result": {
"value": { "sdai_per_dai": 1.1793796039320332, "steakusdc_per_usdc": 1.136558 },
"type": "map<string, double>"
},
"meta": { "blockNumber": "25772360" }
}

Both vaults resolve in one HTTP round trip: 2 eth_calls batched through Multicall3, 1 execution round, 3 units consumed. See Multicall3: batch EVM contract reads for the batching mechanics underneath this. Note the underlying-asset decimals still have to be supplied per vault (sdai.decimals() happens to work for sDAI, 6 is hardcoded for USDC) — batching doesn’t remove the decimals trap from the previous section, it just lets you pay for both vaults’ reads in a single request instead of two.

A CEL gotcha: mixed-type list literals

The natural first instinct is to pull convertToAssets, totalAssets, and asset() in one list expression. That fails:

[vault.convertToAssets(oneShare), vault.totalAssets(), vault.asset()]
→ error: List elements must have the same type, expected type 'sol_int' but found 'sol_address'

CEL list literals require every element to share a type, and asset() returns a sol_address while the others return sol_int. Two fixes: split asset() into its own call (it only needs fetching once per vault anyway, not per query), or use a CEL map literal with { "key": value, ... } syntax like the batching example above — map literals don’t have the homogeneous-type restriction that list literals do, since each field is typed independently by its key.

If you want to explore an unfamiliar vault’s full method set before writing an expression against it, evmquery’s Contract Inspector resolves any address’s ABI and lists every callable method, including whether it’s a standard ERC-4626 vault or something with a nonstandard extension bolted on.

Next steps

Share

Read a vault's share price in one request

Free tier, no credit card. Grab a key and point the expression below at any ERC-4626 vault.