Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 8 additions & 2 deletions packages/migration-claim/src/TangleMigration.sol
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ contract TangleMigration is Ownable, ReentrancyGuard {
error AdminClaimWindowClosed();
error InvalidAdminClaimDeadline();
error EmergencyWithdrawNotAllowed();
error EmergencyWithdrawTokenForbidden();
error ClaimDeadlineNotPassed();
error NoClaimDeadlineSet();
error MerkleRootLocked();
Expand Down Expand Up @@ -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();

Expand Down
31 changes: 24 additions & 7 deletions packages/migration-claim/test/TangleMigration.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading