Skip to content

[Feature] Refresh adapter's cached total_assets before pricing on deposit/withdraw #418

Description

@collinsezedike

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

  • YieldAdapterInterface gains a refresh method; vault.rs calls it before any total_assets() read used for deposit/withdraw pricing.
  • BlendAdapter::refresh() reflects Blend's live b_rate (equivalent to today's accrue() behaviour).
  • DefindexAdapter::refresh() is a no-op; existing DeFindex behaviour and tests are unchanged.
  • Contract tests cover: a depositor arriving after yield has accrued gets correctly priced shares within their own transaction (the case the adapter-only fix couldn't solve); a withdrawer's cache-refresh does not alter their own payout (still Blend-computed live); DefindexAdapter's no-op doesn't regress its existing live-pricing tests.
  • Resource cost sanity-checked via 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 for deposit() 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's meridian-usdc contractId is updated to point at it.
  • pnpm --filter @meridian/contracts tests and cargo clippy pass with no warnings.

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

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26contractsInvolves writing or testing Rust/Soroban contracts in packages/contractsenhancementNew feature or requesthardComplex implementation spanning multiple packages or involving Soroban contractssorobanInvolves Soroban smart contract invocations or Soroban RPC calls

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions