-
Notifications
You must be signed in to change notification settings - Fork 20
[Bug] Transferring mUSDC permanently locks the position #504
Copy link
Copy link
Open
Labels
GrantFox OSSIssue tracked in GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardIssue may be eligible for a GrantFox rewardThird CampaignCampaign: Third CampaignCampaign: Third CampaignbugSomething isn't workingSomething isn't workingcontractsInvolves writing or testing Rust/Soroban contracts in packages/contractsInvolves writing or testing Rust/Soroban contracts in packages/contractshardComplex implementation spanning multiple packages or involving Soroban contractsComplex implementation spanning multiple packages or involving Soroban contractssecuritySecurity hardening, vulnerability fixes, or audit-related workSecurity hardening, vulnerability fixes, or audit-related worksorobanInvolves Soroban smart contract invocations or Soroban RPC callsInvolves Soroban smart contract invocations or Soroban RPC calls
Description
Metadata
Metadata
Assignees
Labels
GrantFox OSSIssue tracked in GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardIssue may be eligible for a GrantFox rewardThird CampaignCampaign: Third CampaignCampaign: Third CampaignbugSomething isn't workingSomething isn't workingcontractsInvolves writing or testing Rust/Soroban contracts in packages/contractsInvolves writing or testing Rust/Soroban contracts in packages/contractshardComplex implementation spanning multiple packages or involving Soroban contractsComplex implementation spanning multiple packages or involving Soroban contractssecuritySecurity hardening, vulnerability fixes, or audit-related workSecurity hardening, vulnerability fixes, or audit-related worksorobanInvolves Soroban smart contract invocations or Soroban RPC callsInvolves Soroban smart contract invocations or Soroban RPC calls
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 ownDataKey::Balance(caller)map (packages/contracts/vault/src/lib.rs:255-258), then later burns from the real mUSDC token balance viaTokenClient::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 plaintransfer()moves the real token balance without touchingDataKey::Balance.If A transfers mUSDC to B:
DataKey::Balanceentry is still 0, so B's withdrawal fails immediately withInsufficientShares, even though B now holds the real tokens.DataKey::Balanceentry still shows the old amount, so A's withdrawal passes theInsufficientSharescheck, but the subsequentburn()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
transfer().vault.withdraw()for the transferred shares.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 theburn()call since A no longer holds the tokens. The position becomes permanently unreachable through the vault's normalwithdraw()flow for both parties.Environment
Possible Cause / Fix
Drop the internal
DataKey::Balancemap entirely and derive share ownership directly from mUSDC's real token balance (TokenClient::balance()) on everywithdraw()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)andDataKey::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.