polyglot-alpha / contracts /src /ReputationRegistry.sol
licaomeng
deploy: main@8970ffb → HF Spaces (2026-05-27T05:19Z)
88d2f2a
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/math/Math.sol";
/// @title ReputationRegistry
/// @notice Tracks per-agent reputation across the PolyglotAlpha translation
/// auction lifecycle. Reputation is an EMA-style score that incorporates
/// win rate, post-auction quality outcomes, and realized fill revenue.
/// @dev All scores are scaled by 1e18 (i.e. 1.0 == 1e18). Unknown agents return
/// the default neutral score (1e18) so the auction can divide bids by a
/// non-zero denominator without a special case at the call site.
contract ReputationRegistry {
// ---------------------------------------------------------------
// Constants (NatSpec-visible "magic numbers")
// ---------------------------------------------------------------
/// @notice Fixed-point ONE; score units are 1e18 == 1.0.
uint256 public constant ONE = 1e18;
/// @notice Fixed-point HALF (0.5e18). Used as the **initial reputation** for
/// a fresh agent so the first `_recompute` does not subtract from a
/// maxed-out prior. Rationale (W14-C, α correction): the formula
/// caps the per-event signal at `winRate * qualityRate * fillSignal`
/// which is bounded above by ~`1.0 * 1.0 * 2.0 = 2.0` but in the
/// realistic mid-range sits around 0.5 — so seeding the prior at 1.0
/// meant every first update strictly decreased the score. Seeding
/// at 0.5 matches the achievable steady-state mid-range.
uint256 public constant HALF = 5e17;
/// @notice USDC has 6 decimals; the registry's fixed-point math uses 1e18.
/// `_fillSignal` is called from `BuilderFeeRouter.updateOnFee` which
/// passes `fillAmount` in 6-decimal USDC base units. We rescale by
/// 1e12 (1e18 / 1e6) so the ln() input is in the right magnitude
/// (W14-C β-fix). Without this rescale the ln() argument was off by
/// 12 orders of magnitude and `fillSignal` was permanently clamped
/// to `FILL_SIGNAL_MIN` for any realistic fee.
uint256 public constant USDC_TO_1E18 = 1e12;
/// @notice EMA decay applied to the previous score on each update
/// (85% old + 15% new). Stored scaled by 1e18: 0.85e18.
/// Per README §5.6 final mechanism design (α = 0.85): slow decay
/// so one bad event drops reputation by ~0.045.
uint256 public constant DECAY_NUMERATOR = 85e16;
/// @notice Weight on the freshly computed signal in the EMA (0.15e18).
uint256 public constant SIGNAL_NUMERATOR = 15e16;
/// @notice Lower clamp for the fill-signal multiplier (0.5e18 == 0.5).
uint256 public constant FILL_SIGNAL_MIN = 5e17;
/// @notice Upper clamp for the fill-signal multiplier (2.0e18 == 2.0).
uint256 public constant FILL_SIGNAL_MAX = 2e18;
/// @notice Scale on cumulative fees (USDC, 18 decimals) used inside the
/// ln() of the fill-signal computation. Matches spec: ln(1 + fees/100).
uint256 public constant FEE_SCALE = 100;
// ---------------------------------------------------------------
// Storage
// ---------------------------------------------------------------
struct Reputation {
uint256 totalBids;
uint256 totalWins;
uint256 totalQualityPasses;
uint256 cumulativeFeesEarned;
/// @dev Score is scaled by 1e18. Default neutral is 1e18 (== 1.0).
uint256 score;
uint256 lastUpdated;
}
mapping(address => Reputation) public reps;
/// @notice Owner deploys the contract and is the only address that can
/// set authorized callers (BuilderFeeRouter, TranslationAuction, operator EOA).
address public owner;
/// @notice Authorized callers permitted to push state updates.
mapping(address => bool) public authorized;
// ---------------------------------------------------------------
// Events
// ---------------------------------------------------------------
event AuctionUpdated(address indexed agent, bool won, uint256 newScore);
event QualityUpdated(address indexed agent, bool passed, uint256 newScore);
event FeeUpdated(address indexed agent, uint256 amount, uint256 newScore);
event AuthorizedSet(address indexed who, bool allowed);
event ReputationSlashed(
address indexed agent,
address indexed by,
uint256 amount,
uint256 newScore,
string reason
);
// ---------------------------------------------------------------
// Modifiers
// ---------------------------------------------------------------
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
modifier onlyAuthorized() {
require(authorized[msg.sender] || msg.sender == owner, "not authorized");
_;
}
// ---------------------------------------------------------------
// Constructor
// ---------------------------------------------------------------
constructor() {
owner = msg.sender;
authorized[msg.sender] = true;
}
function setAuthorized(address who, bool allowed) external onlyOwner {
authorized[who] = allowed;
emit AuthorizedSet(who, allowed);
}
// ---------------------------------------------------------------
// Mutation: auction settled
// ---------------------------------------------------------------
/// @notice Called by the auction contract after every settle (for the winner
/// and for each losing bidder). Bumps bid/win counts then recomputes
/// the EMA score.
function updateOnAuction(address agent, bool won) external onlyAuthorized {
Reputation storage r = reps[agent];
if (r.lastUpdated == 0) {
r.score = HALF; // W14-C α-fix: initialize unknown agent to 0.5
}
r.totalBids += 1;
if (won) {
r.totalWins += 1;
}
r.score = _recompute(r);
r.lastUpdated = block.timestamp;
emit AuctionUpdated(agent, won, r.score);
}
// ---------------------------------------------------------------
// Mutation: quality verdict from 11-judge ensemble
// ---------------------------------------------------------------
function updateOnQuality(address agent, bool passed) external onlyAuthorized {
Reputation storage r = reps[agent];
if (r.lastUpdated == 0) {
r.score = HALF; // W14-C α-fix: initialize unknown agent to 0.5
}
if (passed) {
r.totalQualityPasses += 1;
}
r.score = _recompute(r);
r.lastUpdated = block.timestamp;
emit QualityUpdated(agent, passed, r.score);
}
// ---------------------------------------------------------------
// Mutation: fee accrual signal
// ---------------------------------------------------------------
function updateOnFee(address agent, uint256 amount) external onlyAuthorized {
Reputation storage r = reps[agent];
if (r.lastUpdated == 0) {
r.score = HALF; // W14-C α-fix: initialize unknown agent to 0.5
}
r.cumulativeFeesEarned += amount;
r.score = _recompute(r);
r.lastUpdated = block.timestamp;
emit FeeUpdated(agent, amount, r.score);
}
// ---------------------------------------------------------------
// Mutation: slashing (multi-authority — JudgePanel, TranslationAuction,
// BuilderFeeRouter, or operator EOA may all slash per README §5.6
// "the contract is the slashing authority")
// ---------------------------------------------------------------
/// @notice Hard-subtract reputation. Floors at zero. Any address in the
/// `authorized` map (set by the owner) may slash — typically that
/// is JudgePanel (quality verdict), TranslationAuction (malformed
/// submission inside the 72h window), and BuilderFeeRouter (post-
/// fill review). Owner can also slash directly.
/// @param agent Agent whose score is being reduced.
/// @param amount Amount (in 1e18 units) to subtract from the score.
/// @param reason Human-readable reason, emitted in the event for audit.
function slashReputation(address agent, uint256 amount, string calldata reason)
external
onlyAuthorized
{
require(amount > 0, "zero slash");
Reputation storage r = reps[agent];
if (r.lastUpdated == 0) {
r.score = HALF; // W14-C α-fix: initialize unknown agent to 0.5
}
if (amount >= r.score) {
r.score = 0;
} else {
r.score = r.score - amount;
}
r.lastUpdated = block.timestamp;
emit ReputationSlashed(agent, msg.sender, amount, r.score, reason);
}
// ---------------------------------------------------------------
// Views
// ---------------------------------------------------------------
/// @notice Returns the agent's reputation score in 1e18 units.
/// @dev Returns ONE (1e18) for any agent that has never been touched so
/// that the auction's `bid / reputation` divisor is safe.
function getReputation(address agent) external view returns (uint256) {
Reputation storage r = reps[agent];
if (r.lastUpdated == 0) {
return ONE;
}
return r.score;
}
function getStats(address agent)
external
view
returns (
uint256 totalBids,
uint256 totalWins,
uint256 totalQualityPasses,
uint256 cumulativeFeesEarned,
uint256 score
)
{
Reputation storage r = reps[agent];
return (
r.totalBids,
r.totalWins,
r.totalQualityPasses,
r.cumulativeFeesEarned,
r.lastUpdated == 0 ? ONE : r.score
);
}
// ---------------------------------------------------------------
// Internal: scoring formula
// ---------------------------------------------------------------
/// @dev Score recomputation (README §5.6, α = 0.85):
/// new_score = old_score * 0.85 + (win_rate * quality_rate * fill_signal) * 0.15
/// where:
/// win_rate = totalWins / max(totalBids, 1)
/// quality_rate = totalQualityPasses / max(totalWins, 1)
/// fill_signal = clamp(ln(1 + (cumulativeFees * 1e12 / 1e6) / 100), 0.5, 2.0)
/// — i.e. USDC 6-decimal base units rescaled to 1e18
/// fixed-point before the ln() input.
/// All math is fixed-point with 1e18 scale.
function _recompute(Reputation storage r) internal view returns (uint256) {
uint256 winRate = r.totalBids == 0
? ONE
: Math.mulDiv(r.totalWins, ONE, r.totalBids);
uint256 qualityRate = r.totalWins == 0
? ONE
: Math.mulDiv(r.totalQualityPasses, ONE, r.totalWins);
uint256 fillSignal = _fillSignal(r.cumulativeFeesEarned);
// signal = winRate * qualityRate * fillSignal, all 1e18-scaled.
// mulDiv avoids precision loss and overflow on intermediate products.
uint256 wq = Math.mulDiv(winRate, qualityRate, ONE);
uint256 signal = Math.mulDiv(wq, fillSignal, ONE);
// new_score = old * DECAY + signal * SIGNAL (both 1e18-scaled weights).
uint256 decayed = Math.mulDiv(r.score, DECAY_NUMERATOR, ONE);
uint256 weighted = Math.mulDiv(signal, SIGNAL_NUMERATOR, ONE);
return decayed + weighted;
}
/// @dev Integer-only natural-log approximation, clamped to [0.5, 2.0].
/// We use a 4-term Mercator series for ln(1+x) when x is small, and
/// saturate to FILL_SIGNAL_MAX once cumulative fees exceed the band.
/// @dev W14-C β-fix: `cumulativeFees` arrives in **6-decimal USDC base units**
/// (passed verbatim from `BuilderFeeRouter.recordFill`). The original
/// contract divided by `FEE_SCALE=100` and then treated the result as
/// a 1e18 fixed-point number — that left the ln() argument 12 orders
/// of magnitude too small, so `fillSignal` was permanently clamped to
/// `FILL_SIGNAL_MIN` for any realistic fee. We now rescale by 1e12
/// (1e18 / 1e6) **first**, then divide by `FEE_SCALE`, so the input is
/// in the same fixed-point units as the rest of the math.
function _fillSignal(uint256 cumulativeFees) internal pure returns (uint256) {
// x = (cumulativeFees * 1e12) / 100 — fees in 6-decimal USDC → 1e18 fp.
// For Solidity-friendliness we compute a piecewise approximation.
if (cumulativeFees == 0) {
return FILL_SIGNAL_MIN;
}
// x in 1e18 fixed-point: convert 6-decimal USDC to 1e18 then divide by FEE_SCALE.
// (cumulativeFees * 1e12) cannot overflow at any plausible total fee level:
// 2^256 / 1e12 ≈ 1.16e65, well above any realistic cumulative USDC value.
uint256 x = Math.mulDiv(cumulativeFees, USDC_TO_1E18, FEE_SCALE);
// Saturate above e^2 - 1 (~6.389 in 1e18 units => 6.389e18)
if (x >= 6_389_056_098_930_650_407) {
return FILL_SIGNAL_MAX;
}
// ln(1+x) ~ x - x^2/2 + x^3/3 - x^4/4 for small x.
// To keep things bounded for x up to ~1e18 (i.e. 1.0), we cap the series.
if (x > ONE) {
// For x in (1, ~6.4) use ln(1+x) = ln(2) + ln((1+x)/2) iteratively is
// overkill on-chain. Linearly interpolate between ln(2) and ln(e^2)=2.
// ln(2) ~ 0.6931 in 1e18 units.
uint256 LN2 = 693_147_180_559_945_309;
uint256 TOP = 2 * ONE;
// x_norm = (x - 1e18) / (6.389e18 - 1e18) in 1e18 units
uint256 num = x - ONE;
uint256 den = 6_389_056_098_930_650_407 - ONE;
uint256 t = Math.mulDiv(num, ONE, den);
uint256 interp = LN2 + Math.mulDiv(TOP - LN2, t, ONE);
return _clamp(interp, FILL_SIGNAL_MIN, FILL_SIGNAL_MAX);
}
// Mercator series for small x (x <= 1.0). Use mulDiv to preserve
// precision on the chained x^n / ONE divisions.
uint256 x2 = Math.mulDiv(x, x, ONE);
uint256 x3 = Math.mulDiv(x2, x, ONE);
uint256 x4 = Math.mulDiv(x3, x, ONE);
// term magnitudes: x - x^2/2 + x^3/3 - x^4/4
uint256 pos = x + x3 / 3;
uint256 neg = x2 / 2 + x4 / 4;
uint256 ln = pos > neg ? pos - neg : 0;
return _clamp(ln, FILL_SIGNAL_MIN, FILL_SIGNAL_MAX);
}
function _clamp(uint256 v, uint256 lo, uint256 hi) internal pure returns (uint256) {
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
}