perf(staking): O(1) operator stake aggregate + VPM share-pool slashing#134
Merged
Merged
Conversation
Two scalability/security improvements at the staking layer. Both eliminate
unbounded loops that would otherwise gas-bomb operators with many blueprints
or many delegators, making slashing and stake-accrual safe at production scale.
1) O(1) operator delegated-stake aggregate (`DelegationStorage`)
Before: `_getOperatorDelegatedStakeForAsset(operator, assetHash)` summed
`_rewardPools[op][h].totalAssets + Σ_bp _blueprintPools[op][bp][h].totalAssets`
by iterating every blueprint pool the operator joined — O(B) per call.
Called from the TWAP accrual hook on every delegate/undelegate AND from
subscription billing's `_accrueOperatorWeights`, so cost compounded.
After: `_operatorDelegatedAggregate[operator][assetHash]` holds the running
sum, updated at every pool-totalAssets mutation via `_increaseDelegatedStake`
/ `_decreaseDelegatedStake`. Read is a single SLOAD.
Mutation sites wired:
- `RewardsManager._updateAllModePool` / `_updateFixedModePools` (4 sites)
- `DelegationManagerLib._setDelegatorBlueprintPosition` (Fixed-mode
rebalance, 2 sites)
- `SlashingManager._slashAllModePool` / `_slashBlueprintPool` (2 sites)
Invariant `aggregate == rewardPool.totalAssets + Σ blueprintPool.totalAssets`
asserted by a new foundry invariant test that fuzzes 30,720 delegate /
undelegate / slash / reward combinations.
2) VPM share-pool delegator slashing (`ValidatorPodManager`)
Before: `_slash(operator, slashBps)` iterated `_operatorDelegators[operator]`
to proportionally debit each delegator's `delegations[d][op]` balance — O(D).
Slashing a popular operator with hundreds of delegators became gas-prohibitive
and eventually impossible.
After: each operator owns a `DelegationPool { totalAssets, totalShares }`.
Delegators hold shares (`_delegationShares[d][op]`), with virtual-shares /
virtual-assets offsets (`VIRTUAL_SHARES`, `VIRTUAL_ASSETS`) to defeat the
first-depositor inflation attack. Slash is a single SSTORE on `totalAssets`;
each delegator's effective claim drops proportionally via the new
share-to-asset conversion on read.
External ABI preserved:
- `delegations(d, op)` returns asset-denominated stake (now derived from
shares × totalAssets / totalShares).
- `operatorDelegatedStake(op)` returns `pool.totalAssets`.
- `OperatorPoolSlashed(operator, slashAmount, newTotalAssets, totalShares)`
replaces the per-delegator `DelegatorSlashed` event loop; indexers
recompute per-delegator impact off-chain from share balances + event.
Storage layout: new slots appended at the end of both `DelegationStorage`
(decrementing `__gap` 44 → 43) and `ValidatorPodManager` (no gap reorder).
No existing slots reordered — UUPS-safe.
Tests
- 30,720 fuzz calls on `invariant_operatorDelegatedAggregateMatchesPools`
- 3 new VPM invariant tests (pool-totals match net deposits; slash O(1);
ABI-preserved view returns equal share-to-asset conversion)
- Full regression: 1,454 / 1,454 tests pass (was 1,450; +4 new invariants)
This was referenced May 15, 2026
drewstone
added a commit
that referenced
this pull request
May 16, 2026
Regenerates tnt-core-bindings + fixtures against current main (f64a4e4) and bumps to v0.17.0. Covers PRs #133, #134, #136 since the v0.16.0 release pin. Binding-surface delta is small: ITanglePaymentsInternal gains distributeBillWithKeeper(BillDistribution) selector 0x68cdf660 — the diamond self-call used by billSubscription to hand per-operator weights computed during accrual directly to the distribution facet. Every other facet selector still routes to the Tangle diamond unchanged. Most of the behavioral changes (RFQ requester binding + freshness + cumulative-TTL caps, multi-asset bill weighting, EIP-170 facet split, O(1) operator stake aggregate, VPM share-pool slashing, claimRewardsAll griefing isolation) are observable to indexers via events emitted from the facet contracts. Those event ABIs (OperatorPoolSlashed, RewardsClaimSkipped, KeeperRebateAccrued, TntPaymentDiscountApplied, StakerShareRefundedToEscrow, SubscriptionBaseline*, SubscriptionBill*) are not surfaced on the ITangle / ITangleFull interfaces and therefore are not present in the generated bindings; the changelog enumerates them so indexer consumers can decode by topic hash against the facet source ABIs. Cargo.lock is updated to 0.17.0 — the lockfile on main was still pinning 0.15.0 (the v0.16.0 release commit did not refresh it). DO NOT publish to crates.io until: dApp sync verified green, indexer schema PR merged, at least one downstream blueprint compiles green. Co-authored-by: Drew Stone <drewstone329@gmail.com>
5 tasks
drewstone
added a commit
that referenced
this pull request
May 17, 2026
…144) * audit: reentrancy + griefing red-team 2026-05-16 3 medium / 2 low / 1 informational. No critical or high-severity issues. Medium: `_distributeBill` push to BSM-resolved developer recipient can be permanently griefed by a malicious manager; `withdrawRemainingEscrow` has no fallback when the escrow token is broken; `getNonPaymentTerminationPolicy` is invoked without a gas cap, letting a malicious BSM grief the livelock escape. Low: `_settleDisputeBond` strands dispute bonds on executed proposals when the treasury push fails; `_operatorActiveSlashProposals` cap check ordering is inverted (cosmetic). Confirmed clean: every token-moving entry point holds `nonReentrant`; the OpenZeppelin ReentrancyGuard storage slot is genuinely shared across facets via ERC-7201; operator and keeper payments are pull via `_pendingRewards`; self-call gates fire before any state read; CEI ordering holds on every withdrawal / claim / refund path; the subscription livelock escape (`terminateServiceForNonPayment`) is permissionless. * audit: economic + oracle red-team 2026-05-16 Read-only audit of subscription billing, share-pool inflation defense, slashing replay, oracle adapters, and TWAP weighting under adversarial sequences. Customer is protected by cap-at-nominal; per-operator weight split is the residual attack surface. Severity counts: 0 CRITICAL, 1 HIGH, 3 MEDIUM, 2 LOW, 2 INFORMATIONAL. Top findings: - H-1: oracle-manipulated weight inflation captures the operator pool share of a (capped) bill, redistributing rent from honest operators - M-1: oracle revert during billing bricks subscriptions for the configured assets (no fund loss, denial-of-billing) - M-3: stake-ramp at periodEnd inflates within-period TWAP weight beyond the operator's time-averaged backing * audit: DoS + access control red-team 2026-05-16 Read-only static audit of DoS surfaces and access-control gaps across src/core/, src/staking/, and src/beacon/. Two HIGH findings on the permissionless billing path: unbounded security-commitment arrays brick subscription billing, and try IBlueprintServiceManager(...) callsites forward 63/64 of remaining gas instead of capping at MANAGER_HOOK_GAS_LIMIT. Four MEDIUM and four LOW notes plus 12 clean checks. * audit: storage + UUPS upgrade red-team 2026-05-16 * feat(local-env): end-to-end stress test harness + runbook Single-file harness at scripts/local-env/stress-test.sh that boots a fresh anvil + LocalTestnet deployment and walks 17 ordered economic checks against the merged-PR surface (#132 subscription billing rearchitecture, #133 multi-asset bill weighting + EIP-170 facet split, #134 O(1) operator stake aggregate + share-pool slashing, #136 claimRewardsAll griefing isolation, #138 indexer event handlers). Highlights: - Idempotent: clean cleanup of anvil, broadcast artifacts, indexer state. - Per-step pass/fail with timing + headline metric. Single-line summary. - Optional --with-indexer / --with-dapp / --with-operator flags for the off-chain side processes; none required for the 17-step protocol surface. - Griefing-token isolation step deploys a RevertingTransferERC20 and seeds Tangle's _pendingRewards + _pendingRewardTokens AddressSet via anvil_setStorageAt (vm.store from a broadcast script doesn't propagate to anvil; the harness drives the seed via curl directly). Companion docs at scripts/local-env/STRESS-TEST.md cover prereqs, per-step PR mapping, log locations, debugging recipes, and an extension guide. A full green run takes ~40-85s on a warm-cache checkout. * chore(v0.17.1): audit batch — reports + stress harness + remediation Consolidates the four 2026-05-16 red-team audit reports, the local stress harness, and remediation fixes for 2 HIGH and 5 MEDIUM findings into a single batch. Originally PRs #139 #140 #141 #142 #143 — supersedes those. ## Audit reports landed (docs/audits/REDTEAM-*-2026-05-16.md) - Reentrancy + griefing: 0 H / 3 M / 2 L / 1 Info - Economic + oracle: 1 H / 3 M / 2 L / 2 Info - DoS + access control: 2 H / 4 M / 4 L / 3 Info - Storage + UUPS upgrade: 0 H / 0 M / 1 L / 3 Info ## Stress harness landed `scripts/local-env/stress-test.sh` drives a 17-step end-to-end flow against a local anvil node: deploys the protocol, registers operators, opens services, funds escrow, fires bills, exercises slashing, tests the griefing-token path via `script/StressGriefingSeed.s.sol` + `anvil_setStorageAt`, and asserts final state. 17/17 green across five runs. Runbook in `STRESS-TEST.md`. ## Remediations HIGH — DoS H-1: cap customer-supplied security-requirement arrays at `MAX_SECURITY_REQUIREMENTS_PER_REQUEST = 16` in `_validateSecurityRequirements`. An unbounded array let a customer brick their own subscription bills by forcing the per-bill `O(operators × requirements)` walk past the block gas limit. 16 is well above any realistic heterogeneous-asset blueprint and keeps worst-case `64 × 16 = 1024` inner iterations within one block. HIGH — DoS H-2 (also closes Reentrancy M-3): every bare `try IBlueprintServiceManager(bp.manager).fn() catch` callsite now routes through `_tryStaticcallManager(addr, calldata, minReturnLen)` which forwards exactly `MANAGER_HOOK_GAS_LIMIT` (500_000) gas. Without the cap, Solidity's `try/catch` forwards 63/64 of remaining gas, letting a malicious BSM drain the keeper/proposer's budget. Covers `querySlashingOrigin`, `requiresAggregation` (×2), `getNonPaymentTerminationPolicy`, `canJoin`, `canLeave`, `forceRemoveAllowsBelowMin`, `getExitConfig`, `getMinOperatorStake` (×2), `getHeartbeatInterval`, `getHeartbeatThreshold`, `getRequiredResultCount`, `queryIsPaymentAssetAllowed`, `getAggregationThreshold`, and converts the mutating `onAggregatedResult` hook to `_tryCallManager`. MEDIUM — Reentrancy M-1: developer / TNT-discount / treasury push transfers in `_distributeBill` wrapped in `PaymentLib.tryTransferPayment`. On failure, the un-sent amount folds into the operator pool and emits `PushTransferFailed` with a structured destination tag. A malicious BSM-resolved developer recipient (or paused/blocklisting token) can no longer brick distribution for honest operators. MEDIUM — Economic M-1: `oracle.toUSD` on the billing hot path wrapped in `_safeToUSD` / `_safeToUSDView` helpers (capped at `ORACLE_QUERY_GAS_LIMIT = 250_000`) with raw-amount fallback + `PriceOracleFallback` event. A stalled or reverting oracle now degrades to raw token-second weighting instead of freezing all bills. `_accrueOperatorWeights` drops `view` since the fallback path emits. LOW — Storage L-1: `MultiAssetDelegation._authorizeUpgrade` now requires `UPGRADER_ROLE` (was `ADMIN_ROLE`), restoring the defense-in-depth role separation the protocol-level contracts already use. Role added to the initializer. ## Greenfield cleanup The pre-launch protocol carried audit-round tag comments left over from prior remediation rounds — `M-8 FIX:`, `H-1 FIX:`, `Round 4 audit S-1:`, `G-02 follow-up:`, `C-3 (Round 4):`. Stripped across 30 files. Descriptive content that explains current behavior is retained; historical narrative is deleted. VPM also carried `_legacy*` mappings with comments suggesting they were kept for "storage layout preservation" in a contract that turns out not to be upgradeable (`ValidatorPodManager` is constructor-deployed `Ownable`, not `UUPSUpgradeable`). The mappings and misleading comments are deleted entirely. Also: the `if (config.currentDeposits >= amountReturned) { … } else { … clamp to 0 }` patterns in `DepositManager` and `StakingDelegationsFacet` are collapsed to a single checked subtraction. The clamp described a defensive case that is structurally unreachable under the current accounting. ## Fuzz coverage `test/tangle/SubscriptionEscrowInvariant.t.sol` grew two new invariants and an adversarial actor: - `invariant_billAmountNeverExceedsNominalRate` — catches a regression of the cap-at-nominal clamp under adversarial stake-ramp sequences. - `invariant_baselinePinnedAtActivation` — catches any post-activation code path that re-pins `subscriptionBaselineStake`. - `stakeRamper` handler actor that `depositAndDelegate`s / schedules unstakes against `operator1` during the run. ## What's deliberately deferred to a follow-up - HIGH Economic H-1 (oracle weight inflation): the proper fix is to snapshot per-(op, asset) USD prices at activation and reuse them at every bill, matching the baseline pin. The fix is architecturally involved (touches `TangleStorage`, `PaymentsBilling._accrueOperatorWeights`, `PaymentsDistribution._initSubscriptionBaseline`, `ServicesLifecycle._finalizeJoin`) and warrants its own focused PR with negative-tested invariants. The customer is safe today (cap-at-nominal bounds total damage); the residual risk is distributional between operators in the same service. - MEDIUM Reentrancy M-2 (escrow rescue path): admin-rescue route for stuck escrow tokens when the customer's token is centrally paused / blocklists the service owner. Moderate scope; better as its own PR. Supersedes #139 #140 #141 #142 #143.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two production-readiness scalability fixes at the staking layer. Both replace unbounded loops with O(1) accounting — making stake reads cheap inside the bill hot path and making slashing affordable for operators with hundreds of delegators.
1. O(1) operator delegated-stake aggregate
_getOperatorDelegatedStakeForAsset(op, assetHash)was O(B) — it summed_rewardPools[op][h].totalAssets + Σ_bp _blueprintPools[op][bp][h].totalAssetsby walking every blueprint the operator had joined. This call sits on hot paths:_accrueOperatorWeights, fired per (operator, asset) per billFor an operator on N blueprints, every read was N+1 SLOADs. Cost compounded inside the multi-asset bill loop.
Fix: maintain
_operatorDelegatedAggregate[op][assetHash]incrementally. Every pool-totalAssets mutation now pairs with_increaseDelegatedStake/_decreaseDelegatedStake. Reads are a single SLOAD.Mutation sites wired:
RewardsManager._updateAllModePool/_updateFixedModePools(4 sites)DelegationManagerLib._setDelegatorBlueprintPosition(2 sites, Fixed-mode rebalance)SlashingManager._slashAllModePool/_slashBlueprintPool(2 sites)Invariant:
aggregate == rewardPool.totalAssets + Σ blueprintPool.totalAssets. Asserted by a new foundry invariant test that fuzzes 30,720 calls of delegate/undelegate/slash/reward combinations — passes with zero reverts.2. VPM share-pool delegator slashing
ValidatorPodManager._slashwas O(D) — it walked_operatorDelegators[operator]to proportionally debit each delegator'sdelegations[d][op]balance and emitDelegatorSlashedper delegator. For an operator with hundreds of delegators slashing became gas-prohibitive; with thousands it eventually exceeds block gas limits.Fix: ERC4626-style share-pool. Each operator owns a
DelegationPool { totalAssets, totalShares }; delegators hold shares in_delegationShares[d][op]. Slash is now a single SSTORE onpool.totalAssets; every delegator's effective claim drops proportionally via share→asset conversion at read time.Inflation-attack mitigation:
VIRTUAL_SHARES/VIRTUAL_ASSETSoffsets in theMath.mulDivconversion paths (Solady/OpenZeppelin pattern) defeat the first-depositor attack.External ABI preserved:
delegations(d, op)selector unchanged; now returnsshares * (totalAssets + VIRTUAL_ASSETS) / (totalShares + VIRTUAL_SHARES). Automatically reflects slashing.operatorDelegatedStake(op)returnspool.totalAssets.OperatorPoolSlashed(operator, slashAmount, newTotalAssets, totalShares)replaces the per-delegator emission loop. Indexers reconstruct per-delegator impact off-chain from share balances + event.Storage layout
UUPS-safe:
DelegationStorage.__gapdecremented 44 → 43; new_operatorDelegatedAggregatemapping appended at end.ValidatorPodManagerappended new state (_operatorDelegationPools,_delegationShares) at end; no existing slot reordered.Behavioral preservation
delegations(d, op)now correctly reflects slash impact without per-delegator state writes — a behavioral improvement, not a regression. Pre-PR slash updateddelegations[d][op]directly; post-PR slash dropstotalAssets, and the next read computes the slashed amount via conversion. Round-trip equivalent.Errors.*revert paths.Test plan
invariant_operatorDelegatedAggregateMatchesPools: 30,720 fuzz calls, 0 revertsProduction readiness checklist
Depends on / related
mainso no rebase needed.