Repository navigation
Description
Summary
Five safe, behavior-preserving gas optimizations found during a fresh pass over foundry/src/ (checked against develop @ 6e361cb), all in the repo's highest-call-frequency paths (every registration, every GI finalize/slash, every stake/unstake/slash). None change the public ABI, storage layout, or any revert/event behavior — each just caches a value that's already being read/computed and reuses it instead of re-reading. Batched into one issue since they're the same category of fix (cache-before-reuse) repeated across a few files, rather than filing five near-identical issues.
This followed a scan explicitly looking for a different angle than the security/correctness review already done on this codebase (C-1's gas-DoS, the EIP-170 size-reduction passes) — this is runtime gas-per-call, not bytecode size or unbounded-loop risk. No new unbounded-loop gas-DoS was found beyond the already-tracked C-1/BL-10 class.
Findings
1. Duplicate getStake(msg.sender) call in every aggregator/auditor registration
foundry/src/DINTaskCoordinator.sol:414 and :422 (registerDINaggregator), foundry/src/DINTaskAuditor.sol (registerDINAuditor, identical shape) — dinvalidatorStakeContract.getStake(msg.sender) is called once for the per-model stake-floor check and again for the concurrent-registration-cap check:
uint256 floorMin = dinvalidatorStakeContract.getModelStakeMin(modelId);
if (floorMin > 0 && dinvalidatorStakeContract.getStake(msg.sender) < floorMin) // call #1
revert TC_StakeBelowModelFloor();
uint256 capPerUnit = dinvalidatorStakeContract.maxConcurrentRegistrationsPerStakeUnit();
if (capPerUnit > 0) {
uint256 maxAllowed = (dinvalidatorStakeContract.getStake(msg.sender) / // call #2, same value
dinvalidatorStakeContract.minStake()) * capPerUnit;
...Fix: read uint256 stake = dinvalidatorStakeContract.getStake(msg.sender); once, reuse in both branches. Saves a full external STATICCALL (~150-700 gas) on every registration once both DAO knobs are non-zero (both default to 0 today, so the saving is currently latent, but the fix is free and unconditionally safe to apply now).
2. _activeAggregatorPool/_activeAuditorPool call isValidatorActive twice per registrant
foundry/src/DINTaskCoordinator.sol:692-714, foundry/src/DINTaskAuditor.sol:793-815 — the classic "count then fill" two-pass pattern for sizing a memory array re-queries isValidatorActive(registeredPool[i]) for the same address in both passes:
for (uint i = 0; i < registeredPool.length; i++) {
if (dinvalidatorStakeContract.isValidatorActive(registeredPool[i])) { activeCount++; }
}
activePool = new address[](activeCount);
uint ptr;
for (uint i = 0; i < registeredPool.length; i++) {
address validator = registeredPool[i];
if (dinvalidatorStakeContract.isValidatorActive(validator)) { activePool[ptr++] = validator; } // re-queries
}Fix: store each result in a bool[] memory active during the first pass, read from that in the second instead of re-calling. Saves one external CALL per active registrant — called once per GI by autoCreateTier1AndTier2/createAuditorsBatches, over up to 300 registrants (MAX_REGISTERED_AGGREGATORS/MAX_REGISTERED_AUDITORS), so potentially tens of thousands of gas per GI at scale.
3. array.length/struct-field re-read every loop iteration in the finalize/slash functions
Several spots re-read a storage array's .length or a struct field every iteration of a loop where neither changes during the loop:
DINTaskCoordinator.sol:finalizeT1Aggregation(batches.lengthat the outer loop,b.aggregators.length+b.batchIdre-read in the inner loop),finalizeT2Aggregation(same shape),slashAggregators(t1batches.length/t2batches.length)DINTaskAuditor.sol:finalizeEvaluation(batch.modelIndexes.length/batch.auditors.lengthre-read per inner iteration, plus twice more for twonewarray allocations sized from the same field),slashAuditors(batch.auditors.length/batch.modelIndexes.lengthre-read per inner iteration)
Example (finalizeT1Aggregation):
for (uint i = 0; i < batches.length; i++) {
Tier1Batch storage b = batches[i];
for (uint j = 0; j < b.aggregators.length; j++) { // storage SLOAD every iteration
address aggregator = b.aggregators[j];
if (t1Submitted[_GI][b.batchId][aggregator]) { // b.batchId re-read multiple times tooFix: cache uint256 aggLen = b.aggregators.length; and uint256 batchId = b.batchId; once per outer iteration, and the outer .length once before the loop. Each avoided re-read is a warm SLOAD (~100 gas); for a 3-aggregator batch, b.batchId alone is read ~9x instead of once. These are exactly the functions C-1 already flagged for O(n) asymptotic cost (pagination is still the real fix for that) — this doesn't change the shape, just shaves a constant factor off every call today, called every single GI.
4. _syncValidatorStatus reads status and activeStake twice each
foundry/src/DinValidatorStake.sol:716-737 — validator.status is checked twice (both reads of the same packed slot, no intervening write) and validator.activeStake is read twice in a mutually-exclusive else if chain:
function _syncValidatorStatus(ValidatorInfo storage validator) internal {
if (validator.status == ValidatorStatus.Blacklisted) { return; } // read #1
if (validator.status == ValidatorStatus.Jailed && validator.jailedUntil > block.timestamp) { return; } // read #2
if (validator.pendingWithdrawals > 0) { validator.status = ValidatorStatus.Exiting; }
else if (validator.activeStake >= MIN_STAKE) { validator.status = ValidatorStatus.Active; } // read #1
else if (validator.activeStake > 0) { validator.status = ValidatorStatus.Exiting; } // read #2
else { validator.status = ValidatorStatus.None; }
}Fix: cache both into locals at the top, reuse. This is the most-called internal helper in the contract — invoked from stake, unstake, claimUnstaked, reactivate, unblacklistValidator, and (via _applySlash) every slash/slashPartial/recordNoParticipation call. ~100 gas (one warm SLOAD) per avoided extra read, on every stake and every slash.
5. One stray require(..., "string") instead of a custom error
foundry/src/DINTaskCoordinator.sol:1290-1291 (releaseGIRegistrationSlots) — every other revert path in both task contracts uses a custom error; this function is the one holdout:
require(giEnded, "GI has not ended");
require(!registrationSlotsReleased[_GI], "slots already released");Fix: add TC_GINotEnded()/TC_SlotsAlreadyReleased() custom errors and swap. Small (string-revert overhead only hits the revert path, not the happy path), but free and consistent with the rest of the file.
Considered, not recommending without a decision
Three struct-packing opportunities (DINTaskAuditor.LMSubmission, DinEmission.EmissionState, DINModelRegistry.ModelRequest) could save a storage slot each by reordering/shrinking fields, but all three structs sit behind a public array/mapping whose auto-generated getter encodes fields in declared order (or type, for EmissionState) — so the fix would change the public ABI's output shape, not just internal layout. LMSubmission is the highest-value one (called every submitLocalModel, every client, every GI — a full SSTORE's worth, ~20k+ gas, if it drops from 4 slots to 3), but making lmSubmissions internal and hand-writing a reassembling getter is real added complexity. Not filing as actionable work; noting here in case a maintainer wants to make that call deliberately.
Recommendation
All five numbered findings are mechanical, zero-behavior-change read-caching — safe to implement directly. No storage layout, ABI, event, or revert-selector changes beyond #5 (which only changes the revert error type, already the norm everywhere else in these files).
Activity
umeradl commentedon Oct 15, 2026
Approval review: Approved with amendments
Verified against
origin/develop@6e361cb.Claims
getStake(msg.sender)called twice inregisterDINaggregator/registerDINAuditorDINTaskCoordinator.sol:415and:422(:414is thegetModelStakeMinread);DINTaskAuditor.sol:692,:698_activeAggregatorPool/_activeAuditorPoolre-queryisValidatorActivein the fill passDINTaskCoordinator.sol:692-714,DINTaskAuditor.sol:793; sole callersTC:591,TA:880.length/b.batchIdre-read per iteration in the finalize/slash loopsfinalizeT1Aggregationinner loop readsb.batchIdint1Submitted/t1SubmissionCID/t1Votes;slashAggregatorsTC:1167,:1172;finalizeEvaluationTA:1232-1257;slashAuditorsTA:1358,:1363_syncValidatorStatusreadsstatusandactiveStaketwiceDinValidatorStake.sol:716-737; called from 6 sites (:225,:376,:401,:432,:536,:651)releaseGIRegistrationSlotsis the one string-requireholdoutDINTaskCoordinator.sol:1290-1291; norequire(anywhere inDINTaskAuditor.solMerit
Worth doing. Each change is mechanical caching, and four of the five sit on paths that run every GI or every stake/slash. Tracing it further turned up three things the issue doesn't cover. First,
DINTaskCoordinatoris already over EIP-170 ondevelop(No. 201 Part A blocks any deploy). Gas caching can move bytecode size either way: thebool[] memorysecond pass in finding 2 adds code, while finding 5 removes two revert strings. Second, finding 5 breaks an existing test that matches the string. Third, open PR No. 253 removes both callers of the finding-2 helpers.Amendments
forge build --sizes(via_ir profile) forDINTaskCoordinatorandDINTaskAuditorbefore/after in the PR (--sizesomitsDINTaskAuditor; read its size fromout/deployedBytecode).DINTaskCoordinatormust not grow. If a finding increases its size, drop that finding for the coordinator._activeAggregatorPool(_GI)and_activeAuditorPool(_GI)calls. If No. 253 merges first, skip finding 2. If any helper is left with no callers, delete it instead of optimising it.TC_GINotEnded/TC_SlotsAlreadyReleasedinfoundry/src/DINShared.solwith the otherTC_*errors. Updatefoundry/test/StakingEnforcement.t.sol:485(vm.expectRevert(bytes("slots already released"))) to the selector, and add a test for the not-ended path if none exists. Regeneratedincli/abis/DINTaskCoordinator.json.forge snapshot --diff(or--gas-reportbefore/after) covering registration,finalizeT1Aggregation,finalizeEvaluation,slashAuditors, andstake/slash, so the savings are measured rather than estimated.Documentation/technical/storage_layout.md).Conflicts
DINTaskAuditor.sol,DINTaskCoordinator.sol(same batch-creation functions; see amendment 2)DinValidatorStake.solDINTaskAuditor.sol,DinValidatorStake.sol,StakingEnforcement.t.solgas(DINTaskCoordinator): cache batchId/aggregators.length in finalize…
umeradl commentedon Nov 6, 2026
Done by PR No. 259, merged into
developas1d3b484. Finding 5 landed earlier, through PR No. 287 (e62713f).Closes #249closed this issue automatically at merge, so this is the peer check that MAINTAINING No. 5.2 requires afterwards. CI on thedeveloppush: run 42258275515, Solidityforge test526 passed, 0 failed, size gate green.Checked on
origin/develop@1d3b484:getStake):registerDINaggregatorandregisterDINAuditoreach readuint256 stake = dinvalidatorStakeContract.getStake(msg.sender)once, insideif (floorMin > 0 || capPerUnit > 0), and use it for both the floor check and the cap check.isValidatorActiveonce per registrant):_activeAggregatorPool(DINTaskCoordinator.sol:699) and_activeAuditorPool(DINTaskAuditor.sol:794) store the counting pass's results in abool[] memory activeand fill from it.finalizeT1AggregationandfinalizeT2Aggregationcachebatches.length,b.batchIdandb.aggregators.length.slashAggregatorscachest1batches.lengthandt2batches.length.finalizeEvaluationandslashAuditorscacheauditBatches[_GI].length,batch.modelIndexes.lengthandbatch.auditors.length, and thevotedScores/votedAuditorsallocations use the cached count._syncValidatorStatus):DinValidatorStake.solreadsstatusandactiveStakeinto locals once each.releaseGIRegistrationSlotsreverts withTC_GINotEnded()andTC_SlotsAlreadyReleased()(DINTaskCoordinator.sol:1313-1314). There's norequire(left in either task contract.DINTaskCoordinatordidn't grow. CI's size gate on1d3b484reports 23,393 B (1,183 B margin), down from 23,507 B onae7e8d6, and it's stillwarn, notfail.DINTaskAuditoris 21,319 B (ok).DINTaskCoordinator.sol:598,DINTaskAuditor.sol:884). Whichever of the two lands second deletes the helpers, as already asked on PR No. 253.foundry/src/DINShared.sol:315,317.StakingEnforcement.t.sol:489now expectsTC_SlotsAlreadyReleased.selector, and:499adds the not-ended case withTC_GINotEnded.selector.dincli/abis/DINTaskCoordinator.jsoncontains both errors (:2437,:2582).forge test --gas-reporttable forfinalizeT1Aggregation,finalizeT2Aggregation,slashAggregators,slashAuditors,releaseGIRegistrationSlots, registration andstake/slash. Registration andstake/slashmeasure flat. The existing suites never enable both registration knobs, and via-IR may already merge the duplicate reads.finalizeEvaluationhas no row. I flagged that as non-blocking in the Nov 5 re-review of PR No. 259, and it's accepted as is. The change is plain caching of loop invariants, verified above.structdefinition (git diff 1207bf9 1d3b484 -- foundry/srchas nostructlines).The struct-packing options from "Considered, not recommending" remain unfiled, as both the issue and amendment 5 intended. The PR merged without the peer approval that MAINTAINING No. 4.2 requires. That's recorded on PR No. 259, where @robertocarlous is doing the review after the fact. If that review turns up a problem, I'll reopen this issue.
gas(contracts): cache repeated storage/external reads in hot paths (i…