69390 sc low users cannot revoke migration permit at any time breaking documented guarantee
Submitted on Mar 14th 2026 at 16:12:33 UTC by @go4ko for Audit Comp | Folks Finance: Staking Contracts
Report ID: #69390
Report Type: Smart Contract
Report severity: Low
Target: https://github.com/Folks-Finance/folks-staking-contracts/blob/main/src/Staking.sol
Impacts:
Contract fails to deliver promised returns, but doesn't lose value
Description
Brief/Intro
The setMigrationPermit function enforces a hasRole(MIGRATOR_ROLE, _migrator) check on both granting and revoking permission. When an admin revokes a migrator's role, any user who previously granted that migrator permission is unable to call setMigrationPermit(migrator, false) - the transaction reverts with MigratorNotFound.
This directly contradicts the README's documented guarantee that "The permission can be revoked at any time." If the same address later regains MIGRATOR_ROLE, the migrator can intentionally ot unintentionally migrate the user's funds before the user revokes which the user might not want anymore but had no chance to remove the permission.
Vulnerability Details
In Staking.sol:77-82, the setMigrationPermit function applies the hasRole check unconditionally:
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 hasRole check is appropriate when granting permission (true) - users shouldn't authorize non-migrators. However, it also blocks revocation (false), which has no security reason to be gated behind a role check.
This is distinct from the known issues which acknowledge that stale permit state can exist ("State migrationPermits may contain migrator which had its MIGRATOR_ROLE later revoked"). The known issues frame this as residual data - they do not address the fact that users are blocked from cleaning it up. The user's inability to revoke is the root cause that transforms harmless residual state into an irrevocable consent problem.
Impact Details
This falls under: "Contract fails to deliver promised returns, but doesn't lose value" (Low).
Broken documented guarantee: The README explicitly states "The permission can be revoked at any time by calling
setMigrationPermit(migratorAddress, false)." This is provably false when the migrator's role has been revoked.Loss of user sovereignty over migration consent: A user who granted permission and later decided against migration has no recourse. They must monitor the protocol and hope the address never regains the role.
Forced migration without fresh consent: If the address regains
MIGRATOR_ROLE, the migrator (acting honestly within its privileges) can migrate the user's stakes to a V2 contract the user never agreed to. The user's principal and rewards are moved atomically with no way to prevent it.No funds are lost directly - the stakes are transferred to V2, not stolen. However, the user loses control over where their assets reside.
References
setMigrationPermitimplementation:Staking.sol:77-82migratePositionsFrom(consumes the permit):Staking.sol:166-209README documentation guarantee:
README.md:140— "The permission can be revoked at any time"
Proof of Concept
Please create a file IrrevocableMigrationPermit.t.sol inside the test folder and paste the code below.
Run with:
Code:
Result:
Was this helpful?