From 4d39d15b0a6669b8d7a842a0458fc594aa138f8a Mon Sep 17 00:00:00 2001 From: dev-mondoshawan Date: Sun, 12 Jul 2026 21:48:02 -0500 Subject: [PATCH 1/3] Add CountersigEpochFees registry (oracle epoch prioritization) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements tokenomics §4 "Oracle Epoch Prioritization" + §6 three-stage fee routing. Operators/querying apps prepay $CSIG toward an agent's epoch coverage; the oracle charges one epochFee per agent it scores, and uncovered agents fall out of the active run (the free meetsThreshold read is unaffected). UUPS, matching the repo convention. Charged fees accumulate in `collected` and distributeFees() sweeps them per stage (Bootstrap 100%/0, Transition 80/20, Mature 50/50). epochFee=0 disables gating (testnet default). Anyone may fund an agent; only the operator withdraws unspent balance. 19 tests incl. storage-slot pin for upgrade safety. Co-Authored-By: Claude Opus 4.8 --- src/CountersigEpochFees.sol | 256 ++++++++++++++++++++++++++++++++ test/CountersigEpochFees.t.sol | 262 +++++++++++++++++++++++++++++++++ 2 files changed, 518 insertions(+) create mode 100644 src/CountersigEpochFees.sol create mode 100644 test/CountersigEpochFees.t.sol 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); + } +} From 35a56cce907cf5d54dfc1e44553c8c50bd969608 Mon Sep 17 00:00:00 2001 From: dev-mondoshawan Date: Sun, 12 Jul 2026 21:48:04 -0500 Subject: [PATCH 2/3] Wire opt-in epoch-fee gating into the oracle When FEE_REGISTRY_ADDRESS is set and the on-chain epochFee > 0, the epoch loop checks coverage (concurrently in the read phase), skips uncovered agents, and charges one fee after a successful score proposal. Fully opt-in: with no registry configured the oracle scores every agent exactly as before, so the live testnet oracle is unaffected. chain.js gains feeGatingConfigured/getEpochFee/isCovered/ chargeEpoch (DI-injectable). 2 tests. Co-Authored-By: Claude Opus 4.8 --- oracle/chain.js | 45 +++++++++++++++++++++++++++++++++++++++++++- oracle/chain.test.js | 43 ++++++++++++++++++++++++++++++++++++++++++ oracle/index.js | 34 +++++++++++++++++++++++++++++---- 3 files changed, 117 insertions(+), 5 deletions(-) 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}`); } From 2dff76b7f30c39a73af648a8ce0e173367a70564 Mon Sep 17 00:00:00 2001 From: dev-mondoshawan Date: Sun, 12 Jul 2026 21:48:04 -0500 Subject: [PATCH 3/3] Add EpochFees deploy script + document FEE_REGISTRY_ADDRESS DeployEpochFees.s.sol deploys the registry behind a UUPS proxy, grants ORACLE_ROLE to the oracle wallet, and asserts wiring post-deploy. epochFee defaults to 0 so deploying it does not disrupt the running oracle until governance sets a fee. Co-Authored-By: Claude Opus 4.8 --- oracle/.env.example | 6 ++++ script/DeployEpochFees.s.sol | 68 ++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 script/DeployEpochFees.s.sol 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/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); + } +}