Farseen0/scar-pairs huggingface dataset:
HIGH severity: `Karma.sol` doesn't initialize signature variables. **Description:** Karma.sol only calls following initializers:

However `ERC20VotesUpgradeable` inherits `ERC20PermitUpgradeable`:

**Impact:** It means `Karma.sol` uses `name = ""` and `version = 0` for EIP712 signature, which makes it incompatible with digital wallets. I.e. function...

Vulnerable code:
contract Karma is Initializable, ERC20VotesUpgradeable, UUPSUpgradeable, AccessControlUpgradeable {

    function initialize(address _owner) public initializer {
        ...
        __ERC20_init(NAME, SYMBOL);
        __ERC20Votes_init();
        __UUPSUpgradeable_init();
        __AccessControl_init();

        ...
    }

abstract contract ERC20VotesUpgradeable is Initializable, IVotesUpgradeable, ERC20PermitUpgradeable {

/**
     * @dev Initializes the {EIP712} domain separator using the `name` parameter, and setting `version` to `"1"`.
     *
     * It's a good idea to use the same `name` that is defined as the ERC20 token name.
     */
    function __ERC20Permit_init(string memory name) internal onlyInitializing {
        __EIP712_init_unchained(name, "1");
    }
HIGH severity: Griefer can block user from claiming airdrop. **Description:** Function `KarmaAirdrop::claim` claims airdrop and delegates voting power via a call to `Erc20Votes::delegateBySig`:

There is known vulnerability with EIP2612 permit: signature can be observed in mempool and executed separately, making original call revert. Suppose following...

Vulnerable code:
function claim(
        uint256 index,
        address account,
        uint256 amount,
        bytes32[] calldata merkleProof,
        uint256 nonce,
        uint256 expiry,
        uint8 v,
        bytes32 r,
        bytes32 s
    )
        external
    {
        if (merkleRoot == bytes32(0)) {
            revert KarmaAirdrop__MerkleRootNotSet();
        }
        if (isClaimed(index)) {
            revert KarmaAirdrop__AlreadyClaimed();
        }

        // Verify the merkle proof.
        /// forge-lint: disable-next-line(asm-keccak256)
        bytes32 node = keccak256(abi.encodePacked(index, account, amount));
        if (!MerkleProof.verify(merkleProof, merkleRoot, node)) {
            revert KarmaAirdrop__InvalidProof();
        }

        // Mark it claimed and send the token.
        _setClaimed(index);
        if (!IERC20(TOKEN).transfer(account, amount)) {
            revert KarmaAirdrop__TransferFailed();
        }

        // If the account has no karma balance before this claim, delegate to the default delegatee
        if (IERC20(TOKEN).balanceOf(account) == amount) {
@>          IVotes(TOKEN).delegateBySig(DEFAULT_DELEGATEE, nonce, expiry, v, r, s);
        }
    }

function delegateBySig(
        address delegatee,
        uint256 nonce,
        uint256 expiry,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) public virtual {
        if (block.timestamp > expiry) {
            revert VotesExpiredSignature(expiry);
        }
        address signer = ECDSA.recover(
            _hashTypedDataV4(keccak256(abi.encode(DELEGATION_TYPEHASH, delegatee, nonce, expiry))),
            v,
            r,
            s
        );
@>      _useCheckedNonce(signer, nonce);
        _delegate(signer, delegatee);
    }

    function _useCheckedNonce(address owner, uint256 nonce) internal virtual {
        uint256 current = _useNonce(owner);
        if (nonce != current) {
@>          revert InvalidAccountNonce(owner, current);
        }
    }
MEDIUM severity: User can lose accrued rewards during migration. **Description:** User can migrate his staking position to new vault. However it will overwrite his `rewardsAccrued` in that new vault:

Consider following example:
1) User has staking in old vault
2) New vault version is released
3) User creates stake in new vault
4) Unstakes from new vault
5)...

Vulnerable code:
function migrateToVault(address migrateTo)
        external
        onlyNotEmergencyMode
        whenNotPaused
        onlyTrustedCodehash
        onlyRegisteredVault
    {
        if (vaultOwners[migrateTo] == address(0)) {
            revert StakeManager__InvalidVault();
        }

        if (vaultData[migrateTo].stakedBalance > 0) {
            revert StakeManager__MigrationTargetHasFunds();
        }

        _updateGlobalState();
        _updateVault(msg.sender, false);

        VaultData storage oldVault = vaultData[msg.sender];
        VaultData storage newVault = vaultData[migrateTo];

        // migrate vault data to new vault
        newVault.stakedBalance = oldVault.stakedBalance;
        newVault.rewardIndex = oldVault.rewardIndex;
        newVault.mpAccrued = oldVault.mpAccrued;
        newVault.maxMP = oldVault.maxMP;
        newVault.lastMPUpdateTime = oldVault.lastMPUpdateTime;
@>      newVault.rewardsAccrued = oldVault.rewardsAccrued;

        IStakeVault.MigrationData memory migrationData = IStakeVault.MigrationData({
            lockUntil: IStakeVault(msg.sender).lockUntil(),
            depositedBalance: IStakeVault(msg.sender).depositedBalance()
        });

        IStakeVault(migrateTo).migrateFromVault(migrationData);

        delete vaultData[msg.sender];

        emit VaultMigrated(msg.sender, migrateTo);
    }

function migrateToVault(address migrateTo)
        external
        onlyNotEmergencyMode
        whenNotPaused
        onlyTrustedCodehash
        onlyRegisteredVault
    {
        if (vaultOwners[migrateTo] == address(0)) {
            revert StakeManager__InvalidVault();
        }

-       if (vaultData[migrateTo].stakedBalance > 0) {
+       if (vaultData[migrateTo].stakedBalance > 0 || vaultData[migrateTo].rewardsAccrued > 0) {
            revert StakeManager__MigrationTargetHasFunds();
        }

        ...
    }
MEDIUM severity: `DeployProtocol.s.sol` incorrectly allows transfers to `StakeManager.sol`. **Description:** `DeployProtocol.s.sol` is used to automatically deploy and configure protocol. Contracts `StakeManager.sol` and `SimpleKarmaDistributor.sol` should be allowed to transfer Karma token. However it provides `stakeManager` both times:

**Impact:**...

Vulnerable code:
// whitelist reward distributors for transferring Karma tokens
        karma.setAllowedToTransfer(address(stakeManager), true);
        console.log("Whitelisted reward distributor (StakeManager)", address(stakeManager), "for transfer");
@>      karma.setAllowedToTransfer(address(stakeManager), true);
        console.log(
            "Whitelisted reward distributor (SimpleKarmaDistributor)", address(simpleKarmaDistributor), "for transfer"
        );

function _slash(address account, address rewardRecipient) internal virtual returns (uint256) {
        uint256 currentBalance = _balanceOf(account);
        if (currentBalance == 0) {
            revert Karma__CannotSlashZeroBalance();
        }

        // first, calculate the total amount to slash from the actual reward tokens
        uint256 totalAmountToSlash = _calculateSlashAmount(super.balanceOf(account));

        for (uint256 i = 0; i < rewardDistributors.length(); i++) {
            address distributor = rewardDistributors.at(i);
            uint256 currentDistributorAccountBalance = IRewardDistributor(distributor).rewardsBalanceOfAccount(account);

            // then, calculate the amount to slash from each reward distributor
            totalAmountToSlash += _calculateSlashAmount(currentDistributorAccountBalance);

            // turn virtual Karma into real Karma for slashing
@>          IRewardDistributor(distributor).redeemRewards(account);
        }

// whitelist reward distributors for transferring Karma tokens
        karma.setAllowedToTransfer(address(stakeManager), true);
        console.log("Whitelisted reward distributor (StakeManager)", address(stakeManager), "for transfer");
-       karma.setAllowedToTransfer(address(stakeManager), true);
+       karma.setAllowedToTransfer(address(simpleKarmaDistributor), true);
        console.log(
            "Whitelisted reward distributor (SimpleKarmaDistributor)", address(simpleKarmaDistributor), "for transfer"
        );
MEDIUM severity: Slashing doesn't work if any reward distributor is paused. **Description:** `Karma::_slash` iterates over all reward distributors and claims rewards by calling `IRewardDistributor::redeemRewards`:

Problem is that `StakeManager::redeemRewards` is pausable:

So users can't be slashed if any reward distributor is paused.

**Impact:** Slashing doesn't work...

Vulnerable code:
function _slash(address account, address rewardRecipient) internal virtual returns (uint256) {
        ...

        for (uint256 i = 0; i < rewardDistributors.length(); i++) {
            address distributor = rewardDistributors.at(i);
            uint256 currentDistributorAccountBalance = IRewardDistributor(distributor).rewardsBalanceOfAccount(account);

            // then, calculate the amount to slash from each reward distributor
            totalAmountToSlash += _calculateSlashAmount(currentDistributorAccountBalance);

            // turn virtual Karma into real Karma for slashing
@>          IRewardDistributor(distributor).redeemRewards(account);
        }

        ...
    }

function redeemRewards(address account) external onlyNotEmergencyMode whenNotPaused returns (uint256) {
LOW severity: Migrations to self are possible. **Description:** Migrations to self are possible (if staked balance == 0). This causes weird state transitions like completely deleting the position due to the `delete vaultData[msg.sender];`. While no serious impact has been found yet (except self-harm, user mistake scenarios), it is advised to...

Vulnerable code:
function migrateToVault(address migrateTo) external onlyOwner onlyNotLeft {
+   require(migrateTo != address(this));
    // ...
}
LOW severity: `totalMaxMP` incorrectly accounts for individual `maxMP`. **Description:** Each stake has a limit of MP to accrue, accrual must stop after 4 years. For each stake it calculates `maxMP` and simply stops accrual after that value, and it is correct:

However such approach is incorrect for `totalMaxMP` value:

That's because total value doesn't know how much.....

Vulnerable code:
function _calculateAccrual(
        uint256 _balance,
        uint256 _currentTotalMP,
        uint256 _currentMaxMP,
        uint256 _lastAccrualTime,
        uint256 _processTime
    )
        internal
        pure
        returns (uint256 _deltaMpTotal)
    {
        uint256 dt = _processTime - _lastAccrualTime;
@>      if (_currentTotalMP < _currentMaxMP) {
@>          _deltaMpTotal = Math.min(_accrueMP(_balance, dt), _currentMaxMP - _currentTotalMP);
        }
    }

function _totalMP() internal view returns (uint256) {
        if (totalMaxMP == 0) {
            return totalMPAccrued;
        }

        uint256 currentTime = block.timestamp;
        uint256 timeDiff = currentTime - lastMPUpdatedTime;
        if (timeDiff == 0) {
            return totalMPAccrued;
        }

@>      uint256 accruedMP = _accrueMP(totalStaked, timeDiff);
@>      if (totalMPAccrued + accruedMP > totalMaxMP) {
@>          accruedMP = totalMaxMP - totalMPAccrued;
        }

        uint256 newTotalMPAccrued = totalMPAccrued + accruedMP;

        return newTotalMPAccrued;
    }

if (totalMPAccrued + accruedMP > totalMaxMP) {
            accruedMP = totalMaxMP - totalMPAccrued;
        }
LOW severity: New users can't be registered after slashing contrary to documentation. **Description:** `RLN.sol` has variable `SET_SIZE`, which defines maximum number of registered users. During slashing, users are deleted from registry:

According to documentation https://github.com/status-im/status-network-monorepo/blob/develop/status-network-contracts/docs/rln.md#registry-capacity...

Vulnerable code:
function slash(bytes32 privateKey, address rewardRecipient) private onlyRole(SLASHER_ROLE) {
        // Hash the private key using Poseidon to get identityCommitment
        uint256 identityCommitment = poseidonHasher.hash(uint256(privateKey));

        User memory member = members[identityCommitment];
        if (member.userAddress == address(0)) {
            revert RLN__MemberNotFound();
        }
        karma.slash(member.userAddress, rewardRecipient);
@>      delete members[identityCommitment];
    }

function register(uint256 identityCommitment, address user) external onlyRole(REGISTER_ROLE) {
@>      uint256 index = identityCommitmentIndex;
@>      if (index >= SET_SIZE) {
            revert RLN__SetIsFull();
        }
        if (members[identityCommitment].userAddress != address(0)) {
            revert RLN__IdCommitmentAlreadyRegistered();
        }

        /// forge-lint: disable-next-line(named-struct-fields)
        members[identityCommitment] = User(user, index);
        emit MemberRegistered(identityCommitment, index);

        unchecked {
@>          identityCommitmentIndex = index + 1;
        }
    }
LOW severity: `KarmaNFT.sol` incorrectly simulates minting event. **Description:** It emits Transfer event to simulate minting:

EIP-721 specifies that when NFT is created, it uses `from = address(0)` https://eips.ethereum.org/EIPS/eip-721#specification:
> /// @dev This emits when ownership of any NFT changes by any mechanism.
    ///  This event emits when NFTs.....

Vulnerable code:
/**
     * @notice Emits transfer event to simulate minting an NFT to the caller's address.
     */
    function mint() external {
        emit Transfer(msg.sender, msg.sender, uint256(uint160(msg.sender)));
    }

function mint() external {
-       emit Transfer(msg.sender, msg.sender, uint256(uint160(msg.sender)));
+       emit Transfer(address(0), msg.sender, uint256(uint160(msg.sender)));
    }
LOW severity: `InitializeKarmaTiersScript` doesn't account for decimals. **Description:** Script uses raw token amounts as you can see. However Karma token has 18 decimals, therefore all users will have "legendary" tier.

**Impact:** `KarmaTiers.sol` is initialized with incorrect amounts.

**Recommended Mitigation:** Add e18 notation to token amounts.

**StatusL2:**...

Vulnerable code:
contract InitializeKarmaTiersScript is Script {
    function run() external {
        ...

        // Tier 0: 0 karma = 0 tx (no gasless for users without karma)
        tiers[0] = KarmaTiers.Tier({ minKarma: 0, maxKarma: 1, name: "entry", txPerEpoch: 2 });
        tiers[1] = KarmaTiers.Tier({ minKarma: 2, maxKarma: 49, name: "newbie", txPerEpoch: 6 });
        tiers[2] = KarmaTiers.Tier({ minKarma: 50, maxKarma: 499, name: "basic", txPerEpoch: 16 });
        tiers[3] = KarmaTiers.Tier({ minKarma: 500, maxKarma: 4999, name: "active", txPerEpoch: 96 });
        tiers[4] = KarmaTiers.Tier({ minKarma: 5000, maxKarma: 19_999, name: "regular", txPerEpoch: 480 });
        tiers[5] = KarmaTiers.Tier({ minKarma: 20_000, maxKarma: 99_999, name: "power", txPerEpoch: 960 });
        tiers[6] = KarmaTiers.Tier({ minKarma: 100_000, maxKarma: 499_999, name: "pro", txPerEpoch: 10_080 });
        tiers[7] =
            KarmaTiers.Tier({ minKarma: 500_000, maxKarma: 4_999_999, name: "high-throughput", txPerEpoch: 108_000 });
        tiers[8] = KarmaTiers.Tier({ minKarma: 5_000_000, maxKarma: 9_999_999, name: "s-tier", txPerEpoch: 240_000 });
        tiers[9] = KarmaTiers.Tier({
            minKarma: 10_000_000, maxKarma: type(uint256).max, name: "legendary", txPerEpoch: 480_000
        });

        ...
    }
}
HIGH severity: Potential Divergence Between Off-Chain and On-Chain Poseidon Hashing Causes Unslashable RLN Identities. **Description:** The `PoseidonHasher` contract initializes the Poseidon state using a raw EVM addition:

The Yul `add` instruction wraps modulo `2^256`, not modulo the BN254 field prime `Q`.
As a result, the effective input absorbed into the Poseidon permutation becomes:

However, canonical...

Vulnerable code:
s1_canonical = ( (input mod Q) + C1 ) mod Q = (input + C1) mod Q

C1 / 2^256 ≈ 4.7 × 10⁻⁵  (≈0.0047%, ~1 in 21,000 keys)

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import { Test } from "forge-std/Test.sol";
import { PoseidonHasher } from "../src/rln/PoseidonHasher.sol";

contract PoseidonHasherTest is Test {
    PoseidonHasher public hasher;

    function setUp() public {
        hasher = new PoseidonHasher();
    }

    function test_PoCHashMaxUint() public {
        uint256 input = type(uint256).max;
        // Expected hash from circomlibjs (which handles field arithmetic correctly)
        uint256 expected = 11254588113248280256028662529799552354366536761492627237202955510067774853962;
        uint256 result = hasher.hash(input);
        assertNotEq(result, expected, "Hash mismatch for max uint256 (Overflow issue)");
    }
}
LOW severity: Unvested amount is lost if all users withdraw. **Description:** `sUSBD` permanently locks an initial “burnt” share balance (minted to the vault itself for donation-attack protection). During a vesting period, if all user shares are redeemed/burned such that only the locked shares remain, the remaining unvested rewards continue vesting into...

Vulnerable code:
function test_VestedAmountIsLostIfAllUsersWithraw() public {
    // First set cooldown to 0 to enable direct withdraw/redeem
    vm.prank(operationalAdmin);
    isusbdMgmt.setCooldownDuration(0);

    vm.prank(rewarder);
    isusbd.transferInRewards(100e18);

    // user withdraws after half the vesting period
    vm.warp(block.timestamp + 7 days);

    vm.prank(user1);
    uint256 assets = isusbd.redeem(500e18, user1, user1);

    assertApproxEqAbs(assets, 550e18, 1e18);

    // rest of the vesting period goes
    vm.warp(block.timestamp + 7 days);

    uint256 burntAssets = isusbd.convertToAssets(isusbd.balanceOf(address(isusbd)));

    // rest of the vested amount is vested to the initial burnt shares
    assertApproxEqAbs(burntAssets, 51e18, 1e18);
}
LOW severity: Updating `vestingPeriod` can retroactively reclassify vested rewards as unvested. **Description:** `sUSBD` tracks an active vesting “epoch” using `vestingAmount` and `lastRewardTimestamp`, while `sUSBD::getUnvestedAmount` computes remaining unvested rewards using the current `vestingPeriod`. If `vestingPeriod` is updated after rewards have been transferred in, the vault can...

Vulnerable code:
function test_GetUnvestedAmount_CanResurrectAfterVestingPeriodIncrease_AndMakeTotalAssetsUnderflow()
    public
{
    // Enable direct withdraw/redeem path (so user can exit fully)
    vm.prank(operationalAdmin);
    isusbdMgmt.setCooldownDuration(0);

    vm.prank(operationalAdmin);
    isusbdMgmt.setVestingPeriod(1);

    // Start a vesting epoch
    vm.prank(rewarder);
    isusbd.transferInRewards(REWARD_AMOUNT);

    // Let rewards fully vest under the current vestingPeriod
    vm.warp(block.timestamp + _vault.vestingPeriod());
    assertEq(isusbd.getUnvestedAmount(), 0);

    // User exits, pulling out vested rewards (leaving only the permanently locked shares behind)
    vm.startPrank(user1);
    isusbd.redeem(isusbd.balanceOf(user1), user1, user1);
    vm.stopPrank();
    // Increase vesting period AFTER vesting completed. Because getUnvestedAmount() uses the *current*
    // vestingPeriod with the same vesting epoch state, this can "resurrect" a non-zero unvested amount.
    vm.prank(operationalAdmin);
    isusbdMgmt.setVestingPeriod(uint24(14 days));

    uint256 resurrectedUnvested = isusbd.getUnvestedAmount();
    uint256 vaultBal = IERC20(_vault.asset()).balanceOf(address(isusbd));

    // The problematic condition: unvested becomes larger than the vault's actual balance
    assertGt(resurrectedUnvested, vaultBal);

    // totalAssets() does (balance - unvested) -> underflow panic (0x11)
    vm.expectRevert(stdError.arithmeticError);
    isusbd.totalAssets();
}

+ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+ import { stdError } from "forge-std/StdError.sol";

function setVestingPeriod(
        uint24 period
    ) external onlyRole(OPERATIONAL_ADMIN_ROLE) {
        if (period > MAX_VESTING_PERIOD) revert ExceedsMax();
        if (period == 0 && cooldownDuration == 0) revert ZeroCooldownAndPeriod();
        if (getUnvestedAmount() != 0) revert VestingInProgress();

        uint24 oldPeriod = vestingPeriod;
        vestingPeriod = period;
+       vestingAmount = 0;

        emit VestingPeriodUpdated(msg.sender, oldPeriod, period);
    }
MEDIUM severity: Free memory pointer corruption in inline assembly causes memory allocation panic and denial of service. **Description:** The `AgreementFactory` contract uses inline assembly to derive a CREATE2 salt by writing intermediate values directly into memory. In both `AgreementFactory::create` and `AgreementFactory::computeAddress`, the assembly block writes to memory slot `0x40` using `mstore(0x40,...

Vulnerable code:
In Solidity, memory slot `0x40` is reserved for the free-memory pointer, which tracks the next available memory location for dynamic allocations. Overwriting this slot corrupts the allocator state.

Immediately after this assembly block, the contract performs dynamic memory allocations via `bytes.concat(...)` and `abi.encode(...)`. Since the free-memory pointer has been overwritten with an arbitrary value (`salt`), the allocator attempts to allocate memory at an invalid or extremely large offset, triggering a Solidity panic with error code `0x41` (memory allocation error).

This issue is deterministic and affects any execution path that reaches these memory allocations after the corrupted pointer, making the affected functions unusable.

**Impact:** Both `AgreementFactory::create` and `AgreementFactory::computeAddress` consistently revert with `panic: memory allocation error (0x41)` which results in a denial of service for the core factory functionality.


**Proof of Concept:**
- `AgreementFactoryTest::test_createWithFreeMemoryPointerIssue`:

- `AgreementFactoryTest::test_computeAddressWithFreeMemoryPointerIssue`:

**Recommended Mitigation:** Consider allocating memory using the free-memory pointer safely:
LOW severity: Missing validation allows empty account addresses in agreement scope, creating ambiguous whitehat coverage. **Description:** The `Agreement::_validateChains` validates that the accounts array is non-empty but does not validate the contents of individual `Account` structs. This allows accounts with empty `accountAddress` strings to be added to agreements.

Note that `assetRecoveryAddress` is validated...

Vulnerable code:
function test_POC_emptyAccountAddressAllowed() public {
        // Create account with empty address
        SHAccount[] memory accounts = new SHAccount[](1);
        accounts[0] = SHAccount({
            accountAddress: "",  // Empty!
            childContractScope: ChildContractScope.All
        });

        SHChain[] memory chains = new SHChain[](1);
        chains[0] = SHChain({
            assetRecoveryAddress: "0x742d35Cc6634C0532925a3b844Bc454e",
            accounts: accounts,
            caip2ChainId: "eip155:56"
        });

        vm.prank(owner);
        agreement.addChains(chains); //@note this does not revert

        // Verify empty account was added
        AgreementDetails memory details = agreement.getDetails();

        bool foundBscChain = false;
        for (uint256 i = 0; i < details.chains.length; i++) {
            if (keccak256(bytes(details.chains[i].caip2ChainId)) == keccak256(bytes("eip155:56"))) {
                foundBscChain = true;
                assertEq(details.chains[i].accounts.length, 1);
                assertEq(details.chains[i].accounts[0].accountAddress, "");
                break;
            }
        }
        assertTrue(foundBscChain, "BSC chain not found");
    }