Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions oracle/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
45 changes: 44 additions & 1 deletion oracle/chain.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -136,5 +175,9 @@ module.exports = {
getChallengeWindow,
getLatestBlockTimestamp,
pruneAgent,
feeGatingConfigured,
getEpochFee,
isCovered,
chargeEpoch,
STATUS_SLASHED,
};
43 changes: 43 additions & 0 deletions oracle/chain.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
34 changes: 30 additions & 4 deletions oracle/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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') {
Expand Down Expand Up @@ -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}`);
}
Expand Down
68 changes: 68 additions & 0 deletions script/DeployEpochFees.s.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading