Immunefi Audit Competitions
Folks Finance: Staking Contracts

69860 sc low users are permanently prevented from revoking migration permits if the migrator s role is temporarily or permanently revoked

Submitted on Mar 17th 2026 at 06:39:10 UTC by @OadeHack for Audit Comp | Folks Finance: Staking Contracts

Description

Summary

The setMigrationPermit function tightly couples a user's ability to modify their own permission state with the target migrator's current active role. If a user grants permission to a Migrator, and the Admin subsequently revokes the MIGRATOR_ROLE from that contract, the user is completely blocked from revoking their permission. Their consent is permanently trapped at true. In decentralized systems, a user must always retain the unilateral right to revoke an allowance or permission; tying the ability to revoke consent to the protocol's external role management violates core user asset sovereignty.

Description

Why it bypasses the Tradeoff List: The sponsor noted that "State 'migrationPermits' may contain migrator which had its MIGRATOR_ROLE later revoked". This simply acknowledges that a user's true state persists after an admin action. However, the tradeoff list does not state that it is intentional for a user to be actively blocked from clearing their own state back to false.

In standard smart contract architecture (analogous to ERC20 approve), verifying the validity of a spender is required when granting an allowance, but should never be required when revoking an allowance.

In Staking.sol, the check is strictly enforced for both:

function setMigrationPermit(address _migrator, bool _isMigrationPermitted) external {
    if (!hasRole(MIGRATOR_ROLE, _migrator)) revert MigratorNotFound(_migrator);

    migrationPermits[_migrator][msg.sender] = _isMigrationPermitted;
    emit MigrationPermitUpdated(_migrator, msg.sender, _isMigrationPermitted);
}

The Logic Trap:

The Logic Trap

The Admin grants MIGRATOR_ROLE to Migrator_V1.

Alice explicitly opts-in by calling setMigrationPermit(Migrator_V1, true).

The Admin revokes MIGRATOR_ROLE from Migrator_V1 (e.g., due to a paused migration or deprecated contract).

Alice decides she no longer wishes to have an active approval associated with that address. She attempts to clear her state by calling setMigrationPermit(Migrator_V1, false).

The transaction reverts. Because Migrator_V1 no longer has the role, the hasRole check fails. Alice is permanently locked out of her own permission state.

Impact

  • Loss of User Sovereignty: Users are mathematically denied the fundamental right to revoke approvals on their own assets.
  • Violation of Allowance Standards: The contract incorrectly applies a "granting" security check to a "revoking" action, permanently trapping user state on the blockchain.

Recommendation

Decouple the role verification from the revocation action. The contract should only enforce that the target is a valid migrator when the user is granting permission (_isMigrationPermitted == true). Users must always be allowed to turn their permission to false.

    function setMigrationPermit(address _migrator, bool _isMigrationPermitted) external {
-       if (!hasRole(MIGRATOR_ROLE, _migrator)) revert MigratorNotFound(_migrator);
+       if (_isMigrationPermitted) {
+           if (!hasRole(MIGRATOR_ROLE, _migrator)) revert MigratorNotFound(_migrator);
+       }

        migrationPermits[_migrator][msg.sender] = _isMigrationPermitted;
        emit MigrationPermitUpdated(_migrator, msg.sender, _isMigrationPermitted);
    }

Proof of Concept

Drop the following test into StakingTest.sol. It mathematically proves that Alice is blocked from revoking her own permission once the admin revokes the migrator's role.

function test_Low_CannotRevokePermitWhenRoleRevoked() public {
    // 1. Alice safely opts-in to the migration using the active migrator
    vm.prank(alice);
    staking.setMigrationPermit(migrator, true);

    // Sanity check: Alice's permit is successfully set to true
    assertTrue(staking.migrationPermits(migrator, alice), "Setup: Permit not set");

    // 2. Admin revokes the role (e.g., stopping the migration due to a bug in the proxy)
    vm.prank(admin);
    staking.revokeRole(keccak256("MIGRATOR"), migrator);

    // Sanity check: The migrator definitely lost its role
    assertFalse(staking.hasRole(keccak256("MIGRATOR"), migrator), "Setup: Role not revoked");

    // 3. Alice attempts to revoke her consent to protect her assets.
    vm.prank(alice);

    // This MUST revert due to the strict role check, trapping Alice's state
    vm.expectRevert(abi.encodeWithSelector(IStakingV1.MigratorNotFound.selector, migrator));
    staking.setMigrationPermit(migrator, false);

    // 4. Assert the Bug: Alice's permission is permanently trapped at TRUE
    assertTrue(staking.migrationPermits(migrator, alice), "Bug: Alice's permit was not trapped!");
}