Skip to content

fix(vault): read share ownership from mUSDC, not an internal map - #549

Open
determined-001 wants to merge 1 commit into
drydocs:mainfrom
determined-001:fix/musdc-transfer-locks-position
Open

fix(vault): read share ownership from mUSDC, not an internal map#549
determined-001 wants to merge 1 commit into
drydocs:mainfrom
determined-001:fix/musdc-transfer-locks-position

Conversation

@determined-001

Copy link
Copy Markdown

closes #504

Summary

withdraw() checked the caller's shares against the vault's own DataKey::Balance map, then burned from the real mUSDC balance. A plain transfer() moves the second without touching the first, so after A transfers to B:

  • B's withdrawal failed InsufficientShares against a map that still said zero, despite B holding the tokens.
  • A's withdrawal passed the same check and then reverted inside burn, because A no longer held the tokens.

The position was unreachable through withdraw() for both parties.

This removes DataKey::Balance entirely and reads share ownership from the mUSDC token — the same balance the burn operates on, so the check and the burn can no longer disagree. That is the fix the issue asked for: delete the redundant source of truth rather than keep two in step.

Changes

  • DataKey::Balance is gone. withdraw reads TokenClient::balance(caller); get_position returns the token balance. The explicit share check stays so callers still get the typed InsufficientShares rather than a panic out of burn.
  • Entry stamping keys off the Entry record, not the balance. An address holding transferred mUSDC has never deposited, so its first deposit is a real entry, not a top-up. A full exit still clears the record, so a re-depositor starts a fresh clock.
  • Principal retires against the live balance, so a holder who transferred part of their position away still retires exactly the basis for the shares they burn.
  • get_entry_time / get_principal report 0 for an address holding no mUSDC, so a record left behind by a transfer-out is never shown as a live position.
  • Off-chain: computePosition treats a zero basis alongside a positive balance as "no basis recorded" and reports no yield, instead of counting a transferred-in holder's entire balance as profit.
  • Docs: new Transferable shares section in apps/docs/architecture/vault-contract.md, plus the storage table and per-function notes.

The Entry/Principal split is not implementable as agreed — please read

In the issue thread I proposed splitting cost basis and entry time inside mUSDC's transfer(), on the grounds that "the vault side already owns mint/burn on mUSDC", and you signed that off. That premise was wrong, and I'd rather say so than quietly ship something else under the same heading.

mUSDC is a Stellar Asset Contract (scripts/deploy-testnet.sh:71, stellar contract asset deploy --asset "MUSDC:$DEPLOYER_ADDRESS"). Its transfer is the built-in SAC implementation. The vault is the SAC's admin, which lets it mint and burn — it does not give it the SAC's code, and there is no hook. So there is no point at which the vault can observe a transfer and move the sender's basis, which is exactly what the pro-rata split needed.

Basis and entry time are history, not holdings, so unlike the balance they can't be derived from a snapshot after the fact. That leaves them not following a transfer. What I did instead is make that state honest rather than wrong:

  • a transferred-in holder reports 0 basis, meaning "nothing recorded", and the UI shows no yield rather than 100% yield;
  • a transferred-out address reports 0 for basis and entry time instead of a phantom position;
  • a partial transfer leaves the sender's remaining basis on their remaining shares and retires it proportionally.

No path loses funds or blocks a withdrawal; the gap is confined to the yield figure for a transferred position.

Options for closing it properly, your call:

  1. Custom SEP-41 share token. Replace the mUSDC SAC with a token whose code we control, calling back into the vault on transfer so basis splits pro-rata and entry time merges as a principal-weighted average — the design you signed off, just one layer lower. It needs a new contract crate, vault changes for the callback, deploy-script changes, and a migration of the live share token's holders. Happy to scope it; it is its own issue, not a rider on this one.
  2. Vault-level average basis. Track a single TOTAL_PRINCIPAL and derive any holder's basis as balance * TOTAL_PRINCIPAL / TOTAL_SH, which follows a transfer automatically. Cheap, but it degrades the accurate per-depositor figure everyone gets today into a vault-wide average, so I did not want to make that trade without you.
  3. Leave it documented as it is now.

I've defaulted to (3) in this PR so the lockup fix isn't held up behind a token redeployment. Say which you want and I'll open the follow-up.

