diff --git a/oracle/.env.example b/oracle/.env.example index 65d66bf..3754e93 100644 --- a/oracle/.env.example +++ b/oracle/.env.example @@ -33,11 +33,21 @@ FROM_BLOCK=90338550 # Max blocks per eth_getLogs call. LOG_CHUNK_SIZE=2000 -# Bearer token protecting /attest, /flag, and /epoch from public writes. +# Bearer token protecting /attest, /flag, /link, and /epoch from public writes. # 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= +# Path to the JSON state file (attestations, flags, links, cooldowns). +# Must be writable; parent directories are created automatically. +# In Docker, mount a volume to this directory for persistence across restarts. +ORACLE_STATE_PATH=/data/oracle-state.json + +# Minimum milliseconds before the same attester can re-attest the same agent. +# Prevents attestation spam and score inflation. Default: 3600000 (1 hour). +# Set to 0 to disable cooldowns (not recommended for production). +ATTEST_COOLDOWN_MS=3600000 + # 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 diff --git a/oracle/README.md b/oracle/README.md new file mode 100644 index 0000000..afa87b0 --- /dev/null +++ b/oracle/README.md @@ -0,0 +1,211 @@ +# Countersig Oracle + +Off-chain reputation oracle for the Countersig protocol. Scores registered agents based on attestations, flags, age, and (optionally) cross-protocol ERC-8004 feedback. + +## Quick Start + +```bash +# Copy and configure environment +cp .env.example .env +# Edit .env with your RPC URL, private key, and contract addresses + +# Install dependencies +npm install + +# Run locally +node index.js + +# Run tests +node --test +``` + +## Docker Deployment + +```bash +# From the repo root +docker compose -f docker-compose.oracle.yml up -d +``` + +The compose file mounts `oracle_state` volume to `/data` for persistence. The HTTP port is published as `127.0.0.1:3030` (localhost only) — reverse-proxy with authentication before exposing to the internet. + +## API Endpoints + +### `GET /health` + +Health check with operational signals for production alerting. + +**Response (200 or 503):** +```json +{ + "ok": true, + "epochMs": 3600000, + "uptimeSeconds": 12345, + "lastSuccessfulEpochMs": 1234567890000, + "timeSinceLastEpochMs": 120000, + "storeWritable": true, + "statePath": "/data/oracle-state.json", + "attestCooldownMs": 3600000, + "epochRunning": false +} +``` + +Returns 503 when: +- State file path is not writable (likely volume mount issue) +- No successful epoch in the last 2× epoch interval (stale scoring) + +### `GET /metrics` + +Prometheus-format metrics for monitoring and alerting. + +``` +# HELP countersig_oracle_uptime_seconds Process uptime in seconds +# TYPE countersig_oracle_uptime_seconds gauge +countersig_oracle_uptime_seconds 12345 + +# HELP countersig_oracle_epochs_total Total epochs started +# TYPE countersig_oracle_epochs_total counter +countersig_oracle_epochs_total{status="started"} 100 +countersig_oracle_epochs_total{status="succeeded"} 99 +countersig_oracle_epochs_total{status="failed"} 1 + +# ... and more (propose, finalize, attest, flags, links, rate limits) +``` + +Scrape at `/metrics` with Prometheus or compatible tools. + +### `POST /attest` (auth required) + +Submit an attestation for an agent's task outcome. The `attester` field is mandatory and identifies the party submitting the attestation (e.g., client address or API key hash). A cooldown prevents the same attester from spamming attestations for the same agent. + +**Request:** +```json +{ + "didHash": "0x...", + "success": true, + "attester": "unique-attester-id" +} +``` + +**Response (200):** +```json +{ + "didHash": "0x...", + "attester": "unique-attester-id", + "successful": 10, + "total": 15 +} +``` + +**Error (429) - Cooldown active:** +```json +{ + "error": "Attestation cooldown active", + "attester": "unique-attester-id", + "didHash": "0x...", + "remainingSeconds": 2400, + "cooldownMs": 3600000 +} +``` + +### `POST /flag` (auth required) + +Flag an agent for community review. + +**Request:** +```json +{ "didHash": "0x..." } +``` + +### `POST /link` (auth required) + +Link a Countersig agent to its ERC-8004 identity for cross-protocol scoring. + +**Request:** +```json +{ + "didHash": "0x...", + "agentId": "123" +} +``` + +### `GET /score/:didHash` + +Preview the computed score for an agent without writing to chain. + +### `POST /epoch` (auth required) + +Manually trigger an epoch run. Use for testing; production runs on the configured interval. + +## Environment Variables + +### Required + +| Variable | Description | +|----------|-------------| +| `RPC_URL` | Ethereum JSON-RPC endpoint for the target chain | +| `ORACLE_PRIVATE_KEY` | Private key for the oracle wallet (must have ORACLE_ROLE) | +| `IDENTITY_ADDRESS` | CountersigIdentity contract address | +| `REPUTATION_ADDRESS` | CountersigReputation contract address | + +### Recommended for Production + +| Variable | Default | Description | +|----------|---------|-------------| +| `ORACLE_ADMIN_TOKEN` | (empty) | Bearer token for authenticated endpoints. **Set this before exposing the service.** Generate with `openssl rand -hex 32` | +| `ORACLE_STATE_PATH` | `/data/oracle-state.json` | Path to the persistent state file. Mount a volume here in Docker. | +| `ATTEST_COOLDOWN_MS` | `3600000` (1 hour) | Minimum time before the same attester can re-attest the same agent | + +### Optional + +| Variable | Default | Description | +|----------|---------|-------------| +| `EPOCH_HOURS` | `24` | Hours between automatic epoch runs | +| `HOST` | `127.0.0.1` | HTTP bind address. Use `0.0.0.0` in Docker. | +| `PORT` | `3030` | HTTP port | +| `FROM_BLOCK` | `0` | Block to start scanning AgentRegistered events from | +| `LOG_CHUNK_SIZE` | `2000` | Max blocks per eth_getLogs call | +| `FEE_REGISTRY_ADDRESS` | (empty) | CountersigEpochFees address for fee-gated scoring | +| `EXTERNAL_RPC` | (empty) | RPC for ERC-8004 external score lookups | +| `EXTERNAL_IDENTITY_ADDRESS` | (empty) | ERC-8004 Identity contract | +| `EXTERNAL_REPUTATION_ADDRESS` | (empty) | ERC-8004 Reputation contract | + +## Production Checklist + +- [ ] **Set `ORACLE_ADMIN_TOKEN`** - Required before exposing the HTTP port +- [ ] **Mount persistent volume** to `ORACLE_STATE_PATH` - Attestation and flag state must survive restarts +- [ ] **Configure monitoring:** + - Scrape `/metrics` with Prometheus + - Alert on `/health` returning 503 + - Watch `countersig_oracle_epochs_total{status="failed"}` for epoch failures + - Watch `countersig_oracle_attest_total{result="rejected_cooldown"}` for potential abuse attempts +- [ ] **Reverse proxy** with TLS if exposing beyond localhost +- [ ] **Fund oracle wallet** with native token for gas +- [ ] **Grant ORACLE_ROLE** on CountersigReputation to the oracle address + +## Attestation Cooldown + +The `/attest` endpoint enforces a per-(attester, didHash) cooldown to prevent score inflation. The same attester cannot repeatedly attest the same agent faster than `ATTEST_COOLDOWN_MS`. This: + +- Prevents a single party from artificially inflating an agent's fee/success scores +- Persists across restarts (stored in the state file) +- Returns a clear 429 error with remaining cooldown time when blocked + +## State Persistence + +The oracle persists the following to `ORACLE_STATE_PATH`: + +- **attestations**: Per-agent attestation counts (successful/total) +- **flags**: Per-agent unresolved flag counts +- **links**: Agent-to-ERC-8004 identity links +- **attestCooldowns**: Per-(attester, didHash) last-attestation timestamps + +Writes are atomic (temp file + rename) to prevent corruption. The health endpoint checks writability and returns 503 if the state path is not writable. + +## Tests + +```bash +cd oracle +node --test +``` + +Tests cover scoring formulas, HTTP helpers, store persistence, metrics, and cooldown logic. diff --git a/oracle/index.js b/oracle/index.js index d868cea..80262d3 100644 --- a/oracle/index.js +++ b/oracle/index.js @@ -8,6 +8,7 @@ const external = require('./external'); const { computeScore } = require('./scoring'); const { decideAction } = require('./epoch-policy'); const { json, readBody, isAuthorized, parseScorePath, rateLimited } = require('./http-helpers'); +const metrics = require('./metrics'); // Per-client key for rate limiting. Behind the container's 127.0.0.1 port map all // requests may share one source IP, so this degrades to a global cap — still a @@ -49,7 +50,19 @@ external.init(cfg); // ---- Persistent state ------------------------------------------------------ // attestations/flags drive score factors that accumulate and cannot be // recomputed from chain, so they are persisted to a mounted volume. See store.js. -const { attestations, flags, links, load: loadState, persist: persistState } = require('./store'); +const { + attestations, + flags, + links, + load: loadState, + persist: persistState, + checkAttestCooldown, + recordAttestation, + pruneExpiredCooldowns, + isStatePathWritable, + getStatePath, + ATTEST_COOLDOWN_MS, +} = require('./store'); // ---- Epoch ----------------------------------------------------------------- @@ -75,12 +88,14 @@ async function runEpoch() { async function runEpochInner() { const start = Date.now(); console.log(`[oracle] epoch start — ${new Date().toISOString()}`); + metrics.inc('epochsStarted'); let agents; try { agents = await chain.getRegisteredAgents(); } catch (err) { console.error('[oracle] could not fetch registered agents:', err.message); + metrics.inc('epochsFailed'); return; } @@ -144,18 +159,24 @@ async function runEpochInner() { } if (action === 'finalize-then-propose') { + metrics.inc('finalizeAttempts'); try { const finalizeTx = await chain.finalizeScore(didHash); console.log(`[oracle] ${didHash.slice(0, 10)}… finalized tx=${finalizeTx.slice(0, 10)}…`); finalized++; + metrics.inc('finalizeSuccesses'); } catch (finalizeErr) { // finalizeReputation is permissionless, so another party can front-run // us. If the pending proposal is gone, that's exactly what happened — // the score is live, carry on and propose fresh. Anything else is a // real failure and should skip this agent via the outer catch. const still = await chain.getPendingScore(didHash); - if (still.exists) throw finalizeErr; + if (still.exists) { + metrics.inc('finalizeErrors'); + throw finalizeErr; + } console.log(`[oracle] ${didHash.slice(0, 10)}… already finalized by another party`); + metrics.inc('finalizeSuccesses'); } } @@ -168,7 +189,10 @@ async function runEpochInner() { ? await external.externalScoreFor(linkedId, operator) : 0; const scores = computeScore({ registeredAt, attestations: att, flags: flagCount, externalScore }); + + metrics.inc('proposeAttempts'); const txHash = await chain.proposeScore(didHash, scores); + metrics.inc('proposeSuccesses'); console.log(`[oracle] ${didHash.slice(0, 10)}… proposed score=${scores.total}/100 tx=${txHash.slice(0, 10)}…`); proposed++; @@ -184,10 +208,16 @@ async function runEpochInner() { } } catch (err) { console.error(`[oracle] ${didHash.slice(0, 10)}… error: ${err.message}`); + metrics.inc('proposeErrors'); } } console.log(`[oracle] epoch done — ${proposed} proposed, ${finalized} finalized in ${Date.now() - start}ms`); + metrics.inc('epochsSucceeded'); + metrics.set('lastSuccessfulEpochMs', Date.now()); + metrics.set('activeAgents', proposed); + + pruneExpiredCooldowns(); } // ---- HTTP API -------------------------------------------------------------- @@ -195,10 +225,33 @@ async function runEpochInner() { const server = http.createServer(async (req, res) => { const url = new URL(req.url, `http://localhost:${cfg.port}`); const { pathname } = url; + metrics.inc('httpRequests'); - // GET /health + // GET /health — extended for production alerting if (req.method === 'GET' && pathname === '/health') { - return json(res, 200, { ok: true, epochMs: cfg.epochMs }); + const lastEpoch = metrics.get('lastSuccessfulEpochMs'); + const timeSinceLastEpoch = lastEpoch ? Date.now() - lastEpoch : null; + const storeWritable = isStatePathWritable(); + + const healthy = storeWritable && (!lastEpoch || timeSinceLastEpoch < cfg.epochMs * 2); + + return json(res, healthy ? 200 : 503, { + ok: healthy, + epochMs: cfg.epochMs, + uptimeSeconds: metrics.uptimeSeconds(), + lastSuccessfulEpochMs: lastEpoch, + timeSinceLastEpochMs: timeSinceLastEpoch, + storeWritable, + statePath: getStatePath(), + attestCooldownMs: ATTEST_COOLDOWN_MS, + epochRunning, + }); + } + + // GET /metrics — Prometheus text format + if (req.method === 'GET' && pathname === '/metrics') { + res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' }); + return res.end(metrics.toPrometheusText()); } // POST /epoch — trigger a manual run (useful for testing) @@ -206,26 +259,58 @@ const server = http.createServer(async (req, res) => { // Gated: a manual epoch submits on-chain tx's paid from the oracle wallet, so // it must not be triggerable by anyone who can reach the port. if (!isAuthorized(req.headers, cfg.adminToken)) return json(res, 401, { error: 'Unauthorized' }); - if (rateLimited(clientKey(req))) return json(res, 429, { error: 'Rate limited' }); + if (rateLimited(clientKey(req))) { + metrics.inc('rateLimitHits'); + return json(res, 429, { error: 'Rate limited' }); + } if (epochRunning) return json(res, 409, { error: 'Epoch already running' }); runEpoch().catch(err => console.error('[oracle] manual epoch error:', err.message)); return json(res, 202, { message: 'Epoch started' }); } - // POST /attest — body: { didHash, success } + // POST /attest — body: { didHash, success, attester } + // attester: unique ID for the party submitting the attestation (e.g. client address, API key hash). + // Used for dedupe/cooldown: the same attester cannot attest the same agent within ATTEST_COOLDOWN_MS. if (req.method === 'POST' && pathname === '/attest') { if (!isAuthorized(req.headers, cfg.adminToken)) return json(res, 401, { error: 'Unauthorized' }); - if (rateLimited(clientKey(req))) return json(res, 429, { error: 'Rate limited' }); + if (rateLimited(clientKey(req))) { + metrics.inc('rateLimitHits'); + return json(res, 429, { error: 'Rate limited' }); + } try { - const { didHash, success } = await readBody(req); - if (!didHash) return json(res, 400, { error: 'didHash required' }); + const { didHash, success, attester } = await readBody(req); + if (!didHash) { + metrics.inc('attestRejectedOther'); + return json(res, 400, { error: 'didHash required' }); + } + if (!attester) { + metrics.inc('attestRejectedOther'); + return json(res, 400, { error: 'attester required (unique ID for the attestation source)' }); + } + + const cooldownCheck = checkAttestCooldown(attester, didHash); + if (!cooldownCheck.allowed) { + metrics.inc('attestRejectedCooldown'); + const remainingSec = Math.ceil(cooldownCheck.remainingMs / 1000); + return json(res, 429, { + error: 'Attestation cooldown active', + attester, + didHash, + remainingSeconds: remainingSec, + cooldownMs: ATTEST_COOLDOWN_MS, + }); + } + const att = attestations.get(didHash) ?? { successful: 0, total: 0 }; att.total++; if (success) att.successful++; attestations.set(didHash, att); + recordAttestation(attester, didHash); persistState(); - return json(res, 200, { didHash, ...att }); + metrics.inc('attestAccepted'); + return json(res, 200, { didHash, attester, ...att }); } catch (err) { + metrics.inc('attestRejectedOther'); return json(res, 400, { error: err.message }); } } @@ -233,12 +318,16 @@ const server = http.createServer(async (req, res) => { // POST /flag — body: { didHash } if (req.method === 'POST' && pathname === '/flag') { if (!isAuthorized(req.headers, cfg.adminToken)) return json(res, 401, { error: 'Unauthorized' }); - if (rateLimited(clientKey(req))) return json(res, 429, { error: 'Rate limited' }); + if (rateLimited(clientKey(req))) { + metrics.inc('rateLimitHits'); + return json(res, 429, { error: 'Rate limited' }); + } try { const { didHash } = await readBody(req); if (!didHash) return json(res, 400, { error: 'didHash required' }); flags.set(didHash, (flags.get(didHash) ?? 0) + 1); persistState(); + metrics.inc('flagsReceived'); return json(res, 200, { didHash, flags: flags.get(didHash) }); } catch (err) { return json(res, 400, { error: err.message }); @@ -250,7 +339,10 @@ const server = http.createServer(async (req, res) => { // if the 8004 agent NFT is owned by the same wallet as the Countersig operator. if (req.method === 'POST' && pathname === '/link') { if (!isAuthorized(req.headers, cfg.adminToken)) return json(res, 401, { error: 'Unauthorized' }); - if (rateLimited(clientKey(req))) return json(res, 429, { error: 'Rate limited' }); + if (rateLimited(clientKey(req))) { + metrics.inc('rateLimitHits'); + return json(res, 429, { error: 'Rate limited' }); + } try { const { didHash, agentId } = await readBody(req); if (!didHash || agentId === undefined || agentId === null) { @@ -264,6 +356,7 @@ const server = http.createServer(async (req, res) => { } links.set(didHash, String(agentId)); persistState(); + metrics.inc('linksCreated'); return json(res, 200, { didHash, agentId: String(agentId), operator, linked: true }); } catch (err) { return json(res, 400, { error: err.message }); @@ -295,7 +388,8 @@ server.listen(cfg.port, cfg.host, () => { if (!cfg.adminToken) { console.warn('[oracle] WARNING: ORACLE_ADMIN_TOKEN is unset — /attest, /flag, and /epoch are UNAUTHENTICATED. Set a token before exposing this service.'); } - console.log(`[oracle] HTTP on ${cfg.host}:${cfg.port} epoch every ${cfg.epochMs / 3_600_000}h`); + console.log(`[oracle] HTTP on ${cfg.host}:${cfg.port} epoch every ${cfg.epochMs / 3_600_000}h attest cooldown ${ATTEST_COOLDOWN_MS / 1000}s`); + console.log(`[oracle] state path: ${getStatePath()}`); loadState(); runEpoch(); setInterval(runEpoch, cfg.epochMs); diff --git a/oracle/metrics.js b/oracle/metrics.js new file mode 100644 index 0000000..eca56cb --- /dev/null +++ b/oracle/metrics.js @@ -0,0 +1,129 @@ +'use strict'; + +// Prometheus-style metrics for the Countersig oracle. +// Lightweight: no external deps, just in-memory counters with text exposition. + +const processStartTime = Date.now(); + +const counters = { + epochsStarted: 0, + epochsSucceeded: 0, + epochsFailed: 0, + proposeAttempts: 0, + proposeSuccesses: 0, + proposeErrors: 0, + finalizeAttempts: 0, + finalizeSuccesses: 0, + finalizeErrors: 0, + attestAccepted: 0, + attestRejectedCooldown: 0, + attestRejectedOther: 0, + flagsReceived: 0, + linksCreated: 0, + rateLimitHits: 0, + httpRequests: 0, +}; + +const gauges = { + lastSuccessfulEpochMs: 0, + activeAgents: 0, +}; + +function inc(name, amount = 1) { + if (name in counters) counters[name] += amount; +} + +function set(name, value) { + if (name in gauges) gauges[name] = value; +} + +function get(name) { + if (name in counters) return counters[name]; + if (name in gauges) return gauges[name]; + return undefined; +} + +function uptimeSeconds() { + return Math.floor((Date.now() - processStartTime) / 1000); +} + +function toPrometheusText() { + const lines = []; + lines.push('# HELP countersig_oracle_uptime_seconds Process uptime in seconds'); + lines.push('# TYPE countersig_oracle_uptime_seconds gauge'); + lines.push(`countersig_oracle_uptime_seconds ${uptimeSeconds()}`); + + lines.push(''); + lines.push('# HELP countersig_oracle_epochs_total Total epochs started'); + lines.push('# TYPE countersig_oracle_epochs_total counter'); + lines.push(`countersig_oracle_epochs_total{status="started"} ${counters.epochsStarted}`); + lines.push(`countersig_oracle_epochs_total{status="succeeded"} ${counters.epochsSucceeded}`); + lines.push(`countersig_oracle_epochs_total{status="failed"} ${counters.epochsFailed}`); + + lines.push(''); + lines.push('# HELP countersig_oracle_propose_total Score proposal attempts'); + lines.push('# TYPE countersig_oracle_propose_total counter'); + lines.push(`countersig_oracle_propose_total{result="success"} ${counters.proposeSuccesses}`); + lines.push(`countersig_oracle_propose_total{result="error"} ${counters.proposeErrors}`); + + lines.push(''); + lines.push('# HELP countersig_oracle_finalize_total Score finalization attempts'); + lines.push('# TYPE countersig_oracle_finalize_total counter'); + lines.push(`countersig_oracle_finalize_total{result="success"} ${counters.finalizeSuccesses}`); + lines.push(`countersig_oracle_finalize_total{result="error"} ${counters.finalizeErrors}`); + + lines.push(''); + lines.push('# HELP countersig_oracle_attest_total Attestation requests'); + lines.push('# TYPE countersig_oracle_attest_total counter'); + lines.push(`countersig_oracle_attest_total{result="accepted"} ${counters.attestAccepted}`); + lines.push(`countersig_oracle_attest_total{result="rejected_cooldown"} ${counters.attestRejectedCooldown}`); + lines.push(`countersig_oracle_attest_total{result="rejected_other"} ${counters.attestRejectedOther}`); + + lines.push(''); + lines.push('# HELP countersig_oracle_flags_total Flags received'); + lines.push('# TYPE countersig_oracle_flags_total counter'); + lines.push(`countersig_oracle_flags_total ${counters.flagsReceived}`); + + lines.push(''); + lines.push('# HELP countersig_oracle_links_total Links created'); + lines.push('# TYPE countersig_oracle_links_total counter'); + lines.push(`countersig_oracle_links_total ${counters.linksCreated}`); + + lines.push(''); + lines.push('# HELP countersig_oracle_rate_limit_hits_total Rate limit rejections'); + lines.push('# TYPE countersig_oracle_rate_limit_hits_total counter'); + lines.push(`countersig_oracle_rate_limit_hits_total ${counters.rateLimitHits}`); + + lines.push(''); + lines.push('# HELP countersig_oracle_http_requests_total Total HTTP requests'); + lines.push('# TYPE countersig_oracle_http_requests_total counter'); + lines.push(`countersig_oracle_http_requests_total ${counters.httpRequests}`); + + lines.push(''); + lines.push('# HELP countersig_oracle_last_successful_epoch_timestamp_seconds Unix timestamp of last successful epoch'); + lines.push('# TYPE countersig_oracle_last_successful_epoch_timestamp_seconds gauge'); + lines.push(`countersig_oracle_last_successful_epoch_timestamp_seconds ${Math.floor(gauges.lastSuccessfulEpochMs / 1000)}`); + + lines.push(''); + lines.push('# HELP countersig_oracle_active_agents Number of agents scored in last epoch'); + lines.push('# TYPE countersig_oracle_active_agents gauge'); + lines.push(`countersig_oracle_active_agents ${gauges.activeAgents}`); + + return lines.join('\n') + '\n'; +} + +function reset() { + for (const key of Object.keys(counters)) counters[key] = 0; + for (const key of Object.keys(gauges)) gauges[key] = 0; +} + +module.exports = { + inc, + set, + get, + uptimeSeconds, + toPrometheusText, + reset, + counters, + gauges, +}; diff --git a/oracle/metrics.test.js b/oracle/metrics.test.js new file mode 100644 index 0000000..e2cd739 --- /dev/null +++ b/oracle/metrics.test.js @@ -0,0 +1,145 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const metrics = require('./metrics'); + +test('metrics.inc: increments counter by 1 by default', () => { + metrics.reset(); + assert.equal(metrics.get('epochsStarted'), 0); + metrics.inc('epochsStarted'); + assert.equal(metrics.get('epochsStarted'), 1); + metrics.inc('epochsStarted'); + assert.equal(metrics.get('epochsStarted'), 2); +}); + +test('metrics.inc: increments counter by specified amount', () => { + metrics.reset(); + metrics.inc('proposeSuccesses', 5); + assert.equal(metrics.get('proposeSuccesses'), 5); + metrics.inc('proposeSuccesses', 3); + assert.equal(metrics.get('proposeSuccesses'), 8); +}); + +test('metrics.inc: ignores unknown counter names', () => { + metrics.reset(); + metrics.inc('unknownCounter'); + assert.equal(metrics.get('unknownCounter'), undefined); +}); + +test('metrics.set: sets gauge value', () => { + metrics.reset(); + metrics.set('lastSuccessfulEpochMs', 1234567890); + assert.equal(metrics.get('lastSuccessfulEpochMs'), 1234567890); +}); + +test('metrics.set: ignores unknown gauge names', () => { + metrics.reset(); + metrics.set('unknownGauge', 123); + assert.equal(metrics.get('unknownGauge'), undefined); +}); + +test('metrics.get: returns counter values', () => { + metrics.reset(); + metrics.inc('attestAccepted', 10); + assert.equal(metrics.get('attestAccepted'), 10); +}); + +test('metrics.get: returns gauge values', () => { + metrics.reset(); + metrics.set('activeAgents', 42); + assert.equal(metrics.get('activeAgents'), 42); +}); + +test('metrics.uptimeSeconds: returns non-negative number', () => { + const uptime = metrics.uptimeSeconds(); + assert.ok(uptime >= 0); + assert.ok(Number.isInteger(uptime)); +}); + +test('metrics.reset: clears all counters and gauges', () => { + metrics.inc('epochsStarted', 5); + metrics.inc('proposeSuccesses', 10); + metrics.set('lastSuccessfulEpochMs', 999); + metrics.set('activeAgents', 3); + metrics.reset(); + assert.equal(metrics.get('epochsStarted'), 0); + assert.equal(metrics.get('proposeSuccesses'), 0); + assert.equal(metrics.get('lastSuccessfulEpochMs'), 0); + assert.equal(metrics.get('activeAgents'), 0); +}); + +test('metrics.toPrometheusText: returns valid Prometheus format', () => { + metrics.reset(); + metrics.inc('epochsStarted', 5); + metrics.inc('epochsSucceeded', 4); + metrics.inc('epochsFailed', 1); + metrics.inc('proposeSuccesses', 20); + metrics.inc('attestAccepted', 15); + metrics.inc('attestRejectedCooldown', 3); + metrics.set('lastSuccessfulEpochMs', 1234567890000); + metrics.set('activeAgents', 10); + + const text = metrics.toPrometheusText(); + + assert.ok(text.includes('# HELP'), 'should include HELP comments'); + assert.ok(text.includes('# TYPE'), 'should include TYPE comments'); + assert.ok(text.includes('countersig_oracle_uptime_seconds'), 'should include uptime metric'); + assert.ok(text.includes('countersig_oracle_epochs_total{status="started"} 5'), 'should include epochs started'); + assert.ok(text.includes('countersig_oracle_epochs_total{status="succeeded"} 4'), 'should include epochs succeeded'); + assert.ok(text.includes('countersig_oracle_epochs_total{status="failed"} 1'), 'should include epochs failed'); + assert.ok(text.includes('countersig_oracle_propose_total{result="success"} 20'), 'should include propose successes'); + assert.ok(text.includes('countersig_oracle_attest_total{result="accepted"} 15'), 'should include attest accepted'); + assert.ok(text.includes('countersig_oracle_attest_total{result="rejected_cooldown"} 3'), 'should include attest rejected'); + assert.ok(text.includes('countersig_oracle_last_successful_epoch_timestamp_seconds 1234567890'), 'should include last epoch timestamp'); + assert.ok(text.includes('countersig_oracle_active_agents 10'), 'should include active agents'); + assert.ok(text.endsWith('\n'), 'should end with newline'); +}); + +test('metrics.toPrometheusText: each line is valid', () => { + metrics.reset(); + const text = metrics.toPrometheusText(); + const lines = text.split('\n').filter(line => line.length > 0); + + for (const line of lines) { + const isComment = line.startsWith('#'); + const isMetric = /^[a-z_]+(\{[^}]*\})?\s+-?\d+(\.\d+)?$/.test(line); + assert.ok(isComment || isMetric, `Invalid line: ${line}`); + } +}); + +test('all expected counters exist', () => { + const expectedCounters = [ + 'epochsStarted', + 'epochsSucceeded', + 'epochsFailed', + 'proposeAttempts', + 'proposeSuccesses', + 'proposeErrors', + 'finalizeAttempts', + 'finalizeSuccesses', + 'finalizeErrors', + 'attestAccepted', + 'attestRejectedCooldown', + 'attestRejectedOther', + 'flagsReceived', + 'linksCreated', + 'rateLimitHits', + 'httpRequests', + ]; + + for (const counter of expectedCounters) { + assert.ok(counter in metrics.counters, `Counter ${counter} should exist`); + } +}); + +test('all expected gauges exist', () => { + const expectedGauges = [ + 'lastSuccessfulEpochMs', + 'activeAgents', + ]; + + for (const gauge of expectedGauges) { + assert.ok(gauge in metrics.gauges, `Gauge ${gauge} should exist`); + } +}); diff --git a/oracle/store.js b/oracle/store.js index deb89ff..826a58a 100644 --- a/oracle/store.js +++ b/oracle/store.js @@ -15,12 +15,19 @@ const path = require('path'); const STATE_PATH = process.env.ORACLE_STATE_PATH || '/data/oracle-state.json'; +// Cooldown period for attestations: minimum time (ms) before the same attester +// can attest the same agent again. Prevents attestation spam/inflation. +const DEFAULT_ATTEST_COOLDOWN_MS = 3_600_000; // 1 hour +const ATTEST_COOLDOWN_MS = Number(process.env.ATTEST_COOLDOWN_MS) || DEFAULT_ATTEST_COOLDOWN_MS; + // didHash → { successful, total } const attestations = new Map(); // didHash → unresolved flag count const flags = new Map(); // didHash → ERC-8004 agentId (string) this agent is linked to (ownership-verified at link time) const links = new Map(); +// "attester:didHash" → timestamp (ms) of last attestation — dedupe/cooldown guard +const attestCooldowns = new Map(); function load() { try { @@ -28,7 +35,8 @@ function load() { for (const [k, v] of Object.entries(parsed.attestations || {})) attestations.set(k, v); for (const [k, v] of Object.entries(parsed.flags || {})) flags.set(k, v); for (const [k, v] of Object.entries(parsed.links || {})) links.set(k, v); - console.log(`[oracle] state loaded from ${STATE_PATH}: ${attestations.size} attestations, ${flags.size} flags, ${links.size} links`); + for (const [k, v] of Object.entries(parsed.attestCooldowns || {})) attestCooldowns.set(k, v); + console.log(`[oracle] state loaded from ${STATE_PATH}: ${attestations.size} attestations, ${flags.size} flags, ${links.size} links, ${attestCooldowns.size} cooldowns`); } catch (err) { if (err.code === 'ENOENT') { console.log(`[oracle] no prior state at ${STATE_PATH}, starting fresh`); @@ -46,6 +54,7 @@ function persist() { attestations: Object.fromEntries(attestations), flags: Object.fromEntries(flags), links: Object.fromEntries(links), + attestCooldowns: Object.fromEntries(attestCooldowns), savedAt: new Date().toISOString(), })); fs.renameSync(tmp, STATE_PATH); @@ -54,4 +63,57 @@ function persist() { } } -module.exports = { attestations, flags, links, load, persist }; +function attestCooldownKey(attester, didHash) { + return `${attester}:${didHash}`; +} + +function checkAttestCooldown(attester, didHash, now = Date.now()) { + const key = attestCooldownKey(attester, didHash); + const lastAttest = attestCooldowns.get(key); + if (!lastAttest) return { allowed: true, remainingMs: 0 }; + const elapsed = now - lastAttest; + if (elapsed >= ATTEST_COOLDOWN_MS) return { allowed: true, remainingMs: 0 }; + return { allowed: false, remainingMs: ATTEST_COOLDOWN_MS - elapsed }; +} + +function recordAttestation(attester, didHash, now = Date.now()) { + const key = attestCooldownKey(attester, didHash); + attestCooldowns.set(key, now); +} + +function pruneExpiredCooldowns(now = Date.now()) { + for (const [key, ts] of attestCooldowns.entries()) { + if (now - ts >= ATTEST_COOLDOWN_MS) attestCooldowns.delete(key); + } +} + +function isStatePathWritable() { + try { + fs.mkdirSync(path.dirname(STATE_PATH), { recursive: true }); + const testPath = `${STATE_PATH}.writable-test`; + fs.writeFileSync(testPath, 'ok'); + fs.unlinkSync(testPath); + return true; + } catch { + return false; + } +} + +function getStatePath() { + return STATE_PATH; +} + +module.exports = { + attestations, + flags, + links, + attestCooldowns, + load, + persist, + checkAttestCooldown, + recordAttestation, + pruneExpiredCooldowns, + isStatePathWritable, + getStatePath, + ATTEST_COOLDOWN_MS, +}; diff --git a/oracle/store.test.js b/oracle/store.test.js new file mode 100644 index 0000000..f3889e4 --- /dev/null +++ b/oracle/store.test.js @@ -0,0 +1,102 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { + checkAttestCooldown, + recordAttestation, + pruneExpiredCooldowns, + attestCooldowns, + ATTEST_COOLDOWN_MS, +} = require('./store'); + +function clearCooldowns() { + attestCooldowns.clear(); +} + +test('checkAttestCooldown: allows first attestation', () => { + clearCooldowns(); + const result = checkAttestCooldown('attester1', '0xabc', 1000000); + assert.equal(result.allowed, true); + assert.equal(result.remainingMs, 0); +}); + +test('checkAttestCooldown: blocks attestation within cooldown window', () => { + clearCooldowns(); + const now = 1000000; + recordAttestation('attester1', '0xabc', now); + const result = checkAttestCooldown('attester1', '0xabc', now + 1000); + assert.equal(result.allowed, false); + assert.ok(result.remainingMs > 0); + assert.ok(result.remainingMs <= ATTEST_COOLDOWN_MS); +}); + +test('checkAttestCooldown: allows attestation after cooldown expires', () => { + clearCooldowns(); + const now = 1000000; + recordAttestation('attester1', '0xabc', now); + const result = checkAttestCooldown('attester1', '0xabc', now + ATTEST_COOLDOWN_MS); + assert.equal(result.allowed, true); + assert.equal(result.remainingMs, 0); +}); + +test('checkAttestCooldown: different attesters have independent cooldowns', () => { + clearCooldowns(); + const now = 1000000; + recordAttestation('attester1', '0xabc', now); + const result = checkAttestCooldown('attester2', '0xabc', now + 1000); + assert.equal(result.allowed, true); +}); + +test('checkAttestCooldown: same attester can attest different agents', () => { + clearCooldowns(); + const now = 1000000; + recordAttestation('attester1', '0xabc', now); + const result = checkAttestCooldown('attester1', '0xdef', now + 1000); + assert.equal(result.allowed, true); +}); + +test('recordAttestation: records timestamp for attester+didHash pair', () => { + clearCooldowns(); + const now = 1000000; + recordAttestation('attester1', '0xabc', now); + assert.ok(attestCooldowns.has('attester1:0xabc')); + assert.equal(attestCooldowns.get('attester1:0xabc'), now); +}); + +test('recordAttestation: updates timestamp for subsequent attestation', () => { + clearCooldowns(); + const now1 = 1000000; + const now2 = 2000000; + recordAttestation('attester1', '0xabc', now1); + recordAttestation('attester1', '0xabc', now2); + assert.equal(attestCooldowns.get('attester1:0xabc'), now2); +}); + +test('pruneExpiredCooldowns: removes expired entries', () => { + clearCooldowns(); + const now = 1000000; + recordAttestation('attester1', '0xabc', now - ATTEST_COOLDOWN_MS - 1000); + recordAttestation('attester2', '0xdef', now - 1000); + pruneExpiredCooldowns(now); + assert.equal(attestCooldowns.has('attester1:0xabc'), false, 'expired entry should be pruned'); + assert.equal(attestCooldowns.has('attester2:0xdef'), true, 'recent entry should remain'); +}); + +test('pruneExpiredCooldowns: keeps entries at exactly cooldown boundary', () => { + clearCooldowns(); + const now = 1000000; + recordAttestation('attester1', '0xabc', now - ATTEST_COOLDOWN_MS); + pruneExpiredCooldowns(now); + assert.equal(attestCooldowns.has('attester1:0xabc'), false, 'entry at exactly cooldown boundary should be pruned'); +}); + +test('cooldown calculation returns correct remaining time', () => { + clearCooldowns(); + const now = 1000000; + const halfCooldown = ATTEST_COOLDOWN_MS / 2; + recordAttestation('attester1', '0xabc', now); + const result = checkAttestCooldown('attester1', '0xabc', now + halfCooldown); + assert.equal(result.allowed, false); + assert.equal(result.remainingMs, ATTEST_COOLDOWN_MS - halfCooldown); +});