Skip to content

[Bug] Transferring mUSDC permanently locks the position #504

Description

@collinsezedike

Description

MeridianVault's internal share accounting and the mUSDC token's real balances can drift apart, permanently locking a position.

withdraw() checks the caller has enough shares by reading the vault's own DataKey::Balance(caller) map (packages/contracts/vault/src/lib.rs:255-258), then later burns from the real mUSDC token balance via TokenClient::new(&env, &musdc).burn(&caller, &shares) (packages/contracts/vault/src/lib.rs:278). Nothing keeps these two in sync: mUSDC is a standard transferable token, and a plain transfer() moves the real token balance without touching DataKey::Balance.

If A transfers mUSDC to B:

  • B's DataKey::Balance entry is still 0, so B's withdrawal fails immediately with InsufficientShares, even though B now holds the real tokens.
  • A's DataKey::Balance entry still shows the old amount, so A's withdrawal passes the InsufficientShares check, but the subsequent burn() call fails since A no longer holds the real tokens, and the whole transaction reverts.

Neither party can ever withdraw that position through the vault again.

Steps to Reproduce

  1. Deposit USDC into the vault as address A, receiving mUSDC shares.
  2. Transfer some or all of that mUSDC to address B using mUSDC's standard token transfer().
  3. Have B call vault.withdraw() for the transferred shares.
  4. Have A call vault.withdraw() for the same shares.

Expected Behavior

mUSDC stays freely transferable (it's a legitimate share token, tradeable and usable as collateral elsewhere), and a transferred position withdraws normally through its new holder.

Actual Behavior

B's withdrawal fails with InsufficientShares (internal map still shows 0). A's withdrawal passes the internal check but reverts on the burn() call since A no longer holds the tokens. The position becomes permanently unreachable through the vault's normal withdraw() flow for both parties.

Environment

Field Value
Network testnet
Wallet N/A
Protocol affected None
Browser (if frontend) N/A
Node.js version N/A
pnpm version N/A

Possible Cause / Fix

Drop the internal DataKey::Balance map entirely and derive share ownership directly from mUSDC's real token balance (TokenClient::balance()) on every withdraw() call. That removes the second, redundant source of truth that causes the drift, rather than patching around it: making mUSDC non-transferable would fix the lockup by deleting a legitimate feature, and adding transfer hooks to keep the internal map in sync would add a second system that has to be maintained by hand indefinitely, and is exactly the kind of thing that reintroduces this bug the next time a code path forgets the hook.

This will also touch DataKey::Entry(address) and DataKey::Principal(address), which are keyed off the same caller and currently assume the depositor is the same address that eventually withdraws. Once mUSDC is correctly transferable end to end, a transferred position's cost basis and entry time need to move with the token too, or they'll stay wrong for the new holder even after the withdrawal lockup itself is fixed. Scope that into this same fix rather than leaving it as a follow-up gap.

Additional Context

Found during an independent audit of the vault contract, verified directly against current source (packages/contracts/vault/src/lib.rs) rather than taken on the audit's word alone.

Metadata

Metadata

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardThird CampaignCampaign: Third CampaignbugSomething isn't workingcontractsInvolves writing or testing Rust/Soroban contracts in packages/contractshardComplex implementation spanning multiple packages or involving Soroban contractssecuritySecurity hardening, vulnerability fixes, or audit-related worksorobanInvolves 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