Verification

  • cargo test -p meridian-vault — full vault suite green, including 9 new tests.
  • New tests cover the issue's reproduction steps exactly: deposit as A, transfer to B, B withdraws (succeeds, receives the USDC), A withdraws (clean typed InsufficientShares, no revert). Plus partial transfer with both holders withdrawing their halves, proportional basis retirement after a partial transfer-out, both reporting behaviours above, entry stamping for a transferred-in depositor, and a full-exit-then-redeposit regression guard for the new entry-stamp condition.
  • npm run test, npm run coverage, npm run typecheck, npm run typecheck:api, npm run lint, npx prettier --check . — all green.

Not verified on testnet: the live vault predates this contract (#514 covers the redeployment), so this ships as source plus tests, like the other vault changes in flight.

withdraw() checked the caller's shares against the vault's own
DataKey::Balance map and then burned from the real mUSDC balance. mUSDC is
a normal transferable token, so a plain transfer() moved the second without
touching the first and stranded the position for both parties: the
recipient failed the share check against a map that still read zero, and
the sender passed the same check and then reverted inside burn, holding
none of the tokens it was trying to burn.

Delete the map rather than try to keep two balances in step. Share
ownership now comes from the mUSDC token itself, which is the balance the
burn already operates on, so the check and the burn cannot disagree. The
explicit check stays so callers still get a typed InsufficientShares rather
than a panic out of burn.

Entry stamping keys off whether an Entry record exists instead of the
caller's share balance: an address holding transferred mUSDC has never
deposited, so its first deposit is a real entry rather than a top-up. A
full exit still clears the record, so a re-depositor starts a fresh clock.

Cost basis and entry time cannot follow a transfer. They are history, not a
holding, so unlike the balance they are not derivable from a snapshot, and
moving them means observing the transfer as it happens. mUSDC is a Stellar
Asset Contract: its transfer is the built-in implementation, and the vault
is its admin (mint and burn), not the owner of its code, so there is no
hook to observe. Rather than leave that silently wrong, an address holding
no mUSDC now reports zero for both, so records stranded by a transfer-out
are never shown as a live position, and a transferred-in holder reports no
recorded basis instead of a fabricated one. Off-chain, computePosition
treats a zero basis alongside a positive balance as "none recorded" and
reports no yield, instead of counting the whole position as profit.

Fund safety is unaffected either way: every holder can withdraw exactly
what they hold. Only the yield figure for a transferred position is
unknown, and the vault contract docs now say so and what closing it would
require.

closes drydocs#504
@vercel

vercel Bot commented Aug 22, 2026

Copy link
Copy Markdown

@determined-001 is attempting to deploy a commit to the Collins' projects Team on Vercel.

A member of the Team first needs to authorize it.

@collinsezedike collinsezedike left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more, not anchorable since coordinator.ts isn't touched by this PR: fetchCoordinatorPosition in packages/stellar-sdk-helpers/src/coordinator.ts:117-122 computes earned = Math.max(0, deposited - principalUnits) directly off get_principal, without the hasBasis guard this PR just added to computePosition in positions.ts. A coordinator-deployed vault's transferred-in holder still gets shown their entire balance as yield there, the same bug this PR fixes in the sibling function reading the identical get_principal call. Worth fixing alongside this PR since it's the same class of bug against the same new contract behavior.

///
/// Making basis follow a transfer needs a share token the vault controls
/// the code of; see `apps/docs/architecture/vault.md`.
pub fn get_principal(env: Env, address: Address) -> i128 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The if get_position(...) == 0 { return 0 } guard only protects against a fully-empty holder. It doesn't clear the stale record itself, only withdraw()'s full-exit branch does that. An address that deposits, then moves its shares out via a plain transfer() instead of withdraw(), keeps its old Principal/Entry records sitting in storage. If that same address later receives an unrelated transfer-in (or makes a genuine new deposit), get_position is now nonzero again, the guard no longer applies, and get_principal/get_entry_time return the old stale basis and entry time, not the 0 this PR documents as the guarantee for a transferred-in holder. A genuine re-deposit compounds it: deposit() only skips the entry stamp when !has(&entry_key) (still true here, since the key was never deleted) and adds the new amount on top of the stale Principal, mixing the two deposits' cost bases together.

Needs the plain-transfer-out case covered too, not just the withdraw-to-zero case, either by having get_principal/get_entry_time also clear the stale record inline once they detect a zero-position holder with leftover storage, or by re-checking get_position freshness at both read sites before trusting the stored value.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Transferring mUSDC permanently locks the position

2 participants