fix(audit-2): remediate slashing/jobs/rewards/extensions findings (1 high + 4 med + low)#184
Conversation
…high + 4 med + low) Second audit batch. Each finding was independently validated against the cited code before fixing; F3 and F5 verdicts corrected after ground-truthing the exact files the report named. - F1 (High): per-asset commitments were read LIVE at executeSlash, so an operator could self-dispute, leave, and rejoin with a 1-bps commitment to evade ~99.99% of the slash. Now snapshotted onto a dedicated mapping at propose time and consumed at execute (TangleStorage gap shrunk 29->28; SlashProposal/binding ABI untouched). - F2 (Med): default disputeBond was 0, so an operator could self-dispute for free and freeze all delegator withdrawals for the resolution window. Ship a non-zero default (0.02 ether, matching the production deploy config). - F3 (Med): _ensureAggregationBypass failed OPEN — a reverting/gas-griefed requiresAggregation hook let a single operator finalize a quorum job. Now fails closed (requiresAggregation is implemented by the BSM base, so !ok = genuine hook failure). - F4 (Med): TokenizedBlueprintBase shipped stakeLockDuration=0, enabling same-block JIT instant-mode reward capture. Seed a non-zero failsafe default in the constructor (integrators can retune). - F7 (Low): permissionless distributeEpoch() reallocated the unspent staker slice to other categories, letting a front-runner redirect staker inflation. Only reallocate the staker shortfall when staker distribution was attempted (serviceIds provided); otherwise retain it in the pool to roll forward. - F8 (Low): RewardVaults.assetOperators[] was append-only and double-iterated in epoch distribution. Add swap-and-pop _untrackOperator + prune on zero stake (gap shrunk 50->49). - F9 (Low): duplicate-asset commitment dedup only ran when the service had requirements; duplicates double-slashed/double-billed otherwise. Dedup is now unconditional. - F10 (Low): bound incoming slashBps to <= BPS_DENOMINATOR at L2 receipt (defense-in-depth) so a malformed cross-chain value cannot inflate the deferred accumulator. Documented, not code-changed: - F5 (Med): ServiceFeeDistributor bakes lockMultiplierBps into score with no decay, but receives no lock expiry. A correct fix needs an onDelegationChanged interface change + staking-layer expiry threading + reward-accumulator decay; deferred to a focused follow-up rather than risk reward-accounting regressions. Root cause + plan documented in code. - F6 (Med): the epoch-budget clamp is effectively dead (budget <= freeBalance <= poolBalance); the liability-undercount only bites an upgrade from a pre-liability deployment, which does not exist for this codebase. Documented. - F11 (Info): instantSlashEnabled is read/enforced but intentionally unreachable (proposeSlash hardcodes instant=false); documented as a deliberate, not dead, control. Regression tests updated for the intended default changes (F2 bond, F4 lock, F7 no-reallocation). New repros in test/audit/batch2/AuditBatch2.t.sol (F1/F2/F9) and test/audit/medlow/BeaconL2.t.sol (F10). Full slashing/lifecycle/jobs/rewards/ extensions/beacon/integration/upgrade suites green.
tangletools
left a comment
There was a problem hiding this comment.
✅ Auto-approved PR — 1d4ac030
Blanket team auto-approval is enabled for this reviewer service.
The full PR reviewer audit still runs separately and will publish findings if it detects issues.
tangletools · auto-approval · reason: blanket_auto_approve · 2026-06-19T14:47:59Z
tangletools
left a comment
There was a problem hiding this comment.
🟢 Value Audit — sound
| Verdict | sound |
| Concerns | 2 (1 low, 1 weak-concern) |
| Heuristic | 0.0s |
| Duplication | 0.0s |
| Interrogation | 190.6s (2 bridge agents) |
| Total | 190.6s |
💰 Value — sound
Batch audit remediation (1 high + 4 med + low) across slashing, jobs, rewards, extensions, and beacon-L2 — every fix follows existing codebase patterns and closes real attack vectors without reinventing anything.
- What it does: Fixes 9 validated audit findings: F1 snapshots operator commitments at slash-propose time so execution cannot be evaded via leave/rejoin dilution; F2 sets a non-zero disputeBond default (0.02 ETH); F3 makes the aggregation-bypass guard fail-closed on hook failure; F4 seeds a non-zero stakeLockDuration default (1 day) in TokenizedBlueprintBase; F7 stops permissionless distributeEpoch from redirecti
- Goals it achieves: Close a high-severity slash-evasion vector (F1), harden three default configurations to be secure-by-default (F2, F4, F10), add defense-in-depth at job-result-verification and L2-receipt boundaries (F3, F10), prevent inflation-redirection and unbounded-list growth in rewards (F7, F8), and prevent duplicate-commitment weight inflation in billing/slashing paths (F9).
- Assessment: Every fix is minimal, targeted, and built in the codebase's existing grain — snapshotting follows SlashingManager._snapshotOperator (SlashingManager.sol:764) and ServicesLifecycle._snapshotJoinPrice (ServicesLifecycle.sol:789); fail-closed follows _proposerIsBlueprintControlled (Slashing.sol:286-289) and JobsAggregation's implicit fail-closed at line 94; swap-and-pop mirrors _untrackDelegatorOpera
- Better / existing approach: none — this is the right approach
- Model: opencode/deepseek/deepseek-v4-pro
- Bridge attempts: 1
🎯 Usefulness — sound
9 security fixes correctly wired into existing call paths and a documented deferral; no dead surface, no competing patterns, no awkward interfaces.
- Integration: Every changed function has a live, reachable caller. F1 snapshot is populated at propose (Slashing.sol:112-121), consumed at execute (Slashing.sol:513), deleted after (Slashing.sol:315,370) — 5 code references form a closed loop. F2 default feeds all unconfigured deployments via SlashingLib.initializeConfig:143. F3 fail-closed triggers from _processResultSubmission:97, the only plain-submission pa
- Fit with existing patterns: All changes follow the codebase's established patterns: propose-execute state machines with snapshots (F1 mirrors the _slashProposals pattern already in SlashingLib), swap-and-pop removal (F8 is structurally identical to _untrackDelegatorOperator at RewardVaults.sol:866-884), defense-in-depth guards (F6, F10 match the existing 'never trust cross-chain input' pattern), configurable defaults initial
- Real-world viability: Each fix handles failure modes beyond the happy path. F1: operator self-dispute → leave → rejoin with diluted commitment vector tested in AuditBatch2.t.sol:86-118. F3: hook revert/OOG/gas-grief correctly block submission (Base.sol:819-831 returns ok=false on any failure). F4: 1-day lock applies to every subsequent stake; existing stakes unaffected (correct). F7: front-runner with empty serviceIds
- Model: opencode/deepseek/deepseek-v4-pro
- Bridge attempts: 1
🔎 Heuristic Signals
🟡 Cruft: magic number added test/audit/batch2/AuditBatch2.t.sol
uint64 slashId = tangle.proposeSlash(serviceId, operator1, 1000, keccak256("evidence"));
💰 Value Audit
🟡 F9 inline dedup runs twice when service has requirements [duplication] ``
The new unconditional dedup check at ServicesLifecycle.sol:226-235 fires for every joinServiceWithCommitments call, and _validateSecurityCommitments at line 633-642 runs the identical O(n²) dedup again when requirements.length > 0 (line 238-240). Both pass, so no correctness bug — just redundant work. A cleaner refactor would remove dedup from validateSecurityCommitments and call it unconditionally, but the duplication is minor (commitment arrays are small, bounded by MAX_SECURITY_REQUIREMENTS
What this audit checks
It judges the change on its merits — not whether it was tasked out in an issue. Unticketed, fast-moving work is fine; the question is whether the change is good and whether a better or existing approach should be used instead.
| Pass | What it asks |
|---|---|
| Heuristic | Vague title? Whitespace-only or cruft-bearing diff? (content signals only) |
| Duplication | Do added function/class names already exist elsewhere in the repo? |
| Value Audit | What does it do? What goal does it achieve? Is it good? Better architecture or already-exists? |
| Usefulness Audit | Does it integrate and fit? Will it hold up in real use and actually get used? |
Findings are concerns, not blocks — the human reviewer decides what to do with them.
Summary
Second audit batch (11 findings across slashing, jobs, services-lifecycle, rewards/inflation, extensions, beacon-L2). Each finding was independently validated against the cited code before any fix — two report-vs-analysis discrepancies were resolved by ground-truthing the exact files named (F3, F5). 9 issues fixed in code; F5 deferred with documentation; F6/F11 are latent/intentional and documented. Full affected suites (slashing, lifecycle, jobs, rewards, extensions, beacon, integration, upgrade) are green.
Validity verdicts
disputeBond(0.02 ETH, matches prod config)_ensureAggregationBypassnow fails closed on hook failurestakeLockDuration(blocks JIT capture)distributeEpochno longer reallocates the staker sliceassetOperators[]on zero stake (swap-and-pop)slashBps <= 10000at L2 receipt (defense-in-depth)instantSlashEnabledread+enforced but deliberately unreachable. DocumentedFixes
executeSlashread commitments LIVE, so an operator could self-dispute → leave → rejoin with a 1-bps commitment and evade ~99.99% of the slash. Now snapshotted into a dedicated_slashCommitmentSnapshotsmapping at propose time and consumed at execute. Storage appended at end, gap shrunk 29→28;SlashProposal(and itsITangleSlashingbinding) untouched, so no Rust binding regen needed.disputeBondwas 0 → free self-dispute freezes delegator withdrawals for the resolution window. Ship a non-zero default matchingdeploy/config/*.json._ensureAggregationBypassonly reverted onok && requiresAggregation, failing open when the manager hook reverts/gas-griefs → a single operator could finalize a quorum job. Now!ok || requiresAggregationreverts. Safe because the BSM base implementsrequiresAggregation(returns false), so!okis a genuine failure.stakeLockDuration=0default enabled same-block JIT instant-mode reward capture. Seed a non-zero failsafe default in the constructor.serviceIdspath, letting a front-running operator/customer/dev redirect staker inflation. Now reallocated only when staker distribution was attempted; otherwise retained in the pool to roll forward.assetOperators[]was append-only and double-iterated in epoch distribution. Added_untrackOperator(swap-and-pop) + prune on zero stake; gap shrunk 50→49.slashBpsat receipt so a malformed cross-chain value can't inflate the deferred accumulator.Deferred / documented (no code change)
ServiceFeeDistributor.onDelegationChangedbakeslockMultiplierBpsinto the position score but gets no lock expiry, and the staking layer computes a blended multiplier across multiple locks, so a correct decay requires anonDelegationChangedinterface change + staking-layer expiry threading + lazy decay in the O(1) reward-accumulator math (mirroringRewardVaults._decayExpiredLock). Deferred to a focused follow-up to avoid reward-accounting regressions; root cause + plan documented inline.budget ≤ freeBalance ≤ poolBalance); the liability undercount only bites an upgrade from a pre-pendingRewardsLiabilitydeployment, which does not exist here. Documented; any future migration must seed liability in a reinitializer.instantSlashEnabledis read & enforced inSlashingLib, butproposeSlashhardcodesinstant=false, leaving it intentionally unreachable. Documented as a deliberate control (wiring instant slash needs a dispute-bypass review), not dead code.Tests
test/audit/batch2/AuditBatch2.t.sol(F1 leave/rejoin dilution, F2 bonded self-dispute, F9 duplicate-asset reject) and aBeaconL2receiver test for F10.TokenizedExt/BlueprintExtensions, F7 (staker slice retained, not reallocated) inaudit/medlow/InflationPool.tangle/Slashing*,EndToEndSlashingTest,SvcLifecycle,JobsAgg,BLSAggregation,PerAssetExposureIntegration,DoubleExposureUnderSlashPoC,Rewards,InflationPool(+audit),RewardVaults,TokenizedExt,BlueprintExtensions,BeaconL2,Integration,MultiAssetDelegation,FullStackScenario,UpgradeFlow.