diff --git a/packages/migration-claim/src/TangleMigration.sol b/packages/migration-claim/src/TangleMigration.sol index 1681fd4..645ed6e 100644 --- a/packages/migration-claim/src/TangleMigration.sol +++ b/packages/migration-claim/src/TangleMigration.sol @@ -111,6 +111,7 @@ contract TangleMigration is Ownable, ReentrancyGuard { error AdminClaimWindowClosed(); error InvalidAdminClaimDeadline(); error EmergencyWithdrawNotAllowed(); + error EmergencyWithdrawTokenForbidden(); error ClaimDeadlineNotPassed(); error NoClaimDeadlineSet(); error MerkleRootLocked(); @@ -340,10 +341,15 @@ contract TangleMigration is Ownable, ReentrancyGuard { emit Paused(_paused); } - /// @notice Emergency withdraw tokens (only after deadline or if paused) - /// @param _token Token to withdraw (use address(0) for native) + /// @notice Emergency withdraw mis-sent tokens (only after deadline or if paused) + /// @dev The migration token itself is intentionally NOT withdrawable here: unclaimed + /// migration funds may only ever exit via the permissionless `sweepUnclaimedToTreasury` + /// to the immutable `treasury`. Allowing the owner to pull `token` would defeat that + /// guarantee and reintroduce owner discretion over user-claimable funds. + /// @param _token Token to withdraw (use address(0) for native); must not be the migration token /// @param _amount Amount to withdraw function emergencyWithdraw(address _token, uint256 _amount) external onlyOwner { + if (_token == address(token)) revert EmergencyWithdrawTokenForbidden(); bool deadlinePassed = claimDeadline != 0 && block.timestamp > claimDeadline; if (!paused && !deadlinePassed) revert EmergencyWithdrawNotAllowed(); diff --git a/packages/migration-claim/test/TangleMigration.t.sol b/packages/migration-claim/test/TangleMigration.t.sol index fff1b98..729ee71 100644 --- a/packages/migration-claim/test/TangleMigration.t.sol +++ b/packages/migration-claim/test/TangleMigration.t.sol @@ -736,22 +736,39 @@ contract TangleMigrationTest is Test { // EMERGENCY WITHDRAW TEST // ═══════════════════════════════════════════════════════════════════════ - function test_EmergencyWithdraw() public { + function test_EmergencyWithdraw_RevertsForMigrationToken() public { + // Unclaimed migration funds must only ever exit via sweepUnclaimedToTreasury(); + // the owner cannot pull the migration token here even when paused. migration.setPaused(true); uint256 balance = tnt.balanceOf(address(migration)); - uint256 ownerBalanceBefore = tnt.balanceOf(owner); - + vm.expectRevert(TangleMigration.EmergencyWithdrawTokenForbidden.selector); migration.emergencyWithdraw(address(tnt), balance); + } - assertEq(tnt.balanceOf(address(migration)), 0); - assertEq(tnt.balanceOf(owner), ownerBalanceBefore + balance); + function test_EmergencyWithdraw_RescuesForeignToken() public { + // A foreign token accidentally sent to the contract can still be rescued to owner. + TNT foreign = new TNT(owner); + foreign.mintInitialSupply(owner, 1000 ether); + foreign.transfer(address(migration), 500 ether); + + migration.setPaused(true); + uint256 ownerBalanceBefore = foreign.balanceOf(owner); + + migration.emergencyWithdraw(address(foreign), 500 ether); + + assertEq(foreign.balanceOf(address(migration)), 0); + assertEq(foreign.balanceOf(owner), ownerBalanceBefore + 500 ether); } function test_EmergencyWithdraw_RevertIfNotPausedOrDeadlinePassed() public { - uint256 balance = tnt.balanceOf(address(migration)); + // Use a foreign token so execution reaches the paused/deadline gate. + TNT foreign = new TNT(owner); + foreign.mintInitialSupply(owner, 1000 ether); + foreign.transfer(address(migration), 500 ether); + vm.expectRevert(TangleMigration.EmergencyWithdrawNotAllowed.selector); - migration.emergencyWithdraw(address(tnt), balance); + migration.emergencyWithdraw(address(foreign), 500 ether); } function test_EmergencyWithdraw_OnlyOwner() public {