Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
a2b5767
instant claim update
blockgroot May 12, 2026
011d456
split claim and freeze, drop active stake check
blockgroot May 12, 2026
ad62685
harden sunset: max slippage, pause guards, drop gap
blockgroot May 12, 2026
5e50101
fix(security): stop env validation from leaking secrets
blockgroot May 12, 2026
9cdb61a
drop POLYGON_MIGRATION inline migrate in claimDrainNonces
blockgroot May 12, 2026
af058f2
test: sunset suite
blockgroot May 12, 2026
ddfe108
task: sunset operational hardhat tasks
blockgroot May 12, 2026
88d1d71
docs: SUNSET.md — sunset upgrade engineering guide
blockgroot May 12, 2026
71a9e28
test: update legacy claimWithdrawal paused test for sunset
blockgroot May 12, 2026
c88dc8c
task: add tenderly sunset rehearsal tasks
blockgroot May 12, 2026
d3803e3
docs: add tenderly sunset simulation runbook
blockgroot May 12, 2026
1311fc4
task: harden tenderly edge-case checks
blockgroot May 12, 2026
8de01c9
docs: add split tenderly rehearsal runs
blockgroot May 12, 2026
c1dfe28
test: make sunset fork tests deterministic
blockgroot May 12, 2026
3e86b91
docs: update sunset fork test runbook
blockgroot May 12, 2026
9042773
chore: remove documentation
blockgroot May 12, 2026
1ea1db2
chore: remove tenderly related scripts
blockgroot May 12, 2026
3d7fba3
chore: ran lint
blockgroot May 12, 2026
6414c57
refactor: collapse drainUnbondNonces to scalar mapping
blockgroot May 13, 2026
2a74c1e
refactor: rename sunset surface to asset-recall / terminal-rate
blockgroot May 13, 2026
5c51592
Apply prettier formatting and fix eslint errors in sunset files
blockgroot May 13, 2026
9b28e40
Freeze oracle rate during asset recall to prevent oracle manipulation
blockgroot May 13, 2026
ad155b4
Lock unpause after asset recall begins
blockgroot May 13, 2026
abeeb6a
Add recallClaimsComplete gate and rename assetRecallComplete to termi…
blockgroot May 13, 2026
b2ba250
Swap claim/pop ordering in claimAssetRecallNonces
blockgroot May 13, 2026
3c3ddb5
Block setValidatorRegistry and setFxStateRootTunnel post-recall
blockgroot May 13, 2026
eab8545
Cover all 9 sunset state slots in verify-upgrade and status tasks
blockgroot May 13, 2026
2f84780
Fix broken bulkUnstake double-call expectation and extend sunset cove…
blockgroot May 13, 2026
5cffd86
chore: enhance fork tests
blockgroot May 13, 2026
b1597a4
chore: add sunset mainnnet runbook
blockgroot May 13, 2026
3a56a3d
feat: update instantClaim to redeem full balance and adjust runbook
blockgroot May 13, 2026
4a20058
feat: implement configurable custody delay and update related runbook…
blockgroot May 14, 2026
1d67f1d
feat: remove ValidatorAlreadyRecalled error and related test case
blockgroot May 14, 2026
0e2b4b8
feat: update custody delay mechanism to use sweepToCustodyTimestamp f…
blockgroot May 14, 2026
cf50758
feat: move custody-window footgun gate from finalize to sweep
blockgroot May 14, 2026
dae8089
chore: clean MaticX contract comment
blockgroot May 14, 2026
ee39a13
fix: rename ZeroAmount -> ZeroCustodyDelay in setCustodyDelay
blockgroot May 14, 2026
a9052da
feat: drop recalledPolBalance, use polToken.balanceOf(this) directly
blockgroot May 15, 2026
571f744
feat: fix tuple format in recall/post-finalize oracle branches
blockgroot May 15, 2026
aa85b71
chore: rename recallClaimsComplete -> recallComplete
blockgroot May 15, 2026
78d4d33
chore: match legacy nonce-read ordering in bulkUnstakeAllValidators
blockgroot May 15, 2026
1f0d25a
chore: clarify retry-on-revert comment in claimAssetRecallNonces (rev…
blockgroot May 15, 2026
3e06115
feat: gate claimAssetRecallNonces on recallComplete (review #9)
blockgroot May 15, 2026
fa2fbe5
feat: generic asset sweep + assetCustodied kill-switch (review #10/#1…
blockgroot May 15, 2026
a2d17b1
feat: update sunset tasks to use recallComplete and assetCustodied st…
blockgroot May 15, 2026
13fbe33
chore: reorder sunset storage for bool packing, trim verbose comments
blockgroot May 18, 2026
c4162a3
refactor: tighten sunset state-flag ordering and redeem gate
blockgroot May 18, 2026
497a1ff
refactor: collapse sunset branches in convert functions and hoist tot…
blockgroot May 18, 2026
a077085
refactor: fold instantClaim into requestWithdraw via private helper
blockgroot May 18, 2026
9cdbefe
refactor: drop redundant pause checks in recall claim and finalize
blockgroot May 18, 2026
0fd59f6
refactor: replace instantClaim with requestWithdraw and update relate…
blockgroot May 19, 2026
e613c4d
refactor: remove redundant comments and clean up code in Sunset tests
blockgroot May 19, 2026
b9865a0
docs: fix requestWithdraw NatSpec(Bailsec Issue_16)
blockgroot Jun 5, 2026
7faa3da
refactor: add set-custody-delay step in sunset tasks(Bailsec Issue_18)
blockgroot Jun 5, 2026
dab24cd
refactor: add error handling for missing expected implementation in s…
blockgroot Jun 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
277 changes: 267 additions & 10 deletions contracts/MaticX.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
];
Expand Down Expand Up @@ -491,14 +557,165 @@ 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);
Comment thread
galacticminter marked this conversation as resolved.
Comment thread
galacticminter marked this conversation as resolved.
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();

Comment thread
galacticminter marked this conversation as resolved.
(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(
Comment thread
galacticminter marked this conversation as resolved.
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%.
/// @param _feePercent - Fee percent
// 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();
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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();
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions contracts/interfaces/IMaticX.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion hardhat.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading