Next Generation

Next Generation
Findings & Analysis Report

2025-05-12

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 Next Generation smart contract system. The audit took place from January 31 to February 07, 2025.

Final report assembled by Code4rena.

Summary

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

Additionally, C4 analysis included 18 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 Next Generation team.

Trusted forwarder findings

Of the identified vulnerabilities, 1 high severity, 1 medium severity and 2 low severity findings were associated with the Truster forwarder contract.

⚠️The Next Generation team has decided to hold on deployment of the Trusted forwarder contract, as they are focusing on development for their future roadmap.

The sponsors have requested the following note be included:

While this component was included in the audit scope to validate its current design and security posture, we want to clarify that no immediate deployment of the Trusted Forwarder is planned at this time. These findings will be addressed as development of this component progresses, ensuring that when eventually audited and deployed, the Trusted Forwarder will meet our high security standards.

C4 has included the related findings as a separate section in this report. Original numbering is in continuous order for this section, for ease of reference.

Scope

The code under review can be found within the C4 Next Generation repository, and is composed of 6 smart contracts written in the Solidity programming language and includes 472 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 (2)

[M-01] ERC-20 allowance bypass: spender can force sender to pay extra fees beyond approved amount

Submitted by dd0x7e8, also found by 0xAadi, 0xgh0stcybers3c, Daniel526, deeney, djshan_eden, JCN, mxteem, Roger, and unnamed

https://github.com/code-423n4/2025-01-next-generation/blob/499cfa50a56126c0c3c6caa30808d79f82d31e34/contracts/Token.sol#L166

Finding description and impact

The EURFToken contract includes a transaction fee mechanism where a percentage of the transferred amount is deducted as a fee. This fee is deducted from the sender’s balance additionally. However, in the transferFrom function, which allows spender addresses to transfer tokens on behalf of the actual owner, the contract does not account for the fact that the total deducted amount (including fees) may exceed the approved allowance.

Specifically, if a user approves a spender to transfer 100 tokens, and the transaction fee is 10%, the spender will effectively cause the owner to lose 110 tokens (100 for the transfer and 10 for the fee); even though the allowance was only 100. This violates the ERC-20 standard, where the spender should only be able to transfer up to the approved amount.

function transferFrom(address sender, address recipient, uint256 amount) public override returns (bool) {
    transferSanity(sender, recipient, amount);
    return super.transferFrom(sender, recipient, amount);
}
  • The function calls transferSanity(sender, recipient, amount), which deducts additional fees without checking if amount + fee exceeds the allowance.
  • The actual deduction happens in _payTxFee(sender, amount), which is triggered before executing the transfer:
function transferSanity(address sender, address recipient, uint256 amount) internal {
    adminSanity(sender, recipient);
    if (_txfeeRate > 0) _payTxFee(sender, amount);
}
  • _payTxFee(sender, amount) calculates the transaction fee and deducts it directly from the sender’s balance, without ensuring that the allowance covers it:
function _payTxFee(address from, uint256 txAmount) internal override {
    uint256 txFees = calculateTxFee(txAmount);
    if (balanceOf(from) < txFees + txAmount) revert BalanceTooLow(txFees + txAmount, balanceOf(from));
    if (_feesFaucet != address(0)) _update(from, _feesFaucet, txFees);
    emit FeesPaid(from, txFees);
}

Proof of Concept

The PoC built on Foundry framework to show that the decreased balance exceeds the approved allowance in transferFrom function.

    function test_transferFrom_exceedAllowance() public {
        vm.startPrank(alice);
        token.approve(address(this), 100e6);
        vm.stopPrank();
        uint256 balBefore = token.balanceOf(alice);
        console.log("calling transferFrom to transfer 100 EURF");
        token.transferFrom(alice, bob, 100e6);
        assertGe(balBefore - token.balanceOf(alice), 100e6);
    }

Test result:

[PASS] test_transferFrom_exceedAllowance() (gas: 87670)
Logs:
  calling transferFrom to transfer 100 EURF

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.62ms (867.42µs CPU time)

