From 8b7551812c3dd34ca5f22ce0b835ff47ac01882e Mon Sep 17 00:00:00 2001 From: Similoluwa Abidoye Date: Mon, 13 Jul 2026 03:14:21 +0100 Subject: [PATCH] audit(foundry): elevate H-1/H-2 to Critical, replace gas estimate with real forge measurements Follow-up to PR #22 review feedback: 1. Severity: H-1 (Sybil-cheap gas-DoS) and H-2 (zero-CID sentinel collision) elevated to Critical (now C-1/C-2). Both permanently brick a Global Iteration on non-upgradeable contracts with no recovery path, and both are cheap (C-1) or free (C-2) for a single unprivileged participant to trigger. Remaining High findings renumbered H-1/H-2; no finding content changed, only labels. 2. Gas number: added test_gas_finalizeEvaluation_and_slashAuditors_atScale, measuring real forge gas at two registrant scales (10 and 30 batches at demo params) and deriving the real marginal per-batch and per-(auditor,model)-pair cost from the slope between them. Params.auditorsPerBatch/modelsPerBatch has no setter, so literal spec-scale batch *internals* can't be deployed without modifying the contract (out of scope) -- the report is explicit that the final spec-scale projection layers a structural extrapolation on top of real measured data, not a second measurement, and that the slashAuditors number is a measured floor (its early-break behavior under an all-Sybil, zero-vote attack undermeasures the function's true worst case). Result: 223.5M gas (finalizeEvaluation) / 169.5M gas floor (slashAuditors) projected at full spec scale -- 5.6x-7.5x over Optimism's ~30M block limit, corroborating and quantifying the original estimate rather than changing its conclusion. 10/10 tests passing in SecurityFindingsTest. Reproduced the maintainer's UpgradeValidation.t.sol FFI/npx failure independently in a second environment -- confirmed not related to this report's PoCs. --- .../audits/foundry-src-security-review.md | 53 +++-- foundry/test/SecurityFindings.t.sol | 223 ++++++++++++++++++ 2 files changed, 260 insertions(+), 16 deletions(-) diff --git a/Documentation/technical/audits/foundry-src-security-review.md b/Documentation/technical/audits/foundry-src-security-review.md index 09d9aeb..a62377a 100644 --- a/Documentation/technical/audits/foundry-src-security-review.md +++ b/Documentation/technical/audits/foundry-src-security-review.md @@ -12,7 +12,9 @@ This is a findings-only report. No contract in `foundry/src/` was modified. PoC cd foundry && forge test --match-contract SecurityFindingsTest -vv ``` -Baseline confirmed before review: `forge build` and `forge test` both pass clean at the pinned commit (4/4 existing tests in `UpgradeValidation.t.sol`). +Baseline confirmed before review: `forge build` and `forge test` both pass clean at the pinned commit (4/4 existing tests in `UpgradeValidation.t.sol`). Note for reviewers re-running the baseline: the 4 `UpgradeValidation.t.sol` tests can fail in some environments on an FFI/npx path issue inside the OZ upgrades-core CLI (`readBuildInfo`/`checkOutputSelection`) — reproduced independently in a second environment during the follow-up round below. Not related to any contract or PoC in this report; `SecurityFindingsTest` has no such dependency and is unaffected. + +**Update (follow-up round):** the Sybil-DoS and zero-CID findings (originally H-1/H-2) are elevated to Critical (now C-1/C-2), and C-1's gas estimate is replaced with real measured `forge` numbers plus a clearly-labeled extrapolation — see that section for the reasoning and the new PoC test that produced the numbers (`test_gas_finalizeEvaluation_and_slashAuditors_atScale`, `foundry/test/SecurityFindings.t.sol`). The two remaining High findings are renumbered H-1/H-2 accordingly; no finding content changed as part of the renumbering, only the labels. --- @@ -20,18 +22,20 @@ Baseline confirmed before review: `forge build` and `forge test` both pass clean | Severity | Count | |---|---| -| Critical | 0 | -| High | 4 | +| Critical | 2 | +| High | 2 | | Medium | 4 | | Low / Informational | 8 | The headline risk isn't in the four upgradeable platform contracts — their proxy conversion is careful and the initializer/storage-layout checks in this report all came back clean. It's in the two **non-upgradeable task contracts** (`DINTaskCoordinator`, `DINTaskAuditor`), which run the actual federated-learning round: they have several ways for a single low-stake participant to permanently brick a Global Iteration, and no on-chain recovery once that happens (redeploy is the only path, per the documented "task contracts are disposable" design — but that design assumes *planned* redeployment, not mid-round griefing). +Two of those ways — C-1 and C-2 below — are rated **Critical**, not High: both produce a permanent, unrecoverable brick of an entire Global Iteration on contracts that cannot be upgraded, and both are cheap or free for a single unprivileged participant to trigger, deliberately or (for C-2) even by accident. "Permanent brick + trivial-to-cheap trigger + zero recovery path" is the Critical bar here, not just "High-impact DoS." + --- -## High Severity +## Critical Severity -### H-1. Sybil-cheap gas-DoS: unbounded auditor/aggregator registration feeds four O(n) or worse loops with no pagination +### C-1. Sybil-cheap gas-DoS: unbounded auditor/aggregator registration feeds four O(n) or worse loops with no pagination **Contracts / functions:** - `DINTaskAuditor.sol`: `_activeAuditorPool()` (L275-297), `slashAuditors()` (L620-659), `finalizeEvaluation()` (L552-612) @@ -55,9 +59,24 @@ for (uint b = 0; b < batchCount; b++) { // O(batches) batches scales linearly with registered auditors (`batches ≈ registeredAuditors / auditorsPerBatch`), so total loop body executions are `O(registeredAuditors × modelsPerBatch)`, and every "missed vote" auditor costs one more external `slash()` call (~50k+ gas each with the SSTORE + event in `DinValidatorStake`). -**Concrete cost math, using the contract's own documented parameters** (`Params` struct comments in `DINTaskAuditor.sol` L36-43 literally say "demo: 3… spec: 10" / "demo: 3… spec: 100"): +**Cost math — real `forge` gas measurements, not just a closed-form estimate** (`Params` struct comments in `DINTaskAuditor.sol` L36-43 literally say "demo: 3… spec: 10" / "demo: 3… spec: 100"): + +`Params.auditorsPerBatch`/`modelsPerBatch` is hardcoded to the demo defaults (3, 3) in the `DINTaskAuditor` constructor with no setter — there is no way to deploy or configure a batch with literal spec-scale internal dimensions (`auditorsPerBatch=10`, `modelsPerBatch=100`) without modifying the contract, which is out of scope for this findings-only review. What *is* measurable without touching the contract: real gas cost as **batch count** scales at demo internal size, via `test_gas_finalizeEvaluation_and_slashAuditors_atScale` in `foundry/test/SecurityFindings.t.sol`: + +| | @ 10 batches (30 registered auditors) | @ 30 batches (90 registered auditors) | measured marginal gas/batch | +|---|---|---|---| +| `finalizeEvaluation()` | 447,262 gas | 1,252,062 gas | 40,240 | +| `slashAuditors()` | 290,310 gas | 900,500 gas | 30,509 | + +Batch-count scaling alone (inner size held constant at demo's 3×3) comes out close to linear — 3× the batches costs ~2.8×–3.1× the gas — which is expected for this loop shape and not on its own alarming. The real danger is the *second* dimension: at spec params each batch is also 111× bigger internally, which the batch-count slope above doesn't capture at all. That's the extrapolation below, done as an explicit, separate step rather than folded silently into the batch-count number. + +To project to literal spec scale requires one more step **on top of** that real slope, not instead of it: each batch at spec params is also internally 111× bigger (`10 × 100 = 1,000` `(auditor, model)` pair-checks per batch, vs. the demo `3 × 3 = 9` actually measured above). Dividing the measured marginal cost by 9 gives a real, measured **per-pair** cost (**4,471 gas/pair** for `finalizeEvaluation`), which can then be scaled by the spec-scale pair count and batch count — this second step is a structural (Big-O) extrapolation from the loop shape, applied on top of real data, clearly distinct from the first step which is a direct measurement: + +- **`finalizeEvaluation()` at full spec scale (50 batches, 500 registered auditors): ≈ 223,550,000 gas** — 4,471 gas/pair × 1,000 pairs/batch × 50 batches. +- **`slashAuditors()` at full spec scale: ≈ 169,450,000 gas, and that is a *floor*, not a worst case.** `slashAuditors`'s innermost loop `break`s on the first missed vote it finds; the measurement above used the cheapest attack (Sybils that never vote at all, so the break fires immediately), which *undermeasures* the function's true worst case. An attacker who instead votes on every model but the last in their batch forces the full `modelsPerBatch` traversal before the break fires, pushing real worst-case `slashAuditors` gas toward `finalizeEvaluation`'s per-pair cost instead — i.e., toward the ~223M figure, not the ~169M one. + +Either number is **5.6×–7.5× over Optimism's ~30M block gas limit** as of this review. `slashAuditors`/`slashAggregators`/`finalizeT1Aggregation`/`finalizeT2Aggregation`/`finalizeEvaluation` all process the *entire* GI in one call with **no smaller-batch entry point to work around it** — the transaction reverts with out-of-gas on every retry, permanently, once registrant counts reach this range. -- At spec scale (`auditorsPerBatch=10`, `modelsPerBatch=100`): **one single batch** already requires 1,000 `hasAuditedLM` reads before any slashing even starts. 50 batches (500 registered auditors — a modest number for a "decentralized" network) is 50,000 loop iterations plus up to 500 external `slash()` calls. That is well past any realistic L2 block gas limit (Optimism's is ~30M as of this review); the transaction reverts with out-of-gas every time it's retried, and there is **no smaller-batch entry point to work around it** — `slashAuditors`/`slashAggregators`/`finalizeT1Aggregation`/`finalizeT2Aggregation`/`finalizeEvaluation` all process the *entire* GI in one call. - **And it's cheap to trigger deliberately.** `MIN_STAKE` is 10 DIN, bought at the default rate of 1,000,000 DIN/ETH (`DinCoordinator.initialize`, L58) — **0.00001 ETH per Sybil identity**. Registering 1,000 Sybil auditor addresses costs ~0.01 ETH plus L2 gas, and since staked DIN isn't consumed by registering (only by being slashed), the attacker can unstake and reuse it after the 7-day unbonding window. This is not a scaling accident that only bites at organic success — it's a cheap, repeatable attack a single actor can execute against any model's GI today. **Failure scenario:** An attacker registers a few hundred throwaway addresses as auditors for a target model's GI (trivial cost, no collusion needed with anyone else). Once the model owner calls `slashAuditors()` (or `finalizeEvaluation()`, or the coordinator's `slashAggregators()`/`finalizeT1Aggregation()`/`finalizeT2Aggregation()` with a matching flood of aggregator Sybils), the call runs out of gas every time. The GI is now stuck at that phase permanently — `DINTaskCoordinator`/`DINTaskAuditor` are not upgradeable, so there is no way to patch around it; the model owner's only recourse is to abandon the GI and redeploy new task contracts (losing all state and honest participants' in-flight work for that round). @@ -66,7 +85,7 @@ batches scales linearly with registered auditors (`batches ≈ registeredAuditor --- -### H-2. Zero-CID sentinel collision permanently bricks `finalizeT1Aggregation` / `finalizeT2Aggregation` for the whole GI +### C-2. Zero-CID sentinel collision permanently bricks `finalizeT1Aggregation` / `finalizeT2Aggregation` for the whole GI **Contract / functions:** `DINTaskCoordinator.sol`, `finalizeT1Aggregation()` (L549-582), `finalizeT2Aggregation()` (L627-660), and the corresponding `submitT1Aggregation()` / `submitT2Aggregation()` which accept an unvalidated `_aggregationCID`. @@ -94,7 +113,9 @@ Because `finalizeT1Aggregation`/`finalizeT2Aggregation` loop over **every batch --- -### H-3. No quorum enforced before Tier-1/Tier-2 aggregation is accepted as "final" — a single aggregator's output can become the consensus result +## High Severity + +### H-1. No quorum enforced before Tier-1/Tier-2 aggregation is accepted as "final" — a single aggregator's output can become the consensus result **Contract / functions:** `DINTaskCoordinator.sol`, `finalizeT1Aggregation()` (L549-582), `finalizeT2Aggregation()` (L627-660). @@ -106,7 +127,7 @@ Compare this to `DINTaskAuditor`, which explicitly enforces `minEligibilityQuoru --- -### H-4. Predictable, grindable pseudo-randomness in auditor/aggregator batch shuffling enables collusion +### H-2. Predictable, grindable pseudo-randomness in auditor/aggregator batch shuffling enables collusion **Contracts / functions:** - `DINTaskAuditor.sol`: `_shuffleAddressArray()` (L263-273), `_shuffleUintArray()` (L299-308), both used by `createAuditorsBatches()` @@ -116,7 +137,7 @@ Compare this to `DINTaskAuditor`, which explicitly enforces `minEligibilityQuoru Both contracts derive their Fisher-Yates shuffle entropy from `blockhash(block.number - 1)` (address shuffle) and `block.timestamp` (model-index shuffle, further combined with `msg.sender`, which is always the same fixed coordinator address per model — contributing no real entropy). Both inputs are public and known *before* the batch-creation transaction is even submitted, since the previous block is already final by the time anyone calls `createAuditorsBatches`/`autoCreateTier1AndTier2`. -The caller of these functions (the model owner, since both are gated `onlyOwner`/routed through the owner-only coordinator call) can therefore **compute the resulting batch assignment offline before submitting**, and can choose *when* to submit (i.e., wait for a block whose hash produces a favorable shuffle) since retrying costs only gas on an L2. Combined with H-1's cheap Sybil registration: +The caller of these functions (the model owner, since both are gated `onlyOwner`/routed through the owner-only coordinator call) can therefore **compute the resulting batch assignment offline before submitting**, and can choose *when* to submit (i.e., wait for a block whose hash produces a favorable shuffle) since retrying costs only gas on an L2. Combined with C-1's cheap Sybil registration: **Failure scenario:** A model owner registers a handful of Sybil-controlled auditor addresses alongside honest ones. Before calling `createAuditorsBatches`, they simulate the shuffle for the current `blockhash(block.number - 1)` locally; if the resulting batch doesn't cluster ≥2 of their Sybils into the same batch as their target model (default `auditorsPerBatch=3`, `minEligibilityQuorum=2` — only 2-of-3 needed to control a batch's eligibility outcome), they simply wait for the next block and recompute. Once a favorable block arrives, they submit. Their colluding auditors then rubber-stamp their own (possibly low-quality or malicious) submitted model as eligible, defeating the purpose of independent auditor review. The same grinding applies to `autoCreateTier1AndTier2`'s aggregator/model shuffle. @@ -227,14 +248,14 @@ All four constructors contain exactly one line, `_disableInitializers()`, with ` ## Seeded Leads — Explicitly Confirmed or Refuted -1. **`DINTaskAuditor.slashAuditors()` nested iteration (batches × auditors × models) may not fit in a block at production scale — is this a real DoS?** **Confirmed real.** See H-1. Not hypothetical: cheap to trigger deliberately via Sybil registration (≈0.00001 ETH per identity at the default exchange rate), no collusion needed. -2. **`DINTaskAuditor._activeAuditorPool()` unbounded iteration over all registered auditors.** **Confirmed real**, same root cause as #1 (no registration cap) — folded into H-1 along with its `DINTaskCoordinator._activeAggregatorPool()` counterpart, which has the identical shape and wasn't in the seed list but is exposed to the same attack. -3. **blockhash/timestamp shuffling in auditor selection — predictable by block producers; does slashing/selection fairness depend on unpredictability?** **Confirmed real.** See H-4. It matters more than block-producer-level MEV — the *caller* (model owner, who is not disinterested) can grind the timing of their own transaction against public, pre-known entropy, which is a lower bar than needing block-producer collusion. +1. **`DINTaskAuditor.slashAuditors()` nested iteration (batches × auditors × models) may not fit in a block at production scale — is this a real DoS?** **Confirmed real, Critical.** See C-1. Not hypothetical: cheap to trigger deliberately via Sybil registration (≈0.00001 ETH per identity at the default exchange rate), no collusion needed, and real `forge` gas measurements (see C-1) put spec-scale cost at 5.6×–7.5× the L2 block gas limit. +2. **`DINTaskAuditor._activeAuditorPool()` unbounded iteration over all registered auditors.** **Confirmed real**, same root cause as #1 (no registration cap) — folded into C-1 along with its `DINTaskCoordinator._activeAggregatorPool()` counterpart, which has the identical shape and wasn't in the seed list but is exposed to the same attack. +3. **blockhash/timestamp shuffling in auditor selection — predictable by block producers; does slashing/selection fairness depend on unpredictability?** **Confirmed real.** See H-2. It matters more than block-producer-level MEV — the *caller* (model owner, who is not disinterested) can grind the timing of their own transaction against public, pre-known entropy, which is a lower bar than needing block-producer collusion. --- ## What I'd do differently with more time -- Extend the PoC suite with a real (not just closed-form) gas measurement at spec-scale parameters (`auditorsPerBatch=10`, `modelsPerBatch=100`, ~500 registered auditors) to get an exact gas number instead of the order-of-magnitude estimate in H-1 — the flow to reach `slashAuditors`/`slashAggregators` requires driving a GI through 8+ phase transitions per participant, which is straightforward but time-consuming to script at that scale. -- M-1 (copy-the-leader free-riding) and H-3 (missing quorum) interact: a full fix probably wants to land together (commit-reveal naturally gives you a place to also enforce "N-of-M revealed before finalize is callable"). +- ~~Extend the PoC suite with a real gas measurement at spec-scale parameters~~ — done in the follow-up round (`test_gas_finalizeEvaluation_and_slashAuditors_atScale`, see C-1). What's still not directly measurable: `Params.auditorsPerBatch`/`modelsPerBatch` has no setter, so the literal spec-scale *internal batch size* (as opposed to batch *count*, which is measured) can only be reached via a structural extrapolation on top of the real numbers, not a direct measurement — would need a contract change (out of scope) to close that last gap. +- M-1 (copy-the-leader free-riding) and H-1 (missing quorum) interact: a full fix probably wants to land together (commit-reveal naturally gives you a place to also enforce "N-of-M revealed before finalize is callable"). - Didn't attempt fuzzing `DinCoordinator`'s exchange-rate math or `DinValidatorStake`'s stake accounting (suggested in the task) — manual review didn't turn up an obvious overflow/precision target beyond L-2, and Solidity 0.8's built-in overflow checks close off the classic wraparound class. Would still run `forge fuzz` against `slash()`'s active/pending-withdrawal split accounting given more time, since that's the one place doing subtraction across two balances in the same function. diff --git a/foundry/test/SecurityFindings.t.sol b/foundry/test/SecurityFindings.t.sol index d5f1e55..21e5bbe 100644 --- a/foundry/test/SecurityFindings.t.sol +++ b/foundry/test/SecurityFindings.t.sol @@ -304,4 +304,227 @@ contract SecurityFindingsTest is Test { // to finalizeT1Aggregation reverts the same way, forever. assertEq(uint256(tc.GIstate()), uint256(GIstates.T1AggregationStarted)); } + + // ───────────────────────────────────────────────────────────────────── + // H-1 gas measurement. + // + // DINTaskAuditor.Params (auditorsPerBatch, modelsPerBatch) is hardcoded + // to demo defaults (3, 3) in the constructor with no setter -- literal + // "spec scale" (auditorsPerBatch=10, modelsPerBatch=100) cannot be + // deployed without modifying the contract, which is out of scope for + // this findings-only review (see report §"What I'd do differently"). + // + // Instead: measure REAL forge gas at two registrant scales (demo batch + // size = 3, so batch count = N/3), derive the real marginal per-batch + // cost from the slope between them, and use that measured slope -- + // not an assumed one -- to extrapolate to spec-scale batch counts. + // ───────────────────────────────────────────────────────────────────── + + function _registerNAuditors(uint n) internal returns (address[] memory auditors) { + auditors = new address[](n); + for (uint i = 0; i < n; i++) { + address a = makeAddr(string.concat("gasAuditor", vm.toString(i))); + auditors[i] = a; + _fundAndStake(a); + vm.prank(a); + ta.registerDINAuditor(1); + } + } + + function _submitNModels(uint n) internal { + for (uint i = 0; i < n; i++) { + address c = makeAddr(string.concat("gasClient", vm.toString(i))); + vm.prank(c); + ta.submitLocalModel(bytes32(uint256(9000 + i)), 1); + } + } + + /// @dev Drives the GI up to LMSevaluationStarted with N registered + /// auditors / N submitted models (batches = N/3 at demo params). + /// Only the first batch's 3 auditors actually vote (all models + /// eligible, score 100) -- enough for finalizeEvaluation() to + /// return true so the phase transition doesn't itself revert. + /// Every other registered auditor casts no vote at all: the + /// worst-case, and also the realistic Sybil-registers-and-never- + /// bothers-voting attack from the H-1 failure scenario. + function _setupForGasMeasurement(uint n) internal returns (address[] memory auditors) { + _deployPlatform(); + _deployTaskPair(); + + _fundAndStake(agg1); + _fundAndStake(agg2); + _fundAndStake(agg3); + + vm.startPrank(modelOwner); + tc.startDINaggregatorsRegistration(1); + vm.stopPrank(); + vm.prank(agg1); + tc.registerDINaggregator(1); + vm.prank(agg2); + tc.registerDINaggregator(1); + vm.prank(agg3); + tc.registerDINaggregator(1); + + vm.startPrank(modelOwner); + tc.closeDINaggregatorsRegistration(1); + tc.startDINauditorsRegistration(1); + vm.stopPrank(); + + auditors = _registerNAuditors(n); + + vm.startPrank(modelOwner); + tc.closeDINauditorsRegistration(1); + tc.startLMsubmissions(1); + vm.stopPrank(); + + _submitNModels(n); + + vm.startPrank(modelOwner); + tc.closeLMsubmissions(1); + tc.createAuditorsBatches(1); + tc.setTestDataAssignedFlag(1, true); + tc.startLMsubmissionsEvaluation(1); + vm.stopPrank(); + + // Only batch 0 votes -- guarantees finalizeEvaluation() finalizes + // at least one model (returns true) without every registrant + // needing to participate. + (, address[] memory batch0Auditors, uint[] memory batch0Models,) = ta.getAuditorsBatch(1, 0); + for (uint i = 0; i < batch0Auditors.length; i++) { + for (uint m = 0; m < batch0Models.length; m++) { + vm.prank(batch0Auditors[i]); + ta.setAuditScorenEligibility(1, 0, batch0Models[m], 100, true); + } + } + } + + /// @dev Completes evaluation close (measuring finalizeEvaluation's real + /// gas), then drives T1/T2 aggregation to completion using only + /// the 3 registered aggregators (T1_AGGREGATORS_PER_BATCH is a + /// fixed constant = 3, unrelated to the auditor-side N being + /// measured here) so slashAuditors() becomes callable. + function _finishEvaluationAndAggregation() internal returns (uint256 finalizeEvaluationGas) { + uint256 gasBefore = gasleft(); + vm.prank(modelOwner); + tc.closeLMsubmissionsEvaluation(1); + finalizeEvaluationGas = gasBefore - gasleft(); + + vm.startPrank(modelOwner); + tc.autoCreateTier1AndTier2(1); + tc.startT1Aggregation(1); + vm.stopPrank(); + + (, address[] memory t1aggs,,,) = tc.getTier1Batch(1, 0); + bytes32 realCID = bytes32(uint256(0xC1D)); + for (uint i = 0; i < t1aggs.length; i++) { + vm.prank(t1aggs[i]); + tc.submitT1Aggregation(1, 0, realCID); + } + + vm.startPrank(modelOwner); + tc.finalizeT1Aggregation(1); + tc.startT2Aggregation(1); + // Only 3 aggregators total registered -> 0 left over for a Tier-2 + // batch (needs T1_AGGREGATORS_PER_BATCH=3 remaining after T1 + // consumes all 3), so tier2Batches[1] is empty and this finalizes + // trivially. Not the subject of this measurement (H-1's aggregator- + // side loops scale with *aggregator* count, held fixed here at the + // minimum needed to reach slashAuditors()). + tc.finalizeT2Aggregation(1); + vm.stopPrank(); + } + + function test_gas_finalizeEvaluation_and_slashAuditors_atScale() public { + // ── Small scale: 30 registered auditors -> 10 batches ── + address[] memory auditorsSmall = _setupForGasMeasurement(30); + uint256 gasSmall = _finishEvaluationAndAggregation(); + uint256 gasBeforeSlashSmall = gasleft(); + vm.prank(modelOwner); + tc.slashAuditors(1); + uint256 gasSlashSmall = gasBeforeSlashSmall - gasleft(); + + // Sanity: everyone outside batch 0 (auditors[3:]) missed their + // vote and should have been slashed down from MIN_STAKE. + assertLt(stake.getStake(auditorsSmall[29]), 10 ether, "sanity: non-voting auditor should have been slashed"); + + // ── Larger scale: 90 registered auditors -> 30 batches ── + address[] memory auditorsLarge = _setupForGasMeasurement(90); + uint256 gasLarge = _finishEvaluationAndAggregation(); + uint256 gasBeforeSlashLarge = gasleft(); + vm.prank(modelOwner); + tc.slashAuditors(1); + uint256 gasSlashLarge = gasBeforeSlashLarge - gasleft(); + + assertLt(stake.getStake(auditorsLarge[89]), 10 ether, "sanity: non-voting auditor should have been slashed"); + + // ── Real measured marginal cost per batch (slope between the two + // real data points -- not an assumption) ── + uint256 batchesSmall = 30 / 3; // 10 + uint256 batchesLarge = 90 / 3; // 30 + uint256 marginalGasPerBatch_finalizeEval = (gasLarge - gasSmall) / (batchesLarge - batchesSmall); + uint256 marginalGasPerBatch_slash = (gasSlashLarge - gasSlashSmall) / (batchesLarge - batchesSmall); + + emit log_named_uint("finalizeEvaluation gas @10 batches (30 auditors)", gasSmall); + emit log_named_uint("finalizeEvaluation gas @30 batches (90 auditors)", gasLarge); + emit log_named_uint("slashAuditors gas @10 batches (30 auditors)", gasSlashSmall); + emit log_named_uint("slashAuditors gas @30 batches (90 auditors)", gasSlashLarge); + emit log_named_uint("measured marginal gas/batch, finalizeEvaluation", marginalGasPerBatch_finalizeEval); + emit log_named_uint("measured marginal gas/batch, slashAuditors", marginalGasPerBatch_slash); + + // Spec-scale extrapolation -- TWO factors, not one, or this + // understates the real number by ~111x: + // + // 1. More batches: 500 registrants / auditorsPerBatch=10 = 50 + // batches (vs. 10/30 measured here). Captured by the real + // measured per-batch slope above. + // 2. Each batch is ALSO internally bigger: spec params are + // auditorsPerBatch=10 x modelsPerBatch=100 = 1,000 inner + // (auditor,model) pair-checks per batch, vs. demo's + // 3 x 3 = 9 per batch actually measured here. This dimension + // cannot be empirically measured -- Params.auditorsPerBatch/ + // modelsPerBatch is hardcoded in the DINTaskAuditor + // constructor with no setter, so a batch with spec-scale + // internal dimensions cannot be deployed without modifying + // the contract, which is out of scope for this findings-only + // review. This factor is therefore a structural (Big-O) + // extrapolation from the loop shape, applied on top of the + // real measured per-batch cost -- not itself measured. + uint256 demoInnerPairsPerBatch = 3 * 3; // auditorsPerBatch x modelsPerBatch, demo + uint256 specInnerPairsPerBatch = 10 * 100; // auditorsPerBatch x modelsPerBatch, spec + uint256 specScaleBatches = 50; // ~500 registrants / auditorsPerBatch=10 + + uint256 gasPerPair_finalizeEval = marginalGasPerBatch_finalizeEval / demoInnerPairsPerBatch; + uint256 gasPerPair_slash = marginalGasPerBatch_slash / demoInnerPairsPerBatch; + + uint256 projectedGasPerBatch_finalizeEval = gasPerPair_finalizeEval * specInnerPairsPerBatch; + uint256 projectedGasPerBatch_slash = gasPerPair_slash * specInnerPairsPerBatch; + + uint256 projectedGas_finalizeEval = projectedGasPerBatch_finalizeEval * specScaleBatches; + uint256 projectedGas_slash = projectedGasPerBatch_slash * specScaleBatches; + + emit log_named_uint("measured gas per (auditor,model) pair, finalizeEvaluation", gasPerPair_finalizeEval); + emit log_named_uint("PROJECTED finalizeEvaluation gas, full spec scale (50 batches x 1000 pairs/batch)", projectedGas_finalizeEval); + emit log_named_uint("PROJECTED slashAuditors gas, full spec scale (LOWER BOUND -- see note below)", projectedGas_slash); + + // Caveat on the slashAuditors projection: slashAuditors' innermost + // loop `break`s on the FIRST missed vote it finds. In this test no + // registrant outside batch 0 votes at all, so the break fires + // immediately (m=0) for every one of them -- meaning the measured + // slashAuditors numbers above are a FLOOR, not a worst case. An + // attacker who instead votes on every model in their batch except + // the last would force the full modelsPerBatch traversal before + // triggering the break, making real worst-case slashAuditors gas + // approach finalizeEvaluation's (no-early-break) per-pair cost + // instead of this measurement's. Both the floor above and that + // worst-case (finalizeEvaluation-shaped) figure are well past any + // realistic L2 block gas limit at spec scale either way. + + // Confirms worse-than-linear-in-registrants-alone growth is at + // minimum present (3x batches should cost meaningfully more than + // 3x gas given the O(batches x auditorsPerBatch x modelsPerBatch) + // shape layered on top of per-batch fixed costs); real numbers are + // the point of this test, this assertion just guards against the + // measurement accidentally becoming a no-op. + assertGt(gasLarge, gasSmall * 2, "finalizeEvaluation should scale well above linearly with batch count"); + } }