Skip to content

chore: implement atomic vault migration adapter - #212

Open
V00D00-child wants to merge 9 commits into
chore-add-support-for-veda-protocol-with-compliancefrom
chore/vault-migration-adapter-veda
Open

V00D00-child wants to merge 9 commits into
chore-add-support-for-veda-protocol-with-compliancefrom
chore/vault-migration-adapter-veda

Conversation

@V00D00-child

@V00D00-child V00D00-child commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

Summary

VaultMigrationHelper moves 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:

  • Premium deposits are compliance-gated. Premium withdrawals are not.
  • Base deposits and withdrawals take no compliance signature.
  • Each leg is redeemed by the adapter for that vault, not by this helper.

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. migrateToPremiumByDelegation and migrateToBaseByDelegation run both legs in one transaction. If the deposit reverts (compliance, slippage, delegation), the withdrawal reverts with it. minimumAssets and minimumMint are 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:

Leg Root (user → operator) Leaf (operator → adapter, caveats[0])
Withdraw ERC20TransferAmountEnforcer for the vault share, amount type(uint256).max, and RedeemerEnforcer naming that adapter ERC20TransferAmountEnforcer for the exact share amount. The adapter reads the amount from these terms.
Deposit Same shape, but the token is mUSD and the redeemer is the destination adapter Exact mUSD amount in caveats[0]

Base → premium also forwards ComplianceData into 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 calls redeemDelegations, so it has to be the leaf delegate. RedeemerEnforcer on 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 from or to holds transferAllowedRole. A normal holder and a normal recipient do not have that role, so pvmUSD.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:

  1. The user's account calls pvmUSD.transfer(helper, balanceOf(user)). The hook allows it because the helper is to.
  2. The helper calls pvmUSD.transfer(recipient, amount). The hook allows it because the helper is from.

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.

premiumTransfer always moves the full balance at execution time. The amount is not stored in a caveat.

Delegations for premiumTransfer

The 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:

  • RedeemerEnforcer terms: this helper. Only this contract may redeem.
  • AllowedCalldataEnforcer terms: abi.encodePacked(uint256(0), IERC20.transfer.selector, abi.encode(address(helper))). The redeemed call must be transfer to this helper. The amount is past that prefix and stays open.
  • AllowedTargetsEnforcer terms: 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 root delegate field. The leaf intentionally has no amount caveat: the helper reads balanceOf itself, 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 any to on 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.transfer to 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.

userSignature is how the share owner fixes that address. It is an EIP-191 personal_sign, checked with ERC-1271 on from. The inner hash is:

keccak256(abi.encode(helper, chainId, from, to, root.signature))

root.signature is delegations[length - 1].signature, the signature the owner already put on the root delegation. Putting it in this digest ties to to 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 complianceSignerRole on the premium Teller's RolesAuthority.

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 to that the backend did not sign cannot receive. The digest is stored in usedComplianceSignatures, so the same approval cannot be replayed. The deadline must be in the future and inside the Teller's complianceWindow. If the Teller has compliance disabled (complianceSignerRole == 255), premiumTransfer reverts 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 balanceOf and 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

  • Fork: base → premium and premium → base move the full position, leave mUSD unchanged, and leave no dust on the helper
  • A reverting second leg (slippage, bad compliance, delegator mismatch) restores the source shares
  • pvmUSD transfer of the full balance succeeds only when the helper holds transferAllowedRole
  • User signature over a different to or a different root reverts, and the shares stay put
  • Compliance signature over a different to, amount, expired deadline, wrong signer, or a deposit digest reverts
  • Replayed compliance digest reverts; a later failure in a batch rolls back earlier streams

Dependencies


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 immutable VedaAdapter and ComplianceVedaAdapter so 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 a to address only after dual destination control—ERC-1271 EIP-191 user proof binding from/to/leaf signature, plus a helper-scoped compliance EIP-191 digest (replay-protected, Teller signer role/window, distinct from deposit signatures). Owner withdrawEmergency recovers stray tokens.

Deployment is supported via DeployVaultMigrationHelper.s.sol (CREATE2 + new .env vars) and IComplianceVedaTeller / IRolesAuthority interface 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.

@V00D00-child
V00D00-child requested a review from a team as a code owner September 23, 2026 17:22
@V00D00-child
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
V00D00-child marked this pull request as ready for review September 23, 2026 18:39
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.

2 participants