// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; /** * @title AereBugBountyVault — Perpetual bug-bounty pool paid in AERE * @notice Holds AERE (or WAERE) earmarked for security disclosure rewards. * Two roles: * - Funder (anyone): deposit AERE into the pool, no withdraw right. * - Triager committee (2-of-3 multisig): authorise a payout when a * disclosed vulnerability is validated. Payouts go to the * reporter's address (passed by the multisig at payout time). * * @dev Locked design 2026-06-06. * * Constraints: * - Deposits are PERMANENT (no general withdraw — funds are committed * to the bounty mission). The triager committee can ONLY release * funds via `pay()` which emits a structured PayoutMade event with * a finding-ID and amount. * - The triager is a multisig EOA passed at deploy and IS replaceable * ONLY via a 14-day timelock initiated by itself; no other rotation. * (Implemented via proposeTriager + acceptTriager.) * - Maximum single payout is capped at MAX_PAYOUT_BPS of current * balance, in basis points, set at deploy and immutable. This * prevents a compromised triager key from draining the vault in * a single call. Default 2000 bps = 20%. * - No upgrade proxy. No reset. * * This is intentionally narrower than a full bounty platform — it is * the on-chain SETTLEMENT layer for findings adjudicated off-chain * (Immunefi-style with private submission via libsodium sealed-box * encrypted to the triager X25519 pubkey on aere.network/bug-bounty). * * Whitepaper §AereProof v0. Reserve Year-1 disbursement: 4M AERE * initial + 3M AERE refill buffer (per immediate_build_plan). */ contract AereBugBountyVault is ReentrancyGuard { /* ------------------------------- immutable -------------------------------- */ /// @notice ERC-20 token in which the vault pays bounties (WAERE). address public immutable BOUNTY_TOKEN; /// @notice Max single payout, in bps of current balance. uint16 public immutable MAX_PAYOUT_BPS; /// @notice Timelock between proposeTriager and acceptTriager. uint256 public constant TRIAGER_TIMELOCK = 14 days; /* --------------------------------- state ---------------------------------- */ /// @notice Address that may authorise payouts. Typically a 2-of-3 Foundation /// multisig with Ledger-stored keys. address public triager; /// @notice Pending replacement triager (zero if no proposal active). address public pendingTriager; /// @notice Timestamp at which pendingTriager becomes acceptable. uint256 public pendingTriagerEarliestAccept; /// @notice Monotonic counter to map an emitted PayoutMade to a finding. /// The triager is responsible for the off-chain finding-ID /// (e.g., AERE-VAULT-2026-001) and including it in the call. uint256 public payoutNonce; /* --------------------------------- events --------------------------------- */ event Funded(address indexed funder, uint256 amount); event PayoutMade( uint256 indexed nonce, bytes32 indexed findingId, address indexed reporter, uint256 amount ); event TriagerProposed(address indexed proposer, address indexed newTriager, uint256 earliestAccept); event TriagerAccepted(address indexed oldTriager, address indexed newTriager); event TriagerProposalCancelled(address indexed canceller); /* --------------------------------- errors --------------------------------- */ error NotTriager(); error ZeroAddress(); error ZeroAmount(); error AmountExceedsCap(); error TimelockNotElapsed(uint256 nowTs, uint256 earliest); error NoPendingProposal(); error TransferFailed(); /* ------------------------------- modifiers -------------------------------- */ modifier onlyTriager() { if (msg.sender != triager) revert NotTriager(); _; } /* ------------------------------- constructor ------------------------------ */ /** * @param _bountyToken WAERE address. * @param _triager Initial multisig. * @param _maxPayoutBps Max single payout in bps (e.g. 2000 = 20%). */ constructor(address _bountyToken, address _triager, uint16 _maxPayoutBps) { if (_bountyToken == address(0) || _triager == address(0)) revert ZeroAddress(); require(_maxPayoutBps > 0 && _maxPayoutBps <= 10_000, "bps out of range"); BOUNTY_TOKEN = _bountyToken; triager = _triager; MAX_PAYOUT_BPS = _maxPayoutBps; } /* ----------------------------------- core --------------------------------- */ /// @notice Top up the vault. Permissionless. Once in, AERE is committed. function fund(uint256 amount) external nonReentrant { if (amount == 0) revert ZeroAmount(); bool ok = IERC20(BOUNTY_TOKEN).transferFrom(msg.sender, address(this), amount); if (!ok) revert TransferFailed(); emit Funded(msg.sender, amount); } /** * @notice Triager authorises a payout to a reporter for a validated finding. * @param findingId Stable identifier the triager committee assigns to the * finding off-chain. Public, recorded on-chain. * @param reporter Address that receives the bounty. * @param amount AERE amount, must be ≤ MAX_PAYOUT_BPS of current balance. */ function pay(bytes32 findingId, address reporter, uint256 amount) external onlyTriager nonReentrant { if (reporter == address(0)) revert ZeroAddress(); if (amount == 0) revert ZeroAmount(); uint256 bal = IERC20(BOUNTY_TOKEN).balanceOf(address(this)); uint256 cap = bal * uint256(MAX_PAYOUT_BPS) / 10_000; if (amount > cap) revert AmountExceedsCap(); uint256 n = ++payoutNonce; bool ok = IERC20(BOUNTY_TOKEN).transfer(reporter, amount); if (!ok) revert TransferFailed(); emit PayoutMade(n, findingId, reporter, amount); } /* ----------------------------- triager rotation --------------------------- */ function proposeTriager(address newTriager) external onlyTriager { if (newTriager == address(0)) revert ZeroAddress(); pendingTriager = newTriager; pendingTriagerEarliestAccept = block.timestamp + TRIAGER_TIMELOCK; emit TriagerProposed(msg.sender, newTriager, pendingTriagerEarliestAccept); } function cancelTriagerProposal() external onlyTriager { if (pendingTriager == address(0)) revert NoPendingProposal(); delete pendingTriager; delete pendingTriagerEarliestAccept; emit TriagerProposalCancelled(msg.sender); } function acceptTriager() external onlyTriager { if (pendingTriager == address(0)) revert NoPendingProposal(); if (block.timestamp < pendingTriagerEarliestAccept) { revert TimelockNotElapsed(block.timestamp, pendingTriagerEarliestAccept); } address old = triager; triager = pendingTriager; delete pendingTriager; delete pendingTriagerEarliestAccept; emit TriagerAccepted(old, triager); } /* ---------------------------------- views --------------------------------- */ function balance() external view returns (uint256) { return IERC20(BOUNTY_TOKEN).balanceOf(address(this)); } function singlePayoutCap() external view returns (uint256) { uint256 bal = IERC20(BOUNTY_TOKEN).balanceOf(address(this)); return bal * uint256(MAX_PAYOUT_BPS) / 10_000; } }