Skip to content

feat(USDat): keep M earning through migration and add claimMYield#4

Open
PierrickGT wants to merge 1 commit into
fix/add-timelock-support-to-upgrade-scriptfrom
fix/remove-m-stop-earning
Open

feat(USDat): keep M earning through migration and add claimMYield#4
PierrickGT wants to merge 1 commit into
fix/add-timelock-support-to-upgrade-scriptfrom
fix/remove-m-stop-earning

Conversation

@PierrickGT

Copy link
Copy Markdown
Member

The M reserves are drained into PYUSDX progressively via replaceAsset, which takes as long as it takes. Opting out of M earning at upgrade time forfeited all yield accrued across that entire window, so migrate no longer calls stopEarning.

Earning on alone is not enough: once M is registered as a replaceable alt-asset, MultiMint only tracks the balance snapshot taken at registration. Yield accrues on the real balanceOf, invisible to totalAssets and unreachable by both claimYield (PYUSDX excess only) and replaceAsset (tracked balance only).

claimMYield realizes that surplus: it registers the untracked M as backing and mints the matching USDat to the yield recipient, exactly as migrate already did for its own surplus. Registering alongside the mint is what keeps the PYUSDX-backed portion (totalSupply - totalAssets) unchanged. mYield exposes the pending amount, which is otherwise untrackable on-chain.

The claim is permissionless: the proceeds can only ever reach yieldRecipient, so no role gate buys anything. It keeps the frozen-recipient guard from _beforeClaimYield for the same incident-response reason.

On cap: it stays at the value migrate set. claimMYield raises only balance, pushing it past cap, which is what keeps M wraps blocked across a claim. The cap is a parameter (setAssetCap) and this path should not move it.

That block is narrower than it looks, though, and is worth a reviewer's attention: cap == balance holds only at migration. replaceAsset lowers balance while cap stays put, so each drain of D opens exactly D of wrap room and M can be wrapped back in -- undoing the drain, and round-tripping to PYUSDX at the treasury's expense. Zeroing cap is not the fix: replaceAsset and claimMYield both gate on isAllowedAsset (cap != 0), so it would halt the very drain it protects. This predates this change and is contained today only by the whitelist being enabled. Tracked separately; not addressed here.

M is now a hardcoded constant rather than a parameter, so migrate and claimMYield take no asset argument and no other token can be passed in. This changes the migrate ABI; the upgrade script is updated in step, and nothing external consumes it. IMTokenLike.stopEarning is now unused and dropped.

The fork test warps 30 days past a real mainnet upgrade and asserts the surplus is non-zero, which only holds if earning genuinely survived.

The M reserves are drained into PYUSDX progressively via
`replaceAsset`, which takes as long as it takes. Opting out of M
earning at upgrade time forfeited all yield accrued across that
entire window, so `migrate` no longer calls `stopEarning`.

Earning on alone is not enough: once M is registered as a
replaceable alt-asset, MultiMint only tracks the balance snapshot
taken at registration. Yield accrues on the real `balanceOf`,
invisible to `totalAssets` and unreachable by both `claimYield`
(PYUSDX excess only) and `replaceAsset` (tracked balance only).

`claimMYield` realizes that surplus: it registers the untracked M
as backing and mints the matching USDat to the yield recipient,
exactly as `migrate` already did for its own surplus. Registering
alongside the mint is what keeps the PYUSDX-backed portion
(`totalSupply - totalAssets`) unchanged. `mYield` exposes the
pending amount, which is otherwise untrackable on-chain.

The claim is permissionless: the proceeds can only ever reach
`yieldRecipient`, so no role gate buys anything. It keeps the
frozen-recipient guard from `_beforeClaimYield` for the same
incident-response reason.

On `cap`: it stays at the value `migrate` set. `claimMYield`
raises only `balance`, pushing it past `cap`, which is what keeps
M wraps blocked across a claim. The cap is a governance parameter
(`setAssetCap`) and this path should not move it.

That block is narrower than it looks, though, and is worth a
reviewer's attention: `cap == balance` holds only at migration.
`replaceAsset` lowers `balance` while `cap` stays put, so each
drain of D opens exactly D of wrap room and M can be wrapped back
in -- undoing the drain, and round-tripping to PYUSDX at the
treasury's expense. Zeroing `cap` is not the fix: `replaceAsset`
and `claimMYield` both gate on `isAllowedAsset` (`cap != 0`), so
it would halt the very drain it protects. This predates this
change and is contained today only by the whitelist being
enabled. Tracked separately; not addressed here.

M is now a hardcoded constant rather than a parameter, so
`migrate` and `claimMYield` take no asset argument and no other
token can be passed in. This changes the `migrate` ABI; the
upgrade script is updated in step, and nothing external consumes
it. `IMTokenLike.stopEarning` is now unused and dropped.

The fork test warps 30 days past a real mainnet upgrade and
asserts the surplus is non-zero, which only holds if earning
genuinely survived.
@PierrickGT
PierrickGT requested a review from toninorair July 16, 2026 15:17
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.

1 participant