fix(vault): add two-phase migration with ledger-gap stability check - #606
Conversation
|
@ayobamivictorakinpelu-star Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
|
@ayobamivictorakinpelu-star is attempting to deploy a commit to the Collins' projects Team on Vercel. A member of the Team first needs to authorize it. |
collinsezedike
left a comment
There was a problem hiding this comment.
One more thing, not anchorable since it's a base-branch issue rather than a line in this diff: this PR is based on main from before #600 merged. MigrationNotInitialized = 15 collides with #600's already-merged MinAmountOutNotMet = 15. Needs a rebase onto current main and the new variants renumbered starting from 16.
| return Err(ContractError::MigrationValueDrift); | ||
| } | ||
|
|
||
| // Check 2: stability — the new adapter's current value must be |
There was a problem hiding this comment.
This check doesn't test what it claims to. snapshot.total_assets is new_adapter.total_assets() read by begin_migration, before any deposit, for the normal case (migrating into a fresh, previously-unused adapter) that's ~0. value_after here is read after this function's own new_adapter_client.deposit(&withdrawn) a few lines up, so it includes the funds this migration itself just deposited. The comparison is effectively withdrawn_amount >= 0 * (1 - slippage), which can never fail in the realistic case, it isn't testing whether the adapter's valuation stayed stable, it's just confirming a successful deposit produced a positive balance.
migrate_adapter_fails_when_stability_drift_detected only exercises this because the test's ManipulableMockAdapter has its own total_assets() manually overridden to a large inflated value before begin_migration, unrelated to any real deposit, then deflated afterward, a setup that doesn't correspond to any real migration flow. Against a real target adapter (Blend, DeFindex, or another MockAdapter), the snapshot will always be at or near zero for the intended use case of migrating into a fresh adapter, so this check provides no actual protection there.
For this to catch what issue #567 describes (a transiently-shifted valuation between snapshot and execution), the comparison needs to be against the target adapter's own state at both points, e.g. re-reading new_adapter.total_assets() immediately before this function's deposit call and comparing that fresh read to the begin_migration snapshot, not comparing the snapshot to the post-deposit total which necessarily includes funds the snapshot never accounted for.
There was a problem hiding this comment.
Thanks for the detailed review, @collinsezedike! Both issues have been addressed:
Stability check logic fix (this comment):
The comparison now uses a fresh pre-deposit total_assets() read (pre_deposit_now) captured immediately before the deposit() call, compared against the begin_migration snapshot. Both are total_assets() readings of the same adapter at two different points in time — not a post-deposit delta against a snapshot. This genuinely detects valuation drift during the cooldown gap.
Error code renumbering (review comment):
Rebased onto current upstream/main. New variants renumbered to avoid collision with #600:
MigrationNotInitialized = 17MigrationCooldownNotMet = 18MigrationStabilityDrift = 19
Tests rewritten:
migrate_adapter_fails_when_stability_drift_detectednow usesMockAdapterwith real USDC balance: mints pre-existing funds, then transfers USDC out during cooldown to simulate genuine external withdrawal. The adapter'spre_deposit_nowends up below the snapshot, triggeringMigrationStabilityDrift.stale_snapshot_survives_failed_migrationuses the same realistic pattern.migrate_adapter_excludes_target_pre_existing_balance_from_value_afternow includes the requiredbegin_migrationcall.snapshot_cleared_on_successful_migrationalready covers the positive-path "fresh adapter" case — stability check doesn't spuriously fail when both snapshot andpre_deposit_noware 0.
All 68 tests pass; cargo fmt and cargo clippy clean. The Vercel check failure is a separate authorization issue unrelated to these changes.
There was a problem hiding this comment.
Thanks @collinsezedike! This has been addressed — rebased onto current upstream/main and renumbered the new error variants to avoid the collision with #600:
MigrationNotInitialized = 17MigrationCooldownNotMet = 18MigrationStabilityDrift = 19
All references throughout the vault contract and its tests use the enum variant names (not numeric literals), so no further updates were needed beyond the enum definition.
migrate_adapter now requires a prior begin_migration call that snapshots the target adapter's total_assets() and the current ledger sequence. At least MIN_LEDGER_GAP (12 ledgers, ~1 minute) must elapse before migrate_adapter can be called, and the new adapter's valuation must be stable within the caller's slippage tolerance across that cooldown. This prevents an observer from griefing or masking a migration by front-running a transiently-shifted valuation. Closes drydocs#567
…ility check logic - Rebased onto upstream/main and resolved enum collision: renumbered MigrationNotInitialized=17, MigrationCooldownNotMet=18, MigrationStabilityDrift=19 (upstream claimed 15-16 for MinAmountOutNotMet and NoPendingAdmin). - Fixed the stability check in migrate_adapter: now compares a fresh pre-deposit total_assets() read against the begin_migration snapshot, instead of comparing the post-deposit delta (value_after) which could never meaningfully fail. - Rewrote migrate_adapter_fails_when_stability_drift_detected and stale_snapshot_survives_failed_migration to use MockAdapter with real USDC balance manipulation (mint pre-existing funds, then transfer out during cooldown) so the test genuinely exercises the fixed comparison. - Added begin_migration call to migrate_adapter_excludes_target_pre_existing_balance_from_value_after. - All 68 tests pass; cargo fmt and clippy clean. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
75c8891 to
2c4e3eb
Compare
Overview
This PR introduces a two-phase migration mechanism for
migrate_adapterthat requires the target adapter's valuation to be stable across a minimum elapsed-ledger gap before accepting it as the post-migration value. This prevents an observer from griefing or masking a migration by front-running a transiently-shiftedtotal_assets()reading.Related Issue
Closes #567
Changes
🔒 Two-Phase Migration Security (contracts/vault)
[ADD]
begin_migration(new_adapter)— Phase 1total_assets()and the current ledger sequenceMigrationSnapshotstruct (adapter, total_assets, ledger_seq)MIN_LEDGER_GAP(12 ledgers ≈ 1 minute) before callingmigrate_adapter[MODIFY]
migrate_adapter(new_adapter, max_slippage_bps)— Phase 2begin_migrationcall for the same target adaptervalue_after >= value_before × (1 - slippage)(existing, preserved)value_after >= snapshot_value × (1 - slippage)(new)MIG_ACTIVEflag[ADD]
get_migration_snapshot()— off-chain visibility[ADD]
MigrationSnapshottype,MIG_SNAP/MIG_ACTIVEstorage keys[ADD]
MIN_LEDGER_GAP = 12constant (~1 minute at 5s/ledger close)[ADD] Three new error variants:
MigrationNotInitialized(15) — no priorbegin_migrationcallMigrationCooldownNotMet(16) — ledger gap not yet elapsedMigrationStabilityDrift(17) — adapter valuation drifted beyond tolerance[ADD]
ManipulableMockAdaptertest double — allows settingtotal_assets()independently of actual USDC balance for testing manipulation scenarios[ADD] 9 new tests:
migrate_adapter_requires_prior_begin_migrationmigrate_adapter_fails_before_cooldown_elapsesbegin_migration_fails_for_same_adapterget_migration_snapshot_fails_without_beginbegin_migration_records_snapshot_and_getter_returns_itbegin_migration_overwrites_previous_snapshotmigrate_adapter_fails_when_stability_drift_detectedstale_snapshot_survives_failed_migrationsnapshot_cleared_on_successful_migrationVerification Results
Acceptance Criteria
MIN_LEDGER_GAP = 12ledgers (~1 minute) betweenbegin_migrationandmigrate_adapterget_migration_snapshot()returns snapshot + ledger sequencebegin_migrationcall required beforemigrate_adapterMigration Notes
After this change, any caller of
migrate_adaptermust first callbegin_migration(new_adapter)and wait at least ~1 minute before callingmigrate_adapter(new_adapter, max_slippage_bps). The existing API is tightened, not broken.