Skip to Content
Quorum contracts are live on Base Sepolia. Mainnet ships after external audit. Do not send real funds.
ContractsBondingEscrow

BondingEscrow

BondingEscrow.sol holds FOR and AGAINST stakes for every bounty. 196 LOC, Solidity 0.8.26. AGAINST stake doubles as vote weight — there is no unbond.

See Protocol · Bonding for the conceptual model. This page is the contract-level reference.

Storage layout

struct BountyState { address ideaToken; // 20 bytes uint128 totalFor; // 16 bytes uint128 totalAgainst; // 16 bytes Settlement settlement; // 1 byte (Open / ForWon / AgainstWon) bool protocolFlushed; // 1 byte } struct Position { uint128 forStake; uint128 againstStake; bool claimed; } mapping(uint256 => BountyState) private _bounties; mapping(uint256 => mapping(address => Position)) private _positions; address public forumExecutor; // only this contract can settle address public protocolTreasury; uint16 public protocolSlashBps; // 0..3000 (max 30%)

BountyState packs into two slots. The internal audit (#7) flags this as worth confirming via forge inspect — current layout is tight.

Surface

function bondFor(uint256 bountyId, uint256 amount) external nonReentrant; function bondAgainst(uint256 bountyId, uint256 amount) external nonReentrant; function settle(uint256 bountyId, bool forWon) external; // onlyForumExecutor function claim(uint256 bountyId) external nonReentrant; function flushProtocolCut(uint256 bountyId) external nonReentrant; function getBounty(uint256) external view returns (BountyState memory); function getPosition(uint256 bountyId, address user) external view returns (Position memory); // Owner-gated function setForumExecutor(address) external onlyOwner; function setProtocolTreasury(address) external onlyOwner; function setProtocolSlashBps(uint16) external onlyOwner;

bondFor / bondAgainst

function bondFor(uint256 bountyId, uint256 amount) external nonReentrant { BountyState storage b = _bounties[bountyId]; if (b.settlement != Settlement.Open) revert NotOpen(); if (b.ideaToken == address(0)) revert UnknownBounty(); if (amount == 0) revert ZeroAmount(); IERC20(b.ideaToken).safeTransferFrom(msg.sender, address(this), amount); _positions[bountyId][msg.sender].forStake += uint128(amount); b.totalFor += uint128(amount); emit BondedFor(bountyId, msg.sender, amount); }

The bond is unrecoverable until settlement. There is no unbond. This is the design — if bonding were reversible, AGAINST vote weight could be sybil-amplified by bonding, voting, then unbonding.

settle

Only ForumExecutor can settle. The settle call flips Settlement and emits the event; no token transfer happens here.

function settle(uint256 bountyId, bool forWon) external { if (msg.sender != forumExecutor) revert NotForumExecutor(); BountyState storage b = _bounties[bountyId]; if (b.settlement != Settlement.Open) revert AlreadySettled(); b.settlement = forWon ? Settlement.ForWon : Settlement.AgainstWon; emit Settled(bountyId, forWon); }

claim

After settlement, every winner calls claim to extract their stake + pro-rata share of the slashed loser pool.

function claim(uint256 bountyId) external nonReentrant { BountyState storage b = _bounties[bountyId]; if (b.settlement == Settlement.Open) revert NotSettled(); Position storage p = _positions[bountyId][msg.sender]; if (p.claimed) revert AlreadyClaimed(); p.claimed = true; uint256 own; uint256 grossShare; uint256 winnerBps = 10_000 - protocolSlashBps; if (b.settlement == Settlement.ForWon) { if (p.forStake == 0) revert NotAWinner(); own = p.forStake; grossShare = Math.mulDiv(b.totalAgainst, p.forStake, b.totalFor); } else { if (p.againstStake == 0) revert NotAWinner(); own = p.againstStake; grossShare = Math.mulDiv(b.totalFor, p.againstStake, b.totalAgainst); } uint256 netShare = Math.mulDiv(grossShare, winnerBps, 10_000); uint256 payout = own + netShare; IERC20(b.ideaToken).safeTransfer(msg.sender, payout); emit Claimed(bountyId, msg.sender, payout); }

Audit M-01: the two sequential Math.mulDiv calls each round down independently. Across N winners this accumulates up to 2N wei of unrecoverable dust per bounty. Pre-mainnet fix: single mulDiv (loserPool × forStake × winnerBps) / (totalFor × 10000) plus a rescueDust admin function.

flushProtocolCut

After settlement, anyone can flush the protocol’s slash to treasury:

function flushProtocolCut(uint256 bountyId) external nonReentrant { BountyState storage b = _bounties[bountyId]; if (b.settlement == Settlement.Open) revert NotSettled(); if (b.protocolFlushed) revert AlreadyFlushed(); b.protocolFlushed = true; uint256 loserPool = b.settlement == Settlement.ForWon ? uint256(b.totalAgainst) : uint256(b.totalFor); uint256 cut = (loserPool * protocolSlashBps) / 10_000; if (cut > 0) { IERC20(b.ideaToken).safeTransfer(protocolTreasury, cut); } emit ProtocolFlushed(bountyId, cut); }

The cut is computed once at flush time using the current protocolSlashBps. Audit M-04 flags that owner can change protocolSlashBps mid-bounty, which would affect in-flight cuts; pre-mainnet fix is to snapshot the bps at registerBounty time.

Access control summary

FunctionCaller
bondFor, bondAgainstanyone (with sufficient ERC-20 allowance)
settleforumExecutor only
claimthe winning bonder
flushProtocolCutanyone after settlement
setForumExecutor, setProtocolTreasury, setProtocolSlashBpsowner

Reentrancy

ReentrancyGuard is on every state-mutating external entry point. SafeERC20 is used for every token transfer. The audit explicitly verified that BondingEscrow has no unbond and that Math.mulDiv is used everywhere ratios are computed (preventing uint256 overflow at uint128 input boundaries).

Last updated on