diff --git a/oracle/.env.example b/oracle/.env.example index 1bd8b6d..8bd7e15 100644 --- a/oracle/.env.example +++ b/oracle/.env.example @@ -41,3 +41,9 @@ LOG_CHUNK_SIZE=9 # If unset, those endpoints are open (fine for local testing, not for a public VPS — # the server logs a warning at startup). Generate with: openssl rand -hex 32 ORACLE_ADMIN_TOKEN= + +# Optional: CountersigEpochFees registry address. When set AND its on-chain +# epochFee > 0, the oracle only scores agents with fee coverage and charges one +# epoch fee per agent it scores (tokenomics §4). Leave unset to score every agent +# (testnet default). The oracle wallet must hold ORACLE_ROLE on the registry. +FEE_REGISTRY_ADDRESS= diff --git a/oracle/chain.js b/oracle/chain.js index d60fd4a..9a91e9b 100644 --- a/oracle/chain.js +++ b/oracle/chain.js @@ -17,12 +17,18 @@ const REPUTATION_ABI = [ // (ABI shape), but the contract always overwrites it with block.timestamp on finalize — // see finalizeReputation() in CountersigReputation.sol. The client-side value is discarded. +const FEE_ABI = [ + 'function epochFee() view returns (uint256)', + 'function isCovered(bytes32 didHash) view returns (bool)', + 'function chargeEpoch(bytes32 didHash) returns (bool)', +]; + // AgentStatus enum — must match CountersigIdentity.sol const STATUS_SLASHED = 2; const sleep = ms => new Promise(r => setTimeout(r, ms)); -let provider, wallet, identityContract, reputationContract, cfg_; +let provider, wallet, identityContract, reputationContract, feeContract, cfg_; let lastScannedBlock = null; let knownAgents = new Map(); // didHash => { didHash, agentAddress, blockNumber } @@ -34,6 +40,11 @@ function init(cfg, deps = {}) { wallet = deps.wallet ?? new ethers.Wallet(cfg.privateKey, provider); identityContract = deps.identityContract ?? new ethers.Contract(cfg.identityAddress, IDENTITY_ABI, provider); reputationContract = deps.reputationContract ?? new ethers.Contract(cfg.reputationAddress, REPUTATION_ABI, wallet); + + // Epoch-fee gating is opt-in: only wired when FEE_REGISTRY_ADDRESS is set. When + // absent, the oracle scores every agent as before (testnet default). + feeContract = deps.feeContract ?? + (cfg.feeRegistryAddress ? new ethers.Contract(cfg.feeRegistryAddress, FEE_ABI, wallet) : null); } // Clears in-memory scan state — used between tests, not called in production. @@ -125,6 +136,34 @@ async function getLatestBlockTimestamp() { return Number(block.timestamp); } +// ---- Epoch-fee gating (opt-in) -------------------------------------------- + +// True when a fee registry is configured. When false, all fee helpers below are +// no-ops that report "covered" so the epoch loop scores everyone. +function feeGatingConfigured() { + return !!feeContract; +} + +// Current per-epoch fee in wei. 0 means gating is effectively disabled on-chain. +async function getEpochFee() { + if (!feeContract) return 0n; + return feeContract.epochFee(); +} + +async function isCovered(didHash) { + if (!feeContract) return true; + return feeContract.isCovered(didHash); +} + +// Charges one epoch fee for the agent. isCovered() is checked first by the caller, +// so this is expected to succeed; the tx return is not inspected. +async function chargeEpoch(didHash) { + if (!feeContract) return; + const tx = await feeContract.chargeEpoch(didHash); + await tx.wait(1); + return tx.hash; +} + module.exports = { init, reset, @@ -136,5 +175,9 @@ module.exports = { getChallengeWindow, getLatestBlockTimestamp, pruneAgent, + feeGatingConfigured, + getEpochFee, + isCovered, + chargeEpoch, STATUS_SLASHED, }; diff --git a/oracle/chain.test.js b/oracle/chain.test.js index 998601e..9f7104a 100644 --- a/oracle/chain.test.js +++ b/oracle/chain.test.js @@ -242,3 +242,46 @@ test('getLatestBlockTimestamp: converts block timestamp to a number', async () = assert.equal(ts, 1_700_000_123); assert.equal(typeof ts, 'number'); }); + +// ---- Epoch-fee gating ------------------------------------------------------- + +function makeFakeFeeContract({ epochFee = 0n, covered = {} } = {}) { + const calls = { chargeEpoch: [] }; + return { + calls, + epochFee: async () => epochFee, + isCovered: async didHash => covered[didHash] ?? false, + chargeEpoch: async didHash => { + calls.chargeEpoch.push(didHash); + return { hash: '0xchargeTxHash', wait: async () => ({}) }; + }, + }; +} + +test('fee gating: not configured and helpers are no-ops without a registry', async () => { + chain.init(CFG, { + provider: makeFakeProvider(1), + identityContract: makeFakeIdentityContract(), + reputationContract: makeFakeReputationContract(), + }); + assert.equal(chain.feeGatingConfigured(), false); + assert.equal(await chain.getEpochFee(), 0n); + assert.equal(await chain.isCovered('0xaaa'), true); // everyone covered when off + assert.equal(await chain.chargeEpoch('0xaaa'), undefined); // no-op +}); + +test('fee gating: helpers delegate to the registry when configured', async () => { + const fee = makeFakeFeeContract({ epochFee: 10n, covered: { '0xaaa': true, '0xbbb': false } }); + chain.init(CFG, { + provider: makeFakeProvider(1), + identityContract: makeFakeIdentityContract(), + reputationContract: makeFakeReputationContract(), + feeContract: fee, + }); + assert.equal(chain.feeGatingConfigured(), true); + assert.equal(await chain.getEpochFee(), 10n); + assert.equal(await chain.isCovered('0xaaa'), true); + assert.equal(await chain.isCovered('0xbbb'), false); + await chain.chargeEpoch('0xaaa'); + assert.deepEqual(fee.calls.chargeEpoch, ['0xaaa']); +}); diff --git a/oracle/index.js b/oracle/index.js index 970bec9..ba56527 100644 --- a/oracle/index.js +++ b/oracle/index.js @@ -26,6 +26,9 @@ const cfg = { fromBlock: Number(process.env.FROM_BLOCK || 0), logChunkSize: Number(process.env.LOG_CHUNK_SIZE || 2000), adminToken: process.env.ORACLE_ADMIN_TOKEN || '', + // Optional: CountersigEpochFees address. When set (and its on-chain epochFee > 0), + // the oracle only scores agents with fee coverage and charges them per epoch. + feeRegistryAddress: process.env.FEE_REGISTRY_ADDRESS || '', }; if (!cfg.rpcUrl || !cfg.privateKey || !cfg.identityAddress || !cfg.reputationAddress) { @@ -88,23 +91,30 @@ async function runEpochInner() { // premature finalize that would revert. const chainNow = await chain.getLatestBlockTimestamp(); + // Epoch-fee gating (opt-in via FEE_REGISTRY_ADDRESS). Active only when a fee + // registry is configured AND the on-chain epochFee is non-zero. When inactive, + // every agent is treated as covered and nothing is charged. + const gatingActive = chain.feeGatingConfigured() && (await chain.getEpochFee()) > 0n; + if (gatingActive) console.log('[oracle] epoch-fee gating ACTIVE'); + // Phase 1: reads are stateless — fire them concurrently instead of one at a time. const agentInfos = await Promise.all( agents.map(async ({ didHash }) => { try { - const [info, pending] = await Promise.all([ + const [info, pending, covered] = await Promise.all([ chain.getAgentInfo(didHash), chain.getPendingScore(didHash), + gatingActive ? chain.isCovered(didHash) : Promise.resolve(true), ]); - return { didHash, ...info, pending, error: null }; + return { didHash, ...info, pending, covered, error: null }; } catch (err) { - return { didHash, registeredAt: 0, status: -1, pending: null, error: err }; + return { didHash, registeredAt: 0, status: -1, pending: null, covered: false, error: err }; } }) ); // Phase 2: writes share the oracle wallet's nonce, so they stay sequential. - for (const { didHash, registeredAt, status, pending, error } of agentInfos) { + for (const { didHash, registeredAt, status, pending, covered, error } of agentInfos) { if (error) { console.error(`[oracle] ${didHash.slice(0, 10)}… error: ${error.message}`); continue; @@ -116,6 +126,12 @@ async function runEpochInner() { continue; } + // Uncovered agents fall out of the active scoring run (tokenomics §4). + if (gatingActive && !covered) { + console.log(`[oracle] ${didHash.slice(0, 10)}… uncovered (no epoch fee), skipping`); + continue; + } + const action = decideAction(pending, challengeWindow, chainNow); if (action === 'skip') { @@ -146,6 +162,16 @@ async function runEpochInner() { console.log(`[oracle] ${didHash.slice(0, 10)}… proposed score=${scores.total}/100 tx=${txHash.slice(0, 10)}…`); proposed++; + + // Charge the epoch fee only after a successful proposal, so an agent pays + // solely for scoring runs it was actually included in. + if (gatingActive) { + try { + await chain.chargeEpoch(didHash); + } catch (chargeErr) { + console.error(`[oracle] ${didHash.slice(0, 10)}… epoch-fee charge failed: ${chargeErr.message}`); + } + } } catch (err) { console.error(`[oracle] ${didHash.slice(0, 10)}… error: ${err.message}`); } diff --git a/script/DeployEpochFees.s.sol b/script/DeployEpochFees.s.sol new file mode 100644 index 0000000..20ea63d --- /dev/null +++ b/script/DeployEpochFees.s.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Script.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import "../src/CountersigEpochFees.sol"; + +/** + * @title DeployEpochFees + * @notice Deploys CountersigEpochFees behind a UUPS proxy and grants ORACLE_ROLE + * to the oracle wallet. epochFee defaults to 0, so gating stays DISABLED + * until governance calls setEpochFee — deploying it does not disrupt the + * running oracle. Point the oracle at it via FEE_REGISTRY_ADDRESS to + * activate coverage checks once a non-zero fee is set. + * + * Usage: + * forge script script/DeployEpochFees.s.sol --rpc-url $RPC --broadcast --verify -vvvv + * + * Required env: + * DEPLOYER_PRIVATE_KEY + * CSIG_ADDRESS — $CSIG token + * IDENTITY_ADDRESS — CountersigIdentity proxy + * Optional env (default to the deployer): + * ORACLE_ADDRESS — granted ORACLE_ROLE (the oracle wallet) + * REWARD_POOL — validator/oracle reward destination + * EPOCH_FEE — initial per-epoch fee in wei (default 0 = gating disabled) + */ +contract DeployEpochFees is Script { + function run() external { + uint256 deployerKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address deployer = vm.addr(deployerKey); + + address csig = vm.envAddress("CSIG_ADDRESS"); + address identity = vm.envAddress("IDENTITY_ADDRESS"); + address oracle = vm.envOr("ORACLE_ADDRESS", deployer); + address rewardPool = vm.envOr("REWARD_POOL", deployer); + uint256 epochFee = vm.envOr("EPOCH_FEE", uint256(0)); + + vm.startBroadcast(deployerKey); + + CountersigEpochFees impl = new CountersigEpochFees(); + CountersigEpochFees fees = CountersigEpochFees(address(new ERC1967Proxy( + address(impl), + abi.encodeCall( + CountersigEpochFees.initialize, + (deployer, oracle, csig, identity, rewardPool, epochFee) + ) + ))); + + vm.stopBroadcast(); + + require(address(fees.csig()) == csig, "csig not set"); + require(address(fees.identityRegistry()) == identity, "identity not set"); + require(fees.epochFee() == epochFee, "epochFee not set"); + require(fees.hasRole(fees.ORACLE_ROLE(), oracle), "oracle role not granted"); + + console2.log("=== Countersig EpochFees Deployment ==="); + console2.log("Chain: ", block.chainid); + console2.log("EpochFees proxy: ", address(fees)); + console2.log("EpochFees impl: ", address(impl)); + console2.log("Oracle: ", oracle); + console2.log("Reward pool: ", rewardPool); + console2.log("Epoch fee (wei): ", epochFee); + console2.log("CSIG: ", csig); + console2.log("Identity: ", identity); + } +} diff --git a/src/CountersigEpochFees.sol b/src/CountersigEpochFees.sol new file mode 100644 index 0000000..0de9883 --- /dev/null +++ b/src/CountersigEpochFees.sol @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import "./CountersigIdentity.sol"; + +/** + * @title CountersigEpochFees + * @notice On-chain fee registry that gates inclusion in the oracle's reputation + * scoring epoch (tokenomics v0.3 §4 "Oracle Epoch Prioritization" + §6 + * three-stage fee routing). + * + * Operators or querying applications prepay $CSIG toward an agent's epoch + * coverage. Each epoch, the oracle charges one `epochFee` per agent it + * scores. Agents without coverage fall out of the active scoring run + * (best-effort); the free `meetsThreshold()` read on CountersigReputation + * is unaffected — the fee gates the *write*, not the read. + * + * Charged fees accumulate in `collected` and are swept by distributeFees() + * per the current governance stage: + * Bootstrap -> 100% reward pool, 0% burn + * Transition -> 80% reward pool, 20% burn + * Mature -> 50% reward pool, 50% burn + * + * `epochFee` is USD-targeted and governance-tunable (§5); setting it to 0 + * disables gating entirely (useful on testnet), so chargeEpoch is a no-op + * that always reports "covered." + */ +contract CountersigEpochFees is + Initializable, + AccessControlUpgradeable, + UUPSUpgradeable, + ReentrancyGuard +{ + using SafeERC20 for IERC20; + + // ------------------------------------------------------------------------- + // Roles + // ------------------------------------------------------------------------- + + /// Granted to the oracle wallet(s) authorized to charge epochs. + bytes32 public constant ORACLE_ROLE = keccak256("ORACLE_ROLE"); + bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE"); + + // ------------------------------------------------------------------------- + // Types + // ------------------------------------------------------------------------- + + enum Stage { Bootstrap, Transition, Mature } + + // ------------------------------------------------------------------------- + // Storage — append-only. New variables must be declared AFTER all existing + // ones so a UUPS upgrade never shifts the base slot of the `balance` mapping + // (see the storage-layout test). + // ------------------------------------------------------------------------- + + IERC20 public csig; // slot 0 + CountersigIdentity public identityRegistry; // slot 1 + address public rewardPool; // slot 2 — validator/oracle reward destination + uint256 public epochFee; // slot 3 — $CSIG charged per epoch inclusion + Stage public stage; // slot 4 + uint256 public collected; // slot 5 — charged fees awaiting distribution + mapping(bytes32 => uint256) public balance; // slot 6 — didHash => prepaid $CSIG + + address internal constant BURN_ADDRESS = address(0xdead); + + // ------------------------------------------------------------------------- + // Events + // ------------------------------------------------------------------------- + + event FeesDeposited(bytes32 indexed didHash, address indexed from, uint256 amount, uint256 newBalance); + event FeesWithdrawn(bytes32 indexed didHash, address indexed operator, uint256 amount); + event EpochCharged(bytes32 indexed didHash, uint256 amount, uint256 remaining); + event FeesDistributed(uint256 toRewardPool, uint256 burned, Stage stage); + event EpochFeeUpdated(uint256 newFee); + event StageUpdated(Stage newStage); + event RewardPoolUpdated(address newRewardPool); + + // ------------------------------------------------------------------------- + // Errors + // ------------------------------------------------------------------------- + + error ZeroAddress(); + error ZeroAmount(); + error NotOperator(bytes32 didHash, address caller); + error InsufficientBalance(bytes32 didHash, uint256 requested, uint256 available); + error NothingToDistribute(); + + // ------------------------------------------------------------------------- + // Constructor / Initializer + // ------------------------------------------------------------------------- + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /** + * @param admin DEFAULT_ADMIN_ROLE + UPGRADER_ROLE (governance timelock on mainnet). + * @param oracle Initial ORACLE_ROLE holder. Pass address(0) to grant later. + * @param csigToken $CSIG ERC20. + * @param identityRegistry_ CountersigIdentity proxy (for operator checks on withdraw). + * @param rewardPool_ Destination for the validator/oracle share of fees. + * @param epochFee_ Initial $CSIG charged per epoch inclusion (0 = gating disabled). + */ + function initialize( + address admin, + address oracle, + address csigToken, + address identityRegistry_, + address rewardPool_, + uint256 epochFee_ + ) external initializer { + if ( + admin == address(0) || csigToken == address(0) + || identityRegistry_ == address(0) || rewardPool_ == address(0) + ) revert ZeroAddress(); + + __AccessControl_init(); + + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(UPGRADER_ROLE, admin); + if (oracle != address(0)) _grantRole(ORACLE_ROLE, oracle); + + csig = IERC20(csigToken); + identityRegistry = CountersigIdentity(identityRegistry_); + rewardPool = rewardPool_; + epochFee = epochFee_; + stage = Stage.Bootstrap; + } + + // ------------------------------------------------------------------------- + // Funding + // ------------------------------------------------------------------------- + + /** + * @notice Prepay epoch fees toward an agent's coverage. + * @dev Permissionless funder — operators or querying applications may top up + * any agent (§4). Caller must approve this contract for `amount` first. + */ + function depositFor(bytes32 didHash, uint256 amount) external nonReentrant { + if (amount == 0) revert ZeroAmount(); + csig.safeTransferFrom(msg.sender, address(this), amount); + balance[didHash] += amount; + emit FeesDeposited(didHash, msg.sender, amount, balance[didHash]); + } + + /** + * @notice Withdraw unspent prepaid fees. Only the agent's operator may withdraw. + * @dev Anyone can fund, but only the operator can pull — a stranger's deposit + * cannot be siphoned back out by that stranger. + */ + function withdraw(bytes32 didHash, uint256 amount) external nonReentrant { + CountersigIdentity.AgentIdentity memory id = identityRegistry.getIdentity(didHash); + if (id.operator != msg.sender) revert NotOperator(didHash, msg.sender); + if (amount == 0) revert ZeroAmount(); + + uint256 bal = balance[didHash]; + if (amount > bal) revert InsufficientBalance(didHash, amount, bal); + + balance[didHash] = bal - amount; + csig.safeTransfer(msg.sender, amount); + emit FeesWithdrawn(didHash, msg.sender, amount); + } + + // ------------------------------------------------------------------------- + // Oracle charging + // ------------------------------------------------------------------------- + + /// @notice True if the agent has enough prepaid balance to cover one epoch. + function isCovered(bytes32 didHash) public view returns (bool) { + return epochFee == 0 || balance[didHash] >= epochFee; + } + + /** + * @notice Charge one epochFee for including the agent in the scoring run. + * @dev Oracle-only. Returns false (does NOT revert) when the agent is + * uncovered, so the oracle can treat it as best-effort and skip. When + * epochFee == 0 gating is disabled: always returns true, charges nothing. + * @return charged True if the agent was covered and the fee was taken. + */ + function chargeEpoch(bytes32 didHash) external onlyRole(ORACLE_ROLE) returns (bool charged) { + uint256 fee = epochFee; + if (fee == 0) return true; + + uint256 bal = balance[didHash]; + if (bal < fee) return false; + + balance[didHash] = bal - fee; + collected += fee; + emit EpochCharged(didHash, fee, balance[didHash]); + return true; + } + + // ------------------------------------------------------------------------- + // Distribution + // ------------------------------------------------------------------------- + + /** + * @notice Sweep collected fees: the reward-pool share to `rewardPool`, the + * burn share to 0xdead, split per the current stage. Permissionless — + * destinations are fixed, so no liveness dependence on any one party. + */ + function distributeFees() external nonReentrant { + uint256 amount = collected; + if (amount == 0) revert NothingToDistribute(); + collected = 0; + + uint256 burnAmt = (amount * _burnBps(stage)) / 10_000; + uint256 poolAmt = amount - burnAmt; + + if (poolAmt > 0) csig.safeTransfer(rewardPool, poolAmt); + if (burnAmt > 0) csig.safeTransfer(BURN_ADDRESS, burnAmt); + + emit FeesDistributed(poolAmt, burnAmt, stage); + } + + /// @dev Burn share in basis points for each governance stage (§6). + function _burnBps(Stage s) internal pure returns (uint256) { + if (s == Stage.Transition) return 2_000; // 20% + if (s == Stage.Mature) return 5_000; // 50% + return 0; // Bootstrap + } + + // ------------------------------------------------------------------------- + // Admin (governance timelock on mainnet) + // ------------------------------------------------------------------------- + + function setEpochFee(uint256 newFee) external onlyRole(DEFAULT_ADMIN_ROLE) { + epochFee = newFee; + emit EpochFeeUpdated(newFee); + } + + function setStage(Stage newStage) external onlyRole(DEFAULT_ADMIN_ROLE) { + stage = newStage; + emit StageUpdated(newStage); + } + + function setRewardPool(address newRewardPool) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (newRewardPool == address(0)) revert ZeroAddress(); + rewardPool = newRewardPool; + emit RewardPoolUpdated(newRewardPool); + } + + // ------------------------------------------------------------------------- + // UUPS upgrade authorization + // ------------------------------------------------------------------------- + + function _authorizeUpgrade(address) internal override onlyRole(UPGRADER_ROLE) {} +} diff --git a/test/CountersigEpochFees.t.sol b/test/CountersigEpochFees.t.sol new file mode 100644 index 0000000..f8927d9 --- /dev/null +++ b/test/CountersigEpochFees.t.sol @@ -0,0 +1,262 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "@openzeppelin/contracts/access/IAccessControl.sol"; + +import "../src/CountersigEpochFees.sol"; +import "../src/CountersigIdentity.sol"; +import "../src/CSIGToken.sol"; + +contract CountersigEpochFeesTest is Test { + CountersigEpochFees fees; + CountersigIdentity identity; + CSIGToken csig; + + address admin = makeAddr("admin"); + address oracle = makeAddr("oracle"); + address operator = makeAddr("operator"); + address agent = makeAddr("agent"); + address funder = makeAddr("funder"); + address rewardPool = makeAddr("rewardPool"); + address stranger = makeAddr("stranger"); + + bytes32 constant PUB_KEY = bytes32(uint256(0xdeadbeef)); + uint256 constant EPOCH_FEE = 10e18; + + bytes32 didHash; + + function setUp() public { + // $CSIG (testnet mintable token is fine for fixtures). + csig = new CSIGToken(address(this)); + + // Identity registry behind a proxy; register one agent. + CountersigIdentity idImpl = new CountersigIdentity(); + identity = CountersigIdentity(address(new ERC1967Proxy( + address(idImpl), + abi.encodeCall(CountersigIdentity.initialize, (admin, address(0))) + ))); + vm.prank(operator); + didHash = identity.registerAgent(agent, PUB_KEY); + + // Fee registry behind a proxy. + CountersigEpochFees feesImpl = new CountersigEpochFees(); + fees = CountersigEpochFees(address(new ERC1967Proxy( + address(feesImpl), + abi.encodeCall( + CountersigEpochFees.initialize, + (admin, oracle, address(csig), address(identity), rewardPool, EPOCH_FEE) + ) + ))); + + // Fund operator + funder and approve the registry. + csig.mint(operator, 1_000e18); + csig.mint(funder, 1_000e18); + vm.prank(operator); + csig.approve(address(fees), type(uint256).max); + vm.prank(funder); + csig.approve(address(fees), type(uint256).max); + } + + // ------------------------------------------------------------------------- + // Init + // ------------------------------------------------------------------------- + + function test_init() public view { + assertEq(address(fees.csig()), address(csig)); + assertEq(address(fees.identityRegistry()), address(identity)); + assertEq(fees.rewardPool(), rewardPool); + assertEq(fees.epochFee(), EPOCH_FEE); + assertEq(uint8(fees.stage()), uint8(CountersigEpochFees.Stage.Bootstrap)); + assertTrue(fees.hasRole(fees.ORACLE_ROLE(), oracle)); + } + + // ------------------------------------------------------------------------- + // Funding + // ------------------------------------------------------------------------- + + function test_depositFor_anyoneCanFund() public { + vm.prank(funder); + fees.depositFor(didHash, 100e18); + assertEq(fees.balance(didHash), 100e18); + assertEq(csig.balanceOf(address(fees)), 100e18); + } + + function test_depositFor_accumulates() public { + vm.prank(operator); + fees.depositFor(didHash, 40e18); + vm.prank(funder); + fees.depositFor(didHash, 60e18); + assertEq(fees.balance(didHash), 100e18); + } + + function test_depositFor_zero_reverts() public { + vm.expectRevert(CountersigEpochFees.ZeroAmount.selector); + vm.prank(funder); + fees.depositFor(didHash, 0); + } + + function test_withdraw_operatorOnly() public { + vm.prank(funder); + fees.depositFor(didHash, 100e18); + + vm.prank(operator); + fees.withdraw(didHash, 30e18); + assertEq(fees.balance(didHash), 70e18); + assertEq(csig.balanceOf(operator), 1_000e18 + 30e18); + } + + function test_withdraw_notOperator_reverts() public { + vm.prank(funder); + fees.depositFor(didHash, 100e18); + + vm.expectRevert(abi.encodeWithSelector(CountersigEpochFees.NotOperator.selector, didHash, stranger)); + vm.prank(stranger); + fees.withdraw(didHash, 1e18); + } + + function test_withdraw_moreThanBalance_reverts() public { + vm.prank(operator); + fees.depositFor(didHash, 10e18); + vm.expectRevert(abi.encodeWithSelector(CountersigEpochFees.InsufficientBalance.selector, didHash, 11e18, 10e18)); + vm.prank(operator); + fees.withdraw(didHash, 11e18); + } + + // ------------------------------------------------------------------------- + // Charging + // ------------------------------------------------------------------------- + + function test_isCovered() public { + assertFalse(fees.isCovered(didHash)); + vm.prank(operator); + fees.depositFor(didHash, EPOCH_FEE); + assertTrue(fees.isCovered(didHash)); + } + + function test_chargeEpoch_covered() public { + vm.prank(operator); + fees.depositFor(didHash, 25e18); + + vm.prank(oracle); + bool charged = fees.chargeEpoch(didHash); + assertTrue(charged); + assertEq(fees.balance(didHash), 15e18); + assertEq(fees.collected(), EPOCH_FEE); + } + + function test_chargeEpoch_uncovered_returnsFalse_noStateChange() public { + vm.prank(operator); + fees.depositFor(didHash, 5e18); // < EPOCH_FEE + + vm.prank(oracle); + bool charged = fees.chargeEpoch(didHash); + assertFalse(charged); + assertEq(fees.balance(didHash), 5e18); + assertEq(fees.collected(), 0); + } + + function test_chargeEpoch_feeZero_disablesGating() public { + vm.prank(admin); + fees.setEpochFee(0); + assertTrue(fees.isCovered(didHash)); // covered even with 0 balance + + vm.prank(oracle); + assertTrue(fees.chargeEpoch(didHash)); + assertEq(fees.collected(), 0); // nothing charged + } + + function test_chargeEpoch_notOracle_reverts() public { + vm.expectRevert(abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, stranger, fees.ORACLE_ROLE() + )); + vm.prank(stranger); + fees.chargeEpoch(didHash); + } + + // ------------------------------------------------------------------------- + // Distribution — three-stage routing (§6) + // ------------------------------------------------------------------------- + + function _collect(uint256 epochs) internal { + vm.prank(funder); + fees.depositFor(didHash, EPOCH_FEE * epochs); + for (uint256 i = 0; i < epochs; i++) { + vm.prank(oracle); + fees.chargeEpoch(didHash); + } + } + + function test_distribute_bootstrap_allToPool() public { + _collect(10); // 100e18 collected + fees.distributeFees(); + assertEq(csig.balanceOf(rewardPool), 100e18); + assertEq(csig.balanceOf(address(0xdead)), 0); + assertEq(fees.collected(), 0); + } + + function test_distribute_transition_80_20() public { + vm.prank(admin); + fees.setStage(CountersigEpochFees.Stage.Transition); + _collect(10); // 100e18 + fees.distributeFees(); + assertEq(csig.balanceOf(rewardPool), 80e18); + assertEq(csig.balanceOf(address(0xdead)), 20e18); + } + + function test_distribute_mature_50_50() public { + vm.prank(admin); + fees.setStage(CountersigEpochFees.Stage.Mature); + _collect(10); // 100e18 + fees.distributeFees(); + assertEq(csig.balanceOf(rewardPool), 50e18); + assertEq(csig.balanceOf(address(0xdead)), 50e18); + } + + function test_distribute_nothing_reverts() public { + vm.expectRevert(CountersigEpochFees.NothingToDistribute.selector); + fees.distributeFees(); + } + + // ------------------------------------------------------------------------- + // Admin gating + // ------------------------------------------------------------------------- + + function test_setEpochFee_adminOnly() public { + vm.expectRevert(abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, stranger, bytes32(0) + )); + vm.prank(stranger); + fees.setEpochFee(1e18); + + vm.prank(admin); + fees.setEpochFee(1e18); + assertEq(fees.epochFee(), 1e18); + } + + function test_setStage_and_setRewardPool_adminOnly() public { + vm.prank(admin); + fees.setStage(CountersigEpochFees.Stage.Mature); + assertEq(uint8(fees.stage()), uint8(CountersigEpochFees.Stage.Mature)); + + vm.prank(admin); + fees.setRewardPool(stranger); + assertEq(fees.rewardPool(), stranger); + + vm.expectRevert(CountersigEpochFees.ZeroAddress.selector); + vm.prank(admin); + fees.setRewardPool(address(0)); + } + + // ------------------------------------------------------------------------- + // Storage layout — pins balance mapping to slot 6 for upgrade safety + // ------------------------------------------------------------------------- + + function test_storageLayout_balancePinnedToSlot6() public { + vm.prank(funder); + fees.depositFor(didHash, 42e18); + bytes32 slot = keccak256(abi.encode(didHash, uint256(6))); + assertEq(uint256(vm.load(address(fees), slot)), 42e18); + } +}