fix(contracts): unify VaultRouter <-> tier-vault ABI and asset-scope accounting - #146
Conversation
…accounting VaultRouter already called deposit/withdraw/early_exit/balance/shares/ lock_until with an asset-scoped (user, asset, ...) signature, but none of the four tier vaults (VaultFlex, VaultL3, VaultL6, VaultL12) implemented it — they still took (user, amount) with single-asset global storage keys, so any real (non-mock) deposit/withdraw/early_exit call would fail. - Asset-scope every tier vault's storage: Balance, Shares, LockUntil, Checkpoint (and VaultL3's EmergencyUnlock) are now keyed by (user, asset) instead of user alone, so two assets held by the same user in the same tier no longer share state. shared::checkpoint is asset-scoped the same way, since it's only ever used per-position. - Standardize deposit(user, asset, amount) / withdraw(...) -> i128 / early_exit(...) -> i128 / balance / shares / lock_until across all four vaults, matching what VaultRouter already calls. - relock was still (user) on both the router and the locked vaults, which becomes ambiguous once LockUntil is asset-scoped; added an asset parameter to both sides. - Removed a duplicate token transfer inside each tier vault's deposit(): VaultRouter already moves tokens from user to vault before invoking it, so the vault's own second user -> strategy transfer (hardcoded to the tier's original single USDC address regardless of the asset parameter) double-charged the user and moved the wrong token for any non-USDC asset. Tier vaults are now bookkeeping-only; VaultRouter owns all token movement — this is also what makes real multi-asset deposits work at all rather than just fixing the argument count. - VaultFlex: added partial withdrawal (previously only full withdrawal existed), plus early_exit/lock_until for ABI parity with the locked tiers. Its previous pro-rata yield payout depended on an externally supplied strategy_balance argument the router can no longer provide under the unified signature, and StrategyVault has no way to attribute its pooled balance back to a single calling vault, so Flex now tracks its own principal balance like the locked tiers (documented on VaultFlex::withdraw). - New integration tests in vault_router/tests/ exercise VaultRouter against the real tier vault contracts (not the unit-test MockVault) across two allowlisted assets, including a regression test for the double-transfer bug above. Closes LadderMine#138
|
@soundsng is attempting to deploy a commit to the wumibals' projects Team on Vercel. A member of the Team first needs to authorize it. |
wumibals
left a comment
There was a problem hiding this comment.
Reviewed the VaultRouter <-> tier-vault ABI unification (1/8 in the payment track).
- Asset-scoping
Balance/Shares/LockUntil/Checkpoint(andEmergencyUnlockon L3) by(user, asset)instead ofuseralone is the correct fix — the router already spoke asset-scoped, the vaults didn't, so real (non-mock) multi-asset calls were broken. - The standout fix is removing the duplicate
user -> strategytransfer inside each vault'sdeposit(). That transfer both double-charged the user and hardcoded the tier's original single-USDC address regardless of theassetparam — silently moving the wrong token for any non-USDC asset. Making tier vaults bookkeeping-only, with VaultRouter as sole owner of token movement, is the right architectural boundary and is exactly what actually enables multi-asset deposits (not just the signature change). Good catch, and good that there's now a dedicated regression test for it. - Adding
assettorelockon both router and locked vaults closes a real ambiguity that would've existed onceLockUntilbecame asset-scoped. VaultFlextracking its own principal balance instead of depending on an externalstrategy_balancecallback is a reasonable, well-documented workaround givenStrategyVaultdoesn't attribute balance per caller — and correctly scoped as out-of-bounds for aStrategyVaultredesign here.- Leaving
max_tvl/remaining_capacityun-scoped is consistent withVaultRouter's existing behavior and correctly called out as outside this issue's acceptance criteria. - New integration tests against the real (non-mock) tier vaults, across two assets, covering the double-transfer regression and same-tier asset independence, are a meaningful addition — this repo's four tier vaults had zero test coverage before this PR.
CI (Soroban contracts build, Next.js dashboard, TypeScript SDK) is green; the Vercel status failure is an unauthorized deployment integration link, unrelated to the code.
One thing worth tracking as a fast follow: CI here only runs cargo build, not cargo test, so the new test suite's pass/fail hasn't been machine-verified yet (noted in the PR's own test plan) — worth wiring cargo test into CI given this code moves real funds. Not a blocker for this PR, but flagging for the next issue in the track.
Approving — good foundational fix for the payment track.
Summary
Foundation issue for the payment track (1 of 8):
VaultRouteralready forwardsdeposit/withdraw/early_exit/balance/shares/lock_untilusing an asset-scoped(user, asset, ...)signature, but none of the four tier vaults (VaultFlex,VaultL3,VaultL6,VaultL12) implemented it — they still took(user, amount)with single-asset, non-asset-scoped storage. Real (non-mock) deposit/withdraw/early_exit calls against the actual vaults would fail outright.Changes
Balance,Shares,LockUntil,Checkpoint(andVaultL3'sEmergencyUnlock) are now keyed by(user, asset)instead ofuseralone.shared::checkpointis asset-scoped the same way.deposit(user, asset, amount)/withdraw(...) -> i128/early_exit(...) -> i128/balance/shares/lock_untilacross all four vaults, matching whatVaultRouteralready calls.relockwas still(user)-only on both the router and the locked vaults — ambiguous onceLockUntilis asset-scoped, since a vault can't tell which asset's lock to renew. Added anassetparameter to both sides (router +VaultL3/L6/L12), and updated the router's own MockVault-based tests accordingly.deposit().VaultRouteralready moves tokens from the user to the vault before invoking it; the vault's own additionaluser -> strategytransfer (which also hardcoded the tier's original single USDC address regardless of theassetparameter) double-charged the user and moved the wrong token for any non-USDC asset. Tier vaults are now bookkeeping-only — VaultRouter owns all token movement. This is what actually makes multi-asset deposits work, not just the argument-count fix. See the newtest_deposit_moves_funds_exactly_once_per_assetregression test.VaultFlex: added partial withdrawal (previously only full withdrawal existed), plusearly_exit/lock_untilfor ABI parity with the locked tiers. Its previous pro-rata yield payout depended on an externally suppliedstrategy_balanceargument the router can no longer provide under the unified signature, andStrategyVaultpools capital from all tier vaults without attributing balance back to a single caller — there's no accurate callback available without aStrategyVaultredesign, which is out of scope here.VaultFlexnow tracks its own principal balance like the locked tiers (documented onVaultFlex::withdraw).set_max_tvl/max_tvl/remaining_capacityare intentionally left as-is (single global cap per vault, not asset-scoped) —VaultRouter's existingset_max_tvl/vault_capacitycalls don't pass an asset either, and this is outside the acceptance criteria for this issue.vault_router/tests/real_vaults_integration.rsexerciseVaultRouteragainst the real tier vault contracts (not the unit-testMockVault) across two allowlisted assets (a real Stellar Asset Contract test token for each), including the double-transfer regression test above and a same-tier-two-assets independence test.shared::checkpoint.CHANGELOG.mdentry.Test plan
CI on this repo only runs
cargo build --target wasm32-unknown-unknown --releasefor contracts (nocargo testgate). This Windows dev box has no Windows SDK installed, so I could not runcargo test/cargo buildnatively here (even linker-freecargo checkfails because dependercies' build scripts still need a working host linker) — I reviewed the changes carefully by hand instead and am relying on CI (Ubuntu, proper toolchain) to verify the build. Please let CI run and flag me if anything fails.cargo build --target wasm32-unknown-unknown --release(CI gate)VaultRouter, tests,shared::checkpointcallers)Closes #138