Summary
Add a refresh step to the shared YieldAdapterInterface trait and call it from the vault's deposit()/withdraw() before any total_assets()-driven pricing. For BlendAdapter, this refreshes the cached total from Blend's live b_rate (today's accrue()); for DefindexAdapter, it's a no-op since that adapter already prices live on every call.
A lighter, adapter-only self-accrue fix (calling accrue() inside BlendAdapter::deposit()/withdraw() themselves) was considered first, but the vault reads total_assets() for share pricing before calling into the adapter's deposit(), so an adapter-only fix can't correct pricing for the depositor's own transaction — only for whoever transacts after them. Fixing it properly requires the refresh to happen in the vault, ahead of that read, which means it belongs on the shared trait.
Motivation
BlendAdapter.total_assets() is a cached value that only updates via an explicit accrue() call. Nothing in the app calls accrue() automatically today (no cron, no keeper, no self-accrue on interaction), so the vault's displayed earned figure for Blend-backed positions stays frozen near the deposited principal indefinitely, even though real yield is accruing inside Blend. This makes the dashboard's yield number misleading and inconsistent between vaults (DeFindex-backed vaults already price live).
Proposed Solution
- Add a
refresh(env: Env) method to YieldAdapterInterface in vault/src/lib.rs.
vault.rs's deposit() calls adapter.refresh() before reading total_assets() for share-price math. withdraw() calls it too, ahead of any total_assets()-derived accounting, for cache/display consistency (note: the withdrawer's actual USDC payout is already computed live by Blend inside submit(), independent of our cache, so this doesn't change what a withdrawer receives -- it keeps the cache correct for the next depositor and for display).
BlendAdapter::refresh() does what accrue() does today (can just become accrue()'s new name, or accrue() stays as a permissionless public entry point and refresh() calls it internally -- open to either during implementation).
DefindexAdapter::refresh() is a trivial no-op stub -- no behavioural change, since it already computes total_assets() live.
Resulting freshness guarantee: the cache becomes at most one-transaction stale (self-healing on every deposit/withdraw) rather than frozen indefinitely. It is not a hard "always exactly correct, even on a cold first transaction after a long gap" guarantee -- that would need every reader to trigger a refresh, not just writers, which is a larger change and not in scope here.
Scope
| Field |
Value |
| Area |
Contracts |
| Protocol affected |
Blend (real fix) / DeFindex (no-op stub only) |
| Network |
testnet (mainnet not yet live) |
| Breaking change? |
Yes -- requires a fresh vault deployment (no in-place contract upgrade path). Existing testnet positions are negligible; no migration tooling needed at this stage. |
Alternatives Considered
- Adapter-only self-accrue (
BlendAdapter::deposit()/withdraw() call accrue() internally, no trait/vault change): smaller, contained to blend-adapter, no vault redeploy needed. Rejected as the primary fix because the vault reads total_assets() for pricing before calling adapter.deposit(), so it can't fix the depositor's own transaction price -- only future reads. Still worth doing as a quick interim improvement if this issue's fix is delayed -- but do not also add this on top of the trait-level refresh() once it ships. Within one atomic transaction nothing changes Blend's state between the vault's refresh() call and the following deposit()/withdraw() call, so a second self-accrue inside the adapter would just recompute the same number. Combining both is redundant, not additive.
- Scheduled keeper calling
accrue() on an interval: keeps the display fresh independent of transaction activity, but requires standing keeper infrastructure and a funded signing key to pay fees repeatedly. Doesn't fix pricing correctness at transaction time either way. Could complement this fix later but isn't a substitute for it.
- Off-chain-only display fix (replicate
accrue()'s math client-side from free get_reserve/get_positions reads, or simulate accrue() without submitting): fixes the dashboard number for free, but the actual on-chain price used for minting/burning shares stays stale, which risks the displayed "earned" figure overstating what a withdrawal would actually pay out. Rejected for that honesty gap; may still be worth doing in addition to this fix, not instead of it.
- Conditional vault call based on
get_protocol(): lets DefindexAdapter skip implementing refresh() entirely. Rejected -- reintroduces protocol-specific branching into the vault, which is deliberately protocol-agnostic today (the vault never calls get_pool()/get_protocol()). A trivial no-op stub on DefindexAdapter costs almost nothing and keeps the vault clean.
Acceptance Criteria
Additional Context
Follows from a design discussion on why deposits into the testnet meridian-usdc vault showed near-zero earned despite real yield accruing in Blend -- see the "Alternatives Considered" section above for the full reasoning trail (adapter-only fix considered and set aside first).
Summary
Add a
refreshstep to the sharedYieldAdapterInterfacetrait and call it from the vault'sdeposit()/withdraw()before anytotal_assets()-driven pricing. ForBlendAdapter, this refreshes the cached total from Blend's liveb_rate(today'saccrue()); forDefindexAdapter, it's a no-op since that adapter already prices live on every call.A lighter, adapter-only self-accrue fix (calling
accrue()insideBlendAdapter::deposit()/withdraw()themselves) was considered first, but the vault readstotal_assets()for share pricing before calling into the adapter'sdeposit(), so an adapter-only fix can't correct pricing for the depositor's own transaction — only for whoever transacts after them. Fixing it properly requires the refresh to happen in the vault, ahead of that read, which means it belongs on the shared trait.Motivation
BlendAdapter.total_assets()is a cached value that only updates via an explicitaccrue()call. Nothing in the app callsaccrue()automatically today (no cron, no keeper, no self-accrue on interaction), so the vault's displayedearnedfigure for Blend-backed positions stays frozen near the deposited principal indefinitely, even though real yield is accruing inside Blend. This makes the dashboard's yield number misleading and inconsistent between vaults (DeFindex-backed vaults already price live).Proposed Solution
refresh(env: Env)method toYieldAdapterInterfaceinvault/src/lib.rs.vault.rs'sdeposit()callsadapter.refresh()before readingtotal_assets()for share-price math.withdraw()calls it too, ahead of anytotal_assets()-derived accounting, for cache/display consistency (note: the withdrawer's actual USDC payout is already computed live by Blend insidesubmit(), independent of our cache, so this doesn't change what a withdrawer receives -- it keeps the cache correct for the next depositor and for display).BlendAdapter::refresh()does whataccrue()does today (can just becomeaccrue()'s new name, oraccrue()stays as a permissionless public entry point andrefresh()calls it internally -- open to either during implementation).DefindexAdapter::refresh()is a trivial no-op stub -- no behavioural change, since it already computestotal_assets()live.Resulting freshness guarantee: the cache becomes at most one-transaction stale (self-healing on every deposit/withdraw) rather than frozen indefinitely. It is not a hard "always exactly correct, even on a cold first transaction after a long gap" guarantee -- that would need every reader to trigger a refresh, not just writers, which is a larger change and not in scope here.
Scope
Alternatives Considered
BlendAdapter::deposit()/withdraw()callaccrue()internally, no trait/vault change): smaller, contained toblend-adapter, no vault redeploy needed. Rejected as the primary fix because the vault readstotal_assets()for pricing before callingadapter.deposit(), so it can't fix the depositor's own transaction price -- only future reads. Still worth doing as a quick interim improvement if this issue's fix is delayed -- but do not also add this on top of the trait-levelrefresh()once it ships. Within one atomic transaction nothing changes Blend's state between the vault'srefresh()call and the followingdeposit()/withdraw()call, so a second self-accrue inside the adapter would just recompute the same number. Combining both is redundant, not additive.accrue()on an interval: keeps the display fresh independent of transaction activity, but requires standing keeper infrastructure and a funded signing key to pay fees repeatedly. Doesn't fix pricing correctness at transaction time either way. Could complement this fix later but isn't a substitute for it.accrue()'s math client-side from freeget_reserve/get_positionsreads, or simulateaccrue()without submitting): fixes the dashboard number for free, but the actual on-chain price used for minting/burning shares stays stale, which risks the displayed "earned" figure overstating what a withdrawal would actually pay out. Rejected for that honesty gap; may still be worth doing in addition to this fix, not instead of it.get_protocol(): letsDefindexAdapterskip implementingrefresh()entirely. Rejected -- reintroduces protocol-specific branching into the vault, which is deliberately protocol-agnostic today (the vault never callsget_pool()/get_protocol()). A trivial no-op stub onDefindexAdaptercosts almost nothing and keeps the vault clean.Acceptance Criteria
YieldAdapterInterfacegains arefreshmethod;vault.rscalls it before anytotal_assets()read used for deposit/withdraw pricing.BlendAdapter::refresh()reflects Blend's liveb_rate(equivalent to today'saccrue()behaviour).DefindexAdapter::refresh()is a no-op; existing DeFindex behaviour and tests are unchanged.DefindexAdapter's no-op doesn't regress its existing live-pricing tests.env.cost_estimate().resources()in a contract test, and (once deployed) confirmed against a real testnet simulation -- not expected to be an issue based on a preliminary local measurement (~85K instructions for a refresh call vs. ~229K fordeposit()alone, using test-double contracts; real WASM cost will be higher but Soroban's per-transaction ceiling has wide headroom above this).scripts/deploy-testnet.sh(or a new deploy path) stands up a fresh vault + adapter with the updated interface;known-pools.ts'smeridian-usdccontractIdis updated to point at it.pnpm --filter @meridian/contractstests andcargo clippypass with no warnings.Additional Context
Follows from a design discussion on why deposits into the testnet
meridian-usdcvault showed near-zeroearneddespite real yield accruing in Blend -- see the "Alternatives Considered" section above for the full reasoning trail (adapter-only fix considered and set aside first).