Repository navigation
chore: implement atomic vault migration adapter - #212
Open
V00D00-child wants to merge 9 commits into
Open
V00D00-child wants to merge 9 commits into
V00D00-child wants to merge 9 commits into
Conversation
V00D00-child
marked this pull request as draft
September 23, 2026 17:24
Replace mock call-count checks with signed delegation chains through the real adapters so migrations are verified by mUSD and vault-share balances.
V00D00-child
marked this pull request as ready for review
September 23, 2026 18:39
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
VaultMigrationHelpermoves a user's Monad mUSD position between the base vault and the premium vault (pvmUSD) in one transaction, and it moves pvmUSD itself from one account to another without redeeming it. Callers are permissionless. Authorization is the delegation chain plus, for pvmUSD transfers, two separate EIP-191 signatures.It depends on the existing base and premium adapters. Those adapters redeem their own delegation chains. This contract only sequences the calls.
Why the two migration functions exist
A base-to-premium move withdraws the base vault, then deposits the premium vault. The reverse withdraws the premium vault, then deposits the base vault. Those legs are not symmetric:
Doing this as two user transactions would leave the user holding raw mUSD in between, exposed to a failed second leg and to the vault rate moving between them.
migrateToPremiumByDelegationandmigrateToBaseByDelegationrun both legs in one transaction. If the deposit reverts (compliance, slippage, delegation), the withdrawal reverts with it.minimumAssetsandminimumMintare the slippage bounds. At the end the user's mUSD balance is unchanged; the underlying only passes through the account inside the transaction.Each migration takes two delegation chains, each at least two links, sorted leaf to root:
caveats[0])ERC20TransferAmountEnforcerfor the vault share, amounttype(uint256).max, andRedeemerEnforcernaming that adapterERC20TransferAmountEnforcerfor the exact share amount. The adapter reads the amount from these terms.caveats[0]Base → premium also forwards
ComplianceDatainto the premium deposit. That digest is signed by the compliance role. Premium → base has no compliance argument.The operator redelegation exists so the user can sign a standing root (unlimited amount, redeemer pinned to one adapter) while the operator signs the leaf that caps the exact amount and names the adapter as
delegate. The adapter is what callsredeemDelegations, so it has to be the leaf delegate.RedeemerEnforceron the root is what stops any other contract from redeeming that root.Batch variants run several users in one transaction and roll the whole batch back if any stream fails. Base shares are locked for a short period after deposit; a base → premium migration has to wait out that lock before the withdraw leg can succeed.
Why pvmUSD cannot be transferred directly
pvmUSD is the premium BoringVault share. Its Teller transfer hook only allows a transfer when
fromortoholdstransferAllowedRole. A normal holder and a normal recipient do not have that role, sopvmUSD.transfer(recipient)reverts. Withdrawing to mUSD and depositing again is a different action: it redeems the position, needs a deposit compliance signature, and mints new shares to the same user.This helper is granted
transferAllowedRole, and the share moves in two hops:pvmUSD.transfer(helper, balanceOf(user)). The hook allows it because the helper isto.pvmUSD.transfer(recipient, amount). The hook allows it because the helper isfrom.The Teller hook does not check that the final recipient is premium-enabled. The role only makes the helper a conduit. Who the recipient is, and how much moves, is enforced by the signatures below.
premiumTransferalways moves the full balance at execution time. The amount is not stored in a caveat.Delegations for
premiumTransferThe chain is again leaf to root, and this helper redeems it. The root does not use
ERC20TransferAmountEnforcer, because the amount is the live balance.Root, signed by the share owner, delegate = operator:
RedeemerEnforcerterms: this helper. Only this contract may redeem.AllowedCalldataEnforcerterms:abi.encodePacked(uint256(0), IERC20.transfer.selector, abi.encode(address(helper))). The redeemed call must betransferto this helper. The amount is past that prefix and stays open.AllowedTargetsEnforcerterms: the pvmUSD vault.Leaf, signed by the operator, delegate = this helper, no caveats. The operator redelegation is what sets the helper as the leaf delegate so it can call
redeemDelegations. The user's root stays a stable authorization of the operator; the user does not put the helper in the rootdelegatefield. The leaf intentionally has no amount caveat: the helper readsbalanceOfitself, and a transfer-amount enforcer would freeze a number that goes stale as soon as the balance changes.The redeemed call is always
transfer(helper, amount). The final recipient never appears in the delegation. A caller who only held the chain could choose anytoon the second hop. That is why destination control is split across two more signatures. Neither one is enough on its own.User signature
The delegation only authorizes the first hop:
pvmUSD.transferto this helper, for the full balance. The final recipient is chosen on the second hop, which is an ordinary transfer from the helper, so that address is not fixed by any caveat.userSignatureis how the share owner fixes that address. It is an EIP-191personal_sign, checked with ERC-1271 onfrom. The inner hash is:keccak256(abi.encode(helper, chainId, from, to, root.signature))root.signatureisdelegations[length - 1].signature, the signature the owner already put on the root delegation. Putting it in this digest tiestoto that exact delegation. The same signature does not authorize a different recipient, and it does not authorize a different root (a new salt produces different signature bytes). A permissionless caller cannot point the second hop somewhere else after the user has signed.Compliance signature
EIP-191 as well, recovered directly and checked against
complianceSignerRoleon the premium Teller'sRolesAuthority.Inner hash:
keccak256(abi.encode(helper, teller, chainId, from, to, vault, amount, deadline)).The backend is attesting that this recipient is premium-enabled for this exact full balance. A user-signed
tothat the backend did not sign cannot receive. The digest is stored inusedComplianceSignatures, so the same approval cannot be replayed. The deadline must be in the future and inside the Teller'scomplianceWindow. If the Teller has compliance disabled (complianceSignerRole == 255),premiumTransferreverts rather than skipping the check.The hash starts with this helper, so a premium deposit compliance signature cannot be reused here. If the balance changes after the backend signs, the amount in the digest no longer matches
balanceOfand the transfer reverts until a new compliance signature is issued. The delegation itself has no stateful amount or call-count caveat, so a later top-up can be moved with the same root and the same user signature, but only with a new compliance signature for the new amount and recipient.Test plan
transferAllowedRoletoor a different root reverts, and the shares stay putto, amount, expired deadline, wrong signer, or a deposit digest revertsDependencies
Note
High Risk
Moves vault shares and underlying assets through permissionless entry points with custom compliance and delegation signature schemes; bugs could affect user funds or allow mis-routed premium transfers.
Overview
Introduces
VaultMigrationHelper, a compositor over immutableVedaAdapterandComplianceVedaAdapterso base ↔ premium vault moves run as one atomic transaction via paired withdrawal/deposit delegation chains (single and batch, permissionless callers). To-premium legs still forward premium deposit compliance to the compliance adapter; shared root delegator and slippage checks are enforced in the helper.Also adds
premiumTransfer/ batch: redeems a leaf delegation that sends the owner’s full premium share balance to the helper, then forwards shares to atoaddress only after dual destination control—ERC-1271 EIP-191 user proof bindingfrom/to/leaf signature, plus a helper-scoped compliance EIP-191 digest (replay-protected, Teller signer role/window, distinct from deposit signatures). OwnerwithdrawEmergencyrecovers stray tokens.Deployment is supported via
DeployVaultMigrationHelper.s.sol(CREATE2 + new.envvars) andIComplianceVedaTeller/IRolesAuthorityinterface additions for compliance role checks. Broad Monad fork tests cover migrations, batches, transfer edge cases, and compliance failures.Reviewed by Cursor Bugbot for commit 0844476. Bugbot is set up for automated code reviews on this repo. Configure here.