Ran 1 test suite in 138.69ms (6.62ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Modify transferFrom to ensure that the spender’s allowance covers both the transfer amount and fees:

function transferFrom(address sender, address recipient, uint256 amount) public override returns (bool) {
    uint256 txFees = calculateTxFee(amount);
    uint256 totalCost = amount + txFees;

    require(allowance(sender, msg.sender) >= totalCost, "ERC20: transfer amount exceeds allowance");

    transferSanity(sender, recipient, amount);
    return super.transferFrom(sender, recipient, amount);
}

With this update, Bob can only transfer 100 EURF if Alice explicitly approves at least 110 EURF, preventing unintended token loss.

0xsomeone (judge) commented:

The submission and its duplicates outline that the fee charged by the system is imposed on top of the actual transfer amount. This behavior is highly non-standard, can result in significant issues with DeFi systems, and results in an important issue around allowances. Specifically, a user approved for an amount X is able to affect up to X + fee which effectively goes against the EIP-20 standard.

I consider this observation to be a justifiably medium-level issue as any fee imposed by the system should be a subset of the amount transferred rather than charged on top.

aivarsb (Next Generation) commented:

We would like to clarify our position on this finding:

Current Project Scope: As noted in our initial project planning, the fee capability will not be utilized in the initial deployment of the project. The transaction fee rate will be set to zero at launch.

Implementation Decision: Given that the fee functionality will not be active initially, we have made the decision to defer code modifications addressing this issue until we determine that fee implementation is necessary for our platform.

Future Considerations: We have documented this finding in our internal development roadmap. Should we decide to implement the fee mechanism in the future, we will incorporate the recommended fix to ensure the total amount (transfer + fee) is properly checked against the approved allowance before executing the transfer.


[M-02] Approve operation is not overridden to call transferSanity, thus its allowed to approve blacklisted accounts, which breaks protocol invariant

Submitted by MrValioBg, also found by KupiaSec

In the readme invariant docs it is specified, as requirement for approval operations, the following conditions:

  • Owner not being blacklisted
  • Spender not being blacklisted

Reference.

Note: to view the provided image, please see the original submission here.

However, this invariant is broken by the protocol’s implementation, as there is no logic to enforce that. The approve() function is not overridden and transferSanity() is not called for the operation.

Proof of Concept

To confirm we can refer to Token.sol:

https://github.com/code-423n4/2025-01-next-generation/blob/499cfa50a56126c0c3c6caa30808d79f82d31e34/contracts/Token.sol#L35

The approve() function is not overridden.

Override the approve function and check if the _msgSender() and spender are not blacklisted, before allowing the operation. This can be done with transferSanity() check, the same way it is already implemented for the other operations.

contract EURFToken is
    ERC20MetaTxUpgradeable,
    ERC20AdminUpgradeable,
    ERC20ControlerMinterUpgradeable,
    FeesHandlerUpgradeable,
    UUPSUpgradeable
{
+    function approve(address spender, uint256 value) public override returns (bool) { 
+        transferSanity(_msgSender(), spender, value);
+        return super.approve(spender, value);
+    }

0xsomeone (judge) commented:

The submission outlines a significant discrepancy between the code’s implementation and its documentation that can be considered an important issue; it is possible for blacklisted parties to become approved and thus transact.

I believe a medium-severity evaluation for this submission is correct as a direct, impactful, and unambiguous discrepancy between the documentation and the implementation has been defined.

To note, the sponsor does not intend to rectify this issue; however, it was judged as Medium due to C4 guidelines and how the discrepancy is provable and the documentation properly correlates to the code’s implementation.


Low Risk and Non-Critical Issues

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

The following wardens also submitted reports: 0x23r0, Abysser, Bauchibred, Bluedragon101, DharkArtz, foufrix, ginlee, hyuunn, imkapadia, inh3l, LeoGold, magiccentaur, Maroutis, MerkleSec, PolarizedLight, rspadi, and udo.

[01] Incorrect comparison for uint256 variables

The contract includes redundant checks for a negative value which is presumably a uint type variable that cannot be negative by definition in Solidity, in the following cases:

    function mint(address to, uint256 amount) public virtual {
        if (!hasRole(MASTER_MINTER, _msgSender()) && !hasRole(MINTER_ROLE, _msgSender()))
            revert NotMinter(_msgSender());
@>      if (amount <= 0) revert InvalidAmount(amount);
    function setTxFeeRate(uint256 newTxFeeRate) public virtual {
@>      if (newTxFeeRate > FEE_RATIO || newTxFeeRate < 0) revert InvalidFeeRate(FEE_RATIO, newTxFeeRate);
        _txfeeRate = newTxFeeRate;
        emit TxFeeRateUpdated(newTxFeeRate);
    }
    function setGaslessBasefee(uint256 newGaslessBasefee) public virtual {
@>      if (newGaslessBasefee < 0) revert NegativeBasefee();
        _gaslessBasefee = newGaslessBasefee;
        emit GaslessBasefeeUpdated(newGaslessBasefee);
    }

[02] Inconsistent blacklist enforcement allows minting to blacklisted addresses

The ERC20ControlerMinterUpgradeable contract enforces blacklist restrictions for transfers, including admin-initiated force transfers, but fails to enforce these same restrictions during token minting in the mint() function. This inconsistency allows blacklisted addresses to receive tokens through minting, bypassing the intended restrictions.

This creates a security gap where tokens can still reach blacklisted addresses through the minting process, undermining the entire purpose of the blacklist mechanism.

The following changes are recommended to Token.sol:

+   function mint(address to, uint256 amount) public override {
+        if (isBlacklisted(to)) revert RecipientBlacklistedError(to);
+        super.mint(to, amount);
+    }

[03] Burn function implementation deviates from technical specification

The ERC20ControlerMinterUpgradeable::burn() function does not implement the functionality as specified in the technical documentation.

While the specification states:

  1. Enable MASTER_MINTER or MINTER to burn a given amount into a given account address.
  2. Master minter can burn only to approved addresses.
  3. Delegated minter can burn only to approved addresses.

The implementation only allows burning from the caller’s own account. This is the current code:

    function burn(uint256 amount) public virtual {
        if (!hasRole(MASTER_MINTER, _msgSender()) && !hasRole(MINTER_ROLE, _msgSender()))
            revert NotMinter(_msgSender());
        if (!_operating) revert OperationsOff();
        _burn(_msgSender(), amount);
        emit Burn(_msgSender(), amount);
}

It is recommended to implement the burn function according to the specification by adding a parameter for the account to burn from:

-   function burn(uint256 amount) public virtual {
+   function burn(address from, uint256 amount) public virtual {
        if (!hasRole(MASTER_MINTER, _msgSender()) && !hasRole(MINTER_ROLE, _msgSender()))
            revert NotMinter(_msgSender());
        if (!_operating) revert OperationsOff();
-       _burn(_msgSender(), amount);
-       emit Burn(_msgSender(), amount);
+       _burn(from, amount);
+       emit Burn(from, amount);
}

Also, validate that the from address is approved to burn tokens. Although the documentation does not state who or how this addresses gets approved, one option could be only burn from an account if it is blacklisted.

[04] Inconsistent zero address validation in token transfers

The ERC20MetaTxUpgradeable::transferWithAuthorization() and ERC20AdminUpgradeable::forceTransfer() functions bypasses zero address validation checks by directly calling ERC20Upgradeable::_update() instead of using ERC20Upgradeable::_transfer(), creating an inconsistency where transfers to address(0) are possible through these functions while being prevented in others like transfer() and transferFrom().

Here is the transferWithAuthorization() method:

    function transferWithAuthorization(
        address holder,
        address spender,
        uint256 value,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) public virtual returns (bool) {
        // ... other code

        address signer = ECDSA.recover(hash, v, r, s);
        if (signer != holder) revert InvalidSignature();

@>      _update(holder, spender, value);

        return true;
    }

Here is the forceTransfer() method:

    function forceTransfer(address from, address to, uint256 amount) external onlyRole(ADMIN) {
        adminSanity(from, to);
@>      _update(from, to, amount);
        emit ForcedTransfer(from, to, amount);
    }

The ERC20Upgradeable::transfer() and ERC20Upgradeable::transferFrom():

    function transfer(address to, uint256 value) public virtual returns (bool) {
        address owner = _msgSender();
        _transfer(owner, to, value);
        return true;
    }

    // ...

    function transferFrom(address from, address to, uint256 value) public virtual returns (bool) {
        address spender = _msgSender();
        _spendAllowance(from, spender, value);
        _transfer(from, to, value);
        return true;
    }

The ERC20Upgradeable::_transfer() method:

function _transfer(address from, address to, uint256 value) internal {
    if (from == address(0)) {
        revert ERC20InvalidSender(address(0));
    }
    if (to == address(0)) {
        revert ERC20InvalidReceiver(address(0));
    }
    _update(from, to, value);
}

[05] Missing slippage protection for dynamic txFeeRate and gaslessBasefee fees

The FeesHandlerUpgradeable contract lacks slippage protection mechanisms for users when txFeeRate and gaslessBasefee can change between transaction submission and execution, potentially resulting in users receiving less tokens than expected.

Here is the code:

    function setTxFeeRate(uint256 newTxFeeRate) public virtual {
        if (newTxFeeRate > FEE_RATIO || newTxFeeRate < 0) revert InvalidFeeRate(FEE_RATIO, newTxFeeRate);
        _txfeeRate = newTxFeeRate;
        emit TxFeeRateUpdated(newTxFeeRate);
    }

    function setGaslessBasefee(uint256 newGaslessBasefee) public virtual {
        if (newGaslessBasefee < 0) revert NegativeBasefee();
        _gaslessBasefee = newGaslessBasefee;
        emit GaslessBasefeeUpdated(newGaslessBasefee);
    }

Consider adding a timelock for fee changes to provide users protection against unexpected fee changes. This solution provides several benefits:

  1. Users can see upcoming fee changes in advance.
  2. Provides time to execute transactions under current fee structure.
  3. Gives users predictability and transparency.

[06] Missing blacklist validation in approve() and permit() functions

The Token contract fails to implement blacklist validations in the approve() and permit() functions, contrary to the technical specification which explicitly requires that neither the owner nor spender should be blacklisted.

Link to the docs here.

Here is the ERC20MetaTxUpgradeable::permit() function:

    function permit(
        address owner,
        address spender,
        uint256 value,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) public virtual {
        if (block.timestamp > deadline) revert DeadLineExpired(deadline);

        bytes32 structHash = keccak256(abi.encode(_PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline));

        bytes32 hash = _hashTypedDataV4(structHash);

        address signer = ECDSA.recover(hash, v, r, s);
        if (signer != owner) revert InvalidSignature();

        _approve(owner, spender, value);
    }

And the ERC20Upgradeable functions:

    function approve(address spender, uint256 value) public virtual returns (bool) {
        address owner = _msgSender();
        _approve(owner, spender, value);
        return true;
    }

    function _approve(address owner, address spender, uint256 value) internal {
        _approve(owner, spender, value, true);
    }

    function _approve(address owner, address spender, uint256 value, bool emitEvent) internal virtual {
        ERC20Storage storage $ = _getERC20Storage();
        if (owner == address(0)) {
            revert ERC20InvalidApprover(address(0));
        }
        if (spender == address(0)) {
            revert ERC20InvalidSpender(address(0));
        }
        $._allowances[owner][spender] = value;
        if (emitEvent) {
            emit Approval(owner, spender, value);
        }
    }

[07] forceTransfer function implementation deviates from technical specification

The ERC20AdminUpgradeable::forceTransfer() function does not implement the functionality as specified in the technical documentation.

While the specification states (beyond other conditions):

  1. Sender not blacklisted
  2. Not paused

The implementation skips those two checks. This is the current code:

    function adminSanity(address from, address to) internal view {
@>      if (!hasRole(ADMIN, _msgSender())) { // Here msg.sender is the ADMIN so it skips paused and blacklisted(from) checks
            if (paused()) revert PausedError();
            if (isBlacklisted(from)) revert SenderBlacklistedError(from);
        }
        if (isBlacklisted(to)) revert RecipientBlacklistedError(to);
        if (to == address(this)) revert TransferToContractError();
    }

    function forceTransfer(address from, address to, uint256 amount) external onlyRole(ADMIN) {
        adminSanity(from, to);
        _update(from, to, amount);
        emit ForcedTransfer(from, to, amount);
    }
}

[08] Minting allowance update vulnerable to front-running

The ERC20ControlerMinterUpgradeable::updateMintingAllowance() function is vulnerable to front-running attacks, similar to the classic ERC20 approved front-running issue. A minter could detect an incoming allowance reduction and quickly consume their current allowance before it’s reduced.

The current implementation directly overwrites the minting allowance without any protection:

function updateMintingAllowance(
    address minter,
    uint256 minterAllowedAmount
) external virtual onlyRole(MASTER_MINTER) {
    if (!hasRole(MINTER_ROLE, minter)) revert NotMinter(minter);
@>  minterAllowed[minter] = minterAllowedAmount;
    emit MinterAllowanceUpdated(minter, minterAllowedAmount);
}

This creates a race condition where:

  1. MASTER_MINTER submits transaction to reduce minter’s allowance.
  2. Minter sees this pending transaction.
  3. Minter quickly submits transaction to use their current higher allowance.
  4. Minter gets more total minting capability than intended.

The impact is considered Medium because:

  • Minters could mint more tokens than intended.
  • It could affect token supply control.
  • Undermines master minter’s authority.
  • It could affect token economics.

Proof of Concept

Add the following unit tests to Token.js:

it('updateMintingAllowance front-run', async function () {
    const initialTotalSupply = await eurftoken.totalSupply();

    // add initial minting allowance
    const initialAllowance = BigInt(1000000);
    await eurftoken.connect(masterMinter).addMinter(minter.address, 1000000);

    // MASTER_MINTER attempts to reduce allowance
    // sends tx to pool to update allowance to 500000
    const newAllowance = BigInt(500000);

    // minter front-run and mints for the total initial allowance
    await eurftoken.connect(minter).mint(bob.address, initialAllowance);

    // MASTER_MINTER tx is mined
    await eurftoken.connect(masterMinter).updateMintingAllowance(minter.address, newAllowance);

    // Now minter uses the new allowance to mint again
    await eurftoken.connect(minter).mint(bob.address, newAllowance);

    // as a result, more tokens than intended were minted resulting in a total supply higher than expected
    const finalTotalSupply = await eurftoken.totalSupply();
    expect(finalTotalSupply).to.equal(initialTotalSupply + initialAllowance + newAllowance);
});

Recommendation

Implement a safe allowance update mechanism similar to ERC20’s increaseAllowance/decreaseAllowance:

function decreaseMintingAllowance(
    address minter,
    uint256 subtractedValue
) external virtual onlyRole(MASTER_MINTER) {
    // .. other code
    minterAllowed[minter] = currentAllowance - subtractedValue;
    // .. other code
}

function increaseMintingAllowance(
    address minter,
    uint256 addedValue
) external virtual onlyRole(MASTER_MINTER) {
    // .. other code
    minterAllowed[minter] += addedValue;
    // .. other code
}

0xsomeone (judge) commented:

[01] is a QA (NC) finding


Trusted forwarder

High Risk Findings (1)

[H-01] Cross-chain signature replay attack due to user-supplied domainSeparator and missing deadline check

Submitted by Pelz, also found by 0xGondar, 0xvd, Abysser, agadzhalov, aua_oo7, demonhat12, farismaulana, firmanregar, gregom, HardlyDifficult, hyuunn, i3arba, Infect3d, JCN, Jumcee, komane007, Lamsy, Limbooo, LouisTsai, ok567, patitonar, persik228, Pocas, Prestige, reflectedxss, s4bot3ur, sabanaku77, safie, santipu_, SBSecurity, skypper, ubl4nk, web3km, wiasliaw, and X0sauce

https://github.com/code-423n4/2025-01-next-generation/blob/499cfa50a56126c0c3c6caa30808d79f82d31e34/contracts/Forwarder.sol#L101

https://github.com/code-423n4/2025-01-next-generation/blob/499cfa50a56126c0c3c6caa30808d79f82d31e34/contracts/Forwarder.sol#L153

Finding description

The _verifySig function in Forwarder.sol accepts the domainSeparator as a user-provided input instead of computing it internally. This introduces a vulnerability where signatures can be replayed across different chains if the user’s nonce matches on both chains.

Additionally, there is no deadline check, meaning signatures remain valid indefinitely. This lack of expiration increases the risk of signature replay attacks and unauthorized transaction execution.

Impact

  1. Cross-Chain Signature Replay Attack – An attacker can reuse a valid signature on a different chain where the user’s nonce is the same, potentially leading to unauthorized fund transfers.
  2. Indefinite Signature Validity – Without a deadline check, an attacker could store valid signatures and execute them at any point in the future.

Proof of Concept

Affected Code

  1. _verifySig function (user-controlled domainSeparator and no deadline check):
function _verifySig(
    ForwardRequest memory req,
    bytes32 domainSeparator,
    bytes32 requestTypeHash,
    bytes memory suffixData,
    bytes memory sig
) internal view {
    require(typeHashes[requestTypeHash], "NGEUR Forwarder: invalid request typehash");
    bytes32 digest = keccak256(
        abi.encodePacked("\x19\x01", domainSeparator, keccak256(_getEncoded(req, requestTypeHash, suffixData)))
    );
    require(digest.recover(sig) == req.from, "NGEUR Forwarder: signature mismatch");
}
  1. _getEncoded function (included in the signature computation):
function _getEncoded(
    ForwardRequest memory req,
    bytes32 requestTypeHash,
    bytes memory suffixData
) public pure returns (bytes memory) {
    return abi.encodePacked(
        requestTypeHash,
        abi.encode(req.from, req.to, req.value, req.gas, req.nonce, keccak256(req.data)),
        suffixData
    );
}
  1. execute function (where _verifySig is used):
function execute(
    ForwardRequest calldata req,
    bytes32 domainSeparator,
    bytes32 requestTypeHash,
    bytes calldata suffixData,
    bytes calldata sig
) external payable returns (bool success, bytes memory ret) { 
    _verifyNonce(req);
    _verifySig(req, domainSeparator, requestTypeHash, suffixData, sig);
    _updateNonce(req);

    require(req.to == _eurfAddress, "NGEUR Forwarder: can only forward NGEUR transactions");

    bytes4 transferSelector = bytes4(keccak256("transfer(address,uint256)"));
    bytes4 reqTransferSelector = bytes4(req.data[:4]);

    require(reqTransferSelector == transferSelector, "NGEUR Forwarder: can only forward transfer transactions");

    (success, ret) = req.to.call{gas: req.gas, value: req.value}(abi.encodePacked(req.data, req.from));
    require(success, "NGEUR Forwarder: failed tx execution");

    _eurf.payGaslessBasefee(req.from, _msgSender());

    return (success, ret);
}

Exploitation scenario

  1. A user signs a valid transaction on Chain A.
  2. An attacker copies the signature and submits it on Chain B (where the user has the same nonce).
  3. Since domainSeparator is not verified by the contract, the signature is accepted on both chains.
  4. The attacker successfully executes a transaction on Chain B without the user’s consent.

Additional Risk: The absence of a deadline check means an attacker could store a signature indefinitely and execute it at any time in the future.

  1. Compute domainSeparator On-Chain: Instead of accepting domainSeparator as a function argument, compute it within the contract using block.chainid to ensure domain uniqueness.
  2. Enforce a Deadline Check: Introduce a deadline verification within _verifySig to ensure signatures expire after a reasonable time. Example:

    if (block.timestamp > req.deadline) revert DeadlineExpired(req.deadline);
  3. Use Chain-Specific Identifiers: Incorporate block.chainid into the domain separator computation to prevent cross-chain signature reuse.

By implementing these fixes, the contract can prevent signature replay attacks and unauthorized transactions across different chains.

0xsomeone (judge) increased severity to High and commented:

The submission and its duplicates outline that the domain separator is passed in as an argument to the Forwarder and thus a replay attack is possible due to this trait.

The replay attack would be possible only if the trusted forwarder is re-deployed without any modifications, an event that is considered unlikely. Any upgrade of the Forwarder would need to inherit the utilized nonces of the previous implementation and thus would prevent a replay from occurring, at least in the scenario a few of the submissions focus on.

The in-scope documentation of the project, however, states that the system is expected to be deployed across three distinct blockchains rendering the attack feasible and thus the vulnerability to be of high severity justifiably.

Any submission that did not highlight the cross-chain aspect of the vulnerability will be penalized by 25% (i.e. rewarded 75%) as the local-chain redeployment should normally not be exploitable and would be considered a medium-severity flaw.

To note, L-5 of the bot report has identified this vulnerability but has failed to give it the attention it requires. Specifically, the bot report marked it as a low-severity finding even though it is a high-severity one based on the project’s intentions to be deployed across chains. As the severity has effectively shifted two levels (L -> H), I consider this submission to be in-scope in accordance with previous rulings.

givn (warden) commented:

Having the domain separator supplied from the caller is definitely an issue and should be fixed. There is one thing that’s interesting about this attack path that wasn’t mentioned anywhere.

One part of the signed data is ForwardRequest.to, which contains the EURF contract address. If we sign a transfer on chain A, then an attacker submits the same tx on chain B, for the exploit to succeed the EURF contract should be deployed on the same address. Otherwise, the following require statement will revert the tx on chain B: require(req.to == _eurfAddress, "NGEUR Forwarder: can only forward NGEUR transactions");

Did a quick check on the contracts for the major stable coins and all of them had different addresses across various chains. What are the odds that EURF will be deployed on the same address?

0xsomeone (judge) commented:

Hey @givn, the major stable coins have been deployed several years in the past, prior to consistent addresses across chains becoming the norm (i.e. via create2 or other similar avenues of deterministic deployment addresses).

The issue outlined is significant and applicable to the concept of the cross-chain deployment that the project intends, especially when we consider that the MPC system they will deploy will lead to a consistent address across chains and thus a potentially consistent deployment address for the contracts on each chain.

I would like to note that the sponsors have also confirmed the severity of this finding directly, indicating that they anticipate their deployment will be at the same address across multiple chains, and consider the inclusion of both a salt and a block.chainid to be imperative for the security of the Forwarder implementation.


Medium Risk Findings (1)

[M-03] Lack of deadline check in forwarded request

Submitted by LeoGold, also found by 0x0107, 0xvd, Abysser, air_0x, Bauchibred, Bryan_Conquer, d3e4, francoHacker, Hueber, hyuunn, Infect3d, Kaysoft, KodoSec, komane007, Lamsy, LhoussainePh, MSK, ok567, owanemi, oxchsyston, patitonar, phoenixV110, pindarev, santipu_, SBSecurity, Sherlock__VARM, tourist, unnamed, and vangrim

https://github.com/code-423n4/2025-01-next-generation/blob/499cfa50a56126c0c3c6caa30808d79f82d31e34/contracts/Forwarder.sol#L32-L38

https://github.com/code-423n4/2025-01-next-generation/blob/499cfa50a56126c0c3c6caa30808d79f82d31e34/contracts/Forwarder.sol#L93-L118

Finding description and impact

Without a deadline parameter, each ForwardRequest is potentially valid indefinitely. This means that once a request is signed, it can be executed at any point in the future, provided that the nonce has not yet been used. If a request remains valid forever without a deadline, allowing it to be executed much later than the signer might have intended. This can lead to situations where the execution context (e.g., market conditions, contract states) has drastically changed from when the request was originally signed. Signers have no mechanism to limit the time window during which their request is valid, reducing their control over their own transactions.

Proof of Concept

it('should forward transfer in long future time', async function () {
                 // Increase the time by 6 months (15552000 seconds)
                await ethers.provider.send("evm_increaseTime", [15552000]);
                var data = interface.encodeFunctionData('transfer', [alice.address, 50]);
                var result = await signForward(provider, eurftoken.target, forwarder.target, bob, 1000000000000, data);
                expect(
                    await forwarder.connect(forwardOperator).execute(
                        result.request,
                        result.domainSeparator,
                        result.TypeHash,
                        result.suffixData,
                        result.signature,
                    )
                ).to.emit(eurftoken, 'Transfer').withArgs(bob.address, alice.address, 50);
                expect(await eurftoken.balanceOf(bob.address)).to.equal(950);
                expect(await eurftoken.balanceOf(alice.address)).to.equal(1050);
            });

In Token.js add the above it block to the TRANSFER describe block in the FORWARDER describe and run the test.

LOGS:

FORWARDER
      TRANSFER
 ✔ should forward transfer in long future time

Adjust the ForwardRequest struct to include a deadline parameter. Consider implementing logic within the contract’s execution function to check the current block timestamp against the request’s deadline; rejecting any requests that are past their expiration. You can check the standard that was followed in implementing the forwarder in openGSN codebase here.

0xsomeone (judge) commented:

The submission and its duplicates outline that signed payloads for the Forwarder lack an expiry and can thus remain dormant indefinitely.

Coupled with the fact that no mechanism exists to invalidate a nonce directly, I believe this to be a valid medium risk issue in the code. The system should either introduce a deadline parameter to signed payloads and/or permit nonces to be invalidated directly, ensuring that conflicting permits that would result in an on-chain race condition can be avoided.


Low Risk and Non-Critical Issues

The Trusted forwarder related issues highlighted below are from the report by patitonar, which received the top score from the judge. Note: these were originally submitted as L-01 and L-02.

[09] Solidity pragma should be specific, not wide

Consider using a specific version of Solidity in your contracts instead of a wide version. For example, instead of pragma solidity ^0.8.20;, use pragma solidity 0.8.20;

Present in the following cases:

  • Found in contracts/ERC20AdminUpgradeable.sol Line: 2

    pragma solidity ^0.8.20;
  • Found in contracts/ERC20ControlerMinterUpgradeable.sol Line: 2

    pragma solidity ^0.8.20;
  • Found in contracts/ERC20MetaTxUpgradeable.sol Line: 2

    pragma solidity ^0.8.20;
  • Found in contracts/FeesHandlerUpgradeable.sol Line: 2

    pragma solidity ^0.8.20;
  • Found in contracts/Forwarder.sol Line: 24

    pragma solidity ^0.8.20;
  • Found in contracts/Token.sol Line: 25

    pragma solidity ^0.8.20;

[10] Forwarder::execute payable function can cause loss of funds and failed transactions

The execute() function in the Forwarder contract contains two design flaws:

  1. The function is marked as payable, allowing ETH to be sent with the transaction.
  2. The function sends ETH on the low level call (success, ret) = req.to.call{gas: req.gas, value: req.value}(abi.encodePacked(req.data, req.from)) via req.value.

This is problematic because:

  1. The the low level call can only call the EURFToken token contract, specifically the ERC20 token transfer() function which does not accept ETH value.
  2. The payable modifier suggests functionality that isn’t actually supported.

When users attempt to send transactions with ETH value through the forwarder:

  1. Any transaction sent with value > 0 will be a loss of funds, because there is no way to rescue them.
  2. Transactions with req.value > 0 will fail because the ERC20 token contract does not accept ETH value.

The following changes are recommended:

  1. Remove the payable modifier from the execute() function.
  2. Do not send any value on the request call:
    function execute(
        ForwardRequest calldata req,
        bytes32 domainSeparator,
        bytes32 requestTypeHash,
        bytes calldata suffixData,
        bytes calldata sig
    ) external payable returns (bool success, bytes memory ret) {
        // ... other code

        require(req.to == _eurfAddress, "NGEUR Forwarder: can only forward NGEUR transactions");
+       require(req.value == 0, "NGEUR Forwarder: value must be 0");

        bytes4 transferSelector = bytes4(keccak256("transfer(address,uint256)"));
        bytes4 reqTransferSelector = bytes4(req.data[:4]);

        require(reqTransferSelector == transferSelector, "NGEUR Forwarder: can only forward transfer transactions");

        // solhint-disable-next-line avoid-low-level-calls
-       (success, ret) = req.to.call{gas: req.gas, value: req.value}(abi.encodePacked(req.data, req.from));
+       (success, ret) = req.to.call{gas: req.gas}(abi.encodePacked(req.data, req.from));
        require(success, "NGEUR Forwarder: failed tx execution");

        _eurf.payGaslessBasefee(req.from, _msgSender());

        return (success, ret);
    }

0xsomeone (judge) commented:

[09] is a QA (NC) finding.


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.