diff --git a/contracts/MaticX.sol b/contracts/MaticX.sol index 43273ad4..d25e8d80 100644 --- a/contracts/MaticX.sol +++ b/contracts/MaticX.sol @@ -16,6 +16,7 @@ import { IMaticX } from "./interfaces/IMaticX.sol"; /// @title MaticX contract /// @notice MaticX is the main contract that manages staking and unstaking of /// POL tokens for users. +// solhint-disable-next-line max-states-count contract MaticX is IMaticX, ERC20Upgradeable, @@ -31,6 +32,8 @@ contract MaticX is uint256 private constant NOT_ENTERED = 1; uint256 private constant ENTERED = 2; + uint256 public constant TERMINAL_RATE_PRECISION = 1e18; + IValidatorRegistry private validatorRegistry; IStakeManager private stakeManager; IERC20Upgradeable private maticToken; @@ -45,6 +48,64 @@ contract MaticX is IERC20Upgradeable private polToken; uint256 private reentrancyGuardStatus; + /// ---------------------- Sunset storage (v3) ----------------------------- + bool public recallInitiated; + bool public recallComplete; + bool public terminalRateLocked; + bool public instantRedeemEnabled; + bool public assetCustodied; + + uint256 public preFinalizeRate; + uint256 public terminalRate; + uint256 public sweepToCustodyTimestamp; + + mapping(address => uint256) public assetRecallNonces; + + /// ---------------------- Sunset errors ----------------------------------- + error TerminalRateAlreadyLocked(); + error TerminalRateNotLocked(); + error EmptyContract(); + error InsufficientRecalledBalance(); + error AmountInPolZero(); + error CustodyDelayNotElapsed(); + error ZeroAddress(); + error ZeroAmount(); + error UnpauseLockedAfterRecall(); + error RecallAlreadyInitiated(); + error RecallNotInitiated(); + error RecallClaimsNotComplete(); + error RecallAlreadyComplete(); + error ZeroCustodyDelay(); + error AssetCustodied(); + + /// ---------------------- Sunset events ----------------------------------- + event AssetRecallInitiated( + address indexed validatorShare, + uint256 nonce, + uint256 stake + ); + event AssetRecallCompleted( + uint256 polBalance, + uint256 supplyAtFreeze, + uint256 terminalRate + ); + event TerminalRatePushedToL2( + uint256 supplyAtPush, + uint256 polBalanceAtPush + ); + event InstantRedeemToggled(address indexed by, bool enabled); + event InstantClaimed( + address indexed user, + uint256 amountInMaticX, + uint256 amountInPol + ); + event SweptToCustody( + address indexed asset, + address indexed custody, + uint256 amount + ); + event SetCustodyDelay(uint256 newSweepToCustodyTimestamp); + /// ------------------------------ Modifiers ------------------------------- /// @notice Enables guard from reentrant calls. @@ -202,14 +263,21 @@ contract MaticX is return amountToMint; } - /// @notice Registers a user's request to withdraw an amount of POL tokens. - /// @param _amount - Amount of POL tokens + /// @notice Registers a user's request to withdraw by burning MaticX shares. + /// @param _amount - Amount of MaticX shares to burn // slither-disable-next-line reentrancy-no-eth function requestWithdraw( uint256 _amount - ) external override nonReentrant whenNotPaused { + ) external override nonReentrant { require(_amount > 0, "Invalid amount"); + if (instantRedeemEnabled) { + _instantClaim(_amount); + return; + } + + require(!paused(), "Pausable: paused"); + ( uint256 amountToWithdraw, uint256 totalShares, @@ -307,9 +375,7 @@ contract MaticX is /// @notice Claims POL tokens from a validator share and sends them to the /// user. /// @param _idx - Array index of the user's withdrawal request - function claimWithdrawal( - uint256 _idx - ) external override nonReentrant whenNotPaused { + function claimWithdrawal(uint256 _idx) external override nonReentrant { WithdrawalRequest[] storage userRequests = userWithdrawalRequests[ msg.sender ]; @@ -491,6 +557,151 @@ contract MaticX is ); } + /// ------------------------------ Sunset ---------------------------------- + + /// @notice Unstakes the full stake from every registered validator. + function bulkUnstakeAllValidators() external onlyRole(DEFAULT_ADMIN_ROLE) { + require(paused(), "Pause first"); + if (recallInitiated) revert RecallAlreadyInitiated(); + + // Snapshot pre-recall rate so oracle does not drift toward zero + // as vouchers move into the withdraw pool. + recallInitiated = true; + uint256 supplySnap = totalSupply(); + preFinalizeRate = supplySnap == 0 + ? 0 + : (getTotalStakeAcrossAllValidators() * TERMINAL_RATE_PRECISION) / + supplySnap; + + uint256[] memory validatorIds = validatorRegistry.getValidators(); + uint256 validatorCount = validatorIds.length; + + for (uint256 i = 0; i < validatorCount; ) { + address vs = stakeManager.getValidatorContract(validatorIds[i]); + (uint256 stake, ) = IValidatorShare(vs).getTotalStake( + address(this) + ); + + if (stake > 0) { + IValidatorShare(vs).sellVoucher_newPOL( + stake, + type(uint256).max + ); + uint256 nonce = IValidatorShare(vs).unbondNonces(address(this)); + assetRecallNonces[vs] = nonce; + emit AssetRecallInitiated(vs, nonce, stake); + } + + unchecked { + ++i; + } + } + } + + /// @notice Claims all pending unbond nonces from `bulkUnstakeAllValidators`. + /// Retryable: nonces clear only on successful claim. + function claimAssetRecallNonces() external onlyRole(DEFAULT_ADMIN_ROLE) { + if (!recallInitiated) revert RecallNotInitiated(); + if (recallComplete) revert RecallAlreadyComplete(); + recallComplete = true; + + uint256[] memory validatorIds = validatorRegistry.getValidators(); + uint256 validatorCount = validatorIds.length; + + for (uint256 i = 0; i < validatorCount; ) { + address vs = stakeManager.getValidatorContract(validatorIds[i]); + uint256 nonce = assetRecallNonces[vs]; + if (nonce != 0) { + IValidatorShare(vs).unstakeClaimTokens_newPOL(nonce); + delete assetRecallNonces[vs]; + } + + unchecked { + ++i; + } + } + } + + /// @notice Freezes the MATICx -> POL exchange rate. One-shot. + function finalizeTerminalRate() external onlyRole(DEFAULT_ADMIN_ROLE) { + if (!recallComplete) revert RecallClaimsNotComplete(); + if (terminalRateLocked) revert TerminalRateAlreadyLocked(); + terminalRateLocked = true; + + uint256 polBalance = polToken.balanceOf(address(this)); + uint256 supply = totalSupply(); + if (polBalance == 0 || supply == 0) revert EmptyContract(); + + terminalRate = (polBalance * TERMINAL_RATE_PRECISION) / supply; + + emit AssetRecallCompleted(polBalance, supply, terminalRate); + } + + /// @notice Pushes (totalSupply, polBalance) to the L2 ChildPool. + function pushTerminalRateToL2() external onlyRole(DEFAULT_ADMIN_ROLE) { + if (!terminalRateLocked) revert TerminalRateNotLocked(); + uint256 supply = totalSupply(); + uint256 polBalance = polToken.balanceOf(address(this)); + fxStateRootTunnel.sendMessageToChild(abi.encode(supply, polBalance)); + emit TerminalRatePushedToL2(supply, polBalance); + } + + /// @notice Enables or disables instant redemption. + /// @param _enabled - Whether instant redemption is enabled + function setInstantRedeemEnabled( + bool _enabled + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (!terminalRateLocked) revert TerminalRateNotLocked(); + instantRedeemEnabled = _enabled; + emit InstantRedeemToggled(msg.sender, _enabled); + } + + /// @dev Burns `_amount` MATICx from caller and sends POL at the terminal + /// rate from recalled balance. Routed via requestWithdraw once + /// instantRedeemEnabled is set. + function _instantClaim(uint256 _amount) private { + if (assetCustodied) revert AssetCustodied(); + + (uint256 amountInPol, , ) = _convertMaticXToPOL(_amount); + if (amountInPol == 0) revert AmountInPolZero(); + if (polToken.balanceOf(address(this)) < amountInPol) { + revert InsufficientRecalledBalance(); + } + + _burn(msg.sender, _amount); + polToken.safeTransfer(msg.sender, amountInPol); + + emit InstantClaimed(msg.sender, _amount, amountInPol); + } + + /// @notice Sweeps the full balance of `_asset` to `_custody`. Callable + /// only after `sweepToCustodyTimestamp`. Disables sunset claims. + /// @param _asset - Token to sweep + /// @param _custody - Address to receive the swept tokens + function sweepToCustody( + address _asset, + address _custody + ) external nonReentrant onlyRole(DEFAULT_ADMIN_ROLE) { + if (!terminalRateLocked) revert TerminalRateNotLocked(); + assetCustodied = true; + if ( + sweepToCustodyTimestamp == 0 || + block.timestamp < sweepToCustodyTimestamp + ) { + revert CustodyDelayNotElapsed(); + } + if (_asset == address(0) || _custody == address(0)) { + revert ZeroAddress(); + } + + uint256 bal = IERC20Upgradeable(_asset).balanceOf(address(this)); + if (bal == 0) revert ZeroAmount(); + + IERC20Upgradeable(_asset).safeTransfer(_custody, bal); + + emit SweptToCustody(_asset, _custody, bal); + } + /// ------------------------------ Setters --------------------------------- /// @notice Sets a fee percent where 1 = 0.01%. @@ -498,7 +709,13 @@ contract MaticX is // slither-disable-next-line reentrancy-eth function setFeePercent( uint16 _feePercent - ) external override nonReentrant onlyRole(DEFAULT_ADMIN_ROLE) { + ) + external + override + nonReentrant + whenNotPaused + onlyRole(DEFAULT_ADMIN_ROLE) + { require(_feePercent <= MAX_FEE_PERCENT, "Fee percent is too high"); uint256[] memory validatorIds = validatorRegistry.getValidators(); @@ -527,11 +744,22 @@ contract MaticX is emit SetTreasury(_treasury); } + /// @notice Sets the sweep window. + /// @param _custodyDelay - Seconds from now until sweep becomes callable + function setCustodyDelay( + uint256 _custodyDelay + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (_custodyDelay == 0) revert ZeroCustodyDelay(); + sweepToCustodyTimestamp = block.timestamp + _custodyDelay; + emit SetCustodyDelay(sweepToCustodyTimestamp); + } + /// @notice Sets the address of the validator registry. /// @param _validatorRegistry - Address of the validator registry function setValidatorRegistry( address _validatorRegistry ) external override onlyRole(DEFAULT_ADMIN_ROLE) { + if (recallInitiated) revert RecallAlreadyInitiated(); require( _validatorRegistry != address(0), "Zero validator registry address" @@ -546,6 +774,7 @@ contract MaticX is function setFxStateRootTunnel( address _fxStateRootTunnel ) external override onlyRole(DEFAULT_ADMIN_ROLE) { + if (recallInitiated) revert RecallAlreadyInitiated(); require( _fxStateRootTunnel != address(0), "Zero fx state root tunnel address" @@ -566,8 +795,12 @@ contract MaticX is emit SetVersion(_version); } - /// @notice Toggles the paused status of this contract. + /// @notice Toggles the paused status of this contract. Cannot unpause + /// once `recallInitiated` is true. function togglePause() external override onlyRole(DEFAULT_ADMIN_ROLE) { + if (recallInitiated && paused()) { + revert UnpauseLockedAfterRecall(); + } paused() ? _unpause() : _pause(); } @@ -605,7 +838,19 @@ contract MaticX is uint256 _balance ) private view returns (uint256, uint256, uint256) { uint256 totalShares = totalSupply(); - totalShares = totalShares == 0 ? 1 : totalShares; + if (totalShares == 0) totalShares = 1; + + if (terminalRateLocked || recallInitiated) { + uint256 rate = terminalRateLocked ? terminalRate : preFinalizeRate; + if (rate == 0) rate = 1; + uint256 totalPooled = (totalShares * rate) / + TERMINAL_RATE_PRECISION; + return ( + (_balance * rate) / TERMINAL_RATE_PRECISION, + totalShares, + totalPooled + ); + } uint256 totalPooledAmount = getTotalStakeAcrossAllValidators(); if (totalPooledAmount == 0) { @@ -649,7 +894,19 @@ contract MaticX is uint256 _balance ) private view returns (uint256, uint256, uint256) { uint256 totalShares = totalSupply(); - totalShares = totalShares == 0 ? 1 : totalShares; + if (totalShares == 0) totalShares = 1; + + if (terminalRateLocked || recallInitiated) { + uint256 rate = terminalRateLocked ? terminalRate : preFinalizeRate; + if (rate == 0) rate = 1; + uint256 totalPooled = (totalShares * rate) / + TERMINAL_RATE_PRECISION; + return ( + (_balance * TERMINAL_RATE_PRECISION) / rate, + totalShares, + totalPooled + ); + } uint256 totalPooledAmount = getTotalStakeAcrossAllValidators(); if (totalPooledAmount == 0) { diff --git a/contracts/interfaces/IMaticX.sol b/contracts/interfaces/IMaticX.sol index 2d751aa7..a1c0d199 100644 --- a/contracts/interfaces/IMaticX.sol +++ b/contracts/interfaces/IMaticX.sol @@ -114,8 +114,8 @@ interface IMaticX is IERC20Upgradeable { /// @return Amount of minted MaticX shares function submitPOL(uint256 _amount) external returns (uint256); - /// @notice Registers a user's request to withdraw an amount of POL tokens. - /// @param _amount - Amount of POL tokens + /// @notice Registers a user's request to withdraw by burning MaticX shares. + /// @param _amount - Amount of MaticX shares to burn function requestWithdraw(uint256 _amount) external; /// @notice Claims POL tokens from a validator share and sends them to the diff --git a/hardhat.config.ts b/hardhat.config.ts index 0313ed61..6141ec53 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -127,7 +127,8 @@ const config: HardhatUserConfig = { ], }, mocha: { - reporter: process.env.CI ? "dot" : "nyan", + reporter: + process.env.MOCHA_REPORTER || (process.env.CI ? "dot" : "nyan"), timeout: "1h", }, etherscan: { diff --git a/tasks/index.ts b/tasks/index.ts index eccef9b4..4ce4fa39 100644 --- a/tasks/index.ts +++ b/tasks/index.ts @@ -11,6 +11,7 @@ import "./generate-initializev2-calldata-validator-registry"; import "./import-contract"; import "./initialize-v2-matic-x"; import "./initialize-v2-validator-registry"; +import "./sunset"; import "./upgrade-contract"; import "./validate-child-deployment"; import "./validate-parent-deployment"; diff --git a/tasks/sunset.ts b/tasks/sunset.ts new file mode 100644 index 00000000..83c69a86 --- /dev/null +++ b/tasks/sunset.ts @@ -0,0 +1,403 @@ +import fs from "node:fs"; +import path from "node:path"; +import { Interface } from "ethers"; +import { task, types } from "hardhat/config"; +import { HardhatRuntimeEnvironment } from "hardhat/types"; + +/** + * Operational tasks for the MaticX sunset (v2 — recall-and-hold). + * + * hardhat sunset:deploy-impl --network ethereum + * hardhat sunset:encode-upgrade --network ethereum # multisig/timelock calldata + * hardhat sunset:verify-upgrade --network ethereum # post-upgrade smoke + * hardhat sunset:status --network ethereum # state dump at every step + * hardhat sunset:encode-step --step [--arg ] + * + * Steps for encode-step: pause | bulk-unstake | claim-recall | freeze | + * push-l2 | enable-instant-redeem | disable-instant-redeem | + * set-custody-delay | sweep + */ + +// 2 years (730 days) — sweep window before residual assets can be moved to custody +const CUSTODY_DELAY_SECONDS = 730n * 24n * 60n * 60n; + +const TIMELOCK_SALT_TEXT = "MATICX_SUNSET_V2_UPGRADE"; +const PROXY_ADMIN_ABI = [ + "function upgrade(address proxy, address impl) external", + "function getProxyImplementation(address proxy) view returns (address)", +]; +const TIMELOCK_ABI = [ + "function getMinDelay() view returns (uint256)", + "function schedule(address target, uint256 value, bytes data, bytes32 predecessor, bytes32 salt, uint256 delay)", + "function execute(address target, uint256 value, bytes data, bytes32 predecessor, bytes32 salt) payable", +]; + +interface DeploymentInfo { + eth_maticX_proxy: string; + eth_proxy_admin: string; + eth_multisig: string; + manager: string; + [key: string]: string; +} + +function deploymentPath(network: string): string { + const candidates = [ + `${network}-deployment-info.json`, + network === "mainnet" || network === "ethereum" + ? "mainnet-deployment-info.json" + : null, + ].filter(Boolean) as string[]; + for (const c of candidates) { + const p = path.join(process.cwd(), c); + if (fs.existsSync(p)) return p; + } + throw new Error( + `No deployment-info.json found for network "${network}". Tried: ${candidates.join(", ")}` + ); +} + +function readDeployment(network: string): DeploymentInfo { + return JSON.parse(fs.readFileSync(deploymentPath(network), "utf8")); +} + +function writeDeploymentField( + network: string, + key: string, + value: string +): void { + const file = deploymentPath(network); + const current = JSON.parse(fs.readFileSync(file, "utf8")); + current[key] = value; + fs.writeFileSync(file, JSON.stringify(current, null, "\t") + "\n"); + console.log(` saved ${key} = ${value} -> ${path.basename(file)}`); +} + +task("sunset:deploy-impl") + .setDescription( + "Validates storage layout and deploys the new MaticX sunset implementation" + ) + .setAction(async (_args, hre: HardhatRuntimeEnvironment) => { + const network = hre.network.name; + const dep = readDeployment(network); + const Factory = await hre.ethers.getContractFactory("MaticX"); + + console.log("Validating storage-layout compatibility..."); + await hre.upgrades.validateUpgrade(dep.eth_maticX_proxy, Factory, { + kind: "transparent", + }); + + console.log("Deploying new MaticX implementation..."); + const implAddress = await hre.upgrades.deployImplementation(Factory, { + kind: "transparent", + }); + console.log(" implementation:", implAddress); + + writeDeploymentField( + network, + "eth_maticX_sunset_impl", + implAddress as string + ); + console.log("\nNext: hardhat sunset:encode-upgrade --network", network); + }); + +task("sunset:encode-upgrade") + .setDescription( + "Emits proxy-admin upgrade() calldata. Optionally wraps in a Timelock schedule/execute pair." + ) + .addOptionalParam( + "timelock", + "Timelock address — if provided, emits Timelock-wrapped calldata", + undefined, + types.string + ) + .setAction( + async ( + { timelock }: { timelock?: string }, + hre: HardhatRuntimeEnvironment + ) => { + const network = hre.network.name; + const dep = readDeployment(network); + const impl = dep.eth_maticX_sunset_impl; + if (!impl) { + throw new Error( + "eth_maticX_sunset_impl missing. Run sunset:deploy-impl first." + ); + } + + const proxyAdmin = new hre.ethers.Interface(PROXY_ADMIN_ABI); + const upgradeData = proxyAdmin.encodeFunctionData("upgrade", [ + dep.eth_maticX_proxy, + impl, + ]); + + console.log("Proxy admin:", dep.eth_proxy_admin); + console.log("Upgrade calldata (proxy admin → upgrade):"); + console.log(" ", upgradeData); + + if (!timelock) return; + + const tl = await hre.ethers.getContractAt(TIMELOCK_ABI, timelock); + const delay: bigint = await tl.getMinDelay(); + const iface = new hre.ethers.Interface(TIMELOCK_ABI); + const salt = hre.ethers.id(TIMELOCK_SALT_TEXT); + const scheduleData = iface.encodeFunctionData("schedule", [ + dep.eth_proxy_admin, + 0n, + upgradeData, + hre.ethers.ZeroHash, + salt, + delay, + ]); + const executeData = iface.encodeFunctionData("execute", [ + dep.eth_proxy_admin, + 0n, + upgradeData, + hre.ethers.ZeroHash, + salt, + ]); + + console.log("\nTimelock:", timelock); + console.log(`Schedule (delay ${delay}s):`); + console.log(" ", scheduleData); + console.log("Execute (after delay):"); + console.log(" ", executeData); + } + ); + +task("sunset:verify-upgrade") + .setDescription( + "Verifies post-upgrade state: implementation correct, all sunset state zeroed" + ) + .setAction(async (_args, hre: HardhatRuntimeEnvironment) => { + const network = hre.network.name; + const dep = readDeployment(network); + + const proxyAdmin = await hre.ethers.getContractAt( + PROXY_ADMIN_ABI, + dep.eth_proxy_admin + ); + const liveImpl: string = await proxyAdmin.getProxyImplementation( + dep.eth_maticX_proxy + ); + const expectedImpl = dep.eth_maticX_sunset_impl; + if (!expectedImpl) { + throw new Error( + "eth_maticX_sunset_impl missing from deployment-info — " + + "cannot verify upgrade target. Run sunset:deploy-impl first." + ); + } + console.log("Live impl: ", liveImpl); + console.log("Expected impl:", expectedImpl); + if (liveImpl.toLowerCase() !== expectedImpl.toLowerCase()) { + throw new Error( + "Live implementation does not match expected impl." + ); + } + + const maticX = await hre.ethers.getContractAt( + "MaticX", + dep.eth_maticX_proxy + ); + const [ + paused, + terminalRateLocked, + instantRedeemEnabled, + terminalRate, + sweepToCustodyTimestamp, + recallInitiated, + preFinalizeRate, + recallComplete, + assetCustodied, + ] = await Promise.all([ + maticX.paused(), + maticX.terminalRateLocked(), + maticX.instantRedeemEnabled(), + maticX.terminalRate(), + maticX.sweepToCustodyTimestamp(), + maticX.recallInitiated(), + maticX.preFinalizeRate(), + maticX.recallComplete(), + maticX.assetCustodied(), + ]); + + console.log("paused ", paused); + console.log("terminalRateLocked ", terminalRateLocked); + console.log("instantRedeemEnabled ", instantRedeemEnabled); + console.log("terminalRate ", terminalRate.toString()); + console.log( + "sweepToCustodyTimestamp ", + sweepToCustodyTimestamp.toString() + ); + console.log("recallInitiated ", recallInitiated); + console.log("preFinalizeRate ", preFinalizeRate.toString()); + console.log("recallComplete ", recallComplete); + console.log("assetCustodied ", assetCustodied); + + const fresh = + !terminalRateLocked && + !instantRedeemEnabled && + terminalRate === 0n && + sweepToCustodyTimestamp === 0n && + !recallInitiated && + preFinalizeRate === 0n && + !recallComplete && + !assetCustodied; + if (!fresh) { + throw new Error( + "Post-upgrade sunset state is not fresh. Aborting." + ); + } + console.log("\nSunset upgrade verified — state is fresh."); + }); + +task("sunset:status") + .setDescription("Reads all sunset state from the proxy") + .setAction(async (_args, hre: HardhatRuntimeEnvironment) => { + const dep = readDeployment(hre.network.name); + const maticX = await hre.ethers.getContractAt( + "MaticX", + dep.eth_maticX_proxy + ); + const pol = await hre.ethers.getContractAt( + "IERC20", + "0x455e53CBB86018Ac2B8092FdCd39d8444aFFC3F6" + ); + const matic = await hre.ethers.getContractAt( + "IERC20", + "0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0" + ); + + const [ + paused, + terminalRateLocked, + instantRedeemEnabled, + terminalRate, + sweepToCustodyTimestamp, + recallInitiated, + preFinalizeRate, + recallComplete, + assetCustodied, + totalSupply, + polBalance, + maticBalance, + ] = await Promise.all([ + maticX.paused(), + maticX.terminalRateLocked(), + maticX.instantRedeemEnabled(), + maticX.terminalRate(), + maticX.sweepToCustodyTimestamp(), + maticX.recallInitiated(), + maticX.preFinalizeRate(), + maticX.recallComplete(), + maticX.assetCustodied(), + maticX.totalSupply(), + pol.balanceOf(dep.eth_maticX_proxy), + matic.balanceOf(dep.eth_maticX_proxy), + ]); + + console.log("MaticX proxy:", dep.eth_maticX_proxy); + console.log(" paused :", paused); + console.log(" terminalRateLocked :", terminalRateLocked); + console.log(" instantRedeemEnabled :", instantRedeemEnabled); + console.log(" terminalRate :", terminalRate.toString()); + console.log( + " sweepToCustodyTimestamp :", + sweepToCustodyTimestamp.toString() + ); + console.log(" recallInitiated :", recallInitiated); + console.log( + " preFinalizeRate :", + preFinalizeRate.toString() + ); + console.log(" recallComplete :", recallComplete); + console.log(" assetCustodied :", assetCustodied); + console.log(" totalSupply (MATICx) :", totalSupply.toString()); + console.log(" POL balance :", polBalance.toString()); + console.log(" MATIC balance :", maticBalance.toString()); + }); + +const STEP_ENCODERS: Record< + string, + ( + hre: HardhatRuntimeEnvironment, + dep: DeploymentInfo, + arg?: string + ) => Promise +> = { + pause: async () => encodeMaticX("togglePause", []), + "bulk-unstake": async () => encodeMaticX("bulkUnstakeAllValidators", []), + "claim-recall": async () => encodeMaticX("claimAssetRecallNonces", []), + freeze: async () => encodeMaticX("finalizeTerminalRate", []), + "push-l2": async () => encodeMaticX("pushTerminalRateToL2", []), + "enable-instant-redeem": async () => + encodeMaticX("setInstantRedeemEnabled", [true]), + "disable-instant-redeem": async () => + encodeMaticX("setInstantRedeemEnabled", [false]), + "set-custody-delay": async () => + encodeMaticX("setCustodyDelay", [CUSTODY_DELAY_SECONDS]), + sweep: async (hre, _dep, arg) => { + // `--arg ","` — comma-separated addresses since + // the framework only supports a single string. + const parts = (arg ?? "").split(",").map((p) => p.trim()); + if ( + parts.length !== 2 || + !hre.ethers.isAddress(parts[0]) || + !hre.ethers.isAddress(parts[1]) + ) { + throw new Error( + 'sweep step requires --arg ","' + ); + } + return encodeMaticX("sweepToCustody", [parts[0], parts[1]]); + }, +}; + +function encodeMaticX(fn: string, args: unknown[]): string { + const iface = new Interface([ + "function togglePause() external", + "function bulkUnstakeAllValidators() external", + "function claimAssetRecallNonces() external", + "function finalizeTerminalRate() external", + "function pushTerminalRateToL2() external", + "function setInstantRedeemEnabled(bool _enabled) external", + "function setCustodyDelay(uint256 _custodyDelay) external", + "function sweepToCustody(address _asset, address _custody) external", + ]); + return iface.encodeFunctionData(fn, args); +} + +task("sunset:encode-step") + .setDescription( + "Emits MaticX calldata for one sunset admin step (for multisig submission)" + ) + .addParam( + "step", + `One of: ${Object.keys(STEP_ENCODERS).join(" | ")}`, + undefined, + types.string + ) + .addOptionalParam( + "arg", + "Step-specific arg (e.g. custody address for sweep)", + undefined, + types.string + ) + .setAction( + async ( + { step, arg }: { step: string; arg?: string }, + hre: HardhatRuntimeEnvironment + ) => { + const encoder = STEP_ENCODERS[step]; + if (!encoder) { + throw new Error( + `Unknown step "${step}". Valid: ${Object.keys(STEP_ENCODERS).join(", ")}` + ); + } + const dep = readDeployment(hre.network.name); + const data = await encoder(hre, dep, arg); + console.log("Target (MaticX proxy):", dep.eth_maticX_proxy); + console.log(`Step: ${step}`); + console.log("Calldata:"); + console.log(" ", data); + } + ); diff --git a/test/MaticX.ts b/test/MaticX.ts index e9c23f40..dfa72171 100644 --- a/test/MaticX.ts +++ b/test/MaticX.ts @@ -1709,7 +1709,7 @@ describe("MaticX", function () { describe("Claim a withdrawal", function () { describe("Negative", function () { - it("Should revert with the right error if paused", async function () { + it("Should not be paused-gated (sunset: users can always claim pre-existing withdrawals)", async function () { const { maticX, manager, stakerA } = await loadFixture(deployFixture); @@ -1718,7 +1718,9 @@ describe("MaticX", function () { const promise = ( maticX.connect(stakerA) as MaticX ).claimWithdrawal(0n); - await expect(promise).to.be.revertedWith("Pausable: paused"); + await expect(promise).to.be.revertedWith( + "Withdrawal request does not exist" + ); }); it("Should return the right error if claiming too early", async function () { diff --git a/test/Sunset.ts b/test/Sunset.ts new file mode 100644 index 00000000..5db0d903 --- /dev/null +++ b/test/Sunset.ts @@ -0,0 +1,1635 @@ +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { + getStorageAt, + loadFixture, + reset, + setBalance, + setStorageAt, + time, +} from "@nomicfoundation/hardhat-network-helpers"; +import { expect } from "chai"; +import { ethers, upgrades } from "hardhat"; +import { + FxStateRootTunnel, + IERC20, + IFxStateRootTunnel, + IStakeManager, + MaticX, + ValidatorRegistry, +} from "../typechain-types"; +import { extractEnvironmentVariables } from "../utils/environment"; +import { getProviderUrl, Network } from "../utils/network"; + +const envVars = extractEnvironmentVariables(); + +const providerUrl = + process.env.MAINNET_RPC_URL || + getProviderUrl( + Network.Ethereum, + envVars.RPC_PROVIDER, + envVars.ETHEREUM_API_KEY + ); + +describe("MaticX sunset", function () { + const stakeAmount = ethers.parseUnits("100", 18); + const CUSTODY_DELAY = 3n * 365n * 24n * 60n * 60n; + const TERMINAL_RATE_PRECISION = 10n ** 18n; + + async function impersonate(address: string): Promise { + await setBalance(address, ethers.parseEther("10000")); + return await ethers.getImpersonatedSigner(address); + } + + async function deployFixture() { + + const forkBlock = process.env.MAINNET_RPC_URL + ? undefined + : envVars.FORKING_BLOCK_NUMBER; + await reset(providerUrl, forkBlock); + + const manager = await impersonate( + "0x80A43dd35382C4919991C5Bca7f46Dd24Fde4C67" + ); + const polygonTreasury = await impersonate( + "0xcD6507d87F605F5E95C12F7c4B1fC3279dc944aB" + ); + const stakeManagerGovernance = await impersonate( + "0x6e7a5820baD6cebA8Ef5ea69c0C92EbbDAc9CE48" + ); + + const [, bot, treasury, stakerA, stakerB, custody, attacker] = + await ethers.getSigners(); + + const validatorRegistry = (await ethers.getContractAt( + "ValidatorRegistry", + "0xf556442D5B77A4B0252630E15d8BbE2160870d77", + manager + )) as unknown as ValidatorRegistry; + + const fxStateRootTunnel = (await ethers.getContractAt( + "IFxStateRootTunnel", + "0x40FB804Cc07302b89EC16a9f8d040506f64dFe29", + manager + )) as IFxStateRootTunnel; + + const stakeManager = (await ethers.getContractAt( + "IStakeManager", + "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" + )) as IStakeManager; + + const matic = (await ethers.getContractAt( + "IERC20", + "0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0" + )) as IERC20; + + const pol = (await ethers.getContractAt( + "IERC20", + "0x455e53CBB86018Ac2B8092FdCd39d8444aFFC3F6" + )) as IERC20; + + const MaticXFactory = await ethers.getContractFactory("MaticX"); + const maticXContract = await upgrades.deployProxy(MaticXFactory, [ + await validatorRegistry.getAddress(), + await stakeManager.getAddress(), + await matic.getAddress(), + manager.address, + treasury.address, + ]); + const maticX = maticXContract as unknown as MaticX; + const maticXAddress = await maticX.getAddress(); + + const [preferredDepositValidatorId, preferredWithdrawalValidatorId] = + await validatorRegistry.getValidators(); + await validatorRegistry + .connect(manager) + .setPreferredDepositValidatorId(preferredDepositValidatorId); + await validatorRegistry + .connect(manager) + .setPreferredWithdrawalValidatorId(preferredWithdrawalValidatorId); + + await ( + fxStateRootTunnel.connect(manager) as FxStateRootTunnel + ).setMaticX(maticXAddress); + + await (maticX.connect(manager) as MaticX).initializeV2( + await pol.getAddress() + ); + + await (maticX.connect(manager) as MaticX).setCustodyDelay( + CUSTODY_DELAY + ); + await (maticX.connect(manager) as MaticX).setFxStateRootTunnel( + await fxStateRootTunnel.getAddress() + ); + + const botRole = await maticX.BOT(); + await (maticX.connect(manager) as MaticX).grantRole( + botRole, + bot.address + ); + + for (const staker of [stakerA, stakerB]) { + await pol + .connect(polygonTreasury) + .transfer(staker.address, stakeAmount * 3n); + await pol.connect(staker).approve(maticXAddress, stakeAmount * 3n); + await (maticX.connect(staker) as MaticX).submitPOL(stakeAmount); + } + + return { + maticX, + maticXAddress, + stakeManager, + stakeManagerGovernance, + validatorRegistry, + fxStateRootTunnel, + matic, + pol, + manager, + bot, + treasury, + stakerA, + stakerB, + custody, + attacker, + polygonTreasury, + }; + } + + async function advanceUnbond( + stakeManager: IStakeManager, + stakeManagerGovernance: SignerWithAddress + ) { + const currentEpoch = await stakeManager.epoch(); + const withdrawalDelay = await stakeManager.withdrawalDelay(); + await stakeManager + .connect(stakeManagerGovernance) + .setCurrentEpoch(currentEpoch + withdrawalDelay + 1n); + } + + async function pauseRecallAndFinalize( + fx: Awaited> + ) { + const { maticX, manager, stakeManager, stakeManagerGovernance } = fx; + await (maticX.connect(manager) as MaticX).togglePause(); + await (maticX.connect(manager) as MaticX).bulkUnstakeAllValidators(); + await advanceUnbond(stakeManager, stakeManagerGovernance); + await (maticX.connect(manager) as MaticX).claimAssetRecallNonces(); + await (maticX.connect(manager) as MaticX).finalizeTerminalRate(); + } + + async function findScalarStorageSlot( + address: string, + expectedValue: bigint, + readValue: () => Promise, + probeValue: bigint, + maxSlots = 1000 + ): Promise { + const target = ethers.toBeHex(expectedValue, 32).toLowerCase(); + for (let slot = 0; slot < maxSlots; slot++) { + const value = (await getStorageAt(address, slot)).toLowerCase(); + if (value !== target) continue; + + await setStorageAt(address, slot, probeValue); + const observed = await readValue(); + await setStorageAt(address, slot, expectedValue); + + if (observed === probeValue) return slot; + } + throw new Error( + `Could not find storage slot for ${expectedValue.toString()}` + ); + } + + async function findMappingSlot( + contractAddress: string, + key: string, + readValue: () => Promise, + probeValue: bigint, + maxSlots = 1000 + ): Promise { + const abiCoder = ethers.AbiCoder.defaultAbiCoder(); + for (let s = 0; s < maxSlots; s++) { + const valueSlot = ethers.keccak256( + abiCoder.encode(["address", "uint256"], [key, s]) + ); + const original = await getStorageAt(contractAddress, valueSlot); + await setStorageAt(contractAddress, valueSlot, probeValue); + const observed = await readValue(); + await setStorageAt(contractAddress, valueSlot, original); + if (observed === probeValue) return s; + } + throw new Error( + `Could not find mapping slot for key ${key} on ${contractAddress}` + ); + } + + async function writeMappingValue( + contractAddress: string, + mappingSlot: number, + key: string, + value: bigint + ): Promise { + const abiCoder = ethers.AbiCoder.defaultAbiCoder(); + const valueSlot = ethers.keccak256( + abiCoder.encode(["address", "uint256"], [key, mappingSlot]) + ); + await setStorageAt(contractAddress, valueSlot, value); + } + + describe("End-to-end happy path", function () { + it("runs the full sunset sequence and lets users redeem at the terminal rate", async function () { + const fx = await loadFixture(deployFixture); + const { + maticX, + maticXAddress, + manager, + pol, + stakerA, + stakerB, + custody, + stakeManager, + stakeManagerGovernance, + } = fx; + + await (maticX.connect(manager) as MaticX).togglePause(); + expect(await maticX.paused()).to.equal(true); + + const [preferredId] = await fx.validatorRegistry.getValidators(); + const preferredShare = + await stakeManager.getValidatorContract(preferredId); + const vs = await ethers.getContractAt( + [ + "function getTotalStake(address) view returns (uint256, uint256)", + "function unbondNonces(address) view returns (uint256)", + ], + preferredShare + ); + const [stakeBefore] = (await vs.getTotalStake(maticXAddress)) as [ + bigint, + bigint, + ]; + const nonceBefore = (await vs.unbondNonces( + maticXAddress + )) as bigint; + await expect( + (maticX.connect(manager) as MaticX).bulkUnstakeAllValidators() + ) + .to.emit(maticX, "AssetRecallInitiated") + .withArgs(preferredShare, nonceBefore + 1n, stakeBefore); + + await advanceUnbond(stakeManager, stakeManagerGovernance); + + const polBalBefore = await pol.balanceOf(maticXAddress); + await (maticX.connect(manager) as MaticX).claimAssetRecallNonces(); + const polBalAfter = await pol.balanceOf(maticXAddress); + expect(polBalAfter).to.be.gt(polBalBefore); + + const supply = await maticX.totalSupply(); + const expectedRate = + (polBalAfter * TERMINAL_RATE_PRECISION) / supply; + await expect( + (maticX.connect(manager) as MaticX).finalizeTerminalRate() + ) + .to.emit(maticX, "AssetRecallCompleted") + .withArgs(polBalAfter, supply, expectedRate); + + expect(await maticX.terminalRateLocked()).to.equal(true); + expect(await maticX.terminalRate()).to.equal(expectedRate); + expect(await pol.balanceOf(maticXAddress)).to.equal(polBalAfter); + + await expect( + (maticX.connect(manager) as MaticX).pushTerminalRateToL2() + ) + .to.emit(maticX, "TerminalRatePushedToL2") + .withArgs(supply, polBalAfter); + + await expect( + (maticX.connect(manager) as MaticX).setInstantRedeemEnabled( + true + ) + ) + .to.emit(maticX, "InstantRedeemToggled") + .withArgs(manager.address, true); + + const stakerAShares = await maticX.balanceOf(stakerA.address); + const expectedPol = + (stakerAShares * expectedRate) / TERMINAL_RATE_PRECISION; + + const recalledBefore = await pol.balanceOf(maticXAddress); + await expect( + (maticX.connect(stakerA) as MaticX).requestWithdraw( + stakerAShares + ) + ) + .to.emit(maticX, "InstantClaimed") + .withArgs(stakerA.address, stakerAShares, expectedPol); + + expect(await maticX.balanceOf(stakerA.address)).to.equal(0n); + expect(await pol.balanceOf(maticXAddress)).to.equal( + recalledBefore - expectedPol + ); + expect(await pol.balanceOf(stakerA.address)).to.be.gte(expectedPol); + + const polAddr = await pol.getAddress(); + const maticAddr = await fx.matic.getAddress(); + await expect( + (maticX.connect(manager) as MaticX).sweepToCustody( + polAddr, + custody.address + ) + ).to.be.revertedWithCustomError(maticX, "CustodyDelayNotElapsed"); + + await time.increase(CUSTODY_DELAY + 1n); + + const polBeforeSweep = await pol.balanceOf(maticXAddress); + const maticBeforeSweep = await fx.matic.balanceOf(maticXAddress); + await expect( + (maticX.connect(manager) as MaticX).sweepToCustody( + polAddr, + custody.address + ) + ) + .to.emit(maticX, "SweptToCustody") + .withArgs(polAddr, custody.address, polBeforeSweep); + + if (maticBeforeSweep > 0n) { + await expect( + (maticX.connect(manager) as MaticX).sweepToCustody( + maticAddr, + custody.address + ) + ) + .to.emit(maticX, "SweptToCustody") + .withArgs(maticAddr, custody.address, maticBeforeSweep); + } + + expect(await pol.balanceOf(maticXAddress)).to.equal(0); + expect(await pol.balanceOf(custody.address)).to.equal( + polBeforeSweep + ); + expect(await maticX.assetCustodied()).to.equal(true); + + void stakerB; + }); + }); + + describe("Paused-state matrix", function () { + it("blocks user write paths while paused but lets claimWithdrawal and the instant-redeem path through", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, manager, bot, pol, stakerA } = fx; + + await pauseRecallAndFinalize(fx); + await (maticX.connect(manager) as MaticX).setInstantRedeemEnabled( + true + ); + + await expect( + (maticX.connect(stakerA) as MaticX).submit(stakeAmount) + ).to.be.revertedWith("Pausable: paused"); + await expect( + (maticX.connect(stakerA) as MaticX).submitPOL(stakeAmount) + ).to.be.revertedWith("Pausable: paused"); + await expect( + (maticX.connect(stakerA) as MaticX).withdrawRewards(1n) + ).to.be.revertedWith("Pausable: paused"); + await expect( + (maticX.connect(bot) as MaticX).stakeRewardsAndDistributeFees( + 1n + ) + ).to.be.revertedWith("Pausable: paused"); + await expect( + (maticX.connect(manager) as MaticX).setFeePercent(100) + ).to.be.revertedWith("Pausable: paused"); + + const shares = await maticX.balanceOf(stakerA.address); + await expect( + (maticX.connect(stakerA) as MaticX).requestWithdraw(shares) + ).to.emit(maticX, "InstantClaimed"); + + void pol; + }); + }); + + describe("bulkUnstakeAllValidators", function () { + it("reverts without pause", async function () { + const { maticX, manager } = await loadFixture(deployFixture); + await expect( + (maticX.connect(manager) as MaticX).bulkUnstakeAllValidators() + ).to.be.revertedWith("Pause first"); + }); + + it("reverts for non-admin even when paused", async function () { + const { maticX, manager, attacker } = + await loadFixture(deployFixture); + await (maticX.connect(manager) as MaticX).togglePause(); + await expect( + (maticX.connect(attacker) as MaticX).bulkUnstakeAllValidators() + ).to.be.reverted; + }); + + it("reverts on the second call with RecallAlreadyInitiated", async function () { + const { maticX, manager } = await loadFixture(deployFixture); + await (maticX.connect(manager) as MaticX).togglePause(); + await ( + maticX.connect(manager) as MaticX + ).bulkUnstakeAllValidators(); + await expect( + (maticX.connect(manager) as MaticX).bulkUnstakeAllValidators() + ).to.be.revertedWithCustomError(maticX, "RecallAlreadyInitiated"); + }); + + it("reverts after terminalRateLocked", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, manager } = fx; + await pauseRecallAndFinalize(fx); + await expect( + (maticX.connect(manager) as MaticX).bulkUnstakeAllValidators() + ).to.be.revertedWithCustomError(maticX, "RecallAlreadyInitiated"); + }); + + it("skips validators with zero stake (no nonce, no event)", async function () { + + const fx = await loadFixture(deployFixture); + const { maticX, manager, stakeManager, validatorRegistry } = fx; + await (maticX.connect(manager) as MaticX).togglePause(); + + const validatorIds = await validatorRegistry.getValidators(); + const sharesWithStake: string[] = []; + const sharesWithoutStake: string[] = []; + for (const id of validatorIds) { + const share = await stakeManager.getValidatorContract(id); + const vs = await ethers.getContractAt( + [ + "function getTotalStake(address) view returns (uint256, uint256)", + ], + share + ); + const [stake] = (await vs.getTotalStake( + await maticX.getAddress() + )) as [bigint, bigint]; + if (stake > 0n) sharesWithStake.push(share); + else sharesWithoutStake.push(share); + } + expect(sharesWithStake.length).to.be.gt(0); + expect(sharesWithoutStake.length).to.be.gt(0); + + const tx = await ( + maticX.connect(manager) as MaticX + ).bulkUnstakeAllValidators(); + const receipt = await tx.wait(); + const event = maticX.interface.getEvent("AssetRecallInitiated"); + if (!event) throw new Error("AssetRecallInitiated event not found"); + const topic = event.topicHash; + const emitted = + receipt?.logs.filter((l) => l.topics[0] === topic).length ?? 0; + expect(emitted).to.equal(sharesWithStake.length); + + for (const share of sharesWithStake) { + expect(await maticX.assetRecallNonces(share)).to.be.gt(0n); + } + for (const share of sharesWithoutStake) { + expect(await maticX.assetRecallNonces(share)).to.equal(0n); + } + }); + + it("captured nonce equals validator unbondNonces post-sell (regression: not +1)", async function () { + + const fx = await loadFixture(deployFixture); + const { maticX, maticXAddress, manager, stakeManager, validatorRegistry } = fx; + await (maticX.connect(manager) as MaticX).togglePause(); + await (maticX.connect(manager) as MaticX).bulkUnstakeAllValidators(); + + const validatorIds = await validatorRegistry.getValidators(); + let staked = 0; + for (const id of validatorIds) { + const share = await stakeManager.getValidatorContract(id); + const stored = await maticX.assetRecallNonces(share); + if (stored === 0n) continue; + staked++; + const vs = await ethers.getContractAt( + ["function unbondNonces(address) view returns (uint256)"], + share + ); + const live = (await vs.unbondNonces(maticXAddress)) as bigint; + expect(stored).to.equal(live); + } + expect(staked).to.be.gt(0); + }); + }); + + describe("claimAssetRecallNonces", function () { + it("reverts after recallComplete", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, manager } = fx; + await pauseRecallAndFinalize(fx); + await expect( + (maticX.connect(manager) as MaticX).claimAssetRecallNonces() + ).to.be.revertedWithCustomError(maticX, "RecallAlreadyComplete"); + }); + + it("reverts with RecallNotInitiated when called before bulkUnstake", async function () { + const { maticX, manager } = await loadFixture(deployFixture); + await (maticX.connect(manager) as MaticX).togglePause(); + await expect( + (maticX.connect(manager) as MaticX).claimAssetRecallNonces() + ).to.be.revertedWithCustomError(maticX, "RecallNotInitiated"); + }); + + it("is a no-op (no nonces, no revert) when called twice before the unbond matures", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, manager, stakeManager } = fx; + await (maticX.connect(manager) as MaticX).togglePause(); + await ( + maticX.connect(manager) as MaticX + ).bulkUnstakeAllValidators(); + + const validatorIds = await fx.validatorRegistry.getValidators(); + const shareAddrs = await Promise.all( + validatorIds.map((id) => stakeManager.getValidatorContract(id)) + ); + const noncesBefore = await Promise.all( + shareAddrs.map((vs) => maticX.assetRecallNonces(vs)) + ); + + expect(noncesBefore.some((n) => n > 0n)).to.equal(true); + + await (maticX.connect(manager) as MaticX) + .claimAssetRecallNonces() + .catch(() => { + + }); + + const noncesAfter = await Promise.all( + shareAddrs.map((vs) => maticX.assetRecallNonces(vs)) + ); + expect(noncesAfter).to.deep.equal(noncesBefore); + expect(await maticX.recallComplete()).to.equal(false); + expect(await maticX.terminalRateLocked()).to.equal(false); + }); + + it("sets recallComplete = true after a successful claim", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, manager, stakeManager, stakeManagerGovernance } = + fx; + await (maticX.connect(manager) as MaticX).togglePause(); + await ( + maticX.connect(manager) as MaticX + ).bulkUnstakeAllValidators(); + await advanceUnbond(stakeManager, stakeManagerGovernance); + + expect(await maticX.recallComplete()).to.equal(false); + await (maticX.connect(manager) as MaticX).claimAssetRecallNonces(); + expect(await maticX.recallComplete()).to.equal(true); + + const validatorIds = await fx.validatorRegistry.getValidators(); + for (const id of validatorIds) { + const vs = await stakeManager.getValidatorContract(id); + expect(await maticX.assetRecallNonces(vs)).to.equal(0n); + } + }); + }); + + describe("finalizeTerminalRate", function () { + it("reverts on the second call", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, manager } = fx; + await pauseRecallAndFinalize(fx); + await expect( + (maticX.connect(manager) as MaticX).finalizeTerminalRate() + ).to.be.revertedWithCustomError( + maticX, + "TerminalRateAlreadyLocked" + ); + }); + + it("reverts with RecallClaimsNotComplete when finalize runs before claim", async function () { + const { maticX, manager } = await loadFixture(deployFixture); + await (maticX.connect(manager) as MaticX).togglePause(); + await ( + maticX.connect(manager) as MaticX + ).bulkUnstakeAllValidators(); + await expect( + (maticX.connect(manager) as MaticX).finalizeTerminalRate() + ).to.be.revertedWithCustomError(maticX, "RecallClaimsNotComplete"); + }); + + it("reverts EmptyContract when totalSupply is zero at finalize", async function () { + + const fx = await loadFixture(deployFixture); + const { + maticX, + maticXAddress, + manager, + stakeManager, + stakeManagerGovernance, + } = fx; + await (maticX.connect(manager) as MaticX).togglePause(); + await ( + maticX.connect(manager) as MaticX + ).bulkUnstakeAllValidators(); + await advanceUnbond(stakeManager, stakeManagerGovernance); + await (maticX.connect(manager) as MaticX).claimAssetRecallNonces(); + + const supplyBefore = await maticX.totalSupply(); + expect(supplyBefore).to.be.gt(0n); + const supplySlot = await findScalarStorageSlot( + maticXAddress, + supplyBefore, + () => maticX.totalSupply(), + 123456789n + ); + await setStorageAt(maticXAddress, supplySlot, 0n); + expect(await maticX.totalSupply()).to.equal(0n); + + await expect( + (maticX.connect(manager) as MaticX).finalizeTerminalRate() + ).to.be.revertedWithCustomError(maticX, "EmptyContract"); + }); + + it("reverts EmptyContract when polBalance is zero at finalize", async function () { + + const fx = await loadFixture(deployFixture); + const { + maticX, + maticXAddress, + manager, + pol, + stakeManager, + stakeManagerGovernance, + } = fx; + await (maticX.connect(manager) as MaticX).togglePause(); + await ( + maticX.connect(manager) as MaticX + ).bulkUnstakeAllValidators(); + await advanceUnbond(stakeManager, stakeManagerGovernance); + await (maticX.connect(manager) as MaticX).claimAssetRecallNonces(); + + const polAddr = await pol.getAddress(); + const before = await pol.balanceOf(maticXAddress); + expect(before).to.be.gt(0n); + const balancesSlot = await findMappingSlot( + polAddr, + maticXAddress, + async () => pol.balanceOf(maticXAddress), + 123456789n + ); + await writeMappingValue(polAddr, balancesSlot, maticXAddress, 0n); + expect(await pol.balanceOf(maticXAddress)).to.equal(0n); + + await expect( + (maticX.connect(manager) as MaticX).finalizeTerminalRate() + ).to.be.revertedWithCustomError(maticX, "EmptyContract"); + }); + }); + + describe("pushTerminalRateToL2", function () { + it("reverts before freeze", async function () { + const { maticX, manager } = await loadFixture(deployFixture); + await expect( + (maticX.connect(manager) as MaticX).pushTerminalRateToL2() + ).to.be.revertedWithCustomError(maticX, "TerminalRateNotLocked"); + }); + + it("is idempotent (can be called twice after freeze)", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, manager } = fx; + await pauseRecallAndFinalize(fx); + await (maticX.connect(manager) as MaticX).pushTerminalRateToL2(); + await expect( + (maticX.connect(manager) as MaticX).pushTerminalRateToL2() + ).to.emit(maticX, "TerminalRatePushedToL2"); + }); + + it("emits the LIVE polBalance, not a snapshot from finalize", async function () { + + const fx = await loadFixture(deployFixture); + const { + maticX, + maticXAddress, + manager, + pol, + polygonTreasury, + } = fx; + await pauseRecallAndFinalize(fx); + await (maticX.connect(manager) as MaticX).setInstantRedeemEnabled( + true + ); + + const supply = await maticX.totalSupply(); + const polAtFinalize = await pol.balanceOf(maticXAddress); + + await expect( + (maticX.connect(manager) as MaticX).pushTerminalRateToL2() + ) + .to.emit(maticX, "TerminalRatePushedToL2") + .withArgs(supply, polAtFinalize); + + const donation = ethers.parseUnits("7", 18); + await pol + .connect(polygonTreasury) + .transfer(maticXAddress, donation); + const polAfterDonation = await pol.balanceOf(maticXAddress); + expect(polAfterDonation).to.equal(polAtFinalize + donation); + + await expect( + (maticX.connect(manager) as MaticX).pushTerminalRateToL2() + ) + .to.emit(maticX, "TerminalRatePushedToL2") + .withArgs(supply, polAfterDonation); + }); + }); + + describe("setInstantRedeemEnabled", function () { + it("reverts when enabling pre-freeze", async function () { + const { maticX, manager } = await loadFixture(deployFixture); + await expect( + (maticX.connect(manager) as MaticX).setInstantRedeemEnabled( + true + ) + ).to.be.revertedWithCustomError(maticX, "TerminalRateNotLocked"); + }); + + it("reverts when disabling pre-freeze (gate is symmetric on terminalRateLocked)", async function () { + const { maticX, manager } = await loadFixture(deployFixture); + await expect( + (maticX.connect(manager) as MaticX).setInstantRedeemEnabled( + false + ) + ).to.be.revertedWithCustomError(maticX, "TerminalRateNotLocked"); + }); + + it("admin can toggle on then off post-freeze", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, manager } = fx; + await pauseRecallAndFinalize(fx); + await (maticX.connect(manager) as MaticX).setInstantRedeemEnabled( + true + ); + expect(await maticX.instantRedeemEnabled()).to.equal(true); + await (maticX.connect(manager) as MaticX).setInstantRedeemEnabled( + false + ); + expect(await maticX.instantRedeemEnabled()).to.equal(false); + }); + }); + + describe("instant-redeem path (requestWithdraw routes here once enabled)", function () { + async function freezeAndEnable( + fx: Awaited> + ) { + await pauseRecallAndFinalize(fx); + await ( + fx.maticX.connect(fx.manager) as MaticX + ).setInstantRedeemEnabled(true); + } + + it("reverts Pausable: paused when redeem flag is off (falls through to legacy branch)", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, stakerA } = fx; + await pauseRecallAndFinalize(fx); + const shares = await maticX.balanceOf(stakerA.address); + await expect( + (maticX.connect(stakerA) as MaticX).requestWithdraw(shares) + ).to.be.revertedWith("Pausable: paused"); + }); + + it("reverts Invalid amount on zero input regardless of redeem flag", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, stakerA } = fx; + await freezeAndEnable(fx); + await expect( + (maticX.connect(stakerA) as MaticX).requestWithdraw(0n) + ).to.be.revertedWith("Invalid amount"); + }); + + it("reverts on ERC20 burn underflow when caller holds no MATICx", async function () { + + const fx = await loadFixture(deployFixture); + const { maticX, attacker } = fx; + await freezeAndEnable(fx); + expect(await maticX.balanceOf(attacker.address)).to.equal(0n); + await expect( + (maticX.connect(attacker) as MaticX).requestWithdraw(1n) + ).to.be.reverted; + }); + + it("reverts AmountInPolZero when terminalRate is degenerate (defensive)", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, maticXAddress, stakerA } = fx; + await freezeAndEnable(fx); + + const rateSlot = await findScalarStorageSlot( + maticXAddress, + await maticX.terminalRate(), + () => maticX.terminalRate(), + 123456789n + ); + await setStorageAt(maticXAddress, rateSlot, 0n); + expect(await maticX.terminalRate()).to.equal(0n); + + await expect( + (maticX.connect(stakerA) as MaticX).requestWithdraw(1n) + ).to.be.revertedWithCustomError(maticX, "AmountInPolZero"); + }); + + it("reverts InsufficientRecalledBalance when the pool is below the payout", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, maticXAddress, pol, stakerA } = fx; + await freezeAndEnable(fx); + + const polAddr = await pol.getAddress(); + const polBalanceSlot = await findMappingSlot( + polAddr, + maticXAddress, + () => pol.balanceOf(maticXAddress), + 123456789n + ); + await writeMappingValue(polAddr, polBalanceSlot, maticXAddress, 0n); + expect(await pol.balanceOf(maticXAddress)).to.equal(0n); + + const shares = await maticX.balanceOf(stakerA.address); + await expect( + (maticX.connect(stakerA) as MaticX).requestWithdraw(shares) + ).to.be.revertedWithCustomError( + maticX, + "InsufficientRecalledBalance" + ); + }); + + it("redeems the caller's full balance when amount == balance and zeroes their shares", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, maticXAddress, pol, stakerA } = fx; + await freezeAndEnable(fx); + + const rate = await maticX.terminalRate(); + const sharesBefore = await maticX.balanceOf(stakerA.address); + const recalledBefore = await pol.balanceOf(maticXAddress); + const polBefore = await pol.balanceOf(stakerA.address); + const expectedPol = (sharesBefore * rate) / TERMINAL_RATE_PRECISION; + + await (maticX.connect(stakerA) as MaticX).requestWithdraw( + sharesBefore + ); + + expect(await maticX.balanceOf(stakerA.address)).to.equal(0n); + expect(await pol.balanceOf(maticXAddress)).to.equal( + recalledBefore - expectedPol + ); + expect(await pol.balanceOf(stakerA.address)).to.equal( + polBefore + expectedPol + ); + }); + + it("supports partial redemption (amount < balance)", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, maticXAddress, pol, stakerA } = fx; + await freezeAndEnable(fx); + + const rate = await maticX.terminalRate(); + const sharesBefore = await maticX.balanceOf(stakerA.address); + const half = sharesBefore / 2n; + const recalledBefore = await pol.balanceOf(maticXAddress); + const polBefore = await pol.balanceOf(stakerA.address); + const expectedPol = (half * rate) / TERMINAL_RATE_PRECISION; + + await expect( + (maticX.connect(stakerA) as MaticX).requestWithdraw(half) + ) + .to.emit(maticX, "InstantClaimed") + .withArgs(stakerA.address, half, expectedPol); + + expect(await maticX.balanceOf(stakerA.address)).to.equal( + sharesBefore - half + ); + expect(await pol.balanceOf(maticXAddress)).to.equal( + recalledBefore - expectedPol + ); + expect(await pol.balanceOf(stakerA.address)).to.equal( + polBefore + expectedPol + ); + }); + + it("emits InstantClaimed with the supplied amount", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, stakerA } = fx; + await freezeAndEnable(fx); + + const rate = await maticX.terminalRate(); + const shares = await maticX.balanceOf(stakerA.address); + const expectedPol = (shares * rate) / TERMINAL_RATE_PRECISION; + + await expect( + (maticX.connect(stakerA) as MaticX).requestWithdraw(shares) + ) + .to.emit(maticX, "InstantClaimed") + .withArgs(stakerA.address, shares, expectedPol); + }); + }); + + describe("sweepToCustody", function () { + it("reverts before freeze", async function () { + const { maticX, manager, pol, custody } = + await loadFixture(deployFixture); + await expect( + (maticX.connect(manager) as MaticX).sweepToCustody( + await pol.getAddress(), + custody.address + ) + ).to.be.revertedWithCustomError(maticX, "TerminalRateNotLocked"); + }); + + it("reverts before the custody delay elapses", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, manager, pol, custody } = fx; + await pauseRecallAndFinalize(fx); + await expect( + (maticX.connect(manager) as MaticX).sweepToCustody( + await pol.getAddress(), + custody.address + ) + ).to.be.revertedWithCustomError(maticX, "CustodyDelayNotElapsed"); + }); + + it("reverts on zero custody address", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, manager, pol } = fx; + await pauseRecallAndFinalize(fx); + await time.increase(CUSTODY_DELAY + 1n); + await expect( + (maticX.connect(manager) as MaticX).sweepToCustody( + await pol.getAddress(), + ethers.ZeroAddress + ) + ).to.be.revertedWithCustomError(maticX, "ZeroAddress"); + }); + + it("reverts on zero asset address", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, manager, custody } = fx; + await pauseRecallAndFinalize(fx); + await time.increase(CUSTODY_DELAY + 1n); + await expect( + (maticX.connect(manager) as MaticX).sweepToCustody( + ethers.ZeroAddress, + custody.address + ) + ).to.be.revertedWithCustomError(maticX, "ZeroAddress"); + }); + + it("moves the entire POL+MATIC balance via two per-asset sweeps", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, maticXAddress, manager, pol, matic, custody } = fx; + await pauseRecallAndFinalize(fx); + await time.increase(CUSTODY_DELAY + 1n); + + const polAddr = await pol.getAddress(); + const maticAddr = await matic.getAddress(); + const polBefore = await pol.balanceOf(maticXAddress); + const maticBefore = await matic.balanceOf(maticXAddress); + + await expect( + (maticX.connect(manager) as MaticX).sweepToCustody( + polAddr, + custody.address + ) + ) + .to.emit(maticX, "SweptToCustody") + .withArgs(polAddr, custody.address, polBefore); + + expect(await pol.balanceOf(maticXAddress)).to.equal(0); + expect(await pol.balanceOf(custody.address)).to.equal(polBefore); + + if (maticBefore > 0n) { + await expect( + (maticX.connect(manager) as MaticX).sweepToCustody( + maticAddr, + custody.address + ) + ) + .to.emit(maticX, "SweptToCustody") + .withArgs(maticAddr, custody.address, maticBefore); + expect(await matic.balanceOf(maticXAddress)).to.equal(0); + expect(await matic.balanceOf(custody.address)).to.equal( + maticBefore + ); + } + }); + + it("reverts ZeroAmount when the asset balance is zero", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, manager, matic, custody } = fx; + await pauseRecallAndFinalize(fx); + await time.increase(CUSTODY_DELAY + 1n); + + await expect( + (maticX.connect(manager) as MaticX).sweepToCustody( + await matic.getAddress(), + custody.address + ) + ).to.be.revertedWithCustomError(maticX, "ZeroAmount"); + }); + + it("flips assetCustodied = true on first successful sweep", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, manager, pol, custody } = fx; + await pauseRecallAndFinalize(fx); + await time.increase(CUSTODY_DELAY + 1n); + + expect(await maticX.assetCustodied()).to.equal(false); + await (maticX.connect(manager) as MaticX).sweepToCustody( + await pol.getAddress(), + custody.address + ); + expect(await maticX.assetCustodied()).to.equal(true); + }); + + it("instant-redeem path reverts with AssetCustodied after a sweep, even with instantRedeemEnabled", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, manager, pol, stakerA, custody } = fx; + await pauseRecallAndFinalize(fx); + await (maticX.connect(manager) as MaticX).setInstantRedeemEnabled( + true + ); + await time.increase(CUSTODY_DELAY + 1n); + + await (maticX.connect(manager) as MaticX).sweepToCustody( + await pol.getAddress(), + custody.address + ); + + expect(await maticX.instantRedeemEnabled()).to.equal(true); + expect(await maticX.assetCustodied()).to.equal(true); + const shares = await maticX.balanceOf(stakerA.address); + await expect( + (maticX.connect(stakerA) as MaticX).requestWithdraw(shares) + ).to.be.revertedWithCustomError(maticX, "AssetCustodied"); + }); + + it("succeeds at the exact sweepToCustodyTimestamp boundary (< vs <= check)", async function () { + + const fx = await loadFixture(deployFixture); + const { maticX, manager, pol, custody } = fx; + await pauseRecallAndFinalize(fx); + + const sweepTs = await maticX.sweepToCustodyTimestamp(); + await time.increaseTo(sweepTs); + await expect( + (maticX.connect(manager) as MaticX).sweepToCustody( + await pol.getAddress(), + custody.address + ) + ).to.emit(maticX, "SweptToCustody"); + }); + + it("sweeps non-zero MATIC dust to custody", async function () { + + const fx = await loadFixture(deployFixture); + const { maticX, maticXAddress, manager, matic, custody } = fx; + await pauseRecallAndFinalize(fx); + await time.increase(CUSTODY_DELAY + 1n); + + const maticAddr = await matic.getAddress(); + const dust = ethers.parseUnits("123", 18); + const balancesSlot = await findMappingSlot( + maticAddr, + maticXAddress, + async () => matic.balanceOf(maticXAddress), + 123456789n + ); + await writeMappingValue( + maticAddr, + balancesSlot, + maticXAddress, + dust + ); + expect(await matic.balanceOf(maticXAddress)).to.equal(dust); + + const maticBeforeCustody = await matic.balanceOf(custody.address); + await expect( + (maticX.connect(manager) as MaticX).sweepToCustody( + maticAddr, + custody.address + ) + ) + .to.emit(maticX, "SweptToCustody") + .withArgs(maticAddr, custody.address, dust); + expect(await matic.balanceOf(maticXAddress)).to.equal(0n); + expect(await matic.balanceOf(custody.address)).to.equal( + maticBeforeCustody + dust + ); + }); + + it("allows a second sweep of a different asset after assetCustodied flips", async function () { + + const fx = await loadFixture(deployFixture); + const { maticX, maticXAddress, manager, pol, matic, custody } = fx; + await pauseRecallAndFinalize(fx); + await time.increase(CUSTODY_DELAY + 1n); + + const maticAddr = await matic.getAddress(); + const dust = ethers.parseUnits("42", 18); + const balancesSlot = await findMappingSlot( + maticAddr, + maticXAddress, + async () => matic.balanceOf(maticXAddress), + 123456789n + ); + await writeMappingValue( + maticAddr, + balancesSlot, + maticXAddress, + dust + ); + + const polAddr = await pol.getAddress(); + const polBefore = await pol.balanceOf(maticXAddress); + + await (maticX.connect(manager) as MaticX).sweepToCustody( + polAddr, + custody.address + ); + expect(await maticX.assetCustodied()).to.equal(true); + expect(await pol.balanceOf(custody.address)).to.equal(polBefore); + + await expect( + (maticX.connect(manager) as MaticX).sweepToCustody( + maticAddr, + custody.address + ) + ) + .to.emit(maticX, "SweptToCustody") + .withArgs(maticAddr, custody.address, dust); + expect(await matic.balanceOf(maticXAddress)).to.equal(0n); + expect(await matic.balanceOf(custody.address)).to.equal(dust); + }); + + it("sweeps an arbitrary ERC20 (not POL/MATIC) to custody", async function () { + + const fx = await loadFixture(deployFixture); + const { maticX, maticXAddress, manager, custody } = fx; + await pauseRecallAndFinalize(fx); + await time.increase(CUSTODY_DELAY + 1n); + + const MockFactory = await ethers.getContractFactory("PolygonMock"); + const stray = await MockFactory.connect(manager).deploy(); + await stray.waitForDeployment(); + const strayAddr = await stray.getAddress(); + + const amount = ethers.parseUnits("1000", 18); + await stray.mintTo(maticXAddress, amount); + expect(await stray.balanceOf(maticXAddress)).to.equal(amount); + + await expect( + (maticX.connect(manager) as MaticX).sweepToCustody( + strayAddr, + custody.address + ) + ) + .to.emit(maticX, "SweptToCustody") + .withArgs(strayAddr, custody.address, amount); + + expect(await stray.balanceOf(maticXAddress)).to.equal(0n); + expect(await stray.balanceOf(custody.address)).to.equal(amount); + expect(await maticX.assetCustodied()).to.equal(true); + }); + }); + + describe("Access control", function () { + it("non-admin cannot call any sunset admin function", async function () { + const { maticX, attacker, pol, custody } = + await loadFixture(deployFixture); + await expect( + (maticX.connect(attacker) as MaticX).bulkUnstakeAllValidators() + ).to.be.reverted; + await expect( + (maticX.connect(attacker) as MaticX).claimAssetRecallNonces() + ).to.be.reverted; + await expect( + (maticX.connect(attacker) as MaticX).finalizeTerminalRate() + ).to.be.reverted; + await expect( + (maticX.connect(attacker) as MaticX).pushTerminalRateToL2() + ).to.be.reverted; + await expect( + (maticX.connect(attacker) as MaticX).setInstantRedeemEnabled( + true + ) + ).to.be.reverted; + await expect( + (maticX.connect(attacker) as MaticX).sweepToCustody( + await pol.getAddress(), + custody.address + ) + ).to.be.reverted; + }); + }); + + describe("Pre-sunset claimWithdrawal during sunset", function () { + it("user with a matured withdrawal request can still claim after pause and freeze", async function () { + const fx = await loadFixture(deployFixture); + const { + maticX, + manager, + pol, + stakerA, + stakeManager, + stakeManagerGovernance, + } = fx; + + await (maticX.connect(stakerA) as MaticX).requestWithdraw( + stakeAmount / 2n + ); + const requests = await maticX.getUserWithdrawalRequests( + stakerA.address + ); + const { requestEpoch } = requests[0]; + + await (maticX.connect(manager) as MaticX).togglePause(); + await ( + maticX.connect(manager) as MaticX + ).bulkUnstakeAllValidators(); + + const withdrawalDelay = await stakeManager.withdrawalDelay(); + await stakeManager + .connect(stakeManagerGovernance) + .setCurrentEpoch(BigInt(requestEpoch) + withdrawalDelay + 1n); + + await (maticX.connect(manager) as MaticX).claimAssetRecallNonces(); + await (maticX.connect(manager) as MaticX).finalizeTerminalRate(); + + const maticXAddress = await maticX.getAddress(); + const recalledBefore = await pol.balanceOf(maticXAddress); + const polBeforeUser = await pol.balanceOf(stakerA.address); + + await (maticX.connect(stakerA) as MaticX).claimWithdrawal(0); + + expect(await pol.balanceOf(stakerA.address)).to.be.gt( + polBeforeUser + ); + expect(await pol.balanceOf(maticXAddress)).to.equal(recalledBefore); + }); + }); + + describe("togglePause one-way after recall", function () { + it("reverts unpause with UnpauseLockedAfterRecall once recallInitiated", async function () { + const { maticX, manager } = await loadFixture(deployFixture); + await (maticX.connect(manager) as MaticX).togglePause(); + await ( + maticX.connect(manager) as MaticX + ).bulkUnstakeAllValidators(); + expect(await maticX.paused()).to.equal(true); + expect(await maticX.recallInitiated()).to.equal(true); + await expect( + (maticX.connect(manager) as MaticX).togglePause() + ).to.be.revertedWithCustomError(maticX, "UnpauseLockedAfterRecall"); + }); + }); + + describe("Oracle freeze during recall", function () { + it("serves preFinalizeRate between bulkUnstake and finalize", async function () { + const { maticX, manager } = await loadFixture(deployFixture); + await (maticX.connect(manager) as MaticX).togglePause(); + await ( + maticX.connect(manager) as MaticX + ).bulkUnstakeAllValidators(); + + const snap = await maticX.preFinalizeRate(); + expect(snap).to.be.gt(0n); + + const [polOut] = await maticX.convertMaticXToPOL( + TERMINAL_RATE_PRECISION + ); + expect(polOut).to.equal(snap); + }); + }); + + describe("Recall-gated setters", function () { + it("setValidatorRegistry reverts with RecallAlreadyInitiated post-bulkUnstake", async function () { + const { maticX, manager, validatorRegistry } = + await loadFixture(deployFixture); + await (maticX.connect(manager) as MaticX).togglePause(); + await ( + maticX.connect(manager) as MaticX + ).bulkUnstakeAllValidators(); + await expect( + (maticX.connect(manager) as MaticX).setValidatorRegistry( + await validatorRegistry.getAddress() + ) + ).to.be.revertedWithCustomError(maticX, "RecallAlreadyInitiated"); + }); + + it("setFxStateRootTunnel reverts with RecallAlreadyInitiated post-bulkUnstake", async function () { + const { maticX, manager, fxStateRootTunnel } = + await loadFixture(deployFixture); + await (maticX.connect(manager) as MaticX).togglePause(); + await ( + maticX.connect(manager) as MaticX + ).bulkUnstakeAllValidators(); + await expect( + (maticX.connect(manager) as MaticX).setFxStateRootTunnel( + await fxStateRootTunnel.getAddress() + ) + ).to.be.revertedWithCustomError(maticX, "RecallAlreadyInitiated"); + }); + }); + + describe("Oracle three-tier behavior", function () { + it("pre-recall: serves the live computed rate from validator stakes", async function () { + const { maticX } = await loadFixture(deployFixture); + + const supply = await maticX.totalSupply(); + const [polFor1e18, returnedSupply, returnedPooled] = + await maticX.convertMaticXToPOL(TERMINAL_RATE_PRECISION); + + expect(returnedSupply).to.equal(supply); + expect(returnedPooled).to.be.gt(0n); + expect(polFor1e18).to.be.gt(0n); + }); + + it("during recall: preFinalizeRate matches the pre-recall live rate exactly", async function () { + const { maticX, manager } = await loadFixture(deployFixture); + + const [liveRateBefore] = await maticX.convertMaticXToPOL( + TERMINAL_RATE_PRECISION + ); + + await (maticX.connect(manager) as MaticX).togglePause(); + await ( + maticX.connect(manager) as MaticX + ).bulkUnstakeAllValidators(); + + const snap = await maticX.preFinalizeRate(); + expect(snap).to.equal(liveRateBefore); + }); + + it("post-finalize: serves the locked terminalRate (3rd tier)", async function () { + const fx = await loadFixture(deployFixture); + const { maticX } = fx; + await pauseRecallAndFinalize(fx); + + const terminal = await maticX.terminalRate(); + expect(terminal).to.be.gt(0n); + + const [polFor1e18, totalShares, totalPooled] = + await maticX.convertMaticXToPOL(TERMINAL_RATE_PRECISION); + expect(polFor1e18).to.equal(terminal); + expect(totalShares).to.be.gt(0n); + expect( + (totalPooled * TERMINAL_RATE_PRECISION) / totalShares + ).to.equal(terminal); + }); + + it("convertPOLToMaticX mirrors the 3-tier oracle (during recall + post-finalize)", async function () { + const { maticX, manager } = await loadFixture(deployFixture); + + const [livePre] = await maticX.convertPOLToMaticX( + TERMINAL_RATE_PRECISION + ); + expect(livePre).to.be.gt(0n); + + await (maticX.connect(manager) as MaticX).togglePause(); + await ( + maticX.connect(manager) as MaticX + ).bulkUnstakeAllValidators(); + + const snap = await maticX.preFinalizeRate(); + const [maticXOutDuringRecall, totalShares, totalPooled] = + await maticX.convertPOLToMaticX(TERMINAL_RATE_PRECISION); + expect(maticXOutDuringRecall).to.equal( + (TERMINAL_RATE_PRECISION * TERMINAL_RATE_PRECISION) / snap + ); + expect(totalShares).to.be.gt(0n); + expect( + (totalPooled * TERMINAL_RATE_PRECISION) / totalShares + ).to.equal(snap); + }); + + it("POL donations during recall do NOT move the oracle (manipulation immunity)", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, manager, pol, stakerA } = fx; + + await (maticX.connect(manager) as MaticX).togglePause(); + await ( + maticX.connect(manager) as MaticX + ).bulkUnstakeAllValidators(); + + const snapBefore = await maticX.preFinalizeRate(); + const [oracleBefore] = await maticX.convertMaticXToPOL( + TERMINAL_RATE_PRECISION + ); + expect(oracleBefore).to.equal(snapBefore); + + const donation = stakeAmount; + expect(await pol.balanceOf(stakerA.address)).to.be.gte(donation); + await pol + .connect(stakerA) + .transfer(await maticX.getAddress(), donation); + + const [oracleAfter] = await maticX.convertMaticXToPOL( + TERMINAL_RATE_PRECISION + ); + expect(oracleAfter).to.equal(oracleBefore); + expect(await maticX.preFinalizeRate()).to.equal(snapBefore); + }); + + it("POL donations post-finalize do NOT move the oracle either", async function () { + const fx = await loadFixture(deployFixture); + const { maticX, pol, stakerA } = fx; + await pauseRecallAndFinalize(fx); + + const terminalBefore = await maticX.terminalRate(); + const [oracleBefore] = await maticX.convertMaticXToPOL( + TERMINAL_RATE_PRECISION + ); + expect(oracleBefore).to.equal(terminalBefore); + + const donation = stakeAmount; + expect(await pol.balanceOf(stakerA.address)).to.be.gte(donation); + await pol + .connect(stakerA) + .transfer(await maticX.getAddress(), donation); + + const [oracleAfter] = await maticX.convertMaticXToPOL( + TERMINAL_RATE_PRECISION + ); + expect(oracleAfter).to.equal(oracleBefore); + expect(await maticX.terminalRate()).to.equal(terminalBefore); + }); + + it("during-recall sentinel: rate==1 when preFinalizeRate is 0", async function () { + + const fx = await loadFixture(deployFixture); + const { maticX, maticXAddress, manager } = fx; + await (maticX.connect(manager) as MaticX).togglePause(); + await ( + maticX.connect(manager) as MaticX + ).bulkUnstakeAllValidators(); + + const before = await maticX.preFinalizeRate(); + expect(before).to.be.gt(0n); + const slot = await findScalarStorageSlot( + maticXAddress, + before, + () => maticX.preFinalizeRate(), + 123456789n + ); + await setStorageAt(maticXAddress, slot, 0n); + expect(await maticX.preFinalizeRate()).to.equal(0n); + + const [polFor1e18, totalShares, totalPooled] = + await maticX.convertMaticXToPOL(TERMINAL_RATE_PRECISION); + expect(polFor1e18).to.equal(1n); + expect(totalShares).to.be.gt(0n); + expect( + (totalPooled * TERMINAL_RATE_PRECISION) / totalShares + ).to.equal(1n); + }); + + it("post-finalize sentinel: rate==1 when terminalRate is 0", async function () { + + const fx = await loadFixture(deployFixture); + const { maticX, maticXAddress } = fx; + await pauseRecallAndFinalize(fx); + + const before = await maticX.terminalRate(); + expect(before).to.be.gt(0n); + const slot = await findScalarStorageSlot( + maticXAddress, + before, + () => maticX.terminalRate(), + 123456789n + ); + await setStorageAt(maticXAddress, slot, 0n); + expect(await maticX.terminalRate()).to.equal(0n); + + const [polFor1e18, totalShares, totalPooled] = + await maticX.convertMaticXToPOL(TERMINAL_RATE_PRECISION); + expect(polFor1e18).to.equal(1n); + expect(totalShares).to.be.gt(0n); + expect( + (totalPooled * TERMINAL_RATE_PRECISION) / totalShares + ).to.equal(1n); + }); + }); + + describe("setCustodyDelay (sweep window setter)", function () { + it("updates sweepToCustodyTimestamp = block.timestamp + _custodyDelay and emits the absolute value", async function () { + const { maticX, manager } = await loadFixture(deployFixture); + const newDelay = 7n * 24n * 60n * 60n; + const tx = await ( + maticX.connect(manager) as MaticX + ).setCustodyDelay(newDelay); + if (tx.blockNumber === null) { + throw new Error("setCustodyDelay tx has no blockNumber"); + } + const block = await ethers.provider.getBlock(tx.blockNumber); + if (!block) throw new Error("block not found"); + const expectedTs = BigInt(block.timestamp) + newDelay; + await expect(tx) + .to.emit(maticX, "SetCustodyDelay") + .withArgs(expectedTs); + expect(await maticX.sweepToCustodyTimestamp()).to.equal(expectedTs); + }); + + it("reverts with ZeroCustodyDelay on zero delay", async function () { + const { maticX, manager } = await loadFixture(deployFixture); + await expect( + (maticX.connect(manager) as MaticX).setCustodyDelay(0) + ).to.be.revertedWithCustomError(maticX, "ZeroCustodyDelay"); + }); + + it("reverts for non-admin", async function () { + const { maticX, attacker } = await loadFixture(deployFixture); + await expect( + (maticX.connect(attacker) as MaticX).setCustodyDelay(1n) + ).to.be.reverted; + }); + + it("sweepToCustody reverts when sweepToCustodyTimestamp is unset (footgun guard)", async function () { + + const fx = await loadFixture(deployFixture); + const { maticX, manager, pol, custody } = fx; + await pauseRecallAndFinalize(fx); + + const slot = await findScalarStorageSlot( + await maticX.getAddress(), + await maticX.sweepToCustodyTimestamp(), + () => maticX.sweepToCustodyTimestamp(), + 123456789n + ); + await setStorageAt(await maticX.getAddress(), slot, 0n); + expect(await maticX.sweepToCustodyTimestamp()).to.equal(0n); + + await expect( + (maticX.connect(manager) as MaticX).sweepToCustody( + await pol.getAddress(), + custody.address + ) + ).to.be.revertedWithCustomError(maticX, "CustodyDelayNotElapsed"); + }); + + it("sweepToCustody respects an admin-shortened delay (post-finalize reconfig)", async function () { + + const fx = await loadFixture(deployFixture); + const { maticX, manager, pol, custody } = fx; + await pauseRecallAndFinalize(fx); + + const shortDelay = 60n * 60n; + await (maticX.connect(manager) as MaticX).setCustodyDelay( + shortDelay + ); + const sweepTs = await maticX.sweepToCustodyTimestamp(); + const polAddr = await pol.getAddress(); + + await expect( + (maticX.connect(manager) as MaticX).sweepToCustody( + polAddr, + custody.address + ) + ).to.be.revertedWithCustomError(maticX, "CustodyDelayNotElapsed"); + + await time.increaseTo(sweepTs); + await expect( + (maticX.connect(manager) as MaticX).sweepToCustody( + polAddr, + custody.address + ) + ).to.emit(maticX, "SweptToCustody"); + }); + + it("sweepToCustody respects an admin-extended delay (reconfig restarts the clock)", async function () { + + const fx = await loadFixture(deployFixture); + const { maticX, manager, pol, custody } = fx; + await pauseRecallAndFinalize(fx); + + const originalSweepTs = await maticX.sweepToCustodyTimestamp(); + + await time.increaseTo(originalSweepTs + 100n); + + const extendedDelay = 5n * 365n * 24n * 60n * 60n; + await (maticX.connect(manager) as MaticX).setCustodyDelay( + extendedDelay + ); + + const newSweepTs = await maticX.sweepToCustodyTimestamp(); + expect(newSweepTs).to.be.gt(originalSweepTs); + const polAddr = await pol.getAddress(); + await expect( + (maticX.connect(manager) as MaticX).sweepToCustody( + polAddr, + custody.address + ) + ).to.be.revertedWithCustomError(maticX, "CustodyDelayNotElapsed"); + + await time.increaseTo(newSweepTs); + await expect( + (maticX.connect(manager) as MaticX).sweepToCustody( + polAddr, + custody.address + ) + ).to.emit(maticX, "SweptToCustody"); + }); + }); +}); diff --git a/utils/environment.ts b/utils/environment.ts index b08a546e..9d5ffafd 100644 --- a/utils/environment.ts +++ b/utils/environment.ts @@ -19,7 +19,7 @@ interface EnvironmentSchema { DEPLOYER_ADDRESS: string; } -const API_KEY_REGEX = /^[0-9A-Za-z_-]{32,64}$/; +const API_KEY_REGEX = /^[0-9A-Za-z_-]{21,64}$/; const MNEMONIC_REGEX = /^([a-z ]+){12,24}$/; const ADDRESS_REGEX = /^0x[0-9A-Fa-f]{40}$/; @@ -109,7 +109,9 @@ export function extractEnvironmentVariables(): EnvironmentSchema { }) .validate(process.env); if (error) { - throw new Error(error.annotate()); + // Avoid `error.annotate()` — it dumps the full env (incl. secrets). + const keys = error.details.map((d) => d.path.join(".")).join(", "); + throw new Error(`Invalid environment variables: ${keys}`); } return envVars; }