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

ForumExecutor

ForumExecutor.sol is the Phase 3 execution contract. 357 LOC, Solidity 0.8.26. A fork of GitlawbBounty.sol (Apache-2.0, github.com/Gitlawb/contracts) with three changes:

  1. Token-agnostic (not hard-coded to $GITLAWB).
  2. N-of-M quorum from AGAINST-bonders (not single-creator approval).
  3. Atomic BondingEscrow.settle plug-in on _approve / _reject.

See Protocol · Execution for the lifecycle. This page is the contract-level reference.

State machine

Open → Claimed → Submitted → (Approved | Rejected) │ │ │ └─→ Open (dispute path) └─→ Cancelled

Storage

enum Status { Open, Claimed, Submitted, Approved, Rejected, Cancelled } struct Bounty { address creator; IERC20 token; uint256 amount; bytes32 ideaId; string gitlawbRepo; string acceptanceSpec; uint64 createdAt; uint64 claimDeadline; // seconds from createdAt uint64 reviewDeadline; // seconds from submittedAt Status status; address claimantAddress; string claimantDid; string prId; uint64 claimedAt; uint64 submittedAt; uint256 votesApprove; // weighted by AGAINST stake uint256 votesReject; } mapping(uint256 => Bounty) public bounties; mapping(uint256 => mapping(address => bool)) public hasVoted; uint256 public nextBountyId; IBondingEscrow public bondingEscrow; address public treasury; uint16 public protocolFeeBps; // 0..1000, default 500 (5%) uint64 public defaultClaimDeadline; // default 1 day uint64 public defaultReviewDeadline; // default 3 days

Audit L-05: bounties being public auto-generates a getter that exposes every field including claimantDid (a string), inflating ABI surface. Pre-mainnet fix: mark it internal and rely on the explicit getStatus, getPrId, getClaimant views.

createBounty

function createBounty( address token, uint256 amount, bytes32 ideaId, string calldata gitlawbRepo, string calldata acceptanceSpec, uint64 claimDeadline, uint64 reviewDeadline ) external nonReentrant returns (uint256 bountyId) { if (amount == 0) revert ZeroAmount(); bountyId = nextBountyId++; Bounty storage b = bounties[bountyId]; b.creator = msg.sender; b.token = IERC20(token); b.amount = amount; b.ideaId = ideaId; b.gitlawbRepo = gitlawbRepo; b.acceptanceSpec = acceptanceSpec; b.createdAt = uint64(block.timestamp); b.claimDeadline = claimDeadline == 0 ? defaultClaimDeadline : claimDeadline; b.reviewDeadline = reviewDeadline == 0 ? defaultReviewDeadline : reviewDeadline; b.status = Status.Open; IERC20(token).safeTransferFrom(msg.sender, address(this), amount); emit BountyCreated(bountyId, msg.sender, token, amount, ideaId); }

Audit H-02: any ERC-20 is accepted. Fee-on-transfer / rebasing / blacklist tokens brick accounting. Pre-mainnet fix: require IChamberRegistry(registry).getIdea(token).exists.

claimBounty

function claimBounty(uint256 bountyId, string calldata claimantDid) external { Bounty storage b = bounties[bountyId]; if (b.status != Status.Open) revert NotOpen(); if (block.timestamp > b.createdAt + b.claimDeadline) revert ClaimDeadlinePassed(); b.status = Status.Claimed; b.claimantDid = claimantDid; b.claimantAddress = msg.sender; b.claimedAt = uint64(block.timestamp); emit BountyClaimed(bountyId, msg.sender, claimantDid); }

submitBounty

function submitBounty(uint256 bountyId, string calldata prId) external { Bounty storage b = bounties[bountyId]; if (b.status != Status.Claimed) revert NotClaimed(); if (msg.sender != b.claimantAddress) revert NotClaimant(); b.status = Status.Submitted; b.prId = prId; b.submittedAt = uint64(block.timestamp); emit BountySubmitted(bountyId, prId); }

vote

function vote(uint256 bountyId, bool approve) external { Bounty storage b = bounties[bountyId]; if (b.status != Status.Submitted) revert NotSubmitted(); if (hasVoted[bountyId][msg.sender]) revert AlreadyVoted(); uint256 weight = bondingEscrow.getPosition(bountyId, msg.sender).againstStake; if (weight == 0) revert NoAgainstStake(); hasVoted[bountyId][msg.sender] = true; if (approve) b.votesApprove += weight; else b.votesReject += weight; emit Voted(bountyId, msg.sender, approve, weight); }

Audit H-01: hasVoted is per-(bountyId, voter) only. After disputeBounty, voters from the previous round cannot vote again. Pre-mainnet fix: introduce voteRound[bountyId] counter and namespace votes by (bountyId, voteRound, voter).

finalize

function finalize(uint256 bountyId) external nonReentrant { Bounty storage b = bounties[bountyId]; if (b.status != Status.Submitted) revert NotSubmitted(); IBondingEscrow.BountyState memory bs = bondingEscrow.getBounty(bountyId); uint256 totalAgainst = uint256(bs.totalAgainst); bool deadlinePassed = block.timestamp > b.submittedAt + b.reviewDeadline; bool majorityApprove = totalAgainst > 0 && b.votesApprove * 2 > totalAgainst; bool majorityReject = totalAgainst > 0 && b.votesReject * 2 > totalAgainst; if (!deadlinePassed && !majorityApprove && !majorityReject) revert VotesNotConclusive(); bool approve = totalAgainst == 0 || b.votesApprove >= b.votesReject; if (approve) _approve(bountyId, b); else _reject(bountyId, b); }

Audit H-03: 1 wei of AGAINST stake is sufficient to short-circuit pre-deadline. Pre-mainnet fix: minimum quorum threshold, e.g. max(b.amount / 100, 1 ether).

_approve / _reject

function _approve(uint256 bountyId, Bounty storage b) internal { b.status = Status.Approved; uint256 fee = (b.amount * protocolFeeBps) / 10_000; uint256 payout = b.amount - fee; b.token.safeTransfer(b.claimantAddress, payout); if (fee > 0) b.token.safeTransfer(treasury, fee); bondingEscrow.settle(bountyId, true); emit BountyApproved(bountyId, b.claimantAddress, payout, fee); }

Audit M-07: current order is transfer → settle. Pre-mainnet fix: settle → transfer (CEI). Same applies to _reject.

disputeBounty

If submittedAt + reviewDeadline passes without finalize, anyone can dispute. Status returns to Open, claimant fields and votes are zeroed.

function disputeBounty(uint256 bountyId) external { Bounty storage b = bounties[bountyId]; if (b.status != Status.Submitted) revert NotSubmitted(); if (block.timestamp <= b.submittedAt + b.reviewDeadline) revert NotDisputable(); b.status = Status.Open; b.claimantDid = ""; b.claimantAddress = address(0); b.prId = ""; b.claimedAt = 0; b.submittedAt = 0; b.votesApprove = 0; b.votesReject = 0; emit BountyDisputed(bountyId); }

Access control summary

FunctionCaller
createBountyanyone (with allowance)
claimBountyanyone, during claim window
submitBountyclaimant only
voteAGAINST-bonders only (weight > 0)
finalize, disputeBountyanyone, when conditions met
cancelBountycreator, only while Open and not yet claimed
Settersowner
Last updated on