aere-contracts/contracts/security/AereBugBountyVault.sol
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

185 lines
7.7 KiB
Solidity

// 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;
}
}