Spaces:
Running
Running
| // 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; | |
| } | |
| } | |