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
| Function | Caller |
|---|---|
bondFor, bondAgainst | anyone (with sufficient ERC-20 allowance) |
settle | forumExecutor only |
claim | the winning bonder |
flushProtocolCut | anyone after settlement |
setForumExecutor, setProtocolTreasury, setProtocolSlashBps | owner |
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).