From e82152fb6cfdf7e2d4368a6edff2eb5a482ebbb8 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 7 Jul 2026 17:21:33 -0600 Subject: [PATCH 1/3] wip(tempo): split StakingDelegationsFacet + shrink ServiceFeeDistributor for Tempo 30M per-tx deploy cap Tempo (chain 42431) caps a tx at 30M gas and meters code deposit ~6.7x, so contracts near the EIP-170 24KB limit exceed the deploy cap: - StakingDelegationsFacet (24358B, 33.1M CREATE): split the single-step redeem path (executeDelegatorUnstakeAndWithdraw + helpers) into new StakingUnstakeWithdrawFacet; both share StakingFacetBase so storage/behavior identical, one selector relocates. Now 21237B/~29M. - ServiceFeeDistributor (24302B, 33.4M CREATE): extract heavy math into deployed library ServiceFeeDistributorLib + storage-struct refactor (layout byte-identical). Now 19288B/~26.5M. Registered the new facet at all 22 deploy/test sites + FacetSize assertion. Tests: staking/delegation 223 (6 formerly-UnknownSelector now pass); rewards 174/174 (agent). WIP: pending fresh Tempo broadcast to confirm every CREATE < 30M. --- script/DemoSimulation.s.sol | 2 + script/Deploy.s.sol | 2 + script/DeployContractsOnly.s.sol | 2 + script/FullStackScenario.s.sol | 2 + script/LocalTestnet.s.sol | 2 + .../staking/StakingDelegationsFacet.sol | 337 +------ .../staking/StakingUnstakeWithdrawFacet.sol | 349 +++++++ src/rewards/ServiceFeeDistributor.sol | 953 ++++-------------- src/rewards/ServiceFeeDistributorLib.sol | 718 +++++++++++++ test/BaseTest.sol | 2 + test/FacetSize.t.sol | 2 + test/MultiAssetDelegation.t.sol | 2 + test/audit/batch3/AdapterUnitMismatch.t.sol | 2 + test/audit/medlow/DelegationLib.t.sol | 2 + test/audit/medlow/LiquidVault.t.sol | 2 + test/audit/medlow/StakingAdmin.t.sol | 2 + test/audit/medlow/StakingSlashingFacet.t.sol | 2 + test/blueprints/TestHarness.sol | 2 + ...ression_Delegation_CostBasisSolvency.t.sol | 2 + test/fuzz/InvariantFuzz.t.sol | 2 + test/staking/DelegationEdgeCasesTest.t.sol | 2 + test/staking/DelegationFlowsTest.t.sol | 2 + test/staking/DelegationTestHarness.sol | 2 + test/staking/LiquidDelegationTest.t.sol | 2 + test/staking/ProportionalSlashingTest.t.sol | 2 + test/staking/SlashAccountingInvariant.t.sol | 2 + test/staking/SlashingInvariantTest.t.sol | 2 + 27 files changed, 1315 insertions(+), 1088 deletions(-) create mode 100644 src/facets/staking/StakingUnstakeWithdrawFacet.sol create mode 100644 src/rewards/ServiceFeeDistributorLib.sol diff --git a/script/DemoSimulation.s.sol b/script/DemoSimulation.s.sol index 4e91c720..7a0608af 100644 --- a/script/DemoSimulation.s.sol +++ b/script/DemoSimulation.s.sol @@ -33,6 +33,7 @@ import { TangleSlashingFacet } from "../src/facets/tangle/TangleSlashingFacet.so import { StakingOperatorsFacet } from "../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../src/facets/staking/StakingDepositsFacet.sol"; import { StakingDelegationsFacet } from "../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; import { StakingSlashingFacet } from "../src/facets/staking/StakingSlashingFacet.sol"; import { StakingAssetsFacet } from "../src/facets/staking/StakingAssetsFacet.sol"; import { StakingViewsFacet } from "../src/facets/staking/StakingViewsFacet.sol"; @@ -198,6 +199,7 @@ contract DemoSimulation is Script, BlueprintDefinitionHelper { router.registerFacet(address(new StakingOperatorsFacet())); router.registerFacet(address(new StakingDepositsFacet())); router.registerFacet(address(new StakingDelegationsFacet())); + router.registerFacet(address(new StakingUnstakeWithdrawFacet())); router.registerFacet(address(new StakingSlashingFacet())); router.registerFacet(address(new StakingAssetsFacet())); router.registerFacet(address(new StakingViewsFacet())); diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index 5b5e12bb..215dce72 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -37,6 +37,7 @@ import { TangleSlashingFacet } from "../src/facets/tangle/TangleSlashingFacet.so import { StakingOperatorsFacet } from "../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../src/facets/staking/StakingDepositsFacet.sol"; import { StakingDelegationsFacet } from "../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; import { StakingSlashingFacet } from "../src/facets/staking/StakingSlashingFacet.sol"; import { StakingAssetsFacet } from "../src/facets/staking/StakingAssetsFacet.sol"; import { StakingViewsFacet } from "../src/facets/staking/StakingViewsFacet.sol"; @@ -459,6 +460,7 @@ contract DeployV2 is DeployScriptBase { router.registerFacet(address(new StakingOperatorsFacet())); router.registerFacet(address(new StakingDepositsFacet())); router.registerFacet(address(new StakingDelegationsFacet())); + router.registerFacet(address(new StakingUnstakeWithdrawFacet())); router.registerFacet(address(new StakingSlashingFacet())); router.registerFacet(address(new StakingAssetsFacet())); router.registerFacet(address(new StakingViewsFacet())); diff --git a/script/DeployContractsOnly.s.sol b/script/DeployContractsOnly.s.sol index 30ea87ca..d374d6f4 100644 --- a/script/DeployContractsOnly.s.sol +++ b/script/DeployContractsOnly.s.sol @@ -35,6 +35,7 @@ import { TangleSlashingFacet } from "../src/facets/tangle/TangleSlashingFacet.so import { StakingOperatorsFacet } from "../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../src/facets/staking/StakingDepositsFacet.sol"; import { StakingDelegationsFacet } from "../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; // StakingRewardsFacet removed - no longer exists import { StakingSlashingFacet } from "../src/facets/staking/StakingSlashingFacet.sol"; import { StakingAssetsFacet } from "../src/facets/staking/StakingAssetsFacet.sol"; @@ -147,6 +148,7 @@ contract DeployContractsOnly is Script { mad.registerFacet(address(new StakingOperatorsFacet())); mad.registerFacet(address(new StakingDepositsFacet())); mad.registerFacet(address(new StakingDelegationsFacet())); + mad.registerFacet(address(new StakingUnstakeWithdrawFacet())); // StakingRewardsFacet removed - no longer exists mad.registerFacet(address(new StakingSlashingFacet())); mad.registerFacet(address(new StakingAssetsFacet())); diff --git a/script/FullStackScenario.s.sol b/script/FullStackScenario.s.sol index e1306a9d..63698bb6 100644 --- a/script/FullStackScenario.s.sol +++ b/script/FullStackScenario.s.sol @@ -10,6 +10,7 @@ import { Types } from "../src/libraries/Types.sol"; import { StakingOperatorsFacet } from "../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../src/facets/staking/StakingDepositsFacet.sol"; import { StakingDelegationsFacet } from "../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; import { StakingSlashingFacet } from "../src/facets/staking/StakingSlashingFacet.sol"; import { StakingAssetsFacet } from "../src/facets/staking/StakingAssetsFacet.sol"; import { StakingViewsFacet } from "../src/facets/staking/StakingViewsFacet.sol"; @@ -188,6 +189,7 @@ contract FullStackScenario is Script { router.registerFacet(address(new StakingOperatorsFacet())); router.registerFacet(address(new StakingDepositsFacet())); router.registerFacet(address(new StakingDelegationsFacet())); + router.registerFacet(address(new StakingUnstakeWithdrawFacet())); router.registerFacet(address(new StakingSlashingFacet())); router.registerFacet(address(new StakingAssetsFacet())); router.registerFacet(address(new StakingViewsFacet())); diff --git a/script/LocalTestnet.s.sol b/script/LocalTestnet.s.sol index bc80624f..515ecfa2 100644 --- a/script/LocalTestnet.s.sol +++ b/script/LocalTestnet.s.sol @@ -44,6 +44,7 @@ import { TangleSlashingFacet } from "../src/facets/tangle/TangleSlashingFacet.so import { StakingOperatorsFacet } from "../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../src/facets/staking/StakingDepositsFacet.sol"; import { StakingDelegationsFacet } from "../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; import { StakingSlashingFacet } from "../src/facets/staking/StakingSlashingFacet.sol"; import { StakingAssetsFacet } from "../src/facets/staking/StakingAssetsFacet.sol"; import { StakingViewsFacet } from "../src/facets/staking/StakingViewsFacet.sol"; @@ -1371,6 +1372,7 @@ contract LocalTestnetSetup is Script, BlueprintDefinitionHelper { router.registerFacet(address(new StakingOperatorsFacet())); router.registerFacet(address(new StakingDepositsFacet())); router.registerFacet(address(new StakingDelegationsFacet())); + router.registerFacet(address(new StakingUnstakeWithdrawFacet())); router.registerFacet(address(new StakingSlashingFacet())); router.registerFacet(address(new StakingAssetsFacet())); router.registerFacet(address(new StakingViewsFacet())); diff --git a/src/facets/staking/StakingDelegationsFacet.sol b/src/facets/staking/StakingDelegationsFacet.sol index 23c790a3..436975fc 100644 --- a/src/facets/staking/StakingDelegationsFacet.sol +++ b/src/facets/staking/StakingDelegationsFacet.sol @@ -1,30 +1,19 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.26; -import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; - import { StakingFacetBase } from "../../staking/StakingFacetBase.sol"; -import { DelegationErrors } from "../../staking/DelegationErrors.sol"; import { Types } from "../../libraries/Types.sol"; import { IFacetSelectors } from "../../interfaces/IFacetSelectors.sol"; /// @title StakingDelegationsFacet -/// @notice Facet for delegation lifecycle +/// @notice Facet for delegation lifecycle (delegate in / schedule / undelegate / blueprint edits). +/// @dev The single-step "redeem" path (executeDelegatorUnstakeAndWithdraw) lives in +/// StakingUnstakeWithdrawFacet — split out because the combined facet sat at the EIP-170 +/// runtime-size ceiling and its CREATE exceeded the per-tx gas cap on chains that meter code +/// deposit aggressively (Tempo caps a tx at 30M gas; the combined facet deployed at ~33M). contract StakingDelegationsFacet is StakingFacetBase, IFacetSelectors { - using EnumerableSet for EnumerableSet.AddressSet; - - struct FixedModeBondlessContext { - address delegator; - address operator; - bytes32 assetHash; - uint256 requestedShares; - uint256 totalBlueprintShares; - uint256 equalShare; - uint256 blueprintCount; - } - function selectors() external pure returns (bytes4[] memory selectorList) { - selectorList = new bytes4[](10); + selectorList = new bytes4[](9); selectorList[0] = this.depositAndDelegate.selector; selectorList[1] = this.depositAndDelegateWithOptions.selector; selectorList[2] = this.delegate.selector; @@ -34,7 +23,6 @@ contract StakingDelegationsFacet is StakingFacetBase, IFacetSelectors { selectorList[6] = this.executeDelegatorUnstake.selector; selectorList[7] = this.addBlueprintToDelegation.selector; selectorList[8] = this.removeBlueprintFromDelegation.selector; - selectorList[9] = this.executeDelegatorUnstakeAndWithdraw.selector; } /// @notice Deposit and delegate native tokens in one transaction @@ -110,319 +98,6 @@ contract StakingDelegationsFacet is StakingFacetBase, IFacetSelectors { _executeDelegatorUnstake(); } - /// @notice Execute a specific matured unstake request and immediately withdraw the resulting assets. - /// @dev This is intended for integrations that need a single-step "redeem" once the bond-less delay has passed. - function executeDelegatorUnstakeAndWithdraw( - address operator, - address token, - uint256 shares, - uint64 requestedRound, - address receiver - ) - external - nonReentrant - returns (uint256 amountReturned) - { - _tryAdvanceRound(); - return _executeDelegatorUnstakeAndWithdrawInner(operator, token, shares, requestedRound, receiver); - } - - function _executeDelegatorUnstakeAndWithdrawInner( - address operator, - address token, - uint256 shares, - uint64 requestedRound, - address receiver - ) - private - returns (uint256 amountReturned) - { - if (receiver == address(0)) revert DelegationErrors.ZeroAddress(); - if (shares == 0) revert DelegationErrors.ZeroAmount(); - - // Match the protection on `_executeDelegatorUnstake`: an operator with pending - // slashes must not have any delegation withdrawn until those slashes resolve. - // Without this guard a vault redeem can drain at the pre-slash rate while - // loyal delegators absorb the entire slash. - if (_operatorPendingSlashCount[operator] > 0) { - revert DelegationErrors.PendingSlashExists(operator, _operatorPendingSlashCount[operator]); - } - - Types.Asset memory asset = token == address(0) - ? Types.Asset(Types.AssetKind.Native, address(0)) - : Types.Asset(Types.AssetKind.ERC20, token); - bytes32 assetHash = _assetHash(asset); - - // Find the exact bond-less request (operator, asset, shares, requestedRound). - Types.BondLessRequest[] storage requests = _unstakeRequests[msg.sender]; - uint256 requestIndex = type(uint256).max; - for (uint256 i = 0; i < requests.length; i++) { - Types.BondLessRequest storage candidate = requests[i]; - if ( - candidate.operator == operator && _assetHash(candidate.asset) == assetHash && candidate.shares == shares - && candidate.requestedRound == requestedRound - ) { - requestIndex = i; - break; - } - } - if (requestIndex == type(uint256).max) { - revert DelegationErrors.DelegationNotFound(msg.sender, operator); - } - - Types.BondLessRequest storage req = requests[requestIndex]; - - uint64 unstakeReadyRound = req.requestedRound + delegationBondLessDelay; - if (currentRound < unstakeReadyRound) { - revert DelegationErrors.UnstakeTooEarly(currentRound, unstakeReadyRound); - } - - // The combined execute+withdraw path must impose the SAME total unbonding as the standard - // two-step exit (scheduleDelegatorUnstake -> executeDelegatorUnstake -> scheduleWithdraw -> - // executeWithdraw), where the withdraw delay starts only once the unstake has matured. Both - // delays therefore stack: a single-step redeem cannot become available until - // requestedRound + delegationBondLessDelay + leaveDelegatorsDelay. Measuring the withdraw - // delay from `requestedRound` alone would halve the effective unbonding period (audit LOW). - uint64 withdrawReadyRound = req.requestedRound + delegationBondLessDelay + leaveDelegatorsDelay; - if (currentRound < withdrawReadyRound) { - revert DelegationErrors.WithdrawTooEarly(currentRound, withdrawReadyRound); - } - - // Convert shares to amount at the current exchange rates and update delegations. - amountReturned = _applyBondlessUnstakeToDelegatorState(msg.sender, operator, assetHash, req); - - emit DelegatorUnstakeExecuted(msg.sender, req.operator, req.asset.token, req.shares, amountReturned); - - // Remove processed request (swap and pop). - requests[requestIndex] = requests[requests.length - 1]; - requests.pop(); - - // Withdraw to receiver immediately (after both delays have elapsed). - Types.Deposit storage dep2 = _deposits[msg.sender][assetHash]; - uint256 available = dep2.amount - dep2.delegatedAmount; - uint256 locked = _getLockedAmount(msg.sender, assetHash); - uint256 free = dep2.amount > locked ? dep2.amount - locked : 0; - - if (free < amountReturned) revert DelegationErrors.AmountLocked(locked, amountReturned); - if (available < amountReturned) { - revert DelegationErrors.InsufficientAvailableBalance(available, amountReturned); - } - - dep2.amount -= amountReturned; - _assetConfigs[assetHash].currentDeposits -= amountReturned; - _transferAssetAndEmitWithdraw(asset, receiver, amountReturned); - } - - function _applyBondlessUnstakeToDelegatorState( - address delegator, - address operator, - bytes32 assetHash, - Types.BondLessRequest storage req - ) - private - returns (uint256 amountReturned) - { - bool updated = false; - Types.BondInfoDelegator[] storage delegations = _delegations[delegator]; - for (uint256 j = 0; j < delegations.length; j++) { - Types.BondInfoDelegator storage d = delegations[j]; - if (d.operator != req.operator || _assetHash(d.asset) != assetHash) continue; - - uint64[] memory blueprintIds; - uint256[] memory blueprintShares; - if (d.selectionMode == Types.BlueprintSelectionMode.Fixed) { - (blueprintIds, blueprintShares, amountReturned) = - _applyFixedModeBondlessUnstake(delegator, j, assetHash, req); - } else { - blueprintIds = new uint64[](0); - blueprintShares = new uint256[](0); - amountReturned = _sharesToAmount(req.operator, assetHash, req.shares); - } - - _notifyDelegationChangedForBondlessExecution( - delegator, - req.operator, - req.asset, - req.shares, - amountReturned, - d.selectionMode, - blueprintIds, - blueprintShares - ); - - d.shares -= req.shares; - - // Mirror the two-step path (DelegationManagerLib._settleDelegatedCostBasis): remove the - // unstaked shares' cost-basis from `delegatedAmount` and write off realized slash loss - // against `dep.amount` / `currentDeposits` so slashed principal cannot strand. - // `slashFactorSnapshot` carries the schedule-time cost-basis for this request. - Types.Deposit storage dep = _deposits[delegator][assetHash]; - _settleDelegatedCostBasisInFacet(delegator, assetHash, dep, req.slashFactorSnapshot, amountReturned); - - if (d.shares == 0) { - _operatorMetadata[req.operator].delegationCount--; - delegations[j] = delegations[delegations.length - 1]; - delegations.pop(); - if (_getDelegatorSharesForOperator(delegator, req.operator) == 0) { - _operatorDelegators[req.operator].remove(delegator); - } - } - - updated = true; - break; - } - - if (!updated) { - revert DelegationErrors.DelegationNotFound(delegator, operator); - } - } - - /// @notice Facet-local copy of DelegationManagerLib._settleDelegatedCostBasis (which is `private`). - /// @dev Removes the unstaked shares' cost-basis from `delegatedAmount` and writes off realized - /// slash loss against `dep.amount` / `currentDeposits`. The realized return - /// (`realizedAmount`) is withdrawn separately by the caller; this only touches the - /// cost-basis and the slash-loss delta, so there is no double-count. Legacy requests - /// (`costBasis == 0`, scheduled pre-upgrade) fall back to the original behavior. - function _settleDelegatedCostBasisInFacet( - address delegator, - bytes32 assetHash, - Types.Deposit storage dep, - uint256 costBasis, - uint256 realizedAmount - ) - private - { - if (costBasis == 0) { - uint256 legacyReduction = realizedAmount > dep.delegatedAmount ? dep.delegatedAmount : realizedAmount; - dep.delegatedAmount -= legacyReduction; - return; - } - - uint256 delReduction = costBasis > dep.delegatedAmount ? dep.delegatedAmount : costBasis; - dep.delegatedAmount -= delReduction; - - if (costBasis <= realizedAmount) return; - uint256 slashLoss = costBasis - realizedAmount; - uint256 byAmount = slashLoss > dep.amount ? dep.amount : slashLoss; - uint256 cur = _assetConfigs[assetHash].currentDeposits; - uint256 applied = byAmount > cur ? cur : byAmount; - if (applied == 0) return; - - dep.amount -= applied; - _assetConfigs[assetHash].currentDeposits = cur - applied; - - emit SlashedPrincipalReconciled(delegator, assetHash, applied); - } - - function _applyFixedModeBondlessUnstake( - address delegator, - uint256 delegationIndex, - bytes32 assetHash, - Types.BondLessRequest storage req - ) - private - returns (uint64[] memory blueprintIds, uint256[] memory blueprintShares, uint256 amountReturned) - { - blueprintIds = _delegationBlueprints[delegator][delegationIndex]; - blueprintShares = new uint256[](blueprintIds.length); - - if (blueprintIds.length == 0) { - amountReturned = _sharesToAmount(req.operator, assetHash, req.shares); - return (blueprintIds, blueprintShares, amountReturned); - } - - uint256 totalBlueprintShares = - _getTotalDelegatorBlueprintShares(delegator, req.operator, assetHash, blueprintIds); - FixedModeBondlessContext memory ctx = FixedModeBondlessContext({ - delegator: delegator, - operator: req.operator, - assetHash: assetHash, - requestedShares: req.shares, - totalBlueprintShares: totalBlueprintShares, - equalShare: req.shares / blueprintIds.length, - blueprintCount: blueprintIds.length - }); - uint256 remainingShares = req.shares; - - for (uint256 k = 0; k < blueprintIds.length; k++) { - (uint256 bpShare, uint256 amountReturnedDelta) = - _applyBlueprintBondlessReduction(ctx, remainingShares, blueprintIds[k], k); - - remainingShares = remainingShares > bpShare ? remainingShares - bpShare : 0; - blueprintShares[k] = bpShare; - amountReturned += amountReturnedDelta; - } - } - - function _getTotalDelegatorBlueprintShares( - address delegator, - address operator, - bytes32 assetHash, - uint64[] memory blueprintIds - ) - private - view - returns (uint256 totalBlueprintShares) - { - for (uint256 k = 0; k < blueprintIds.length; k++) { - totalBlueprintShares += _delegatorBlueprintShares[delegator][operator][assetHash][blueprintIds[k]]; - } - } - - function _applyBlueprintBondlessReduction( - FixedModeBondlessContext memory ctx, - uint256 remainingShares, - uint64 blueprintId, - uint256 blueprintIndex - ) - private - returns (uint256 bpShare, uint256 amountReturnedDelta) - { - uint256 currentBpShares = _delegatorBlueprintShares[ctx.delegator][ctx.operator][ctx.assetHash][blueprintId]; - if (ctx.totalBlueprintShares > 0) { - bpShare = blueprintIndex == ctx.blueprintCount - 1 - ? remainingShares - : (ctx.requestedShares * currentBpShares) / ctx.totalBlueprintShares; - } else { - bpShare = blueprintIndex == ctx.blueprintCount - 1 ? remainingShares : ctx.equalShare; - } - - _delegatorBlueprintShares[ctx.delegator][ctx.operator][ctx.assetHash][blueprintId] = - bpShare > currentBpShares ? 0 : currentBpShares - bpShare; - amountReturnedDelta = _sharesToAmountForBlueprint(ctx.operator, blueprintId, ctx.assetHash, bpShare); - } - - function _notifyDelegationChangedForBondlessExecution( - address delegator, - address operator, - Types.Asset memory asset, - uint256 shares, - uint256 amount, - Types.BlueprintSelectionMode selectionMode, - uint64[] memory blueprintIds, - uint256[] memory blueprintShares - ) - private - { - _onDelegationChanged( - delegator, - operator, - asset, - shares, - amount, - false, - selectionMode, - blueprintIds, - blueprintShares, - _getLockMultiplierBps(Types.LockMultiplier.None) - ); - } - - function _transferAssetAndEmitWithdraw(Types.Asset memory asset, address receiver, uint256 amount) private { - _transferAsset(asset, receiver, amount); - emit Withdrawn(msg.sender, asset.token, amount); - } - /// @notice Add a blueprint to a Fixed mode delegation function addBlueprintToDelegation(uint256 delegationIndex, uint64 blueprintId) external whenNotPaused { _addBlueprintToDelegation(delegationIndex, blueprintId); diff --git a/src/facets/staking/StakingUnstakeWithdrawFacet.sol b/src/facets/staking/StakingUnstakeWithdrawFacet.sol new file mode 100644 index 00000000..7595140d --- /dev/null +++ b/src/facets/staking/StakingUnstakeWithdrawFacet.sol @@ -0,0 +1,349 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import { StakingFacetBase } from "../../staking/StakingFacetBase.sol"; +import { DelegationErrors } from "../../staking/DelegationErrors.sol"; +import { Types } from "../../libraries/Types.sol"; +import { IFacetSelectors } from "../../interfaces/IFacetSelectors.sol"; + +/// @title StakingUnstakeWithdrawFacet +/// @notice Facet for the single-step "redeem" path — execute a matured bond-less unstake and +/// withdraw the resulting assets in one transaction. +/// @dev Split out of StakingDelegationsFacet: that facet sat at the EIP-170 runtime-size ceiling +/// AND its CREATE exceeded the per-transaction gas cap on chains that meter code deposit more +/// aggressively (e.g. Tempo caps a tx at 30M gas; the combined facet's deploy cost ~33M). +/// Both facets extend the same StakingFacetBase, so they share storage and behavior verbatim; +/// only the `executeDelegatorUnstakeAndWithdraw` selector routes here instead. +contract StakingUnstakeWithdrawFacet is StakingFacetBase, IFacetSelectors { + using EnumerableSet for EnumerableSet.AddressSet; + + struct FixedModeBondlessContext { + address delegator; + address operator; + bytes32 assetHash; + uint256 requestedShares; + uint256 totalBlueprintShares; + uint256 equalShare; + uint256 blueprintCount; + } + + function selectors() external pure returns (bytes4[] memory selectorList) { + selectorList = new bytes4[](1); + selectorList[0] = this.executeDelegatorUnstakeAndWithdraw.selector; + } + + /// @notice Execute a specific matured unstake request and immediately withdraw the resulting assets. + /// @dev This is intended for integrations that need a single-step "redeem" once the bond-less delay has passed. + function executeDelegatorUnstakeAndWithdraw( + address operator, + address token, + uint256 shares, + uint64 requestedRound, + address receiver + ) + external + nonReentrant + returns (uint256 amountReturned) + { + _tryAdvanceRound(); + return _executeDelegatorUnstakeAndWithdrawInner(operator, token, shares, requestedRound, receiver); + } + + function _executeDelegatorUnstakeAndWithdrawInner( + address operator, + address token, + uint256 shares, + uint64 requestedRound, + address receiver + ) + private + returns (uint256 amountReturned) + { + if (receiver == address(0)) revert DelegationErrors.ZeroAddress(); + if (shares == 0) revert DelegationErrors.ZeroAmount(); + + // Match the protection on `_executeDelegatorUnstake`: an operator with pending + // slashes must not have any delegation withdrawn until those slashes resolve. + // Without this guard a vault redeem can drain at the pre-slash rate while + // loyal delegators absorb the entire slash. + if (_operatorPendingSlashCount[operator] > 0) { + revert DelegationErrors.PendingSlashExists(operator, _operatorPendingSlashCount[operator]); + } + + Types.Asset memory asset = token == address(0) + ? Types.Asset(Types.AssetKind.Native, address(0)) + : Types.Asset(Types.AssetKind.ERC20, token); + bytes32 assetHash = _assetHash(asset); + + // Find the exact bond-less request (operator, asset, shares, requestedRound). + Types.BondLessRequest[] storage requests = _unstakeRequests[msg.sender]; + uint256 requestIndex = type(uint256).max; + for (uint256 i = 0; i < requests.length; i++) { + Types.BondLessRequest storage candidate = requests[i]; + if ( + candidate.operator == operator && _assetHash(candidate.asset) == assetHash && candidate.shares == shares + && candidate.requestedRound == requestedRound + ) { + requestIndex = i; + break; + } + } + if (requestIndex == type(uint256).max) { + revert DelegationErrors.DelegationNotFound(msg.sender, operator); + } + + Types.BondLessRequest storage req = requests[requestIndex]; + + uint64 unstakeReadyRound = req.requestedRound + delegationBondLessDelay; + if (currentRound < unstakeReadyRound) { + revert DelegationErrors.UnstakeTooEarly(currentRound, unstakeReadyRound); + } + + // The combined execute+withdraw path must impose the SAME total unbonding as the standard + // two-step exit (scheduleDelegatorUnstake -> executeDelegatorUnstake -> scheduleWithdraw -> + // executeWithdraw), where the withdraw delay starts only once the unstake has matured. Both + // delays therefore stack: a single-step redeem cannot become available until + // requestedRound + delegationBondLessDelay + leaveDelegatorsDelay. Measuring the withdraw + // delay from `requestedRound` alone would halve the effective unbonding period (audit LOW). + uint64 withdrawReadyRound = req.requestedRound + delegationBondLessDelay + leaveDelegatorsDelay; + if (currentRound < withdrawReadyRound) { + revert DelegationErrors.WithdrawTooEarly(currentRound, withdrawReadyRound); + } + + // Convert shares to amount at the current exchange rates and update delegations. + amountReturned = _applyBondlessUnstakeToDelegatorState(msg.sender, operator, assetHash, req); + + emit DelegatorUnstakeExecuted(msg.sender, req.operator, req.asset.token, req.shares, amountReturned); + + // Remove processed request (swap and pop). + requests[requestIndex] = requests[requests.length - 1]; + requests.pop(); + + // Withdraw to receiver immediately (after both delays have elapsed). + Types.Deposit storage dep2 = _deposits[msg.sender][assetHash]; + uint256 available = dep2.amount - dep2.delegatedAmount; + uint256 locked = _getLockedAmount(msg.sender, assetHash); + uint256 free = dep2.amount > locked ? dep2.amount - locked : 0; + + if (free < amountReturned) revert DelegationErrors.AmountLocked(locked, amountReturned); + if (available < amountReturned) { + revert DelegationErrors.InsufficientAvailableBalance(available, amountReturned); + } + + dep2.amount -= amountReturned; + _assetConfigs[assetHash].currentDeposits -= amountReturned; + _transferAssetAndEmitWithdraw(asset, receiver, amountReturned); + } + + function _applyBondlessUnstakeToDelegatorState( + address delegator, + address operator, + bytes32 assetHash, + Types.BondLessRequest storage req + ) + private + returns (uint256 amountReturned) + { + bool updated = false; + Types.BondInfoDelegator[] storage delegations = _delegations[delegator]; + for (uint256 j = 0; j < delegations.length; j++) { + Types.BondInfoDelegator storage d = delegations[j]; + if (d.operator != req.operator || _assetHash(d.asset) != assetHash) continue; + + uint64[] memory blueprintIds; + uint256[] memory blueprintShares; + if (d.selectionMode == Types.BlueprintSelectionMode.Fixed) { + (blueprintIds, blueprintShares, amountReturned) = + _applyFixedModeBondlessUnstake(delegator, j, assetHash, req); + } else { + blueprintIds = new uint64[](0); + blueprintShares = new uint256[](0); + amountReturned = _sharesToAmount(req.operator, assetHash, req.shares); + } + + _notifyDelegationChangedForBondlessExecution( + delegator, + req.operator, + req.asset, + req.shares, + amountReturned, + d.selectionMode, + blueprintIds, + blueprintShares + ); + + d.shares -= req.shares; + + // Mirror the two-step path (DelegationManagerLib._settleDelegatedCostBasis): remove the + // unstaked shares' cost-basis from `delegatedAmount` and write off realized slash loss + // against `dep.amount` / `currentDeposits` so slashed principal cannot strand. + // `slashFactorSnapshot` carries the schedule-time cost-basis for this request. + Types.Deposit storage dep = _deposits[delegator][assetHash]; + _settleDelegatedCostBasisInFacet(delegator, assetHash, dep, req.slashFactorSnapshot, amountReturned); + + if (d.shares == 0) { + _operatorMetadata[req.operator].delegationCount--; + delegations[j] = delegations[delegations.length - 1]; + delegations.pop(); + if (_getDelegatorSharesForOperator(delegator, req.operator) == 0) { + _operatorDelegators[req.operator].remove(delegator); + } + } + + updated = true; + break; + } + + if (!updated) { + revert DelegationErrors.DelegationNotFound(delegator, operator); + } + } + + /// @notice Facet-local copy of DelegationManagerLib._settleDelegatedCostBasis (which is `private`). + /// @dev Removes the unstaked shares' cost-basis from `delegatedAmount` and writes off realized + /// slash loss against `dep.amount` / `currentDeposits`. The realized return + /// (`realizedAmount`) is withdrawn separately by the caller; this only touches the + /// cost-basis and the slash-loss delta, so there is no double-count. Legacy requests + /// (`costBasis == 0`, scheduled pre-upgrade) fall back to the original behavior. + function _settleDelegatedCostBasisInFacet( + address delegator, + bytes32 assetHash, + Types.Deposit storage dep, + uint256 costBasis, + uint256 realizedAmount + ) + private + { + if (costBasis == 0) { + uint256 legacyReduction = realizedAmount > dep.delegatedAmount ? dep.delegatedAmount : realizedAmount; + dep.delegatedAmount -= legacyReduction; + return; + } + + uint256 delReduction = costBasis > dep.delegatedAmount ? dep.delegatedAmount : costBasis; + dep.delegatedAmount -= delReduction; + + if (costBasis <= realizedAmount) return; + uint256 slashLoss = costBasis - realizedAmount; + uint256 byAmount = slashLoss > dep.amount ? dep.amount : slashLoss; + uint256 cur = _assetConfigs[assetHash].currentDeposits; + uint256 applied = byAmount > cur ? cur : byAmount; + if (applied == 0) return; + + dep.amount -= applied; + _assetConfigs[assetHash].currentDeposits = cur - applied; + + emit SlashedPrincipalReconciled(delegator, assetHash, applied); + } + + function _applyFixedModeBondlessUnstake( + address delegator, + uint256 delegationIndex, + bytes32 assetHash, + Types.BondLessRequest storage req + ) + private + returns (uint64[] memory blueprintIds, uint256[] memory blueprintShares, uint256 amountReturned) + { + blueprintIds = _delegationBlueprints[delegator][delegationIndex]; + blueprintShares = new uint256[](blueprintIds.length); + + if (blueprintIds.length == 0) { + amountReturned = _sharesToAmount(req.operator, assetHash, req.shares); + return (blueprintIds, blueprintShares, amountReturned); + } + + uint256 totalBlueprintShares = + _getTotalDelegatorBlueprintShares(delegator, req.operator, assetHash, blueprintIds); + FixedModeBondlessContext memory ctx = FixedModeBondlessContext({ + delegator: delegator, + operator: req.operator, + assetHash: assetHash, + requestedShares: req.shares, + totalBlueprintShares: totalBlueprintShares, + equalShare: req.shares / blueprintIds.length, + blueprintCount: blueprintIds.length + }); + uint256 remainingShares = req.shares; + + for (uint256 k = 0; k < blueprintIds.length; k++) { + (uint256 bpShare, uint256 amountReturnedDelta) = + _applyBlueprintBondlessReduction(ctx, remainingShares, blueprintIds[k], k); + + remainingShares = remainingShares > bpShare ? remainingShares - bpShare : 0; + blueprintShares[k] = bpShare; + amountReturned += amountReturnedDelta; + } + } + + function _getTotalDelegatorBlueprintShares( + address delegator, + address operator, + bytes32 assetHash, + uint64[] memory blueprintIds + ) + private + view + returns (uint256 totalBlueprintShares) + { + for (uint256 k = 0; k < blueprintIds.length; k++) { + totalBlueprintShares += _delegatorBlueprintShares[delegator][operator][assetHash][blueprintIds[k]]; + } + } + + function _applyBlueprintBondlessReduction( + FixedModeBondlessContext memory ctx, + uint256 remainingShares, + uint64 blueprintId, + uint256 blueprintIndex + ) + private + returns (uint256 bpShare, uint256 amountReturnedDelta) + { + uint256 currentBpShares = _delegatorBlueprintShares[ctx.delegator][ctx.operator][ctx.assetHash][blueprintId]; + if (ctx.totalBlueprintShares > 0) { + bpShare = blueprintIndex == ctx.blueprintCount - 1 + ? remainingShares + : (ctx.requestedShares * currentBpShares) / ctx.totalBlueprintShares; + } else { + bpShare = blueprintIndex == ctx.blueprintCount - 1 ? remainingShares : ctx.equalShare; + } + + _delegatorBlueprintShares[ctx.delegator][ctx.operator][ctx.assetHash][blueprintId] = + bpShare > currentBpShares ? 0 : currentBpShares - bpShare; + amountReturnedDelta = _sharesToAmountForBlueprint(ctx.operator, blueprintId, ctx.assetHash, bpShare); + } + + function _notifyDelegationChangedForBondlessExecution( + address delegator, + address operator, + Types.Asset memory asset, + uint256 shares, + uint256 amount, + Types.BlueprintSelectionMode selectionMode, + uint64[] memory blueprintIds, + uint256[] memory blueprintShares + ) + private + { + _onDelegationChanged( + delegator, + operator, + asset, + shares, + amount, + false, + selectionMode, + blueprintIds, + blueprintShares, + _getLockMultiplierBps(Types.LockMultiplier.None) + ); + } + + function _transferAssetAndEmitWithdraw(Types.Asset memory asset, address receiver, uint256 amount) private { + _transferAsset(asset, receiver, amount); + emit Withdrawn(msg.sender, asset.token, amount); + } +} diff --git a/src/rewards/ServiceFeeDistributor.sol b/src/rewards/ServiceFeeDistributor.sol index 5894c715..23f90918 100644 --- a/src/rewards/ServiceFeeDistributor.sol +++ b/src/rewards/ServiceFeeDistributor.sol @@ -14,10 +14,15 @@ import { IServiceFeeDistributor } from "../interfaces/IServiceFeeDistributor.sol import { ITangleSecurityView } from "../interfaces/ITangleSecurityView.sol"; import { IPriceOracle } from "../oracles/interfaces/IPriceOracle.sol"; import { IStreamingPaymentManager } from "../interfaces/IStreamingPaymentManager.sol"; +import { ServiceFeeDistributorLib } from "./ServiceFeeDistributorLib.sol"; /// @title ServiceFeeDistributor /// @notice Distributes service-fee staker shares in the payment token, weighted by USD value and per-asset /// commitments. @dev O(1) per-operator/per-asset/per-token distribution using accumulated-per-score accounting. +/// @dev The heavy distribution/USD/score/harvest math lives in the deployed {ServiceFeeDistributorLib} (linked +/// via delegatecall) so this contract's runtime bytecode stays small enough for gas-metered code deposits. All +/// state lives in a single {ServiceFeeDistributorLib.Layout} at slot 0, in the exact slot order this contract +/// used before the extraction — the on-chain storage layout is unchanged. contract ServiceFeeDistributor is Initializable, UUPSUpgradeable, @@ -28,6 +33,7 @@ contract ServiceFeeDistributor is using SafeERC20 for IERC20; using EnumerableSet for EnumerableSet.AddressSet; using EnumerableSet for EnumerableSet.Bytes32Set; + using ServiceFeeDistributorLib for ServiceFeeDistributorLib.Layout; bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE"); @@ -35,89 +41,9 @@ contract ServiceFeeDistributor is uint256 public constant BPS_DENOMINATOR = 10_000; uint256 public constant PRECISION = 1e18; - /// @notice Authoritative staking contract that emits delegation-change hooks. - address public staking; - - /// @notice Tangle contract (source of service requirements + per-asset operator commitments + treasury). - address public tangle; - - /// @notice Price oracle used for USD weighting (address(0) disables USD weighting and uses raw amounts). - IPriceOracle public priceOracle; - - /// @notice InflationPool authorized to distribute inflation-funded staker rewards. - address public inflationPool; - - /// @notice StreamingPaymentManager for handling streamed payments - IStreamingPaymentManager public streamingManager; - - /// @notice TNT token address for score boost - address public tntToken; - - /// @notice TNT score rate: how much "USD score" 1 TNT is worth (18 decimals). - /// @dev If tntScoreRate = 1e18, then 1 TNT = $1 score regardless of actual market price. - /// If tntScoreRate = 0, TNT uses oracle price like other tokens. - uint256 public tntScoreRate; - - // operator => rewardToken set (payment tokens ever distributed for this operator) - // Note: This is a superset used for view functions. The more precise set is _operatorAssetRewardTokens - mapping(address => EnumerableSet.AddressSet) private _operatorRewardTokens; - - // operator => assetHash => rewardToken set (tokens distributed for this specific operator-asset pair) - // This is the primary set used for iteration - much smaller than _operatorRewardTokens - mapping(address => mapping(bytes32 => EnumerableSet.AddressSet)) private _operatorAssetRewardTokens; - - // operator => asset set (staking assets ever seen for this operator) - mapping(address => EnumerableSet.Bytes32Set) private _operatorAssetHashes; - - // assetHash => canonical asset metadata for USD pricing - mapping(bytes32 => Types.Asset) private _assetByHash; - mapping(bytes32 => bool) private _assetKnown; - - // Totals (score units are amount * lockMultiplierBps / 10_000). - mapping(address => mapping(bytes32 => uint256)) public totalAllScore; // operator => assetHash => totalScore - mapping(address => mapping(uint64 => mapping(bytes32 => uint256))) public totalFixedScore; // operator => - // blueprintId => assetHash => totalScore - mapping(address => mapping(bytes32 => uint256)) private _totalFixedScoreByAsset; // operator => assetHash => - // totalFixedScore - - // Slash factors (PRECISION = no slash). Applied only for USD weighting across assets. - mapping(address => mapping(bytes32 => uint256)) private _allSlashFactor; // operator => assetHash => factor - mapping(address => mapping(uint64 => mapping(bytes32 => uint256))) private _fixedSlashFactor; // operator => - // blueprintId => assetHash => factor - - // Accumulators. - mapping(address => mapping(bytes32 => mapping(address => uint256))) public accAllPerScore; // operator => assetHash - // => rewardToken => acc - mapping(address => mapping(uint64 => mapping(bytes32 => mapping(address => uint256)))) public accFixedPerScore; // operator - // => blueprintId => assetHash => rewardToken => acc - - // Position mode: 0 = none, 1 = All, 2 = Fixed - mapping(address => mapping(address => mapping(bytes32 => uint8))) private _positionMode; - // Principal and score for the position (delegator => operator => assetHash) - mapping(address => mapping(address => mapping(bytes32 => uint256))) private _positionPrincipal; - mapping(address => mapping(address => mapping(bytes32 => uint256))) private _positionScore; - mapping(address => mapping(address => mapping(bytes32 => mapping(uint64 => uint256)))) private _positionFixedScore; - - // Fixed-mode blueprint selection (delegator => operator => assetHash => blueprints) - mapping(address => mapping(address => mapping(bytes32 => uint64[]))) private _fixedBlueprints; - mapping(address => mapping(address => mapping(bytes32 => mapping(uint64 => uint256)))) private - _fixedBlueprintIndexPlusOne; - - // Reward debt (per token) to prevent retroactive claims. - // debt = score * accumulator at time of last sync. pending = score * acc - debt - mapping(address => mapping(address => mapping(bytes32 => mapping(address => uint256)))) private _debtAll; - // delegator => operator => assetHash => token => debt - mapping(address => mapping(address => mapping(uint64 => mapping(bytes32 => mapping(address => uint256))))) private - _debtFixed; - // delegator => operator => blueprintId => assetHash => token => debt - - // Claimable balances (per payment token). - mapping(address => mapping(address => uint256)) public claimable; // account => token => amount - - // Delegator position tracking for claimAll iteration - mapping(address => EnumerableSet.AddressSet) private _delegatorOperators; // delegator => operators - mapping(address => mapping(address => EnumerableSet.Bytes32Set)) private _delegatorAssets; // delegator => operator - // => assetHashes + /// @dev All distributor storage, declared in {ServiceFeeDistributorLib.Layout} at slot 0. Field order there + /// matches the pre-refactor plain-variable layout slot-for-slot, so this is a layout-preserving change. + ServiceFeeDistributorLib.Layout private _s; event StakingConfigured(address indexed staking); event TangleConfigured(address indexed tangle); @@ -154,6 +80,68 @@ contract ServiceFeeDistributor is error UnexpectedMsgValue(); error EthTransferFailed(); + // ═══════════════════════════════════════════════════════════════════════════ + // PUBLIC STATE GETTERS + // ═══════════════════════════════════════════════════════════════════════════ + // These reproduce the auto-generated getters of the former `public` state variables so the ABI is unchanged. + + function staking() external view returns (address) { + return _s.staking; + } + + function tangle() external view returns (address) { + return _s.tangle; + } + + function priceOracle() external view returns (IPriceOracle) { + return _s.priceOracle; + } + + function inflationPool() external view returns (address) { + return _s.inflationPool; + } + + function streamingManager() external view returns (IStreamingPaymentManager) { + return _s.streamingManager; + } + + function tntToken() external view returns (address) { + return _s.tntToken; + } + + function tntScoreRate() external view returns (uint256) { + return _s.tntScoreRate; + } + + function totalAllScore(address operator, bytes32 assetHash) external view returns (uint256) { + return _s.totalAllScore[operator][assetHash]; + } + + function totalFixedScore(address operator, uint64 blueprintId, bytes32 assetHash) external view returns (uint256) { + return _s.totalFixedScore[operator][blueprintId][assetHash]; + } + + function accAllPerScore(address operator, bytes32 assetHash, address rewardToken) external view returns (uint256) { + return _s.accAllPerScore[operator][assetHash][rewardToken]; + } + + function accFixedPerScore( + address operator, + uint64 blueprintId, + bytes32 assetHash, + address rewardToken + ) + external + view + returns (uint256) + { + return _s.accFixedPerScore[operator][blueprintId][assetHash][rewardToken]; + } + + function claimable(address account, address token) external view returns (uint256) { + return _s.claimable[account][token]; + } + /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); @@ -168,9 +156,9 @@ contract ServiceFeeDistributor is _grantRole(ADMIN_ROLE, admin); _grantRole(UPGRADER_ROLE, admin); - staking = staking_; - tangle = tangle_; - priceOracle = IPriceOracle(oracle_); + _s.staking = staking_; + _s.tangle = tangle_; + _s.priceOracle = IPriceOracle(oracle_); emit StakingConfigured(staking_); emit TangleConfigured(tangle_); @@ -178,27 +166,27 @@ contract ServiceFeeDistributor is } function setStaking(address staking_) external onlyRole(ADMIN_ROLE) { - staking = staking_; + _s.staking = staking_; emit StakingConfigured(staking_); } function setTangle(address tangle_) external onlyRole(ADMIN_ROLE) { - tangle = tangle_; + _s.tangle = tangle_; emit TangleConfigured(tangle_); } function setPriceOracle(address oracle_) external onlyRole(ADMIN_ROLE) { - priceOracle = IPriceOracle(oracle_); + _s.priceOracle = IPriceOracle(oracle_); emit PriceOracleConfigured(oracle_); } function setInflationPool(address pool_) external onlyRole(ADMIN_ROLE) { - inflationPool = pool_; + _s.inflationPool = pool_; emit InflationPoolConfigured(pool_); } function setStreamingManager(address streamingManager_) external onlyRole(ADMIN_ROLE) { - streamingManager = IStreamingPaymentManager(streamingManager_); + _s.streamingManager = IStreamingPaymentManager(streamingManager_); emit StreamingManagerConfigured(streamingManager_); } @@ -209,8 +197,8 @@ contract ServiceFeeDistributor is /// @dev Example: If TNT market price is $0.10 and scoreRate_ = 1e18, /// then 1 TNT earns 10x the fee share compared to its market value. function setTntScoreRate(address tntToken_, uint256 scoreRate_) external onlyRole(ADMIN_ROLE) { - tntToken = tntToken_; - tntScoreRate = scoreRate_; + _s.tntToken = tntToken_; + _s.tntScoreRate = scoreRate_; emit TntScoreRateConfigured(tntToken_, scoreRate_); } @@ -233,33 +221,33 @@ contract ServiceFeeDistributor is external override { - if (msg.sender != staking) revert NotStaking(); + if (msg.sender != _s.staking) revert NotStaking(); // IMPORTANT: Drip all active streams for this operator BEFORE score changes // This ensures rewards are distributed with the scores that were active // during the streaming period, not the new scores after delegation change _dripOperatorStreams(operator); - bytes32 assetHash = _assetHash(asset); + bytes32 assetHash = ServiceFeeDistributorLib.assetHash(asset); // F5: collapse any PRIOR expired lock boost to base before applying this change, so a // stale boost is never carried into the new score (and stops diluting others). _settleExpiredLock(delegator, operator, assetHash); - if (!_assetKnown[assetHash]) { - _assetKnown[assetHash] = true; - _assetByHash[assetHash] = Types.Asset({ kind: asset.kind, token: asset.token }); + if (!_s._assetKnown[assetHash]) { + _s._assetKnown[assetHash] = true; + _s._assetByHash[assetHash] = Types.Asset({ kind: asset.kind, token: asset.token }); } - _operatorAssetHashes[operator].add(assetHash); + _s._operatorAssetHashes[operator].add(assetHash); uint8 newMode = selectionMode == Types.BlueprintSelectionMode.All ? 1 : 2; - uint8 existingMode = _positionMode[delegator][operator][assetHash]; + uint8 existingMode = _s._positionMode[delegator][operator][assetHash]; if (existingMode != 0 && existingMode != newMode) revert InvalidModeChange(); // Harvest for all known payment tokens before mutating scores. - _harvestAllTokens(delegator, operator, assetHash, existingMode == 0 ? newMode : existingMode); + _s.harvestAllTokens(delegator, operator, assetHash, existingMode == 0 ? newMode : existingMode); // Update principal/score. - uint256 principalBefore = _positionPrincipal[delegator][operator][assetHash]; - uint256 scoreBefore = _positionScore[delegator][operator][assetHash]; + uint256 principalBefore = _s._positionPrincipal[delegator][operator][assetHash]; + uint256 scoreBefore = _s._positionScore[delegator][operator][assetHash]; // F5: `lockMultiplierBps` boosts the score here, and `lockExpiry` (threaded from the // staking layer) records when that boost ends. `_settleExpiredLock` (called above and on @@ -273,8 +261,8 @@ contract ServiceFeeDistributor is scoreDelta = (amount * lockMultiplierBps) / BPS_DENOMINATOR; principalAfter = principalBefore + amount; scoreAfter = scoreBefore + scoreDelta; - _positionPrincipal[delegator][operator][assetHash] = principalAfter; - _positionScore[delegator][operator][assetHash] = scoreAfter; + _s._positionPrincipal[delegator][operator][assetHash] = principalAfter; + _s._positionScore[delegator][operator][assetHash] = scoreAfter; } else { // Decrease: preserve proportional score (handles prior lock multipliers). principalAfter = amount > principalBefore ? 0 : principalBefore - amount; @@ -282,36 +270,36 @@ contract ServiceFeeDistributor is scoreDelta = (scoreBefore * amount) / principalBefore; } scoreAfter = scoreDelta > scoreBefore ? 0 : scoreBefore - scoreDelta; - _positionPrincipal[delegator][operator][assetHash] = principalAfter; - _positionScore[delegator][operator][assetHash] = scoreAfter; + _s._positionPrincipal[delegator][operator][assetHash] = principalAfter; + _s._positionScore[delegator][operator][assetHash] = scoreAfter; } - _positionMode[delegator][operator][assetHash] = newMode; + _s._positionMode[delegator][operator][assetHash] = newMode; // F5: record the lock expiry that backs this (possibly boosted) score so the boost can // be decayed to base once every lock elapses. - _positionLockExpiry[delegator][operator][assetHash] = lockExpiry; + _s._positionLockExpiry[delegator][operator][assetHash] = lockExpiry; // Track delegator position for claimAll iteration (only on first interaction) if (existingMode == 0) { - _delegatorOperators[delegator].add(operator); - _delegatorAssets[delegator][operator].add(assetHash); + _s._delegatorOperators[delegator].add(operator); + _s._delegatorAssets[delegator][operator].add(assetHash); } // Sync Fixed blueprint set from staking payload (authoritative). if (newMode == 2) { - _setFixedBlueprints(delegator, operator, assetHash, blueprintIds); + _s.setFixedBlueprints(delegator, operator, assetHash, blueprintIds); } // Update totals. if (newMode == 1) { - totalAllScore[operator][assetHash] = isIncrease - ? totalAllScore[operator][assetHash] + scoreDelta - : (scoreDelta > totalAllScore[operator][assetHash] + _s.totalAllScore[operator][assetHash] = isIncrease + ? _s.totalAllScore[operator][assetHash] + scoreDelta + : (scoreDelta > _s.totalAllScore[operator][assetHash] ? 0 - : totalAllScore[operator][assetHash] - scoreDelta); + : _s.totalAllScore[operator][assetHash] - scoreDelta); } else { - _applyFixedScoreDelta( + _s.applyFixedScoreDelta( delegator, operator, assetHash, scoreDelta, amount, isIncrease, blueprintIds, blueprintAmounts ); } @@ -319,7 +307,7 @@ contract ServiceFeeDistributor is _pruneOperatorAssetIfEmpty(operator, assetHash); // Reset reward debt to prevent retroactive claims with the new score. - _syncDebtsToCurrentAcc(delegator, operator, assetHash, newMode); + _s.syncDebtsToCurrentAcc(delegator, operator, assetHash, newMode); if (principalAfter == 0 && scoreAfter == 0) { _pruneDelegatorPosition(delegator, operator, assetHash); @@ -330,19 +318,19 @@ contract ServiceFeeDistributor is operator, asset.token, selectionMode, - _positionPrincipal[delegator][operator][assetHash], - _positionScore[delegator][operator][assetHash] + _s._positionPrincipal[delegator][operator][assetHash], + _s._positionScore[delegator][operator][assetHash] ); } function onAllModeSlashed(address operator, Types.Asset calldata asset, uint16 slashBps) external override { - if (msg.sender != staking) revert NotStaking(); + if (msg.sender != _s.staking) revert NotStaking(); if (slashBps == 0) return; if (slashBps > BPS_DENOMINATOR) slashBps = uint16(BPS_DENOMINATOR); - bytes32 assetHash = _assetHash(asset); - uint256 current = _getAllSlashFactor(operator, assetHash); - _allSlashFactor[operator][assetHash] = (current * (BPS_DENOMINATOR - slashBps)) / BPS_DENOMINATOR; + bytes32 assetHash = ServiceFeeDistributorLib.assetHash(asset); + uint256 current = _s.getAllSlashFactor(operator, assetHash); + _s._allSlashFactor[operator][assetHash] = (current * (BPS_DENOMINATOR - slashBps)) / BPS_DENOMINATOR; } function onFixedModeSlashed( @@ -354,13 +342,14 @@ contract ServiceFeeDistributor is external override { - if (msg.sender != staking) revert NotStaking(); + if (msg.sender != _s.staking) revert NotStaking(); if (slashBps == 0) return; if (slashBps > BPS_DENOMINATOR) slashBps = uint16(BPS_DENOMINATOR); - bytes32 assetHash = _assetHash(asset); - uint256 current = _getFixedSlashFactor(operator, blueprintId, assetHash); - _fixedSlashFactor[operator][blueprintId][assetHash] = (current * (BPS_DENOMINATOR - slashBps)) / BPS_DENOMINATOR; + bytes32 assetHash = ServiceFeeDistributorLib.assetHash(asset); + uint256 current = _s.getFixedSlashFactor(operator, blueprintId, assetHash); + _s._fixedSlashFactor[operator][blueprintId][assetHash] = + (current * (BPS_DENOMINATOR - slashBps)) / BPS_DENOMINATOR; } function onBlueprintsRebalanced( @@ -373,13 +362,13 @@ contract ServiceFeeDistributor is external override { - if (msg.sender != staking) revert NotStaking(); + if (msg.sender != _s.staking) revert NotStaking(); // Drip before score changes _dripOperatorStreams(operator); - bytes32 assetHash = _assetHash(asset); - if (_positionMode[delegator][operator][assetHash] != 2) { + bytes32 assetHash = ServiceFeeDistributorLib.assetHash(asset); + if (_s._positionMode[delegator][operator][assetHash] != 2) { revert InvalidModeChange(); } @@ -390,63 +379,11 @@ contract ServiceFeeDistributor is revert InvalidBlueprintAmounts(); } - _harvestAllTokens(delegator, operator, assetHash, 2); - - uint64[] storage existing = _fixedBlueprints[delegator][operator][assetHash]; - // `_totalFixedScoreByAsset[operator][assetHash]` is one slot re-touched every iteration; - // load once, apply the identical saturating subtraction in memory, store once after the - // loop. Each step reads exactly what the previous wrote, so the stored result is identical. - uint256 curTotalByAsset = _totalFixedScoreByAsset[operator][assetHash]; - uint256 existingLen = existing.length; - for (uint256 i = 0; i < existingLen;) { - uint64 bpId = existing[i]; - uint256 oldScore = _positionFixedScore[delegator][operator][assetHash][bpId]; - if (oldScore != 0) { - uint256 cur = totalFixedScore[operator][bpId][assetHash]; - totalFixedScore[operator][bpId][assetHash] = oldScore > cur ? 0 : cur - oldScore; - curTotalByAsset = oldScore > curTotalByAsset ? 0 : curTotalByAsset - oldScore; - _positionFixedScore[delegator][operator][assetHash][bpId] = 0; - } - unchecked { - ++i; - } - } - _totalFixedScoreByAsset[operator][assetHash] = curTotalByAsset; - - uint256 totalAmount = 0; - uint256 amountsLen = blueprintAmounts.length; - for (uint256 i = 0; i < amountsLen;) { - totalAmount += blueprintAmounts[i]; - unchecked { - ++i; - } - } - - uint256 userScore = _positionScore[delegator][operator][assetHash]; - if (totalAmount > 0 && userScore > 0) { - uint256 remainingScore = userScore; - uint256 idsLen = blueprintIds.length; - // Accumulate the single `_totalFixedScoreByAsset` slot in memory, store once at the end. - uint256 acc = _totalFixedScoreByAsset[operator][assetHash]; - for (uint256 i = 0; i < idsLen;) { - uint64 bpId = blueprintIds[i]; - uint256 scoreForBlueprint = - i == idsLen - 1 ? remainingScore : (userScore * blueprintAmounts[i]) / totalAmount; - remainingScore = remainingScore > scoreForBlueprint ? remainingScore - scoreForBlueprint : 0; - - _positionFixedScore[delegator][operator][assetHash][bpId] = scoreForBlueprint; - totalFixedScore[operator][bpId][assetHash] += scoreForBlueprint; - acc += scoreForBlueprint; - unchecked { - ++i; - } - } - _totalFixedScoreByAsset[operator][assetHash] = acc; - } + _s.harvestAllTokens(delegator, operator, assetHash, 2); - _setFixedBlueprints(delegator, operator, assetHash, blueprintIds); + _s.rebalanceBlueprints(delegator, operator, assetHash, blueprintIds, blueprintAmounts); - _syncDebtsToCurrentAcc(delegator, operator, assetHash, 2); + _s.syncDebtsToCurrentAcc(delegator, operator, assetHash, 2); _pruneOperatorAssetIfEmpty(operator, assetHash); } @@ -454,18 +391,18 @@ contract ServiceFeeDistributor is /// @notice Called when an operator is about to leave a service /// @dev Delegates to StreamingPaymentManager to drip before removal function onOperatorLeaving(uint64 serviceId, address operator) external override { - if (msg.sender != tangle) revert NotTangle(); - if (address(streamingManager) != address(0)) { - streamingManager.onOperatorLeaving(serviceId, operator); + if (msg.sender != _s.tangle) revert NotTangle(); + if (address(_s.streamingManager) != address(0)) { + _s.streamingManager.onOperatorLeaving(serviceId, operator); } } /// @notice Called when a service is terminated early - refunds remaining streamed payments /// @dev Delegates to StreamingPaymentManager which handles refunds function onServiceTerminated(uint64 serviceId, address refundRecipient) external override { - if (msg.sender != tangle) revert NotTangle(); - if (address(streamingManager) != address(0)) { - streamingManager.onServiceTerminated(serviceId, refundRecipient); + if (msg.sender != _s.tangle) revert NotTangle(); + if (address(_s.streamingManager) != address(0)) { + _s.streamingManager.onServiceTerminated(serviceId, refundRecipient); } } @@ -485,7 +422,7 @@ contract ServiceFeeDistributor is override nonReentrant { - if (msg.sender != tangle) revert NotTangle(); + if (msg.sender != _s.tangle) revert NotTangle(); if (amount == 0) return; // Pull-payment for the ERC20 leg (via the caller's approval) lets the caller @@ -493,11 +430,11 @@ contract ServiceFeeDistributor is // stranding tokens at the distributor. _receivePayment(paymentToken, amount, true); - _operatorRewardTokens[operator].add(paymentToken); + _s._operatorRewardTokens[operator].add(paymentToken); // Check if service has TTL and streaming is enabled - if so, use streaming - Types.Service memory svc = ITangleSecurityView(tangle).getService(serviceId); - if (svc.ttl > 0 && address(streamingManager) != address(0)) { + Types.Service memory svc = ITangleSecurityView(_s.tangle).getService(serviceId); + if (svc.ttl > 0 && address(_s.streamingManager) != address(0)) { // Stream payment over service lifetime uint64 startTime = svc.createdAt; uint64 endTime = svc.createdAt + svc.ttl; @@ -511,9 +448,9 @@ contract ServiceFeeDistributor is if (endTime > startTime) { // Transfer payment to streaming manager if (paymentToken != address(0)) { - IERC20(paymentToken).safeTransfer(address(streamingManager), amount); + IERC20(paymentToken).safeTransfer(address(_s.streamingManager), amount); } - streamingManager.createStream{ value: paymentToken == address(0) ? amount : 0 }( + _s.streamingManager.createStream{ value: paymentToken == address(0) ? amount : 0 }( serviceId, blueprintId, operator, paymentToken, amount, startTime, endTime ); emit ServiceFeeDistributed(serviceId, blueprintId, operator, paymentToken, amount); @@ -540,17 +477,18 @@ contract ServiceFeeDistributor is override nonReentrant { - if (msg.sender != inflationPool) revert NotInflationPool(); + if (msg.sender != _s.inflationPool) revert NotInflationPool(); if (amount == 0) return; _receivePayment(paymentToken, amount, false); - _operatorRewardTokens[operator].add(paymentToken); + _s._operatorRewardTokens[operator].add(paymentToken); _distributeImmediate(serviceId, blueprintId, operator, paymentToken, amount); emit ServiceFeeDistributed(serviceId, blueprintId, operator, paymentToken, amount); } - /// @notice Distribute payment immediately (for services without TTL) + /// @notice Distribute payment immediately (for services without TTL). Thin wrapper over the + /// deployed library's consolidated `distributeImmediate` (keeps the 3 call sites terse). function _distributeImmediate( uint64 serviceId, uint64 blueprintId, @@ -560,218 +498,7 @@ contract ServiceFeeDistributor is ) internal { - Types.AssetSecurityRequirement[] memory reqs = - ITangleSecurityView(tangle).getServiceSecurityRequirements(serviceId); - if (reqs.length == 0) { - _distributeWithoutRequirements(serviceId, blueprintId, operator, paymentToken, amount); - return; - } - - (uint256 allUsdTotal, uint256 fixedUsdTotal) = - _computeUsdTotalsForRequirements(serviceId, blueprintId, operator, reqs); - - uint256 totalUsd = allUsdTotal + fixedUsdTotal; - if (totalUsd == 0) { - _transferPayment(ITangleSecurityView(tangle).treasury(), paymentToken, amount); - return; - } - - uint256 allAmount = (amount * allUsdTotal) / totalUsd; - uint256 fixedAmount = amount - allAmount; - - if (allAmount > 0 && allUsdTotal > 0) { - _distributeForRequirements(serviceId, 0, operator, paymentToken, allAmount, allUsdTotal, reqs, false); - } - - if (fixedAmount > 0 && fixedUsdTotal > 0) { - _distributeForRequirements( - serviceId, blueprintId, operator, paymentToken, fixedAmount, fixedUsdTotal, reqs, true - ); - } - } - - function _distributeWithoutRequirements( - uint64 serviceId, - uint64 blueprintId, - address operator, - address paymentToken, - uint256 amount - ) - internal - { - serviceId; // silence unused warning (reserved for future policy gating) - - EnumerableSet.Bytes32Set storage set = _operatorAssetHashes[operator]; - uint256 assetCount = set.length(); - if (assetCount == 0) { - _transferPayment(ITangleSecurityView(tangle).treasury(), paymentToken, amount); - return; - } - - (uint256 allUsdTotal, uint256 fixedUsdTotal) = _computeUsdTotalsForOperatorAssets(operator, blueprintId, set); - - uint256 totalUsd = allUsdTotal + fixedUsdTotal; - if (totalUsd == 0) { - _transferPayment(ITangleSecurityView(tangle).treasury(), paymentToken, amount); - return; - } - - uint256 allAmount = (amount * allUsdTotal) / totalUsd; - uint256 fixedAmount = amount - allAmount; - - if (allAmount > 0 && allUsdTotal > 0) { - _distributeForOperatorAssets(operator, 0, paymentToken, allAmount, allUsdTotal, set, false); - } - - if (fixedAmount > 0 && fixedUsdTotal > 0) { - _distributeForOperatorAssets(operator, blueprintId, paymentToken, fixedAmount, fixedUsdTotal, set, true); - } - } - - function _computeUsdTotalsForRequirements( - uint64 serviceId, - uint64 blueprintId, - address operator, - Types.AssetSecurityRequirement[] memory reqs - ) - internal - view - returns (uint256 allUsdTotal, uint256 fixedUsdTotal) - { - for (uint256 i = 0; i < reqs.length; i++) { - Types.Asset memory a = reqs[i].asset; - bytes32 assetHash = _assetHash(a); - - uint16 commitmentBps = - ITangleSecurityView(tangle).getServiceSecurityCommitmentBps(serviceId, operator, a.kind, a.token); - if (commitmentBps == 0) continue; - - uint256 allScore = totalAllScore[operator][assetHash]; - uint256 fixedScore = totalFixedScore[operator][blueprintId][assetHash]; - - uint256 allEffective = _applySlashFactor(allScore, _getAllSlashFactor(operator, assetHash)); - uint256 fixedEffective = - _applySlashFactor(fixedScore, _getFixedSlashFactor(operator, blueprintId, assetHash)); - - uint256 allExposed = (allEffective * commitmentBps) / BPS_DENOMINATOR; - uint256 fixedExposed = (fixedEffective * commitmentBps) / BPS_DENOMINATOR; - - allUsdTotal += _toUsd(a, allExposed); - fixedUsdTotal += _toUsd(a, fixedExposed); - } - } - - /// @dev Score-weighted split of `amount` across a service's security requirements. - /// `fixedMode` selects the Fixed-mode pools (per-blueprint) vs the All-mode pools. - function _distributeForRequirements( - uint64 serviceId, - uint64 blueprintId, - address operator, - address paymentToken, - uint256 amount, - uint256 usdTotal, - Types.AssetSecurityRequirement[] memory reqs, - bool fixedMode - ) - internal - { - uint256 remaining = amount; - uint256 remainingUsd = usdTotal; - - for (uint256 i = 0; i < reqs.length && remaining > 0; i++) { - Types.Asset memory a = reqs[i].asset; - bytes32 assetHash = _assetHash(a); - uint16 commitmentBps = - ITangleSecurityView(tangle).getServiceSecurityCommitmentBps(serviceId, operator, a.kind, a.token); - if (commitmentBps == 0) continue; - - uint256 score = - fixedMode ? totalFixedScore[operator][blueprintId][assetHash] : totalAllScore[operator][assetHash]; - if (score == 0) continue; - - uint256 factor = fixedMode - ? _getFixedSlashFactor(operator, blueprintId, assetHash) - : _getAllSlashFactor(operator, assetHash); - uint256 usd = _toUsd(a, (_applySlashFactor(score, factor) * commitmentBps) / BPS_DENOMINATOR); - if (usd == 0) continue; - - uint256 share = (remaining * usd) / remainingUsd; - remaining -= share; - remainingUsd -= usd; - if (share == 0) continue; - - if (fixedMode) { - accFixedPerScore[operator][blueprintId][assetHash][paymentToken] += (share * PRECISION) / score; - } else { - accAllPerScore[operator][assetHash][paymentToken] += (share * PRECISION) / score; - } - _operatorAssetRewardTokens[operator][assetHash].add(paymentToken); - } - } - - function _computeUsdTotalsForOperatorAssets( - address operator, - uint64 blueprintId, - EnumerableSet.Bytes32Set storage set - ) - internal - view - returns (uint256 allUsdTotal, uint256 fixedUsdTotal) - { - uint256 assetCount = set.length(); - for (uint256 i = 0; i < assetCount; i++) { - bytes32 assetHash = set.at(i); - Types.Asset memory asset = _assetByHash[assetHash]; - uint256 allScore = totalAllScore[operator][assetHash]; - uint256 fixedScore = totalFixedScore[operator][blueprintId][assetHash]; - allUsdTotal += _toUsd(asset, _applySlashFactor(allScore, _getAllSlashFactor(operator, assetHash))); - fixedUsdTotal += _toUsd( - asset, _applySlashFactor(fixedScore, _getFixedSlashFactor(operator, blueprintId, assetHash)) - ); - } - } - - /// @dev Score-weighted split of `amount` across an operator's registered assets when the - /// service declares no requirements. `fixedMode` selects Fixed vs All pools. - function _distributeForOperatorAssets( - address operator, - uint64 blueprintId, - address paymentToken, - uint256 amount, - uint256 usdTotal, - EnumerableSet.Bytes32Set storage set, - bool fixedMode - ) - internal - { - uint256 remaining = amount; - uint256 remainingUsd = usdTotal; - - uint256 assetCount = set.length(); - for (uint256 i = 0; i < assetCount && remaining > 0; i++) { - bytes32 assetHash = set.at(i); - uint256 denom = - fixedMode ? totalFixedScore[operator][blueprintId][assetHash] : totalAllScore[operator][assetHash]; - if (denom == 0) continue; - - uint256 factor = fixedMode - ? _getFixedSlashFactor(operator, blueprintId, assetHash) - : _getAllSlashFactor(operator, assetHash); - uint256 usd = _toUsd(_assetByHash[assetHash], _applySlashFactor(denom, factor)); - if (usd == 0) continue; - - uint256 share = (remaining * usd) / remainingUsd; - remaining -= share; - remainingUsd -= usd; - if (share == 0) continue; - - if (fixedMode) { - accFixedPerScore[operator][blueprintId][assetHash][paymentToken] += (share * PRECISION) / denom; - } else { - accAllPerScore[operator][assetHash][paymentToken] += (share * PRECISION) / denom; - } - _operatorAssetRewardTokens[operator][assetHash].add(paymentToken); - } + _s.distributeImmediate(serviceId, blueprintId, operator, paymentToken, amount); } // ═══════════════════════════════════════════════════════════════════════════ @@ -787,14 +514,14 @@ contract ServiceFeeDistributor is nonReentrant returns (uint256 amount) { - bytes32 assetHash = _assetHash(asset); - uint8 mode = _positionMode[msg.sender][operator][assetHash]; + bytes32 assetHash = ServiceFeeDistributorLib.assetHash(asset); + uint8 mode = _s._positionMode[msg.sender][operator][assetHash]; if (mode != 0) { // Drip any pending streaming payments to get up-to-date rewards _dripOperatorStreams(operator); // F5: decay an expired lock boost before harvesting so future accrual is at base. _settleExpiredLock(msg.sender, operator, assetHash); - _harvestToken(msg.sender, operator, assetHash, token, mode); + _s.harvestToken(msg.sender, operator, assetHash, token, mode); } amount = _payoutClaimable(msg.sender, token); @@ -803,9 +530,9 @@ contract ServiceFeeDistributor is /// @dev Zero out and pay a claimant's accrued `claimable` balance for `token`. Shared /// terminal-transfer step for the claim entrypoints (each carries `nonReentrant`). function _payoutClaimable(address account, address token) internal returns (uint256 amount) { - amount = claimable[account][token]; + amount = _s.claimable[account][token]; if (amount == 0) return 0; - claimable[account][token] = 0; + _s.claimable[account][token] = 0; _transferPayment(payable(account), token, amount); emit Claimed(account, token, amount); @@ -838,7 +565,7 @@ contract ServiceFeeDistributor is /// followed by `_transferPayment` is strict CEI for the top-level /// transfer. Audit Round 2 Slither finding — verified non-exploitable. function _claimAllForToken(address account, address token) internal returns (uint256 totalAmount) { - EnumerableSet.AddressSet storage operators = _delegatorOperators[account]; + EnumerableSet.AddressSet storage operators = _s._delegatorOperators[account]; uint256 opLen = operators.length(); for (uint256 i = 0; i < opLen; i++) { @@ -847,91 +574,23 @@ contract ServiceFeeDistributor is // Drip all streams for this operator to get up-to-date rewards _dripOperatorStreams(operator); - EnumerableSet.Bytes32Set storage assets = _delegatorAssets[account][operator]; + EnumerableSet.Bytes32Set storage assets = _s._delegatorAssets[account][operator]; uint256 assetLen = assets.length(); for (uint256 j = 0; j < assetLen; j++) { bytes32 assetHash = assets.at(j); - uint8 mode = _positionMode[account][operator][assetHash]; + uint8 mode = _s._positionMode[account][operator][assetHash]; if (mode == 0) continue; // F5: decay an expired lock boost before harvesting so future accrual is at base. _settleExpiredLock(account, operator, assetHash); - _harvestToken(account, operator, assetHash, token, mode); + _s.harvestToken(account, operator, assetHash, token, mode); } } totalAmount = _payoutClaimable(account, token); } - /// @dev Compute a position's pending reward for `token` without mutating debt. Shared - /// read-side accrual math for {_harvestToken} and {pendingRewards}; the harvest - /// path advances debt separately via {_settlePositionDebt} so this stays `view`. - function _pendingPosition( - address delegator, - address operator, - bytes32 assetHash, - address token, - uint8 mode - ) - internal - view - returns (uint256 pending) - { - if (mode == 1) { - uint256 userScore = _positionScore[delegator][operator][assetHash]; - if (userScore == 0) return 0; - uint256 accumulated = (userScore * accAllPerScore[operator][assetHash][token]) / PRECISION; - uint256 debt = _debtAll[delegator][operator][assetHash][token]; - if (accumulated > debt) pending = accumulated - debt; - return pending; - } - - uint64[] storage bps = _fixedBlueprints[delegator][operator][assetHash]; - for (uint256 i = 0; i < bps.length; i++) { - uint64 bpId = bps[i]; - uint256 blueprintScore = _positionFixedScore[delegator][operator][assetHash][bpId]; - if (blueprintScore == 0) continue; - uint256 accumulated = (blueprintScore * accFixedPerScore[operator][bpId][assetHash][token]) / PRECISION; - uint256 debt = _debtFixed[delegator][operator][bpId][assetHash][token]; - if (accumulated > debt) pending += accumulated - debt; - } - } - - /// @dev Advance a harvested position's reward debt to the current accumulator. - function _settlePositionDebt( - address delegator, - address operator, - bytes32 assetHash, - address token, - uint8 mode - ) - internal - { - if (mode == 1) { - uint256 userScore = _positionScore[delegator][operator][assetHash]; - if (userScore == 0) return; - _debtAll[delegator][operator][assetHash][token] = - (userScore * accAllPerScore[operator][assetHash][token]) / PRECISION; - return; - } - - uint64[] storage bps = _fixedBlueprints[delegator][operator][assetHash]; - for (uint256 i = 0; i < bps.length; i++) { - uint64 bpId = bps[i]; - uint256 blueprintScore = _positionFixedScore[delegator][operator][assetHash][bpId]; - if (blueprintScore == 0) continue; - _debtFixed[delegator][operator][bpId][assetHash][token] = - (blueprintScore * accFixedPerScore[operator][bpId][assetHash][token]) / PRECISION; - } - } - - function _harvestToken(address delegator, address operator, bytes32 assetHash, address token, uint8 mode) internal { - uint256 pending = _pendingPosition(delegator, operator, assetHash, token, mode); - if (pending > 0) claimable[delegator][token] += pending; - _settlePositionDebt(delegator, operator, assetHash, token, mode); - } - // ═══════════════════════════════════════════════════════════════════════════ // VIEWS // ═══════════════════════════════════════════════════════════════════════════ @@ -946,10 +605,13 @@ contract ServiceFeeDistributor is override returns (uint256 allScore, uint256 fixedScore) { - bytes32 assetHash = _assetHash(asset); - allScore = _applySlashFactor(totalAllScore[operator][assetHash], _getAllSlashFactor(operator, assetHash)); - fixedScore = _applySlashFactor( - totalFixedScore[operator][blueprintId][assetHash], _getFixedSlashFactor(operator, blueprintId, assetHash) + bytes32 assetHash = ServiceFeeDistributorLib.assetHash(asset); + allScore = ServiceFeeDistributorLib.applySlashFactor( + _s.totalAllScore[operator][assetHash], _s.getAllSlashFactor(operator, assetHash) + ); + fixedScore = ServiceFeeDistributorLib.applySlashFactor( + _s.totalFixedScore[operator][blueprintId][assetHash], + _s.getFixedSlashFactor(operator, blueprintId, assetHash) ); } @@ -964,30 +626,30 @@ contract ServiceFeeDistributor is returns (uint256 totalUsdExposure) { Types.AssetSecurityRequirement[] memory reqs = - ITangleSecurityView(tangle).getServiceSecurityRequirements(serviceId); + ITangleSecurityView(_s.tangle).getServiceSecurityRequirements(serviceId); if (reqs.length == 0) { - EnumerableSet.Bytes32Set storage set = _operatorAssetHashes[operator]; - (uint256 allUsd, uint256 fixedUsd) = _computeUsdTotalsForOperatorAssets(operator, blueprintId, set); + EnumerableSet.Bytes32Set storage set = _s._operatorAssetHashes[operator]; + (uint256 allUsd, uint256 fixedUsd) = _s.computeUsdTotalsForOperatorAssets(operator, blueprintId, set); return allUsd + fixedUsd; } (uint256 allUsdTotal, uint256 fixedUsdTotal) = - _computeUsdTotalsForRequirements(serviceId, blueprintId, operator, reqs); + _s.computeUsdTotalsForRequirements(serviceId, blueprintId, operator, reqs); return allUsdTotal + fixedUsdTotal; } function operatorRewardTokens(address operator) external view returns (address[] memory) { - return _operatorRewardTokens[operator].values(); + return _s._operatorRewardTokens[operator].values(); } /// @notice Get all operators a delegator has positions with function delegatorOperators(address delegator) external view returns (address[] memory) { - return _delegatorOperators[delegator].values(); + return _s._delegatorOperators[delegator].values(); } /// @notice Get all asset hashes a delegator has positions for with a specific operator function delegatorAssets(address delegator, address operator) external view returns (bytes32[] memory) { - return _delegatorAssets[delegator][operator].values(); + return _s._delegatorAssets[delegator][operator].values(); } /// @notice Get a delegator's position details @@ -1000,28 +662,28 @@ contract ServiceFeeDistributor is view returns (uint8 mode, uint256 principal, uint256 score) { - mode = _positionMode[delegator][operator][assetHash]; - principal = _positionPrincipal[delegator][operator][assetHash]; - score = _positionScore[delegator][operator][assetHash]; + mode = _s._positionMode[delegator][operator][assetHash]; + principal = _s._positionPrincipal[delegator][operator][assetHash]; + score = _s._positionScore[delegator][operator][assetHash]; } /// @notice Preview pending rewards for a delegator across all positions for a token function pendingRewards(address delegator, address token) external view returns (uint256 pending) { - pending = claimable[delegator][token]; + pending = _s.claimable[delegator][token]; - EnumerableSet.AddressSet storage operators = _delegatorOperators[delegator]; + EnumerableSet.AddressSet storage operators = _s._delegatorOperators[delegator]; uint256 opLen = operators.length(); for (uint256 i = 0; i < opLen; i++) { address operator = operators.at(i); - EnumerableSet.Bytes32Set storage assets = _delegatorAssets[delegator][operator]; + EnumerableSet.Bytes32Set storage assets = _s._delegatorAssets[delegator][operator]; uint256 assetLen = assets.length(); for (uint256 j = 0; j < assetLen; j++) { bytes32 assetHash = assets.at(j); - uint8 mode = _positionMode[delegator][operator][assetHash]; + uint8 mode = _s._positionMode[delegator][operator][assetHash]; if (mode == 0) continue; - pending += _pendingPosition(delegator, operator, assetHash, token, mode); + pending += _s.pendingPosition(delegator, operator, assetHash, token, mode); } } } @@ -1038,63 +700,9 @@ contract ServiceFeeDistributor is /// live, lazily), THEN the score is collapsed to principal and the per-token debt re-synced /// so future accrual is at base. Callers MUST drip operator streams before calling. /// No-op when there is no active lock record, the lock has not expired, or there is no - /// boost left to remove. Idempotent. + /// boost left to remove. Idempotent. Body lives in {ServiceFeeDistributorLib.settleExpiredLock}. function _settleExpiredLock(address delegator, address operator, bytes32 assetHash) internal { - uint64 expiry = _positionLockExpiry[delegator][operator][assetHash]; - if (expiry == 0 || block.timestamp < expiry) return; - - uint8 mode = _positionMode[delegator][operator][assetHash]; - uint256 score = _positionScore[delegator][operator][assetHash]; - uint256 principal = _positionPrincipal[delegator][operator][assetHash]; - - // Clear the marker regardless; the boost (if any) is being removed now. - _positionLockExpiry[delegator][operator][assetHash] = 0; - if (mode == 0 || score <= principal) return; - - // Settle accrued rewards at the boosted score before collapsing it. - _harvestAllTokens(delegator, operator, assetHash, mode); - - uint256 boost = score - principal; - if (mode == 1) { - uint256 cur = totalAllScore[operator][assetHash]; - totalAllScore[operator][assetHash] = boost > cur ? 0 : cur - boost; - _positionScore[delegator][operator][assetHash] = principal; - } else { - // Fixed mode: scale every per-blueprint score down by principal/score and reduce the - // operator/blueprint totals by the removed amount. - uint64[] storage bps = _fixedBlueprints[delegator][operator][assetHash]; - uint256 newAggregate = 0; - // `_totalFixedScoreByAsset[operator][assetHash]` is the same slot every iteration; - // load once, apply the identical saturating subtraction in memory, store once. Each - // iteration reads exactly what the previous wrote, so intermediate and final values - // match the per-iteration SSTORE/SLOAD version byte-for-byte. `bps` is a de-duplicated - // set (see `_setFixedBlueprints`), so `totalFixedScore[...][bpId]` is a distinct slot - // per iteration and stays inline. - uint256 cfa = _totalFixedScoreByAsset[operator][assetHash]; - uint256 bpsLen = bps.length; - for (uint256 i = 0; i < bpsLen;) { - uint64 bpId = bps[i]; - uint256 bScore = _positionFixedScore[delegator][operator][assetHash][bpId]; - if (bScore != 0) { - uint256 bNew = (bScore * principal) / score; // rounds down toward base - uint256 bDelta = bScore - bNew; - _positionFixedScore[delegator][operator][assetHash][bpId] = bNew; - - uint256 cf = totalFixedScore[operator][bpId][assetHash]; - totalFixedScore[operator][bpId][assetHash] = bDelta > cf ? 0 : cf - bDelta; - cfa = bDelta > cfa ? 0 : cfa - bDelta; - newAggregate += bNew; - } - unchecked { - ++i; - } - } - _totalFixedScoreByAsset[operator][assetHash] = cfa; - _positionScore[delegator][operator][assetHash] = newAggregate; - } - - // Re-sync per-token debt to the collapsed score so future accrual is at base. - _syncDebtsToCurrentAcc(delegator, operator, assetHash, mode); + _s.settleExpiredLock(delegator, operator, assetHash); } /// @notice Permissionlessly decay a position's expired lock-multiplier boost (F5). @@ -1102,138 +710,32 @@ contract ServiceFeeDistributor is /// the locker never interacts again, instead of relying on the locker's next claim/change. function settleExpiredLock(address delegator, address operator, Types.Asset calldata asset) external nonReentrant { _dripOperatorStreams(operator); - _settleExpiredLock(delegator, operator, _assetHash(asset)); - } - - function _harvestAllTokens(address delegator, address operator, bytes32 assetHash, uint8 mode) internal { - // Use per-asset token set for efficiency - only iterates tokens actually distributed for this asset - EnumerableSet.AddressSet storage set = _operatorAssetRewardTokens[operator][assetHash]; - uint256 length = set.length(); - for (uint256 i = 0; i < length; i++) { - _harvestToken(delegator, operator, assetHash, set.at(i), mode); - } + _settleExpiredLock(delegator, operator, ServiceFeeDistributorLib.assetHash(asset)); } function _pruneOperatorAssetIfEmpty(address operator, bytes32 assetHash) internal { - if (totalAllScore[operator][assetHash] != 0) return; - if (_totalFixedScoreByAsset[operator][assetHash] != 0) return; - _operatorAssetHashes[operator].remove(assetHash); + if (_s.totalAllScore[operator][assetHash] != 0) return; + if (_s._totalFixedScoreByAsset[operator][assetHash] != 0) return; + _s._operatorAssetHashes[operator].remove(assetHash); } function _pruneDelegatorPosition(address delegator, address operator, bytes32 assetHash) internal { - _positionMode[delegator][operator][assetHash] = 0; - _positionPrincipal[delegator][operator][assetHash] = 0; - _positionScore[delegator][operator][assetHash] = 0; - _positionLockExpiry[delegator][operator][assetHash] = 0; + _s._positionMode[delegator][operator][assetHash] = 0; + _s._positionPrincipal[delegator][operator][assetHash] = 0; + _s._positionScore[delegator][operator][assetHash] = 0; + _s._positionLockExpiry[delegator][operator][assetHash] = 0; - uint64[] storage existing = _fixedBlueprints[delegator][operator][assetHash]; + uint64[] storage existing = _s._fixedBlueprints[delegator][operator][assetHash]; for (uint256 i = existing.length; i > 0; i--) { uint64 id = existing[i - 1]; - _fixedBlueprintIndexPlusOne[delegator][operator][assetHash][id] = 0; - _positionFixedScore[delegator][operator][assetHash][id] = 0; + _s._fixedBlueprintIndexPlusOne[delegator][operator][assetHash][id] = 0; + _s._positionFixedScore[delegator][operator][assetHash][id] = 0; existing.pop(); } - _delegatorAssets[delegator][operator].remove(assetHash); - if (_delegatorAssets[delegator][operator].length() == 0) { - _delegatorOperators[delegator].remove(operator); - } - } - - function _applyFixedScoreDelta( - address delegator, - address operator, - bytes32 assetHash, - uint256 scoreDelta, - uint256 amount, - bool isIncrease, - uint64[] calldata blueprintIds, - uint256[] calldata blueprintAmounts - ) - internal - { - if (blueprintIds.length == 0) return; - if (blueprintIds.length != blueprintAmounts.length) { - revert InvalidBlueprintAmounts(); - } - if (scoreDelta == 0 || amount == 0) return; - - uint256 totalAmount = 0; - uint256 amountsLen = blueprintAmounts.length; - for (uint256 i = 0; i < amountsLen;) { - totalAmount += blueprintAmounts[i]; - unchecked { - ++i; - } - } - if (totalAmount == 0) return; - - uint256 remainingScore = scoreDelta; - uint256 idsLen = blueprintIds.length; - // `_totalFixedScoreByAsset[operator][assetHash]` is one slot mutated every iteration - // (same read-modify-write in either branch); load once, mutate in memory, store once. - // Each step observes exactly what the previous wrote, so the result is identical. - uint256 byAssetAcc = _totalFixedScoreByAsset[operator][assetHash]; - for (uint256 i = 0; i < idsLen;) { - uint64 bpId = blueprintIds[i]; - uint256 scoreForBlueprint = - i == idsLen - 1 ? remainingScore : (scoreDelta * blueprintAmounts[i]) / totalAmount; - remainingScore = remainingScore > scoreForBlueprint ? remainingScore - scoreForBlueprint : 0; - - if (isIncrease) { - _positionFixedScore[delegator][operator][assetHash][bpId] += scoreForBlueprint; - totalFixedScore[operator][bpId][assetHash] += scoreForBlueprint; - byAssetAcc += scoreForBlueprint; - } else { - uint256 currentScore = _positionFixedScore[delegator][operator][assetHash][bpId]; - uint256 dec = scoreForBlueprint > currentScore ? currentScore : scoreForBlueprint; - _positionFixedScore[delegator][operator][assetHash][bpId] = currentScore - dec; - - uint256 curTotal = totalFixedScore[operator][bpId][assetHash]; - totalFixedScore[operator][bpId][assetHash] = dec > curTotal ? 0 : curTotal - dec; - byAssetAcc = dec > byAssetAcc ? 0 : byAssetAcc - dec; - } - unchecked { - ++i; - } - } - _totalFixedScoreByAsset[operator][assetHash] = byAssetAcc; - } - - /// @dev Updates all reward debts to current accumulator state after a score change. - /// This is necessary for correct accounting - prevents retroactive claims with new score. - /// @notice O(N) where N = number of reward tokens for this specific operator-asset pair. - /// Much more efficient than iterating all operator tokens. - function _syncDebtsToCurrentAcc(address delegator, address operator, bytes32 assetHash, uint8 mode) internal { - // Per-asset token set: only the tokens actually distributed for this operator-asset. - EnumerableSet.AddressSet storage set = _operatorAssetRewardTokens[operator][assetHash]; - uint256 length = set.length(); - for (uint256 i = 0; i < length; i++) { - _settlePositionDebt(delegator, operator, assetHash, set.at(i), mode); - } - } - - function _setFixedBlueprints( - address delegator, - address operator, - bytes32 assetHash, - uint64[] calldata blueprintIds - ) - internal - { - // Clear existing set - uint64[] storage existing = _fixedBlueprints[delegator][operator][assetHash]; - for (uint256 i = existing.length; i > 0; i--) { - uint64 id = existing[i - 1]; - _fixedBlueprintIndexPlusOne[delegator][operator][assetHash][id] = 0; - existing.pop(); - } - - for (uint256 i = 0; i < blueprintIds.length; i++) { - uint64 id = blueprintIds[i]; - if (_fixedBlueprintIndexPlusOne[delegator][operator][assetHash][id] != 0) continue; - existing.push(id); - _fixedBlueprintIndexPlusOne[delegator][operator][assetHash][id] = existing.length; + _s._delegatorAssets[delegator][operator].remove(assetHash); + if (_s._delegatorAssets[delegator][operator].length() == 0) { + _s._delegatorOperators[delegator].remove(operator); } } @@ -1244,14 +746,14 @@ contract ServiceFeeDistributor is /// @notice Drip all active streams for an operator and distribute chunks /// @dev Called before score changes to ensure fair distribution with current scores function _dripOperatorStreams(address operator) internal { - if (address(streamingManager) == address(0)) return; + if (address(_s.streamingManager) == address(0)) return; ( uint64[] memory serviceIds, uint64[] memory blueprintIds, address[] memory paymentTokens, uint256[] memory amounts - ) = streamingManager.dripOperatorStreams(operator); + ) = _s.streamingManager.dripOperatorStreams(operator); // Distribute each dripped chunk using current scores for (uint256 i = 0; i < serviceIds.length; i++) { @@ -1280,10 +782,10 @@ contract ServiceFeeDistributor is /// @notice Public function to drip a specific stream and distribute to delegators /// @dev Drips the stream and distributes the chunk based on current scores function drip(uint64 serviceId, address operator) external nonReentrant { - if (address(streamingManager) == address(0)) return; + if (address(_s.streamingManager) == address(0)) return; (uint256 amount, uint64 blueprintId, address paymentToken) = - streamingManager.dripAndGetChunk(serviceId, operator); + _s.streamingManager.dripAndGetChunk(serviceId, operator); if (amount > 0) { _distributeChunk(serviceId, blueprintId, operator, paymentToken, amount); @@ -1298,67 +800,20 @@ contract ServiceFeeDistributor is /// @notice Get active stream IDs for an operator function getOperatorActiveStreams(address operator) external view returns (uint64[] memory) { - if (address(streamingManager) == address(0)) return new uint64[](0); - return streamingManager.getOperatorActiveStreams(operator); + if (address(_s.streamingManager) == address(0)) return new uint64[](0); + return _s.streamingManager.getOperatorActiveStreams(operator); } /// @notice Calculate pending drip amount without executing function pendingDrip(uint64 serviceId, address operator) external view returns (uint256) { - if (address(streamingManager) == address(0)) return 0; - return streamingManager.pendingDrip(serviceId, operator); + if (address(_s.streamingManager) == address(0)) return 0; + return _s.streamingManager.pendingDrip(serviceId, operator); } // ═══════════════════════════════════════════════════════════════════════════ // INTERNAL HELPERS // ═══════════════════════════════════════════════════════════════════════════ - function _assetHash(Types.Asset memory asset) internal pure returns (bytes32) { - // forge-lint: disable-next-line(asm-keccak256) - return keccak256(abi.encode(asset.kind, asset.token)); - } - - function _getAllSlashFactor(address operator, bytes32 assetHash) internal view returns (uint256) { - uint256 factor = _allSlashFactor[operator][assetHash]; - return factor == 0 ? PRECISION : factor; - } - - function _getFixedSlashFactor( - address operator, - uint64 blueprintId, - bytes32 assetHash - ) - internal - view - returns (uint256) - { - uint256 factor = _fixedSlashFactor[operator][blueprintId][assetHash]; - return factor == 0 ? PRECISION : factor; - } - - function _applySlashFactor(uint256 score, uint256 factor) internal pure returns (uint256) { - if (score == 0) return 0; - return (score * factor) / PRECISION; - } - - /// @notice Convert asset amount to USD score value. - /// @dev TNT gets a boosted score rate if tntScoreRate > 0, otherwise uses oracle. - /// For all other tokens, uses oracle price. - function _toUsd(Types.Asset memory asset, uint256 amount) internal view returns (uint256) { - if (amount == 0) return 0; - - address token = asset.kind == Types.AssetKind.Native ? address(0) : asset.token; - - // TNT boost: if tntScoreRate is set, 1 TNT = tntScoreRate/1e18 USD score - // This allows TNT to earn outsized fee share regardless of market price - if (token != address(0) && token == tntToken && tntScoreRate > 0) { - return (amount * tntScoreRate) / PRECISION; - } - - // All other tokens use oracle price - if (address(priceOracle) == address(0)) return amount; - return priceOracle.toUSD(token, amount); - } - /// @dev Validate/collect an inbound payment: native value must equal `amount`; /// an ERC20 leg forbids stray value and (when `pullErc20`) pulls via approval. function _receivePayment(address paymentToken, uint256 amount, bool pullErc20) internal { @@ -1371,27 +826,11 @@ contract ServiceFeeDistributor is } function _transferPayment(address payable to, address token, uint256 amount) internal { - if (amount == 0) return; - if (token == address(0)) { - (bool ok,) = to.call{ value: amount }(""); - if (!ok) revert EthTransferFailed(); - } else { - IERC20(token).safeTransfer(to, amount); - } + ServiceFeeDistributorLib.transferPayment(to, token, amount); } function _authorizeUpgrade(address) internal override onlyRole(UPGRADER_ROLE) { } /// @notice Receive ETH for native token distributions from StreamingPaymentManager receive() external payable { } - - /// @notice F5: latest active lock expiry per position (delegator => operator => assetHash). - /// @dev The lock-multiplier boost baked into the position score is only valid until this - /// timestamp; past it `_settleExpiredLock` collapses the score back to base (principal). - /// 0 = no active lock / already settled. Appended at end of storage (gap shrunk 50 -> 49). - mapping(address => mapping(address => mapping(bytes32 => uint64))) private _positionLockExpiry; - - /// @dev Reserved storage slots for future upgrades (Round 2 storage F-3). Shrunk 50 -> 49 - /// when `_positionLockExpiry` was appended (F5). - uint256[49] private __gap; } diff --git a/src/rewards/ServiceFeeDistributorLib.sol b/src/rewards/ServiceFeeDistributorLib.sol new file mode 100644 index 00000000..c66d30e5 --- /dev/null +++ b/src/rewards/ServiceFeeDistributorLib.sol @@ -0,0 +1,718 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import { Types } from "../libraries/Types.sol"; +import { ITangleSecurityView } from "../interfaces/ITangleSecurityView.sol"; +import { IPriceOracle } from "../oracles/interfaces/IPriceOracle.sol"; +import { IStreamingPaymentManager } from "../interfaces/IStreamingPaymentManager.sol"; + +/// @title ServiceFeeDistributorLib +/// @notice Deployed (delegatecall-linked) library holding the heavy distribution/USD/score math for +/// {ServiceFeeDistributor}. Extracting these `public` functions moves their code out of the +/// distributor's runtime bytecode so the contract fits under chains that meter code-deposit gas +/// aggressively (e.g. Tempo's 30M-gas transaction cap). +/// @dev All functions take `Layout storage self` — the distributor's entire storage, declared in the +/// identical slot order it used before this refactor, so the on-chain storage layout is unchanged. +library ServiceFeeDistributorLib { + using EnumerableSet for EnumerableSet.AddressSet; + using EnumerableSet for EnumerableSet.Bytes32Set; + using SafeERC20 for IERC20; + + uint256 internal constant BPS_DENOMINATOR = 10_000; + uint256 internal constant PRECISION = 1e18; + + /// @dev Shared with {ServiceFeeDistributor}; same name/args → identical 4-byte selector. + error InvalidBlueprintAmounts(); + /// @dev Shared with {ServiceFeeDistributor}; same name/args → identical 4-byte selector. + error EthTransferFailed(); + + /// @notice The distributor's storage, one field per slot in the original declaration order. + /// @dev Slot indices (0..31) MUST match the pre-refactor plain-variable layout. Do not reorder, + /// insert, or retype fields; append only, and shrink `__gap` to match. + struct Layout { + // slot 0 + address staking; + // slot 1 + address tangle; + // slot 2 + IPriceOracle priceOracle; + // slot 3 + address inflationPool; + // slot 4 + IStreamingPaymentManager streamingManager; + // slot 5 + address tntToken; + // slot 6 + uint256 tntScoreRate; + // slot 7 + mapping(address => EnumerableSet.AddressSet) _operatorRewardTokens; + // slot 8 + mapping(address => mapping(bytes32 => EnumerableSet.AddressSet)) _operatorAssetRewardTokens; + // slot 9 + mapping(address => EnumerableSet.Bytes32Set) _operatorAssetHashes; + // slot 10 + mapping(bytes32 => Types.Asset) _assetByHash; + // slot 11 + mapping(bytes32 => bool) _assetKnown; + // slot 12 + mapping(address => mapping(bytes32 => uint256)) totalAllScore; + // slot 13 + mapping(address => mapping(uint64 => mapping(bytes32 => uint256))) totalFixedScore; + // slot 14 + mapping(address => mapping(bytes32 => uint256)) _totalFixedScoreByAsset; + // slot 15 + mapping(address => mapping(bytes32 => uint256)) _allSlashFactor; + // slot 16 + mapping(address => mapping(uint64 => mapping(bytes32 => uint256))) _fixedSlashFactor; + // slot 17 + mapping(address => mapping(bytes32 => mapping(address => uint256))) accAllPerScore; + // slot 18 + mapping(address => mapping(uint64 => mapping(bytes32 => mapping(address => uint256)))) accFixedPerScore; + // slot 19 + mapping(address => mapping(address => mapping(bytes32 => uint8))) _positionMode; + // slot 20 + mapping(address => mapping(address => mapping(bytes32 => uint256))) _positionPrincipal; + // slot 21 + mapping(address => mapping(address => mapping(bytes32 => uint256))) _positionScore; + // slot 22 + mapping(address => mapping(address => mapping(bytes32 => mapping(uint64 => uint256)))) _positionFixedScore; + // slot 23 + mapping(address => mapping(address => mapping(bytes32 => uint64[]))) _fixedBlueprints; + // slot 24 + mapping(address => mapping(address => mapping(bytes32 => mapping(uint64 => uint256)))) + _fixedBlueprintIndexPlusOne; + // slot 25 + mapping(address => mapping(address => mapping(bytes32 => mapping(address => uint256)))) _debtAll; + // slot 26 + mapping(address => mapping(address => mapping(uint64 => mapping(bytes32 => mapping(address => uint256))))) + _debtFixed; + // slot 27 + mapping(address => mapping(address => uint256)) claimable; + // slot 28 + mapping(address => EnumerableSet.AddressSet) _delegatorOperators; + // slot 29 + mapping(address => mapping(address => EnumerableSet.Bytes32Set)) _delegatorAssets; + // slot 30 + mapping(address => mapping(address => mapping(bytes32 => uint64))) _positionLockExpiry; + // slot 31 + uint256[49] __gap; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // PURE / VIEW MATH + // ═══════════════════════════════════════════════════════════════════════════ + + function assetHash(Types.Asset memory asset) public pure returns (bytes32) { + // forge-lint: disable-next-line(asm-keccak256) + return keccak256(abi.encode(asset.kind, asset.token)); + } + + function getAllSlashFactor(Layout storage self, address operator, bytes32 aHash) public view returns (uint256) { + uint256 factor = self._allSlashFactor[operator][aHash]; + return factor == 0 ? PRECISION : factor; + } + + function getFixedSlashFactor( + Layout storage self, + address operator, + uint64 blueprintId, + bytes32 aHash + ) + public + view + returns (uint256) + { + uint256 factor = self._fixedSlashFactor[operator][blueprintId][aHash]; + return factor == 0 ? PRECISION : factor; + } + + function applySlashFactor(uint256 score, uint256 factor) public pure returns (uint256) { + if (score == 0) return 0; + return (score * factor) / PRECISION; + } + + /// @notice Convert asset amount to USD score value. + /// @dev TNT gets a boosted score rate if tntScoreRate > 0, otherwise uses oracle. + /// For all other tokens, uses oracle price. + function toUsd(Layout storage self, Types.Asset memory asset, uint256 amount) public view returns (uint256) { + if (amount == 0) return 0; + + address token = asset.kind == Types.AssetKind.Native ? address(0) : asset.token; + + // TNT boost: if tntScoreRate is set, 1 TNT = tntScoreRate/1e18 USD score + // This allows TNT to earn outsized fee share regardless of market price + if (token != address(0) && token == self.tntToken && self.tntScoreRate > 0) { + return (amount * self.tntScoreRate) / PRECISION; + } + + // All other tokens use oracle price + if (address(self.priceOracle) == address(0)) return amount; + return self.priceOracle.toUSD(token, amount); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // USD TOTALS + // ═══════════════════════════════════════════════════════════════════════════ + + function computeUsdTotalsForRequirements( + Layout storage self, + uint64 serviceId, + uint64 blueprintId, + address operator, + Types.AssetSecurityRequirement[] memory reqs + ) + public + view + returns (uint256 allUsdTotal, uint256 fixedUsdTotal) + { + for (uint256 i = 0; i < reqs.length; i++) { + Types.Asset memory a = reqs[i].asset; + bytes32 aHash = assetHash(a); + + uint16 commitmentBps = + ITangleSecurityView(self.tangle).getServiceSecurityCommitmentBps(serviceId, operator, a.kind, a.token); + if (commitmentBps == 0) continue; + + uint256 allScore = self.totalAllScore[operator][aHash]; + uint256 fixedScore = self.totalFixedScore[operator][blueprintId][aHash]; + + uint256 allEffective = applySlashFactor(allScore, getAllSlashFactor(self, operator, aHash)); + uint256 fixedEffective = + applySlashFactor(fixedScore, getFixedSlashFactor(self, operator, blueprintId, aHash)); + + uint256 allExposed = (allEffective * commitmentBps) / BPS_DENOMINATOR; + uint256 fixedExposed = (fixedEffective * commitmentBps) / BPS_DENOMINATOR; + + allUsdTotal += toUsd(self, a, allExposed); + fixedUsdTotal += toUsd(self, a, fixedExposed); + } + } + + function computeUsdTotalsForOperatorAssets( + Layout storage self, + address operator, + uint64 blueprintId, + EnumerableSet.Bytes32Set storage set + ) + public + view + returns (uint256 allUsdTotal, uint256 fixedUsdTotal) + { + uint256 assetCount = set.length(); + for (uint256 i = 0; i < assetCount; i++) { + bytes32 aHash = set.at(i); + Types.Asset memory asset = self._assetByHash[aHash]; + uint256 allScore = self.totalAllScore[operator][aHash]; + uint256 fixedScore = self.totalFixedScore[operator][blueprintId][aHash]; + allUsdTotal += toUsd(self, asset, applySlashFactor(allScore, getAllSlashFactor(self, operator, aHash))); + fixedUsdTotal += toUsd( + self, asset, applySlashFactor(fixedScore, getFixedSlashFactor(self, operator, blueprintId, aHash)) + ); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // DISTRIBUTION + // ═══════════════════════════════════════════════════════════════════════════ + + /// @dev Score-weighted split of `amount` across a service's security requirements. + /// `fixedMode` selects the Fixed-mode pools (per-blueprint) vs the All-mode pools. + function distributeForRequirements( + Layout storage self, + uint64 serviceId, + uint64 blueprintId, + address operator, + address paymentToken, + uint256 amount, + uint256 usdTotal, + Types.AssetSecurityRequirement[] memory reqs, + bool fixedMode + ) + public + { + uint256 remaining = amount; + uint256 remainingUsd = usdTotal; + + for (uint256 i = 0; i < reqs.length && remaining > 0; i++) { + Types.Asset memory a = reqs[i].asset; + bytes32 aHash = assetHash(a); + uint16 commitmentBps = + ITangleSecurityView(self.tangle).getServiceSecurityCommitmentBps(serviceId, operator, a.kind, a.token); + if (commitmentBps == 0) continue; + + uint256 score = + fixedMode ? self.totalFixedScore[operator][blueprintId][aHash] : self.totalAllScore[operator][aHash]; + if (score == 0) continue; + + uint256 factor = fixedMode + ? getFixedSlashFactor(self, operator, blueprintId, aHash) + : getAllSlashFactor(self, operator, aHash); + uint256 usd = toUsd(self, a, (applySlashFactor(score, factor) * commitmentBps) / BPS_DENOMINATOR); + if (usd == 0) continue; + + uint256 share = (remaining * usd) / remainingUsd; + remaining -= share; + remainingUsd -= usd; + if (share == 0) continue; + + if (fixedMode) { + self.accFixedPerScore[operator][blueprintId][aHash][paymentToken] += (share * PRECISION) / score; + } else { + self.accAllPerScore[operator][aHash][paymentToken] += (share * PRECISION) / score; + } + self._operatorAssetRewardTokens[operator][aHash].add(paymentToken); + } + } + + /// @dev Score-weighted split of `amount` across an operator's registered assets when the + /// service declares no requirements. `fixedMode` selects Fixed vs All pools. + function distributeForOperatorAssets( + Layout storage self, + address operator, + uint64 blueprintId, + address paymentToken, + uint256 amount, + uint256 usdTotal, + EnumerableSet.Bytes32Set storage set, + bool fixedMode + ) + public + { + uint256 remaining = amount; + uint256 remainingUsd = usdTotal; + + uint256 assetCount = set.length(); + for (uint256 i = 0; i < assetCount && remaining > 0; i++) { + bytes32 aHash = set.at(i); + uint256 denom = + fixedMode ? self.totalFixedScore[operator][blueprintId][aHash] : self.totalAllScore[operator][aHash]; + if (denom == 0) continue; + + uint256 factor = fixedMode + ? getFixedSlashFactor(self, operator, blueprintId, aHash) + : getAllSlashFactor(self, operator, aHash); + uint256 usd = toUsd(self, self._assetByHash[aHash], applySlashFactor(denom, factor)); + if (usd == 0) continue; + + uint256 share = (remaining * usd) / remainingUsd; + remaining -= share; + remainingUsd -= usd; + if (share == 0) continue; + + if (fixedMode) { + self.accFixedPerScore[operator][blueprintId][aHash][paymentToken] += (share * PRECISION) / denom; + } else { + self.accAllPerScore[operator][aHash][paymentToken] += (share * PRECISION) / denom; + } + self._operatorAssetRewardTokens[operator][aHash].add(paymentToken); + } + } + + function transferPayment(address payable to, address token, uint256 amount) public { + if (amount == 0) return; + if (token == address(0)) { + (bool ok,) = to.call{ value: amount }(""); + if (!ok) revert EthTransferFailed(); + } else { + IERC20(token).safeTransfer(to, amount); + } + } + + /// @notice Immediate (non-streamed) score-weighted distribution of `amount` for a payment. + /// @dev Consolidated entry point — moves the whole immediate-distribution flow (requirements vs + /// operator-asset fallback, USD split, treasury sweep of unbacked funds) out of the + /// distributor's bytecode. Delegatecall-linked, so `transferPayment`'s native leg still runs + /// in the distributor's context. + function distributeImmediate( + Layout storage self, + uint64 serviceId, + uint64 blueprintId, + address operator, + address paymentToken, + uint256 amount + ) + public + { + Types.AssetSecurityRequirement[] memory reqs = + ITangleSecurityView(self.tangle).getServiceSecurityRequirements(serviceId); + if (reqs.length == 0) { + EnumerableSet.Bytes32Set storage set = self._operatorAssetHashes[operator]; + uint256 assetCount = set.length(); + if (assetCount == 0) { + transferPayment(ITangleSecurityView(self.tangle).treasury(), paymentToken, amount); + return; + } + + (uint256 allUsd0, uint256 fixedUsd0) = computeUsdTotalsForOperatorAssets(self, operator, blueprintId, set); + + uint256 totalUsd0 = allUsd0 + fixedUsd0; + if (totalUsd0 == 0) { + transferPayment(ITangleSecurityView(self.tangle).treasury(), paymentToken, amount); + return; + } + + uint256 allAmount0 = (amount * allUsd0) / totalUsd0; + uint256 fixedAmount0 = amount - allAmount0; + + if (allAmount0 > 0 && allUsd0 > 0) { + distributeForOperatorAssets(self, operator, 0, paymentToken, allAmount0, allUsd0, set, false); + } + if (fixedAmount0 > 0 && fixedUsd0 > 0) { + distributeForOperatorAssets( + self, operator, blueprintId, paymentToken, fixedAmount0, fixedUsd0, set, true + ); + } + return; + } + + (uint256 allUsdTotal, uint256 fixedUsdTotal) = + computeUsdTotalsForRequirements(self, serviceId, blueprintId, operator, reqs); + + uint256 totalUsd = allUsdTotal + fixedUsdTotal; + if (totalUsd == 0) { + transferPayment(ITangleSecurityView(self.tangle).treasury(), paymentToken, amount); + return; + } + + uint256 allAmount = (amount * allUsdTotal) / totalUsd; + uint256 fixedAmount = amount - allAmount; + + if (allAmount > 0 && allUsdTotal > 0) { + distributeForRequirements(self, serviceId, 0, operator, paymentToken, allAmount, allUsdTotal, reqs, false); + } + + if (fixedAmount > 0 && fixedUsdTotal > 0) { + distributeForRequirements( + self, serviceId, blueprintId, operator, paymentToken, fixedAmount, fixedUsdTotal, reqs, true + ); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // SCORE BOOKKEEPING + // ═══════════════════════════════════════════════════════════════════════════ + + function applyFixedScoreDelta( + Layout storage self, + address delegator, + address operator, + bytes32 aHash, + uint256 scoreDelta, + uint256 amount, + bool isIncrease, + uint64[] calldata blueprintIds, + uint256[] calldata blueprintAmounts + ) + public + { + if (blueprintIds.length == 0) return; + if (blueprintIds.length != blueprintAmounts.length) { + revert InvalidBlueprintAmounts(); + } + if (scoreDelta == 0 || amount == 0) return; + + uint256 totalAmount = 0; + uint256 amountsLen = blueprintAmounts.length; + for (uint256 i = 0; i < amountsLen;) { + totalAmount += blueprintAmounts[i]; + unchecked { + ++i; + } + } + if (totalAmount == 0) return; + + uint256 remainingScore = scoreDelta; + uint256 idsLen = blueprintIds.length; + // `_totalFixedScoreByAsset[operator][aHash]` is one slot mutated every iteration + // (same read-modify-write in either branch); load once, mutate in memory, store once. + // Each step observes exactly what the previous wrote, so the result is identical. + uint256 byAssetAcc = self._totalFixedScoreByAsset[operator][aHash]; + for (uint256 i = 0; i < idsLen;) { + uint64 bpId = blueprintIds[i]; + uint256 scoreForBlueprint = + i == idsLen - 1 ? remainingScore : (scoreDelta * blueprintAmounts[i]) / totalAmount; + remainingScore = remainingScore > scoreForBlueprint ? remainingScore - scoreForBlueprint : 0; + + if (isIncrease) { + self._positionFixedScore[delegator][operator][aHash][bpId] += scoreForBlueprint; + self.totalFixedScore[operator][bpId][aHash] += scoreForBlueprint; + byAssetAcc += scoreForBlueprint; + } else { + uint256 currentScore = self._positionFixedScore[delegator][operator][aHash][bpId]; + uint256 dec = scoreForBlueprint > currentScore ? currentScore : scoreForBlueprint; + self._positionFixedScore[delegator][operator][aHash][bpId] = currentScore - dec; + + uint256 curTotal = self.totalFixedScore[operator][bpId][aHash]; + self.totalFixedScore[operator][bpId][aHash] = dec > curTotal ? 0 : curTotal - dec; + byAssetAcc = dec > byAssetAcc ? 0 : byAssetAcc - dec; + } + unchecked { + ++i; + } + } + self._totalFixedScoreByAsset[operator][aHash] = byAssetAcc; + } + + function setFixedBlueprints( + Layout storage self, + address delegator, + address operator, + bytes32 aHash, + uint64[] calldata blueprintIds + ) + public + { + // Clear existing set + uint64[] storage existing = self._fixedBlueprints[delegator][operator][aHash]; + for (uint256 i = existing.length; i > 0; i--) { + uint64 id = existing[i - 1]; + self._fixedBlueprintIndexPlusOne[delegator][operator][aHash][id] = 0; + existing.pop(); + } + + for (uint256 i = 0; i < blueprintIds.length; i++) { + uint64 id = blueprintIds[i]; + if (self._fixedBlueprintIndexPlusOne[delegator][operator][aHash][id] != 0) continue; + existing.push(id); + self._fixedBlueprintIndexPlusOne[delegator][operator][aHash][id] = existing.length; + } + } + + /// @notice F5: decay an expired lock-multiplier boost on a position back to base (principal). + /// @dev Callers MUST drip operator streams before calling. No-op when there is no active lock + /// record, the lock has not expired, or there is no boost left to remove. Idempotent. + function settleExpiredLock(Layout storage self, address delegator, address operator, bytes32 aHash) public { + uint64 expiry = self._positionLockExpiry[delegator][operator][aHash]; + if (expiry == 0 || block.timestamp < expiry) return; + + uint8 mode = self._positionMode[delegator][operator][aHash]; + uint256 score = self._positionScore[delegator][operator][aHash]; + uint256 principal = self._positionPrincipal[delegator][operator][aHash]; + + // Clear the marker regardless; the boost (if any) is being removed now. + self._positionLockExpiry[delegator][operator][aHash] = 0; + if (mode == 0 || score <= principal) return; + + // Settle accrued rewards at the boosted score before collapsing it. + harvestAllTokens(self, delegator, operator, aHash, mode); + + uint256 boost = score - principal; + if (mode == 1) { + uint256 cur = self.totalAllScore[operator][aHash]; + self.totalAllScore[operator][aHash] = boost > cur ? 0 : cur - boost; + self._positionScore[delegator][operator][aHash] = principal; + } else { + // Fixed mode: scale every per-blueprint score down by principal/score and reduce the + // operator/blueprint totals by the removed amount. + uint64[] storage bps = self._fixedBlueprints[delegator][operator][aHash]; + uint256 newAggregate = 0; + uint256 cfa = self._totalFixedScoreByAsset[operator][aHash]; + uint256 bpsLen = bps.length; + for (uint256 i = 0; i < bpsLen;) { + uint64 bpId = bps[i]; + uint256 bScore = self._positionFixedScore[delegator][operator][aHash][bpId]; + if (bScore != 0) { + uint256 bNew = (bScore * principal) / score; // rounds down toward base + uint256 bDelta = bScore - bNew; + self._positionFixedScore[delegator][operator][aHash][bpId] = bNew; + + uint256 cf = self.totalFixedScore[operator][bpId][aHash]; + self.totalFixedScore[operator][bpId][aHash] = bDelta > cf ? 0 : cf - bDelta; + cfa = bDelta > cfa ? 0 : cfa - bDelta; + newAggregate += bNew; + } + unchecked { + ++i; + } + } + self._totalFixedScoreByAsset[operator][aHash] = cfa; + self._positionScore[delegator][operator][aHash] = newAggregate; + } + + // Re-sync per-token debt to the collapsed score so future accrual is at base. + syncDebtsToCurrentAcc(self, delegator, operator, aHash, mode); + } + + /// @notice Fixed-mode blueprint rebalance: clear existing per-blueprint scores and redistribute + /// the position's score across `blueprintIds` weighted by `blueprintAmounts`. + /// @dev Consolidated body of `onBlueprintsRebalanced` after the mode/expiry/harvest preamble. + function rebalanceBlueprints( + Layout storage self, + address delegator, + address operator, + bytes32 aHash, + uint64[] calldata blueprintIds, + uint256[] calldata blueprintAmounts + ) + public + { + uint64[] storage existing = self._fixedBlueprints[delegator][operator][aHash]; + uint256 curTotalByAsset = self._totalFixedScoreByAsset[operator][aHash]; + uint256 existingLen = existing.length; + for (uint256 i = 0; i < existingLen;) { + uint64 bpId = existing[i]; + uint256 oldScore = self._positionFixedScore[delegator][operator][aHash][bpId]; + if (oldScore != 0) { + uint256 cur = self.totalFixedScore[operator][bpId][aHash]; + self.totalFixedScore[operator][bpId][aHash] = oldScore > cur ? 0 : cur - oldScore; + curTotalByAsset = oldScore > curTotalByAsset ? 0 : curTotalByAsset - oldScore; + self._positionFixedScore[delegator][operator][aHash][bpId] = 0; + } + unchecked { + ++i; + } + } + self._totalFixedScoreByAsset[operator][aHash] = curTotalByAsset; + + uint256 totalAmount = 0; + uint256 amountsLen = blueprintAmounts.length; + for (uint256 i = 0; i < amountsLen;) { + totalAmount += blueprintAmounts[i]; + unchecked { + ++i; + } + } + + uint256 userScore = self._positionScore[delegator][operator][aHash]; + if (totalAmount > 0 && userScore > 0) { + uint256 remainingScore = userScore; + uint256 idsLen = blueprintIds.length; + uint256 acc = self._totalFixedScoreByAsset[operator][aHash]; + for (uint256 i = 0; i < idsLen;) { + uint64 bpId = blueprintIds[i]; + uint256 scoreForBlueprint = + i == idsLen - 1 ? remainingScore : (userScore * blueprintAmounts[i]) / totalAmount; + remainingScore = remainingScore > scoreForBlueprint ? remainingScore - scoreForBlueprint : 0; + + self._positionFixedScore[delegator][operator][aHash][bpId] = scoreForBlueprint; + self.totalFixedScore[operator][bpId][aHash] += scoreForBlueprint; + acc += scoreForBlueprint; + unchecked { + ++i; + } + } + self._totalFixedScoreByAsset[operator][aHash] = acc; + } + + setFixedBlueprints(self, delegator, operator, aHash, blueprintIds); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // HARVEST / DEBT + // ═══════════════════════════════════════════════════════════════════════════ + + /// @dev Compute a position's pending reward for `token` without mutating debt. + function pendingPosition( + Layout storage self, + address delegator, + address operator, + bytes32 aHash, + address token, + uint8 mode + ) + public + view + returns (uint256 pending) + { + if (mode == 1) { + uint256 userScore = self._positionScore[delegator][operator][aHash]; + if (userScore == 0) return 0; + uint256 accumulated = (userScore * self.accAllPerScore[operator][aHash][token]) / PRECISION; + uint256 debt = self._debtAll[delegator][operator][aHash][token]; + if (accumulated > debt) pending = accumulated - debt; + return pending; + } + + uint64[] storage bps = self._fixedBlueprints[delegator][operator][aHash]; + for (uint256 i = 0; i < bps.length; i++) { + uint64 bpId = bps[i]; + uint256 blueprintScore = self._positionFixedScore[delegator][operator][aHash][bpId]; + if (blueprintScore == 0) continue; + uint256 accumulated = (blueprintScore * self.accFixedPerScore[operator][bpId][aHash][token]) / PRECISION; + uint256 debt = self._debtFixed[delegator][operator][bpId][aHash][token]; + if (accumulated > debt) pending += accumulated - debt; + } + } + + /// @dev Advance a harvested position's reward debt to the current accumulator. + function settlePositionDebt( + Layout storage self, + address delegator, + address operator, + bytes32 aHash, + address token, + uint8 mode + ) + public + { + if (mode == 1) { + uint256 userScore = self._positionScore[delegator][operator][aHash]; + if (userScore == 0) return; + self._debtAll[delegator][operator][aHash][token] = + (userScore * self.accAllPerScore[operator][aHash][token]) / PRECISION; + return; + } + + uint64[] storage bps = self._fixedBlueprints[delegator][operator][aHash]; + for (uint256 i = 0; i < bps.length; i++) { + uint64 bpId = bps[i]; + uint256 blueprintScore = self._positionFixedScore[delegator][operator][aHash][bpId]; + if (blueprintScore == 0) continue; + self._debtFixed[delegator][operator][bpId][aHash][token] = + (blueprintScore * self.accFixedPerScore[operator][bpId][aHash][token]) / PRECISION; + } + } + + function harvestToken( + Layout storage self, + address delegator, + address operator, + bytes32 aHash, + address token, + uint8 mode + ) + public + { + uint256 pending = pendingPosition(self, delegator, operator, aHash, token, mode); + if (pending > 0) self.claimable[delegator][token] += pending; + settlePositionDebt(self, delegator, operator, aHash, token, mode); + } + + function harvestAllTokens( + Layout storage self, + address delegator, + address operator, + bytes32 aHash, + uint8 mode + ) + public + { + // Use per-asset token set for efficiency - only iterates tokens actually distributed for this asset + EnumerableSet.AddressSet storage set = self._operatorAssetRewardTokens[operator][aHash]; + uint256 length = set.length(); + for (uint256 i = 0; i < length; i++) { + harvestToken(self, delegator, operator, aHash, set.at(i), mode); + } + } + + /// @dev Updates all reward debts to current accumulator state after a score change. + function syncDebtsToCurrentAcc( + Layout storage self, + address delegator, + address operator, + bytes32 aHash, + uint8 mode + ) + public + { + EnumerableSet.AddressSet storage set = self._operatorAssetRewardTokens[operator][aHash]; + uint256 length = set.length(); + for (uint256 i = 0; i < length; i++) { + settlePositionDebt(self, delegator, operator, aHash, set.at(i), mode); + } + } +} diff --git a/test/BaseTest.sol b/test/BaseTest.sol index e4bddffa..e44787c7 100644 --- a/test/BaseTest.sol +++ b/test/BaseTest.sol @@ -31,6 +31,7 @@ import { TangleSlashingFacet } from "../src/facets/tangle/TangleSlashingFacet.so import { StakingOperatorsFacet } from "../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../src/facets/staking/StakingDepositsFacet.sol"; import { StakingDelegationsFacet } from "../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; import { StakingSlashingFacet } from "../src/facets/staking/StakingSlashingFacet.sol"; import { StakingAssetsFacet } from "../src/facets/staking/StakingAssetsFacet.sol"; import { StakingViewsFacet } from "../src/facets/staking/StakingViewsFacet.sol"; @@ -201,6 +202,7 @@ abstract contract BaseTest is Test, BlueprintDefinitionHelper { router.registerFacet(address(new StakingOperatorsFacet())); router.registerFacet(address(new StakingDepositsFacet())); router.registerFacet(address(new StakingDelegationsFacet())); + router.registerFacet(address(new StakingUnstakeWithdrawFacet())); router.registerFacet(address(new StakingSlashingFacet())); router.registerFacet(address(new StakingAssetsFacet())); router.registerFacet(address(new StakingViewsFacet())); diff --git a/test/FacetSize.t.sol b/test/FacetSize.t.sol index cba6ff4d..cbd27dc2 100644 --- a/test/FacetSize.t.sol +++ b/test/FacetSize.t.sol @@ -23,6 +23,7 @@ import { TangleSlashingFacet } from "../src/facets/tangle/TangleSlashingFacet.so import { StakingAdminFacet } from "../src/facets/staking/StakingAdminFacet.sol"; import { StakingAssetsFacet } from "../src/facets/staking/StakingAssetsFacet.sol"; import { StakingDelegationsFacet } from "../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; import { StakingDepositsFacet } from "../src/facets/staking/StakingDepositsFacet.sol"; import { StakingOperatorsFacet } from "../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingSlashingFacet } from "../src/facets/staking/StakingSlashingFacet.sol"; @@ -84,6 +85,7 @@ contract FacetSizeTest is Test { _assertUnderLimit(address(new StakingAdminFacet()), "StakingAdminFacet"); _assertUnderLimit(address(new StakingAssetsFacet()), "StakingAssetsFacet"); _assertUnderLimit(address(new StakingDelegationsFacet()), "StakingDelegationsFacet"); + _assertUnderLimit(address(new StakingUnstakeWithdrawFacet()), "StakingUnstakeWithdrawFacet"); _assertUnderLimit(address(new StakingDepositsFacet()), "StakingDepositsFacet"); _assertUnderLimit(address(new StakingOperatorsFacet()), "StakingOperatorsFacet"); _assertUnderLimit(address(new StakingSlashingFacet()), "StakingSlashingFacet"); diff --git a/test/MultiAssetDelegation.t.sol b/test/MultiAssetDelegation.t.sol index deaf79f2..b0e0d207 100644 --- a/test/MultiAssetDelegation.t.sol +++ b/test/MultiAssetDelegation.t.sol @@ -12,6 +12,7 @@ import { MockERC20 } from "./mocks/MockERC20.sol"; import { StakingOperatorsFacet } from "../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../src/facets/staking/StakingDepositsFacet.sol"; import { StakingDelegationsFacet } from "../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; import { StakingSlashingFacet } from "../src/facets/staking/StakingSlashingFacet.sol"; import { StakingAssetsFacet } from "../src/facets/staking/StakingAssetsFacet.sol"; import { StakingViewsFacet } from "../src/facets/staking/StakingViewsFacet.sol"; @@ -76,6 +77,7 @@ contract MultiAssetDelegationTest is Test { router.registerFacet(address(new StakingOperatorsFacet())); router.registerFacet(address(new StakingDepositsFacet())); router.registerFacet(address(new StakingDelegationsFacet())); + router.registerFacet(address(new StakingUnstakeWithdrawFacet())); router.registerFacet(address(new StakingSlashingFacet())); router.registerFacet(address(new StakingAssetsFacet())); router.registerFacet(address(new StakingViewsFacet())); diff --git a/test/audit/batch3/AdapterUnitMismatch.t.sol b/test/audit/batch3/AdapterUnitMismatch.t.sol index af8cb1c0..eb3e19de 100644 --- a/test/audit/batch3/AdapterUnitMismatch.t.sol +++ b/test/audit/batch3/AdapterUnitMismatch.t.sol @@ -14,6 +14,7 @@ import { Types } from "../../../src/libraries/Types.sol"; import { StakingOperatorsFacet } from "../../../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../../../src/facets/staking/StakingDepositsFacet.sol"; import { StakingDelegationsFacet } from "../../../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../../../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; import { StakingSlashingFacet } from "../../../src/facets/staking/StakingSlashingFacet.sol"; import { StakingAssetsFacet } from "../../../src/facets/staking/StakingAssetsFacet.sol"; import { StakingViewsFacet } from "../../../src/facets/staking/StakingViewsFacet.sol"; @@ -86,6 +87,7 @@ contract AdapterUnitMismatchTest is Test { router.registerFacet(address(new StakingOperatorsFacet())); router.registerFacet(address(new StakingDepositsFacet())); router.registerFacet(address(new StakingDelegationsFacet())); + router.registerFacet(address(new StakingUnstakeWithdrawFacet())); router.registerFacet(address(new StakingSlashingFacet())); router.registerFacet(address(new StakingAssetsFacet())); router.registerFacet(address(new StakingViewsFacet())); diff --git a/test/audit/medlow/DelegationLib.t.sol b/test/audit/medlow/DelegationLib.t.sol index 543004b9..85bf6f87 100644 --- a/test/audit/medlow/DelegationLib.t.sol +++ b/test/audit/medlow/DelegationLib.t.sol @@ -14,6 +14,7 @@ import { Types } from "../../../src/libraries/Types.sol"; import { StakingOperatorsFacet } from "../../../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../../../src/facets/staking/StakingDepositsFacet.sol"; import { StakingDelegationsFacet } from "../../../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../../../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; import { StakingSlashingFacet } from "../../../src/facets/staking/StakingSlashingFacet.sol"; import { StakingAssetsFacet } from "../../../src/facets/staking/StakingAssetsFacet.sol"; import { StakingViewsFacet } from "../../../src/facets/staking/StakingViewsFacet.sol"; @@ -55,6 +56,7 @@ contract DelegationLibAuditTest is Test { router.registerFacet(address(new StakingOperatorsFacet())); router.registerFacet(address(new StakingDepositsFacet())); router.registerFacet(address(new StakingDelegationsFacet())); + router.registerFacet(address(new StakingUnstakeWithdrawFacet())); router.registerFacet(address(new StakingSlashingFacet())); router.registerFacet(address(new StakingAssetsFacet())); router.registerFacet(address(new StakingViewsFacet())); diff --git a/test/audit/medlow/LiquidVault.t.sol b/test/audit/medlow/LiquidVault.t.sol index b2debf4c..8b52f5e4 100644 --- a/test/audit/medlow/LiquidVault.t.sol +++ b/test/audit/medlow/LiquidVault.t.sol @@ -15,6 +15,7 @@ import { RebasingAssetAdapter } from "../../../src/staking/adapters/RebasingAsse import { StakingOperatorsFacet } from "../../../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../../../src/facets/staking/StakingDepositsFacet.sol"; import { StakingDelegationsFacet } from "../../../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../../../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; import { StakingSlashingFacet } from "../../../src/facets/staking/StakingSlashingFacet.sol"; import { StakingAssetsFacet } from "../../../src/facets/staking/StakingAssetsFacet.sol"; import { StakingViewsFacet } from "../../../src/facets/staking/StakingViewsFacet.sol"; @@ -116,6 +117,7 @@ contract LiquidVaultAuditTest is Test { router.registerFacet(address(new StakingOperatorsFacet())); router.registerFacet(address(new StakingDepositsFacet())); router.registerFacet(address(new StakingDelegationsFacet())); + router.registerFacet(address(new StakingUnstakeWithdrawFacet())); router.registerFacet(address(new StakingSlashingFacet())); router.registerFacet(address(new StakingAssetsFacet())); router.registerFacet(address(new StakingViewsFacet())); diff --git a/test/audit/medlow/StakingAdmin.t.sol b/test/audit/medlow/StakingAdmin.t.sol index d4cb8478..99fde72e 100644 --- a/test/audit/medlow/StakingAdmin.t.sol +++ b/test/audit/medlow/StakingAdmin.t.sol @@ -11,6 +11,7 @@ import { MultiAssetDelegation } from "../../../src/staking/MultiAssetDelegation. import { StakingOperatorsFacet } from "../../../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../../../src/facets/staking/StakingDepositsFacet.sol"; import { StakingDelegationsFacet } from "../../../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../../../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; import { StakingSlashingFacet } from "../../../src/facets/staking/StakingSlashingFacet.sol"; import { StakingAssetsFacet } from "../../../src/facets/staking/StakingAssetsFacet.sol"; import { StakingViewsFacet } from "../../../src/facets/staking/StakingViewsFacet.sol"; @@ -59,6 +60,7 @@ contract StakingAdminAuditTest is Test { router.registerFacet(address(new StakingOperatorsFacet())); router.registerFacet(address(new StakingDepositsFacet())); router.registerFacet(address(new StakingDelegationsFacet())); + router.registerFacet(address(new StakingUnstakeWithdrawFacet())); router.registerFacet(address(new StakingSlashingFacet())); router.registerFacet(address(new StakingAssetsFacet())); router.registerFacet(address(new StakingViewsFacet())); diff --git a/test/audit/medlow/StakingSlashingFacet.t.sol b/test/audit/medlow/StakingSlashingFacet.t.sol index 9182ba49..9fe90489 100644 --- a/test/audit/medlow/StakingSlashingFacet.t.sol +++ b/test/audit/medlow/StakingSlashingFacet.t.sol @@ -12,6 +12,7 @@ import { Types } from "../../../src/libraries/Types.sol"; import { StakingOperatorsFacet } from "../../../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../../../src/facets/staking/StakingDepositsFacet.sol"; import { StakingDelegationsFacet } from "../../../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../../../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; import { StakingSlashingFacet } from "../../../src/facets/staking/StakingSlashingFacet.sol"; import { StakingAssetsFacet } from "../../../src/facets/staking/StakingAssetsFacet.sol"; import { StakingViewsFacet } from "../../../src/facets/staking/StakingViewsFacet.sol"; @@ -55,6 +56,7 @@ contract StakingSlashingFacetAuditTest is Test { router.registerFacet(address(new StakingOperatorsFacet())); router.registerFacet(address(new StakingDepositsFacet())); router.registerFacet(address(new StakingDelegationsFacet())); + router.registerFacet(address(new StakingUnstakeWithdrawFacet())); router.registerFacet(address(new StakingSlashingFacet())); router.registerFacet(address(new StakingAssetsFacet())); router.registerFacet(address(new StakingViewsFacet())); diff --git a/test/blueprints/TestHarness.sol b/test/blueprints/TestHarness.sol index bb96c279..81c9c542 100644 --- a/test/blueprints/TestHarness.sol +++ b/test/blueprints/TestHarness.sol @@ -30,6 +30,7 @@ import { TangleSlashingFacet } from "../../src/facets/tangle/TangleSlashingFacet import { StakingOperatorsFacet } from "../../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../../src/facets/staking/StakingDepositsFacet.sol"; import { StakingDelegationsFacet } from "../../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; import { StakingSlashingFacet } from "../../src/facets/staking/StakingSlashingFacet.sol"; import { StakingAssetsFacet } from "../../src/facets/staking/StakingAssetsFacet.sol"; import { StakingViewsFacet } from "../../src/facets/staking/StakingViewsFacet.sol"; @@ -173,6 +174,7 @@ abstract contract BlueprintTestHarness is Test, BlueprintDefinitionHelper { router.registerFacet(address(new StakingOperatorsFacet())); router.registerFacet(address(new StakingDepositsFacet())); router.registerFacet(address(new StakingDelegationsFacet())); + router.registerFacet(address(new StakingUnstakeWithdrawFacet())); router.registerFacet(address(new StakingSlashingFacet())); router.registerFacet(address(new StakingAssetsFacet())); router.registerFacet(address(new StakingViewsFacet())); diff --git a/test/exploit/Regression_Delegation_CostBasisSolvency.t.sol b/test/exploit/Regression_Delegation_CostBasisSolvency.t.sol index d2d76d22..a173a308 100644 --- a/test/exploit/Regression_Delegation_CostBasisSolvency.t.sol +++ b/test/exploit/Regression_Delegation_CostBasisSolvency.t.sol @@ -11,6 +11,7 @@ import { Types } from "../../src/libraries/Types.sol"; import { StakingOperatorsFacet } from "../../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../../src/facets/staking/StakingDepositsFacet.sol"; import { StakingDelegationsFacet } from "../../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; import { StakingSlashingFacet } from "../../src/facets/staking/StakingSlashingFacet.sol"; import { StakingAssetsFacet } from "../../src/facets/staking/StakingAssetsFacet.sol"; import { StakingViewsFacet } from "../../src/facets/staking/StakingViewsFacet.sol"; @@ -62,6 +63,7 @@ contract Regression_Delegation_CostBasisSolvency is Test { router.registerFacet(address(new StakingOperatorsFacet())); router.registerFacet(address(new StakingDepositsFacet())); router.registerFacet(address(new StakingDelegationsFacet())); + router.registerFacet(address(new StakingUnstakeWithdrawFacet())); router.registerFacet(address(new StakingSlashingFacet())); router.registerFacet(address(new StakingAssetsFacet())); router.registerFacet(address(new StakingViewsFacet())); diff --git a/test/fuzz/InvariantFuzz.t.sol b/test/fuzz/InvariantFuzz.t.sol index f4536eaf..6dbf5a3c 100644 --- a/test/fuzz/InvariantFuzz.t.sol +++ b/test/fuzz/InvariantFuzz.t.sol @@ -33,6 +33,7 @@ import { TangleSlashingFacet } from "../../src/facets/tangle/TangleSlashingFacet import { StakingOperatorsFacet } from "../../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../../src/facets/staking/StakingDepositsFacet.sol"; import { StakingDelegationsFacet } from "../../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; import { StakingSlashingFacet } from "../../src/facets/staking/StakingSlashingFacet.sol"; import { StakingAssetsFacet } from "../../src/facets/staking/StakingAssetsFacet.sol"; import { StakingViewsFacet } from "../../src/facets/staking/StakingViewsFacet.sol"; @@ -570,6 +571,7 @@ contract InvariantFuzzTest is Test, BlueprintDefinitionHelper { router.registerFacet(address(new StakingOperatorsFacet())); router.registerFacet(address(new StakingDepositsFacet())); router.registerFacet(address(new StakingDelegationsFacet())); + router.registerFacet(address(new StakingUnstakeWithdrawFacet())); router.registerFacet(address(new StakingSlashingFacet())); router.registerFacet(address(new StakingAssetsFacet())); router.registerFacet(address(new StakingViewsFacet())); diff --git a/test/staking/DelegationEdgeCasesTest.t.sol b/test/staking/DelegationEdgeCasesTest.t.sol index cdd50caa..fd3353c3 100644 --- a/test/staking/DelegationEdgeCasesTest.t.sol +++ b/test/staking/DelegationEdgeCasesTest.t.sol @@ -12,6 +12,7 @@ import { Types } from "../../src/libraries/Types.sol"; import { StakingOperatorsFacet } from "../../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../../src/facets/staking/StakingDepositsFacet.sol"; import { StakingDelegationsFacet } from "../../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; import { StakingSlashingFacet } from "../../src/facets/staking/StakingSlashingFacet.sol"; import { StakingAssetsFacet } from "../../src/facets/staking/StakingAssetsFacet.sol"; import { StakingViewsFacet } from "../../src/facets/staking/StakingViewsFacet.sol"; @@ -118,6 +119,7 @@ contract DelegationEdgeCasesTest is Test { router.registerFacet(address(new StakingOperatorsFacet())); router.registerFacet(address(new StakingDepositsFacet())); router.registerFacet(address(new StakingDelegationsFacet())); + router.registerFacet(address(new StakingUnstakeWithdrawFacet())); router.registerFacet(address(new StakingSlashingFacet())); router.registerFacet(address(new StakingAssetsFacet())); router.registerFacet(address(new StakingViewsFacet())); diff --git a/test/staking/DelegationFlowsTest.t.sol b/test/staking/DelegationFlowsTest.t.sol index d9901432..d8d66f24 100644 --- a/test/staking/DelegationFlowsTest.t.sol +++ b/test/staking/DelegationFlowsTest.t.sol @@ -12,6 +12,7 @@ import { Types } from "../../src/libraries/Types.sol"; import { StakingOperatorsFacet } from "../../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../../src/facets/staking/StakingDepositsFacet.sol"; import { StakingDelegationsFacet } from "../../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; import { StakingSlashingFacet } from "../../src/facets/staking/StakingSlashingFacet.sol"; import { StakingAssetsFacet } from "../../src/facets/staking/StakingAssetsFacet.sol"; import { StakingViewsFacet } from "../../src/facets/staking/StakingViewsFacet.sol"; @@ -72,6 +73,7 @@ contract DelegationFlowsTest is Test { router.registerFacet(address(new StakingOperatorsFacet())); router.registerFacet(address(new StakingDepositsFacet())); router.registerFacet(address(new StakingDelegationsFacet())); + router.registerFacet(address(new StakingUnstakeWithdrawFacet())); router.registerFacet(address(new StakingSlashingFacet())); router.registerFacet(address(new StakingAssetsFacet())); router.registerFacet(address(new StakingViewsFacet())); diff --git a/test/staking/DelegationTestHarness.sol b/test/staking/DelegationTestHarness.sol index d1b7ebca..58985623 100644 --- a/test/staking/DelegationTestHarness.sol +++ b/test/staking/DelegationTestHarness.sol @@ -11,6 +11,7 @@ import { Types } from "../../src/libraries/Types.sol"; import { StakingOperatorsFacet } from "../../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../../src/facets/staking/StakingDepositsFacet.sol"; import { StakingDelegationsFacet } from "../../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; import { StakingSlashingFacet } from "../../src/facets/staking/StakingSlashingFacet.sol"; import { StakingAssetsFacet } from "../../src/facets/staking/StakingAssetsFacet.sol"; import { StakingViewsFacet } from "../../src/facets/staking/StakingViewsFacet.sol"; @@ -241,6 +242,7 @@ abstract contract DelegationTestHarness is Test { router.registerFacet(address(new StakingOperatorsFacet())); router.registerFacet(address(new StakingDepositsFacet())); router.registerFacet(address(new StakingDelegationsFacet())); + router.registerFacet(address(new StakingUnstakeWithdrawFacet())); router.registerFacet(address(new StakingSlashingFacet())); router.registerFacet(address(new StakingAssetsFacet())); router.registerFacet(address(new StakingViewsFacet())); diff --git a/test/staking/LiquidDelegationTest.t.sol b/test/staking/LiquidDelegationTest.t.sol index 165d7493..a91d783e 100644 --- a/test/staking/LiquidDelegationTest.t.sol +++ b/test/staking/LiquidDelegationTest.t.sol @@ -13,6 +13,7 @@ import { Types } from "../../src/libraries/Types.sol"; import { StakingOperatorsFacet } from "../../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../../src/facets/staking/StakingDepositsFacet.sol"; import { StakingDelegationsFacet } from "../../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; import { StakingSlashingFacet } from "../../src/facets/staking/StakingSlashingFacet.sol"; import { StakingAssetsFacet } from "../../src/facets/staking/StakingAssetsFacet.sol"; import { StakingViewsFacet } from "../../src/facets/staking/StakingViewsFacet.sol"; @@ -102,6 +103,7 @@ contract LiquidDelegationTest is Test { router.registerFacet(address(new StakingOperatorsFacet())); router.registerFacet(address(new StakingDepositsFacet())); router.registerFacet(address(new StakingDelegationsFacet())); + router.registerFacet(address(new StakingUnstakeWithdrawFacet())); router.registerFacet(address(new StakingSlashingFacet())); router.registerFacet(address(new StakingAssetsFacet())); router.registerFacet(address(new StakingViewsFacet())); diff --git a/test/staking/ProportionalSlashingTest.t.sol b/test/staking/ProportionalSlashingTest.t.sol index 0cebf16a..9ecd120d 100644 --- a/test/staking/ProportionalSlashingTest.t.sol +++ b/test/staking/ProportionalSlashingTest.t.sol @@ -10,6 +10,7 @@ import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy import { StakingOperatorsFacet } from "../../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../../src/facets/staking/StakingDepositsFacet.sol"; import { StakingDelegationsFacet } from "../../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; import { StakingSlashingFacet } from "../../src/facets/staking/StakingSlashingFacet.sol"; import { StakingAssetsFacet } from "../../src/facets/staking/StakingAssetsFacet.sol"; import { StakingViewsFacet } from "../../src/facets/staking/StakingViewsFacet.sol"; @@ -86,6 +87,7 @@ contract ProportionalSlashingTest is Test { router.registerFacet(address(new StakingOperatorsFacet())); router.registerFacet(address(new StakingDepositsFacet())); router.registerFacet(address(new StakingDelegationsFacet())); + router.registerFacet(address(new StakingUnstakeWithdrawFacet())); router.registerFacet(address(new StakingSlashingFacet())); router.registerFacet(address(new StakingAssetsFacet())); router.registerFacet(address(new StakingViewsFacet())); diff --git a/test/staking/SlashAccountingInvariant.t.sol b/test/staking/SlashAccountingInvariant.t.sol index 9772b700..4ed5ed2f 100644 --- a/test/staking/SlashAccountingInvariant.t.sol +++ b/test/staking/SlashAccountingInvariant.t.sol @@ -13,6 +13,7 @@ import { Types } from "../../src/libraries/Types.sol"; import { StakingOperatorsFacet } from "../../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../../src/facets/staking/StakingDepositsFacet.sol"; import { StakingDelegationsFacet } from "../../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; import { StakingSlashingFacet } from "../../src/facets/staking/StakingSlashingFacet.sol"; import { StakingAssetsFacet } from "../../src/facets/staking/StakingAssetsFacet.sol"; import { StakingViewsFacet } from "../../src/facets/staking/StakingViewsFacet.sol"; @@ -228,6 +229,7 @@ contract SlashAccountingInvariantTest is StdInvariant, Test { router.registerFacet(address(new StakingOperatorsFacet())); router.registerFacet(address(new StakingDepositsFacet())); router.registerFacet(address(new StakingDelegationsFacet())); + router.registerFacet(address(new StakingUnstakeWithdrawFacet())); router.registerFacet(address(new StakingSlashingFacet())); router.registerFacet(address(new StakingAssetsFacet())); router.registerFacet(address(new StakingViewsFacet())); diff --git a/test/staking/SlashingInvariantTest.t.sol b/test/staking/SlashingInvariantTest.t.sol index 638f994d..59bdfa7b 100644 --- a/test/staking/SlashingInvariantTest.t.sol +++ b/test/staking/SlashingInvariantTest.t.sol @@ -12,6 +12,7 @@ import { Types } from "../../src/libraries/Types.sol"; import { StakingOperatorsFacet } from "../../src/facets/staking/StakingOperatorsFacet.sol"; import { StakingDepositsFacet } from "../../src/facets/staking/StakingDepositsFacet.sol"; import { StakingDelegationsFacet } from "../../src/facets/staking/StakingDelegationsFacet.sol"; +import { StakingUnstakeWithdrawFacet } from "../../src/facets/staking/StakingUnstakeWithdrawFacet.sol"; import { StakingSlashingFacet } from "../../src/facets/staking/StakingSlashingFacet.sol"; import { StakingAssetsFacet } from "../../src/facets/staking/StakingAssetsFacet.sol"; import { StakingViewsFacet } from "../../src/facets/staking/StakingViewsFacet.sol"; @@ -96,6 +97,7 @@ contract SlashForBlueprintFuzzTest is Test { router.registerFacet(address(new StakingOperatorsFacet())); router.registerFacet(address(new StakingDepositsFacet())); router.registerFacet(address(new StakingDelegationsFacet())); + router.registerFacet(address(new StakingUnstakeWithdrawFacet())); router.registerFacet(address(new StakingSlashingFacet())); router.registerFacet(address(new StakingAssetsFacet())); router.registerFacet(address(new StakingViewsFacet())); From 4f02942235cb1a2a430ba9f5b856ff4c5080e2c9 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 7 Jul 2026 17:37:55 -0600 Subject: [PATCH 2/3] chore(deploy): Tempo (chain 42431) 0.19 deployment manifest Full-stack FullDeploy broadcast to Tempo Moderato testnet succeeded (143 txs, ~707M gas / $2.38) after the split+shrink brought every CREATE under Tempo's 30M per-tx cap. Verified on-chain: proxies have code, TNT token symbol() reads, and executeDelegatorUnstakeAndWithdraw routes to the new StakingUnstakeWithdrawFacet. tangle=0xff137b9c879c47c28ce389e84501925438ab4cda staking=0x9484d07899b98384f1d66bd5b2659f3ed346f89e --- deployments/tempo/latest.json | 30 +++++++++++++++--------------- deployments/tempo/migration.json | 6 +++--- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/deployments/tempo/latest.json b/deployments/tempo/latest.json index a0c4a1d5..d0a82bfa 100644 --- a/deployments/tempo/latest.json +++ b/deployments/tempo/latest.json @@ -6,22 +6,22 @@ "treasury": "0x2420fff17c4213a4075cf5f7b6dc33429aaf22bb", "timelock": "0x2420fff17c4213a4075cf5f7b6dc33429aaf22bb", "multisig": "0x2420fff17c4213a4075cf5f7b6dc33429aaf22bb", - "tangle": "0x77c1cdfdda642a9c2587545768088c7cad6c4ced", - "staking": "0x8ed44aa6c65ef51c880fed08c9b2a611604b5693", - "restaking": "0x8ed44aa6c65ef51c880fed08c9b2a611604b5693", - "statusRegistry": "0x9f5799bbb9abfcb89645f5bb4c4844efd3c87c0c", - "tntToken": "0xb91177666047459e13a6e1254fffae60762ec2b9", - "metrics": "0xedffd611b47a273f28a35b9de5ee29c793c58f7c", - "rewardVaults": "0xa2f876e7cb7702e161290b704e45271dd240fa76", - "inflationPool": "0x53894ba39bf548ef38018f5d29c67a57206173b4", - "serviceFeeDistributor": "0xdc7ff0f48e409fcec01e1ba375fd5af558b0986e", + "tangle": "0xff137b9c879c47c28ce389e84501925438ab4cda", + "staking": "0x9484d07899b98384f1d66bd5b2659f3ed346f89e", + "restaking": "0x9484d07899b98384f1d66bd5b2659f3ed346f89e", + "statusRegistry": "0xaedaffd260e21b41cb9926370e32d14bef812b48", + "tntToken": "0x64fb7ffae82c1682574e9edb4e2f4e377132f671", + "metrics": "0x485289432c55ca68a392a204c4bddf1e4a0acf8f", + "rewardVaults": "0x9a1615fdcaaf2f659d78eb0d72fd3759480af7c4", + "inflationPool": "0x3602b76c625464895205466c645feff0d911836f", + "serviceFeeDistributor": "0x39d16ff4e0bce0e6b00a4a49be80c2b7ad2fb4b4", "streamingPaymentManager": "0x0000000000000000000000000000000000000000", - "credits": "0x2adc4f3f6c7a7fceed3f51a0c2da576ebb872332", + "credits": "0x94e8d532c3205346ee048cf6e37d5e7653c353ae", "epochLength": 604800, "stakeAssets": [ { "symbol": "TNT", - "token": "0xb91177666047459e13a6e1254fffae60762ec2b9", + "token": "0x64fb7ffae82c1682574e9edb4e2f4e377132f671", "adapter": "0x0000000000000000000000000000000000000000", "minOperatorStake": "1000000000000000000", "minDelegation": "100000000000000000", @@ -31,7 +31,7 @@ ], "rewardVaultsConfig": [ { - "asset": "0xb91177666047459e13a6e1254fffae60762ec2b9", + "asset": "0x64fb7ffae82c1682574e9edb4e2f4e377132f671", "depositCap": "250000000000000000000000", "active": true } @@ -72,11 +72,11 @@ "treasuryAmount": "32588831841878446700145909", "foundationRecipient": "0x2420fff17c4213a4075cf5f7b6dc33429aaf22bb", "foundationAmount": "15040809826744825912214026", - "claimDeadline": "1814491629", + "claimDeadline": "1815003116", "unlockedBps": 0, "cliffDuration": 0, "vestingDuration": 0, - "tangleMigration": "0xd29708d5768e192d31d3adcbd14532fb2a220d6f", - "zkVerifier": "0xc48a0f728df02647da5d923bd2193fd1f411c707" + "tangleMigration": "0x090853d08ac6dfb9496e1f6fa828469e41eac64d", + "zkVerifier": "0xe4e168cfab3bdf80f78d50121d036f04c10bb5c6" } } \ No newline at end of file diff --git a/deployments/tempo/migration.json b/deployments/tempo/migration.json index 1d436a3b..70249cab 100644 --- a/deployments/tempo/migration.json +++ b/deployments/tempo/migration.json @@ -1,7 +1,7 @@ { - "tntToken": "0xb91177666047459e13a6e1254fffae60762ec2b9", - "tangleMigration": "0xd29708d5768e192d31d3adcbd14532fb2a220d6f", - "zkVerifier": "0xc48a0f728df02647da5d923bd2193fd1f411c707", + "tntToken": "0x64fb7ffae82c1682574e9edb4e2f4e377132f671", + "tangleMigration": "0x090853d08ac6dfb9496e1f6fa828469e41eac64d", + "zkVerifier": "0xe4e168cfab3bdf80f78d50121d036f04c10bb5c6", "sp1VerifierGateway": "0x397a5f7f3dbd538f23de225b51f532c34448da9b", "programVKey": "0x000d74444f16d75f72527687a36c1bd4c49e6799816dac4989be3bc175e86fd7", "merkleRoot": "0x54ec5497e0a2f43ec721c1ec5392cb224cf710b1210b579dcaefea002bb51adb", From b07970f878ff4fad3275bba532c3560a6eebb537 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 7 Jul 2026 19:29:02 -0600 Subject: [PATCH 3/3] feat(tempo,payments): size-budget gate + beacon shrink + fail-closed join-seed + SFD deploy-once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boil-the-ocean pre-testnet hardening pass, all folded onto the Tempo-deployability branch: - SIZE GATE (durable): new test_DeployablesUnderTempoDeployCap asserts every deployable contract (26 facets + near-ceiling impls) stays < 21,500 B — Tempo's 30M per-tx cap meters code-deposit ~1,372 gas/byte, so the deploy ceiling is ~21,870 B, STRICTER than EIP-170. CI now fails before any contract can grow past the point where a Tempo broadcast reverts. - BEACON SHRINK: ValidatorPodManager 21,760 -> 19,929 B via a delegatecall-linked ValidatorPodManagerLib (delegateTo/queueUndelegation/completeUndelegation/_slash extracted), storage + behavior byte-identical. Gives beacon real Tempo headroom (was 40 B). - FAIL-CLOSED REFINEMENT (audit LOW): an operator joining an already-USD-pinned subscription while the oracle is off now seeds an identity snapshot (1e18) so its leg bills at raw scale instead of being zeroed. Gated strictly on the service being ALREADY USD-pinned so the "snapshot present <=> USD-pinned" witness is never corrupted. _subscriptionPinnedInUsd moved to shared Base (ServicesLifecycle + Payments are separate facets; one definition avoids drift). - SFD DEPLOY-ONCE: FullDeploy deployed ServiceFeeDistributor impl twice (inherited DeployV2._configureServiceFeeDistributor + FullDeploy's own path, the first orphaned). New virtual seam _deploysServiceFeeDistributorExternally() lets FullDeploy skip the inherited deploy; base DeployV2 behavior unchanged. Saves one ~26.7M-gas orphaned CREATE. - Tests: fail-closed branch now covered (join-seed + fail-closed + non-USD invariant, 3 tests); the fail-closed test locates its storage slot dynamically (survives reorders) rather than hardcoding it. Verified: src builds; every gated contract < 21,500 B (ValidatorPodManager 19,929, SFD 19,288, StakingDelegationsFacet 21,237); FacetSize gate 3/3; PaymentsBilling 6/6; behavior sweep green. --- script/Deploy.s.sol | 19 +- script/FullDeploy.s.sol | 9 + src/beacon/ValidatorPodManager.sol | 263 ++------------ src/beacon/ValidatorPodManagerLib.sol | 452 ++++++++++++++++++++++++ src/core/Base.sol | 47 +++ src/core/PaymentsBilling.sol | 44 --- src/core/ServicesLifecycle.sol | 41 ++- test/FacetSize.t.sol | 73 ++++ test/audit/medlow/PaymentsBilling.t.sol | 243 ++++++++++++- 9 files changed, 908 insertions(+), 283 deletions(-) create mode 100644 src/beacon/ValidatorPodManagerLib.sol diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index 215dce72..4eec6c48 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -294,7 +294,16 @@ contract DeployV2 is DeployScriptBase { _ensureTntToken(bootstrapAdmin); _configureTntDefaults(tangleProxy, stakingProxy); - _configureServiceFeeDistributor(bootstrapAdmin, stakingProxy, tangleProxy); + // Subclasses that own the ServiceFeeDistributor lifecycle end-to-end + // (FullDeploy deploys + wires + hands off + records it in the manifest) + // suppress this inherited deploy so the impl is created exactly once. + // Without the guard the base path deploys+wires instance A here and the + // subclass deploys+wires instance B in its own run(), overwriting the + // pointers and orphaning A (a wasted ~26.7M-gas CREATE). The standalone + // DeployV2.run() path leaves this false and still gets its SFD here. + if (!_deploysServiceFeeDistributorExternally()) { + _configureServiceFeeDistributor(bootstrapAdmin, stakingProxy, tangleProxy); + } if (broadcast) { vm.stopBroadcast(); @@ -345,6 +354,14 @@ contract DeployV2 is DeployScriptBase { } } + /// @notice Whether a subclass deploys and wires the ServiceFeeDistributor itself. + /// @dev When true, `_deployCore` skips the inherited `_configureServiceFeeDistributor` + /// so the distributor impl is created exactly once. Base `DeployV2.run()` keeps + /// this false and deploys the distributor inside `_deployCore` as before. + function _deploysServiceFeeDistributorExternally() internal view virtual returns (bool) { + return false; + } + function _configureServiceFeeDistributor(address admin, address stakingProxy, address tangleProxy) internal { Tangle tangle = Tangle(payable(tangleProxy)); IMultiAssetDelegation staking = IMultiAssetDelegation(payable(stakingProxy)); diff --git a/script/FullDeploy.s.sol b/script/FullDeploy.s.sol index 9bbafbb9..4bb90120 100644 --- a/script/FullDeploy.s.sol +++ b/script/FullDeploy.s.sol @@ -870,6 +870,15 @@ contract FullDeploy is DeployV2 { console2.log("Deployed InflationPool:", proxy); } + /// @notice FullDeploy owns the ServiceFeeDistributor lifecycle end-to-end: it deploys + /// the proxy in `run()` (via `_deployServiceFeeDistributorProxy`), wires it into + /// Tangle/staking/inflation-pool/streaming-manager, hands off its roles, and + /// records it in the manifest. Suppressing the inherited `_deployCore` deploy + /// keeps the impl a single CREATE instead of an orphaned duplicate. + function _deploysServiceFeeDistributorExternally() internal view override returns (bool) { + return true; + } + function _deployServiceFeeDistributorProxy( address admin, address staking, diff --git a/src/beacon/ValidatorPodManager.sol b/src/beacon/ValidatorPodManager.sol index 643e1e7b..f4f2a5d4 100644 --- a/src/beacon/ValidatorPodManager.sol +++ b/src/beacon/ValidatorPodManager.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.26; import { IBeaconOracle } from "./IBeaconOracle.sol"; import { ValidatorPod } from "./ValidatorPod.sol"; +import { ValidatorPodManagerLib } from "./ValidatorPodManagerLib.sol"; import { IStaking } from "../interfaces/IStaking.sol"; import { Types } from "../libraries/Types.sol"; import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; @@ -294,6 +295,24 @@ contract ValidatorPodManager is IStaking, Ownable, ReentrancyGuard { withdrawalDelayBlocks = DEFAULT_WITHDRAWAL_DELAY; } + // ═══════════════════════════════════════════════════════════════════════════ + // DELEGATECALL-LINKED LIBRARY BINDING + // ═══════════════════════════════════════════════════════════════════════════ + + /// @notice A {ValidatorPodManagerLib.Layout} pointer overlaying this contract's storage at slot 0. + /// @dev This contract's storage is the sequential layout `Ownable._owner` (slot 0), + /// `ReentrancyGuard._status` (slot 1), then this contract's own variables (slots 2..) in + /// declared order. `ValidatorPodManagerLib.Layout` mirrors that exact order, so a Layout based + /// at slot 0 aliases the same storage the manager reads/writes directly. The heavy + /// `completeUndelegation`/`_slash` bodies live in the library (linked by DELEGATECALL) so they + /// run in this contract's context — storage, `msg.sender`, and events are unchanged — while + /// their code no longer counts against this contract's runtime bytecode. + function _layout() private pure returns (ValidatorPodManagerLib.Layout storage $) { + assembly { + $.slot := 0 + } + } + // ═══════════════════════════════════════════════════════════════════════════ // POD MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ @@ -526,51 +545,9 @@ contract ValidatorPodManager is IStaking, Ownable, ReentrancyGuard { /// @param operator The operator to delegate to /// @param amount Amount to delegate (in wei) function delegateTo(address operator, uint256 amount) external nonReentrant { - if (!_operators[operator]) revert NotOperator(); - if (amount == 0) revert ZeroAmount(); - - // Only beacon shares that are neither already queued for withdrawal nor already - // locked behind another delegation may back a new delegation. Enforcing this in - // share space (the canonical custody unit) — instead of trusting the conservative - // asset counter alone — closes the double-count where shares queued for withdrawal - // were delegated again, then withdrawn for real, leaving a phantom delegation. - // INVARIANT: _shares[d] >= queuedShares[d] + delegatedShares[d] after this call. - BeaconPool storage beaconPool = _pools[msg.sender]; - uint256 ownerShares = _shares[msg.sender]; - uint256 lockedShares = queuedShares[msg.sender] + delegatedShares[msg.sender]; - uint256 freeShares = ownerShares > lockedShares ? ownerShares - lockedShares : 0; - uint256 freeAssets = _convertToAssets(beaconPool, freeShares); - if (freeAssets < amount) { - revert InsufficientShares(); - } - - // Lock the beacon shares that collateralize this delegation, at the current pool rate. - // Over-delegation is already prevented by the `freeAssets < amount` gate above; this - // escrow is what `completeUndelegation` later releases (surviving) and burns (slashed). - // Floor a non-zero delegation to at least one share and clamp to the free shares so - // the no-double-spend INVARIANT (_shares >= queued + delegated) can never be violated. - uint256 escrowShares = _convertToShares(beaconPool, amount); - if (escrowShares == 0) escrowShares = 1; - if (escrowShares > freeShares) escrowShares = freeShares; - - DelegationPool storage pool = _operatorDelegationPools[operator]; - uint256 mintedShares = _convertDelegationToShares(pool, amount); - if (mintedShares == 0) revert ZeroShares(); - - pool.totalAssets += amount; - pool.totalShares += mintedShares; - _delegationShares[msg.sender][operator] += mintedShares; - - // Lock the collateralizing beacon shares. - delegatedShares[msg.sender] += escrowShares; - _delegatorOperatorEscrowShares[msg.sender][operator] += escrowShares; - - // Maintain the aggregate counter and its per-operator partition in lockstep so - // the INVARIANT (aggregate == Σ per-operator) holds. - delegatorTotalDelegated[msg.sender] += amount; - _delegatorOperatorDelegated[msg.sender][operator] += amount; - - emit Delegated(msg.sender, operator, amount); + // Body lives in {ValidatorPodManagerLib.delegateTo}, linked by DELEGATECALL so it runs in this + // contract's context (storage, msg.sender, events). Behavior unchanged; `nonReentrant` guards here. + ValidatorPodManagerLib.delegateTo(_layout(), operator, amount); } /// @notice Queue an undelegation from an operator. @@ -588,28 +565,9 @@ contract ValidatorPodManager is IStaking, Ownable, ReentrancyGuard { nonReentrant returns (bytes32 undelegationRoot) { - if (amount == 0) revert ZeroAmount(); - - uint256 currentDelegation = - _convertDelegationToAssets(_operatorDelegationPools[operator], _delegationShares[msg.sender][operator]); - uint256 alreadyQueued = queuedUndelegations[msg.sender][operator]; - - if (currentDelegation < alreadyQueued + amount) revert InsufficientShares(); - - uint256 nonce = undelegationNonce[msg.sender]++; - undelegationRoot = keccak256(abi.encodePacked(msg.sender, operator, amount, block.number, nonce)); - - pendingUndelegations[undelegationRoot] = Undelegation({ - delegator: msg.sender, - operator: operator, - amount: amount, - startBlock: uint32(block.number), - completed: false - }); - - queuedUndelegations[msg.sender][operator] += amount; - - emit UndelegationQueued(undelegationRoot, msg.sender, operator, amount); + // Body lives in {ValidatorPodManagerLib.queueUndelegation}, linked by DELEGATECALL so it runs in + // this contract's context (storage, msg.sender, events). Behavior unchanged; `nonReentrant` guards. + return ValidatorPodManagerLib.queueUndelegation(_layout(), operator, amount); } /// @notice Complete a pending undelegation after delay period. @@ -619,143 +577,10 @@ contract ValidatorPodManager is IStaking, Ownable, ReentrancyGuard { /// burns all of the delegator's remaining shares for this operator and /// transfers what is available. function completeUndelegation(bytes32 undelegationRoot) external nonReentrant { - Undelegation storage undelegation = pendingUndelegations[undelegationRoot]; - - if (undelegation.delegator != msg.sender) revert UndelegationNotFound(); - if (undelegation.completed) revert UndelegationAlreadyCompleted(); - - if (block.number < undelegation.startBlock + withdrawalDelayBlocks) { - revert UndelegationNotReady(); - } - - undelegation.completed = true; - - address operator = undelegation.operator; - uint256 amount = undelegation.amount; - - queuedUndelegations[msg.sender][operator] -= amount; - - DelegationPool storage pool = _operatorDelegationPools[operator]; - uint256 ownerShares = _delegationShares[msg.sender][operator]; - uint256 liveAssets = _convertDelegationToAssets(pool, ownerShares); - - uint256 realizedAssets; - uint256 sharesBurned; - if (liveAssets <= amount) { - // Slashed below the requested amount: realize whatever is left and zero out. - realizedAssets = liveAssets; - sharesBurned = ownerShares; - } else { - realizedAssets = amount; - sharesBurned = _convertDelegationToShares(pool, amount); - if (sharesBurned > ownerShares) sharesBurned = ownerShares; - } - - uint256 remainingShares = ownerShares - sharesBurned; - _delegationShares[msg.sender][operator] = remainingShares; - pool.totalShares -= sharesBurned; - // Share→asset conversion can round a hair above the pool's tracked assets - // (e.g. live valuation 16.5e18+dust vs totalAssets 16.5e18 when this delegator - // holds all shares post-slash). Clamp so the decrement — and the realized payout — - // never exceed the pool, which would underflow and re-brick the very withdrawal - // this path exists to unblock. - if (realizedAssets > pool.totalAssets) realizedAssets = pool.totalAssets; - pool.totalAssets -= realizedAssets; - - // Pay down the deposit-accounted counters. The counter is asset-denominated - // against the *deposited* principal, not the slashed live valuation, so we must - // NOT decrement by `realizedAssets` (which can be below the deposited amount - // after a slash) — doing so would leave a permanent residue that can never reach - // 0 and would brick `queueWithdrawal` forever. - // - // When this fully unwinds the delegator's position with this operator - // (remainingShares == 0), clear the ENTIRE per-operator deposited commitment from - // both counters — that residue is exactly the value lost to slashing and is no - // longer recoverable, so it must not keep blocking withdrawals. - // - // On a partial undelegation (remainingShares > 0), decrement by the requested - // `amount`. Since `queueUndelegation` bounds `amount` by the live valuation, - // `amount <= depositedForOperator`, so this can never underflow the per-operator - // entry; we clamp defensively regardless. This preserves the - // INVARIANT (aggregate == Σ per-operator) on every path. - uint256 depositedForOperator = _delegatorOperatorDelegated[msg.sender][operator]; - uint256 counterDelta; - if (remainingShares == 0) { - counterDelta = depositedForOperator; - } else { - counterDelta = amount <= depositedForOperator ? amount : depositedForOperator; - } - - _delegatorOperatorDelegated[msg.sender][operator] = depositedForOperator - counterDelta; - - uint256 counter = delegatorTotalDelegated[msg.sender]; - delegatorTotalDelegated[msg.sender] = counter >= counterDelta ? counter - counterDelta : 0; - - // Reconcile the escrowed beacon shares behind this delegation. The portion of the - // escrow covered by this unwind is proportional to the delegation-pool shares burned. - // Of that covered escrow, the delegator only KEEPS the slash-adjusted fraction - // (realized value / deposited value); the slashed remainder is BURNED from their - // beacon pool so the principal lost to slashing can never be withdrawn. This is what - // makes a service slash punitive: without it the delegator releases their full escrow - // and withdraws 100% of beacon principal regardless of the slash. - // INVARIANT after release+burn: a delegator's withdrawable beacon principal reflects - // every slash that hit the operators they delegated to. - uint256 escrowForOperator = _delegatorOperatorEscrowShares[msg.sender][operator]; - if (escrowForOperator > 0) { - uint256 escrowCovered; - if (remainingShares == 0) { - // Full unwind of this operator: reconcile the entire escrow. - escrowCovered = escrowForOperator; - } else { - // Partial unwind: cover escrow proportional to delegation shares burned. - escrowCovered = escrowForOperator.mulDiv(sharesBurned, ownerShares, Math.Rounding.Floor); - if (escrowCovered > escrowForOperator) escrowCovered = escrowForOperator; - } - - // Surviving (releasable) escrow = covered * realized / depositedCovered. - // depositedCovered is the deposit-accounted value of the portion being unwound. - uint256 depositedCovered = counterDelta; - uint256 escrowToRelease; - if (depositedCovered == 0 || realizedAssets >= depositedCovered) { - // No value lost on the covered portion: release all covered escrow. - escrowToRelease = escrowCovered; - } else { - escrowToRelease = escrowCovered.mulDiv(realizedAssets, depositedCovered, Math.Rounding.Floor); - } - uint256 escrowToBurn = escrowCovered - escrowToRelease; - - // Unlock the covered escrow from the delegation locks. - _delegatorOperatorEscrowShares[msg.sender][operator] = escrowForOperator - escrowCovered; - uint256 locked = delegatedShares[msg.sender]; - delegatedShares[msg.sender] = locked >= escrowCovered ? locked - escrowCovered : 0; - - // Burn the slashed portion out of the delegator's beacon pool: destroy the - // shares and the principal they represent so they are never withdrawable. - if (escrowToBurn > 0) { - BeaconPool storage bp = _pools[msg.sender]; - uint256 burnShares = escrowToBurn > _shares[msg.sender] ? _shares[msg.sender] : escrowToBurn; - if (burnShares > bp.totalShares) burnShares = bp.totalShares; - uint256 burnAssets = _convertToAssets(bp, burnShares); - _shares[msg.sender] -= burnShares; - _aggregateShares -= burnShares; - bp.totalShares -= burnShares; - bp.totalAssets = burnAssets >= bp.totalAssets ? 0 : bp.totalAssets - burnAssets; - // forge-lint: disable-next-line(unsafe-typecast) - emit BeaconRebase(msg.sender, -int256(burnAssets), bp.totalAssets, bp.totalShares); - - // The burn lowered `totalAssets`, but the slashed ETH (if it has physically - // arrived) is still in the pod. Tell the pod to floor `withdrawNonBeaconChainEth` - // at the burned amount so the owner cannot drain the slashed principal as fake - // "non-beacon surplus" once the floor drops. Without this the service slash is - // non-punitive: the owner re-extracts 100% of principal despite the slash. - address podAddr = ownerToPod[msg.sender]; - if (podAddr != address(0) && burnAssets > 0) { - ValidatorPod(payable(podAddr)).recordSlashedPrincipalRetained(burnAssets); - } - } - } - - emit UndelegationCompleted(undelegationRoot, msg.sender, operator, realizedAssets); + // Body lives in {ValidatorPodManagerLib.completeUndelegation}, linked by DELEGATECALL so it runs + // in this contract's context (storage, msg.sender, events). Behavior is unchanged; only the code + // location moved out of this contract's runtime bytecode. `nonReentrant` still guards here. + ValidatorPodManagerLib.completeUndelegation(_layout(), undelegationRoot); } /// @notice Get undelegation info. @@ -1044,33 +869,9 @@ contract ValidatorPodManager is IStaking, Ownable, ReentrancyGuard { /// @dev Off-chain consumers can derive per-delegator slash impact from the /// `OperatorPoolSlashed` event plus cached share balances. function _slash(address operator, uint16 slashBps) internal returns (uint256 actualSlashed) { - if (slashBps > BPS_DENOMINATOR) { - slashBps = uint16(BPS_DENOMINATOR); - } - - DelegationPool storage pool = _operatorDelegationPools[operator]; - uint256 selfBefore = operatorStake[operator]; - uint256 delegatedBefore = pool.totalAssets; - uint256 totalStake = selfBefore + delegatedBefore; - - uint256 amount = (totalStake * slashBps) / BPS_DENOMINATOR; - actualSlashed = amount; - - // Self-stake first. - uint256 selfSlash = amount > selfBefore ? selfBefore : amount; - if (selfSlash > 0) { - operatorStake[operator] = selfBefore - selfSlash; - amount -= selfSlash; - } - - // Delegation pool: decrement totalAssets only; shares are untouched so every - // delegator's effective claim drops proportionally in a single SSTORE. - if (amount > 0 && delegatedBefore > 0) { - uint256 poolSlash = amount > delegatedBefore ? delegatedBefore : amount; - uint256 newTotal = delegatedBefore - poolSlash; - pool.totalAssets = newTotal; - emit OperatorPoolSlashed(operator, poolSlash, newTotal, pool.totalShares); - } + // Body lives in {ValidatorPodManagerLib.slash}, linked by DELEGATECALL so it mutates this + // contract's storage and emits `OperatorPoolSlashed` from this address. Behavior unchanged. + return ValidatorPodManagerLib.slash(_layout(), operator, slashBps); } /// @inheritdoc IStaking diff --git a/src/beacon/ValidatorPodManagerLib.sol b/src/beacon/ValidatorPodManagerLib.sol new file mode 100644 index 00000000..85639aee --- /dev/null +++ b/src/beacon/ValidatorPodManagerLib.sol @@ -0,0 +1,452 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; +import { ValidatorPod } from "./ValidatorPod.sol"; + +/// @title ValidatorPodManagerLib +/// @notice Deployed (delegatecall-linked) library holding the heaviest state-mutating routines of +/// {ValidatorPodManager}: `delegateTo`, `queueUndelegation`, `completeUndelegation`, and `_slash`. +/// Declaring the entry points `public` forces the compiler to emit them as a standalone library +/// contract and link them by `DELEGATECALL` instead of inlining, so their code leaves the manager's +/// runtime bytecode. +/// @dev This is the identical technique {ServiceFeeDistributorLib} uses for {ServiceFeeDistributor}. +/// It exists so `ValidatorPodManager` fits under chains that meter code-deposit gas aggressively — +/// Tempo (chain 42431) caps a tx at 30M gas and charges ~1,372 gas/byte, giving a ~21,870 B deploy +/// ceiling. The library runs by `DELEGATECALL`, so `msg.sender`, storage, and events resolve in the +/// manager's context exactly as they did inline; behavior, access control, and events are unchanged. +/// +/// The library operates on {Layout storage self} — the manager's ENTIRE storage, one field per slot +/// in the manager's exact declared order (including the two inherited leading slots from `Ownable` +/// and `ReentrancyGuard`). Slot indices MUST match the pre-refactor layout; do not reorder, insert, +/// or retype any field. +library ValidatorPodManagerLib { + using Math for uint256; + + /// @dev Virtual shares/assets offset. MUST equal the manager's `VIRTUAL_SHARES`/`VIRTUAL_ASSETS`. + uint256 internal constant VIRTUAL_SHARES = 1e3; + uint256 internal constant VIRTUAL_ASSETS = 1e3; + + /// @dev Basis-point denominator. MUST equal the manager's `BPS_DENOMINATOR`. + uint256 internal constant BPS_DENOMINATOR = 10_000; + + // ═══════════════════════════════════════════════════════════════════════════ + // STORAGE-MIRRORED STRUCTS (must match {ValidatorPodManager} bit-for-bit) + // ═══════════════════════════════════════════════════════════════════════════ + + /// @dev Mirror of `ValidatorPodManager.BeaconPool`. + struct BeaconPool { + uint256 totalAssets; + uint256 totalShares; + } + + /// @dev Mirror of `ValidatorPodManager.DelegationPool`. + struct DelegationPool { + uint256 totalAssets; + uint256 totalShares; + } + + /// @dev Mirror of `ValidatorPodManager.Withdrawal`. Unused here but kept so a slot referencing it + /// resolves to the same encoding if a future function is added; not read by this library. + struct Withdrawal { + address staker; + uint256 shares; + uint256 assets; + uint32 startBlock; + bool completed; + } + + /// @dev Mirror of `ValidatorPodManager.Undelegation`. + struct Undelegation { + address delegator; + address operator; + uint256 amount; + uint32 startBlock; + bool completed; + } + + /// @notice The manager's storage, one field per slot in the original declaration order. + /// @dev Slots 0..1 are the inherited fields (`Ownable._owner`, `ReentrancyGuard._status`). + /// Slots 2.. are `ValidatorPodManager`'s own variables, in declared order. `constant`s carry + /// no slot and are omitted. Do not reorder, insert, or retype fields. + struct Layout { + // slot 0 — Ownable._owner + address _owner; + // slot 1 — ReentrancyGuard._status + uint256 _status; + // slot 2 + address beaconOracle; + // slot 3 + uint256 minOperatorStakeAmount; + // slot 4 + mapping(address => address) ownerToPod; + // slot 5 + mapping(address => address) podToOwner; + // slot 6 + uint256 podCount; + // slot 7 + mapping(address => BeaconPool) _pools; + // slot 8 + mapping(address => uint256) _shares; + // slot 9 + uint256 _aggregateShares; + // slot 10 + mapping(address => bool) _operators; + // slot 11 + mapping(address => uint256) operatorStake; + // slot 12 + mapping(address => uint256) delegatorTotalDelegated; + // slot 13 + mapping(address => bool) _slashers; + // slot 14 + uint32 withdrawalDelayBlocks; + // slot 15 + mapping(bytes32 => Withdrawal) pendingWithdrawals; + // slot 16 + mapping(address => uint256) withdrawalNonce; + // slot 17 + mapping(address => uint256) queuedShares; + // slot 18 + mapping(address => uint256) delegatedShares; + // slot 19 + mapping(bytes32 => Undelegation) pendingUndelegations; + // slot 20 + mapping(address => uint256) undelegationNonce; + // slot 21 + mapping(address => mapping(address => uint256)) queuedUndelegations; + // slot 22 + mapping(address => DelegationPool) _operatorDelegationPools; + // slot 23 + mapping(address => mapping(address => uint256)) _delegationShares; + // slot 24 + mapping(address => mapping(address => uint256)) _delegatorOperatorDelegated; + // slot 25 + mapping(address => mapping(address => uint256)) _delegatorOperatorEscrowShares; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // EVENTS (same name/args as the manager → identical topic0 when emitted here) + // ═══════════════════════════════════════════════════════════════════════════ + + event BeaconRebase(address indexed owner, int256 assetsDelta, uint256 newTotalAssets, uint256 totalSharesPool); + event Delegated(address indexed delegator, address indexed operator, uint256 amount); + event UndelegationQueued( + bytes32 indexed undelegationRoot, address indexed delegator, address indexed operator, uint256 amount + ); + event UndelegationCompleted( + bytes32 indexed undelegationRoot, address indexed delegator, address indexed operator, uint256 amount + ); + event OperatorPoolSlashed( + address indexed operator, uint256 slashedAssets, uint256 newTotalAssets, uint256 totalShares + ); + + // ═══════════════════════════════════════════════════════════════════════════ + // ERRORS (same selectors as the manager) + // ═══════════════════════════════════════════════════════════════════════════ + + error NotOperator(); + error ZeroAmount(); + error ZeroShares(); + error InsufficientShares(); + error UndelegationNotFound(); + error UndelegationNotReady(); + error UndelegationAlreadyCompleted(); + + // ═══════════════════════════════════════════════════════════════════════════ + // CONVERSION HELPERS (mirror the manager's internal helpers exactly) + // ═══════════════════════════════════════════════════════════════════════════ + + function _convertToAssets(BeaconPool storage pool, uint256 shares) private view returns (uint256) { + return shares.mulDiv(pool.totalAssets + VIRTUAL_ASSETS, pool.totalShares + VIRTUAL_SHARES, Math.Rounding.Floor); + } + + function _convertToShares(BeaconPool storage pool, uint256 assets) private view returns (uint256) { + return assets.mulDiv(pool.totalShares + VIRTUAL_SHARES, pool.totalAssets + VIRTUAL_ASSETS, Math.Rounding.Floor); + } + + function _convertDelegationToAssets(DelegationPool storage pool, uint256 shares) private view returns (uint256) { + return shares.mulDiv(pool.totalAssets + VIRTUAL_ASSETS, pool.totalShares + VIRTUAL_SHARES, Math.Rounding.Floor); + } + + function _convertDelegationToShares(DelegationPool storage pool, uint256 assets) private view returns (uint256) { + return assets.mulDiv(pool.totalShares + VIRTUAL_SHARES, pool.totalAssets + VIRTUAL_ASSETS, Math.Rounding.Floor); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // COMPLETE UNDELEGATION (moved verbatim from the manager) + // ═══════════════════════════════════════════════════════════════════════════ + + /// @notice Complete a pending undelegation after the delay period. Runs by `DELEGATECALL` from the + /// manager, so `msg.sender`, storage, and events are the manager's — identical to inline. + /// @dev Behavior, access, and events match the pre-refactor `ValidatorPodManager.completeUndelegation` + /// exactly; only the code location moved. + function completeUndelegation(Layout storage self, bytes32 undelegationRoot) public { + Undelegation storage undelegation = self.pendingUndelegations[undelegationRoot]; + + if (undelegation.delegator != msg.sender) revert UndelegationNotFound(); + if (undelegation.completed) revert UndelegationAlreadyCompleted(); + + if (block.number < undelegation.startBlock + self.withdrawalDelayBlocks) { + revert UndelegationNotReady(); + } + + undelegation.completed = true; + + address operator = undelegation.operator; + uint256 amount = undelegation.amount; + + self.queuedUndelegations[msg.sender][operator] -= amount; + + DelegationPool storage pool = self._operatorDelegationPools[operator]; + uint256 ownerShares = self._delegationShares[msg.sender][operator]; + uint256 liveAssets = _convertDelegationToAssets(pool, ownerShares); + + uint256 realizedAssets; + uint256 sharesBurned; + if (liveAssets <= amount) { + // Slashed below the requested amount: realize whatever is left and zero out. + realizedAssets = liveAssets; + sharesBurned = ownerShares; + } else { + realizedAssets = amount; + sharesBurned = _convertDelegationToShares(pool, amount); + if (sharesBurned > ownerShares) sharesBurned = ownerShares; + } + + uint256 remainingShares = ownerShares - sharesBurned; + self._delegationShares[msg.sender][operator] = remainingShares; + pool.totalShares -= sharesBurned; + // Share→asset conversion can round a hair above the pool's tracked assets + // (e.g. live valuation 16.5e18+dust vs totalAssets 16.5e18 when this delegator + // holds all shares post-slash). Clamp so the decrement — and the realized payout — + // never exceed the pool, which would underflow and re-brick the very withdrawal + // this path exists to unblock. + if (realizedAssets > pool.totalAssets) realizedAssets = pool.totalAssets; + pool.totalAssets -= realizedAssets; + + // Pay down the deposit-accounted counters. The counter is asset-denominated + // against the *deposited* principal, not the slashed live valuation, so we must + // NOT decrement by `realizedAssets` (which can be below the deposited amount + // after a slash) — doing so would leave a permanent residue that can never reach + // 0 and would brick `queueWithdrawal` forever. + // + // When this fully unwinds the delegator's position with this operator + // (remainingShares == 0), clear the ENTIRE per-operator deposited commitment from + // both counters — that residue is exactly the value lost to slashing and is no + // longer recoverable, so it must not keep blocking withdrawals. + // + // On a partial undelegation (remainingShares > 0), decrement by the requested + // `amount`. Since `queueUndelegation` bounds `amount` by the live valuation, + // `amount <= depositedForOperator`, so this can never underflow the per-operator + // entry; we clamp defensively regardless. This preserves the + // INVARIANT (aggregate == Σ per-operator) on every path. + uint256 depositedForOperator = self._delegatorOperatorDelegated[msg.sender][operator]; + uint256 counterDelta; + if (remainingShares == 0) { + counterDelta = depositedForOperator; + } else { + counterDelta = amount <= depositedForOperator ? amount : depositedForOperator; + } + + self._delegatorOperatorDelegated[msg.sender][operator] = depositedForOperator - counterDelta; + + uint256 counter = self.delegatorTotalDelegated[msg.sender]; + self.delegatorTotalDelegated[msg.sender] = counter >= counterDelta ? counter - counterDelta : 0; + + // Reconcile the escrowed beacon shares behind this delegation. The portion of the + // escrow covered by this unwind is proportional to the delegation-pool shares burned. + // Of that covered escrow, the delegator only KEEPS the slash-adjusted fraction + // (realized value / deposited value); the slashed remainder is BURNED from their + // beacon pool so the principal lost to slashing can never be withdrawn. This is what + // makes a service slash punitive: without it the delegator releases their full escrow + // and withdraws 100% of beacon principal regardless of the slash. + // INVARIANT after release+burn: a delegator's withdrawable beacon principal reflects + // every slash that hit the operators they delegated to. + uint256 escrowForOperator = self._delegatorOperatorEscrowShares[msg.sender][operator]; + if (escrowForOperator > 0) { + uint256 escrowCovered; + if (remainingShares == 0) { + // Full unwind of this operator: reconcile the entire escrow. + escrowCovered = escrowForOperator; + } else { + // Partial unwind: cover escrow proportional to delegation shares burned. + escrowCovered = escrowForOperator.mulDiv(sharesBurned, ownerShares, Math.Rounding.Floor); + if (escrowCovered > escrowForOperator) escrowCovered = escrowForOperator; + } + + // Surviving (releasable) escrow = covered * realized / depositedCovered. + // depositedCovered is the deposit-accounted value of the portion being unwound. + uint256 depositedCovered = counterDelta; + uint256 escrowToRelease; + if (depositedCovered == 0 || realizedAssets >= depositedCovered) { + // No value lost on the covered portion: release all covered escrow. + escrowToRelease = escrowCovered; + } else { + escrowToRelease = escrowCovered.mulDiv(realizedAssets, depositedCovered, Math.Rounding.Floor); + } + uint256 escrowToBurn = escrowCovered - escrowToRelease; + + // Unlock the covered escrow from the delegation locks. + self._delegatorOperatorEscrowShares[msg.sender][operator] = escrowForOperator - escrowCovered; + uint256 locked = self.delegatedShares[msg.sender]; + self.delegatedShares[msg.sender] = locked >= escrowCovered ? locked - escrowCovered : 0; + + // Burn the slashed portion out of the delegator's beacon pool: destroy the + // shares and the principal they represent so they are never withdrawable. + if (escrowToBurn > 0) { + BeaconPool storage bp = self._pools[msg.sender]; + uint256 burnShares = escrowToBurn > self._shares[msg.sender] ? self._shares[msg.sender] : escrowToBurn; + if (burnShares > bp.totalShares) burnShares = bp.totalShares; + uint256 burnAssets = _convertToAssets(bp, burnShares); + self._shares[msg.sender] -= burnShares; + self._aggregateShares -= burnShares; + bp.totalShares -= burnShares; + bp.totalAssets = burnAssets >= bp.totalAssets ? 0 : bp.totalAssets - burnAssets; + // forge-lint: disable-next-line(unsafe-typecast) + emit BeaconRebase(msg.sender, -int256(burnAssets), bp.totalAssets, bp.totalShares); + + // The burn lowered `totalAssets`, but the slashed ETH (if it has physically + // arrived) is still in the pod. Tell the pod to floor `withdrawNonBeaconChainEth` + // at the burned amount so the owner cannot drain the slashed principal as fake + // "non-beacon surplus" once the floor drops. Without this the service slash is + // non-punitive: the owner re-extracts 100% of principal despite the slash. + address podAddr = self.ownerToPod[msg.sender]; + if (podAddr != address(0) && burnAssets > 0) { + ValidatorPod(payable(podAddr)).recordSlashedPrincipalRetained(burnAssets); + } + } + } + + emit UndelegationCompleted(undelegationRoot, msg.sender, operator, realizedAssets); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // DELEGATE (moved verbatim from the manager) + // ═══════════════════════════════════════════════════════════════════════════ + + /// @notice Delegate beacon-pool assets to an operator. Runs by `DELEGATECALL`, so it mutates the + /// manager's storage and emits from the manager's address. Behavior/events unchanged. + function delegateTo(Layout storage self, address operator, uint256 amount) public { + if (!self._operators[operator]) revert NotOperator(); + if (amount == 0) revert ZeroAmount(); + + // Only beacon shares that are neither already queued for withdrawal nor already + // locked behind another delegation may back a new delegation. Enforcing this in + // share space (the canonical custody unit) — instead of trusting the conservative + // asset counter alone — closes the double-count where shares queued for withdrawal + // were delegated again, then withdrawn for real, leaving a phantom delegation. + // INVARIANT: _shares[d] >= queuedShares[d] + delegatedShares[d] after this call. + BeaconPool storage beaconPool = self._pools[msg.sender]; + uint256 ownerShares = self._shares[msg.sender]; + uint256 lockedShares = self.queuedShares[msg.sender] + self.delegatedShares[msg.sender]; + uint256 freeShares = ownerShares > lockedShares ? ownerShares - lockedShares : 0; + uint256 freeAssets = _convertToAssets(beaconPool, freeShares); + if (freeAssets < amount) { + revert InsufficientShares(); + } + + // Lock the beacon shares that collateralize this delegation, at the current pool rate. + // Over-delegation is already prevented by the `freeAssets < amount` gate above; this + // escrow is what `completeUndelegation` later releases (surviving) and burns (slashed). + // Floor a non-zero delegation to at least one share and clamp to the free shares so + // the no-double-spend INVARIANT (_shares >= queued + delegated) can never be violated. + uint256 escrowShares = _convertToShares(beaconPool, amount); + if (escrowShares == 0) escrowShares = 1; + if (escrowShares > freeShares) escrowShares = freeShares; + + DelegationPool storage pool = self._operatorDelegationPools[operator]; + uint256 mintedShares = _convertDelegationToShares(pool, amount); + if (mintedShares == 0) revert ZeroShares(); + + pool.totalAssets += amount; + pool.totalShares += mintedShares; + self._delegationShares[msg.sender][operator] += mintedShares; + + // Lock the collateralizing beacon shares. + self.delegatedShares[msg.sender] += escrowShares; + self._delegatorOperatorEscrowShares[msg.sender][operator] += escrowShares; + + // Maintain the aggregate counter and its per-operator partition in lockstep so + // the INVARIANT (aggregate == Σ per-operator) holds. + self.delegatorTotalDelegated[msg.sender] += amount; + self._delegatorOperatorDelegated[msg.sender][operator] += amount; + + emit Delegated(msg.sender, operator, amount); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // QUEUE UNDELEGATION (moved verbatim from the manager) + // ═══════════════════════════════════════════════════════════════════════════ + + /// @notice Queue an undelegation from an operator. Runs by `DELEGATECALL`; behavior/events unchanged. + function queueUndelegation( + Layout storage self, + address operator, + uint256 amount + ) + public + returns (bytes32 undelegationRoot) + { + if (amount == 0) revert ZeroAmount(); + + uint256 currentDelegation = _convertDelegationToAssets( + self._operatorDelegationPools[operator], self._delegationShares[msg.sender][operator] + ); + uint256 alreadyQueued = self.queuedUndelegations[msg.sender][operator]; + + if (currentDelegation < alreadyQueued + amount) revert InsufficientShares(); + + uint256 nonce = self.undelegationNonce[msg.sender]++; + undelegationRoot = keccak256(abi.encodePacked(msg.sender, operator, amount, block.number, nonce)); + + self.pendingUndelegations[undelegationRoot] = Undelegation({ + delegator: msg.sender, + operator: operator, + amount: amount, + startBlock: uint32(block.number), + completed: false + }); + + self.queuedUndelegations[msg.sender][operator] += amount; + + emit UndelegationQueued(undelegationRoot, msg.sender, operator, amount); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // SLASH (moved verbatim from the manager) + // ═══════════════════════════════════════════════════════════════════════════ + + /// @notice Internal slash. O(1): one SLOAD/SSTORE for self-stake, one for the delegation pool's + /// `totalAssets`. Per-delegator effective stake drops proportionally via unchanged shares. + /// @dev Behavior and events match the pre-refactor `ValidatorPodManager._slash` exactly; only the + /// code location moved. Runs by `DELEGATECALL`, so it mutates the manager's storage. + function slash(Layout storage self, address operator, uint16 slashBps) public returns (uint256 actualSlashed) { + if (slashBps > BPS_DENOMINATOR) { + slashBps = uint16(BPS_DENOMINATOR); + } + + DelegationPool storage pool = self._operatorDelegationPools[operator]; + uint256 selfBefore = self.operatorStake[operator]; + uint256 delegatedBefore = pool.totalAssets; + uint256 totalStake = selfBefore + delegatedBefore; + + uint256 amount = (totalStake * slashBps) / BPS_DENOMINATOR; + actualSlashed = amount; + + // Self-stake first. + uint256 selfSlash = amount > selfBefore ? selfBefore : amount; + if (selfSlash > 0) { + self.operatorStake[operator] = selfBefore - selfSlash; + amount -= selfSlash; + } + + // Delegation pool: decrement totalAssets only; shares are untouched so every + // delegator's effective claim drops proportionally in a single SSTORE. + if (amount > 0 && delegatedBefore > 0) { + uint256 poolSlash = amount > delegatedBefore ? delegatedBefore : amount; + uint256 newTotal = delegatedBefore - poolSlash; + pool.totalAssets = newTotal; + emit OperatorPoolSlashed(operator, poolSlash, newTotal, pool.totalShares); + } + } +} diff --git a/src/core/Base.sol b/src/core/Base.sol index 89d309e4..00772c1f 100644 --- a/src/core/Base.sol +++ b/src/core/Base.sol @@ -246,6 +246,53 @@ abstract contract Base is return Types.Asset({ kind: Types.AssetKind.ERC20, token: bond }); } + /// @notice Recover a subscription's activation-time billing scale (USD vs raw). + /// @dev The denominator (`subscriptionBaselineStake`) was pinned at activation in one + /// of two scales and can never change for the life of the service. We read that + /// decision from durable state rather than the mutable `_priceOracle`: a non-zero + /// `_baselinePriceByOpAsset` on any seeded (op, asset) is written ONLY when an + /// oracle was configured at seeding time (`_snapshotBaselinePrice` / + /// `_snapshotJoinPrice` early-return on a zero oracle), so its presence is a + /// permanent witness that the baseline was pinned in USD scale. Returning the + /// activation-time mode here keeps every bill's numerator in the same scale as + /// its denominator even after `setPriceOracle` is toggled mid-subscription. + /// Lives in `Base` so both the payment-billing facet (bill-time scale recovery) + /// and the service-lifecycle facet (join-time identity seeding) share ONE + /// definition of the witness — a duplicated loop could drift and corrupt it. + /// @return true iff this subscription's baseline was pinned in USD scale. + function _subscriptionPinnedInUsd( + uint64 serviceId, + address[] memory operators, + Types.Asset memory bondAsset + ) + internal + view + returns (bool) + { + bytes32 bondAssetHash = keccak256(abi.encode(bondAsset.kind, bondAsset.token)); + uint256 n = operators.length; + for (uint256 i = 0; i < n;) { + address op = operators[i]; + Types.AssetSecurityCommitment[] storage commitments = _serviceSecurityCommitments[serviceId][op]; + uint256 m = commitments.length; + if (m == 0) { + if (_baselinePriceByOpAsset[serviceId][op][bondAssetHash] != 0) return true; + } else { + for (uint256 j = 0; j < m;) { + bytes32 assetHash = keccak256(abi.encode(commitments[j].asset.kind, commitments[j].asset.token)); + if (_baselinePriceByOpAsset[serviceId][op][assetHash] != 0) return true; + unchecked { + ++j; + } + } + } + unchecked { + ++i; + } + } + return false; + } + // ═══════════════════════════════════════════════════════════════════════════ // METRICS HOOKS (lightweight, fail-safe) // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/core/PaymentsBilling.sol b/src/core/PaymentsBilling.sol index ef00e206..a144ef4d 100644 --- a/src/core/PaymentsBilling.sol +++ b/src/core/PaymentsBilling.sol @@ -421,50 +421,6 @@ abstract contract PaymentsBilling is PaymentsCore { // or folds into the operator pool. } - /// @notice Recover a subscription's activation-time billing scale (USD vs raw). - /// @dev The denominator (`subscriptionBaselineStake`) was pinned at activation in one - /// of two scales and can never change for the life of the service. We read that - /// decision from durable state rather than the mutable `_priceOracle`: a non-zero - /// `_baselinePriceByOpAsset` on any seeded (op, asset) is written ONLY when an - /// oracle was configured at seeding time (`_snapshotBaselinePrice` / - /// `_snapshotJoinPrice` early-return on a zero oracle), so its presence is a - /// permanent witness that the baseline was pinned in USD scale. Returning the - /// activation-time mode here keeps every bill's numerator in the same scale as - /// its denominator even after `setPriceOracle` is toggled mid-subscription. - /// @return true iff this subscription's baseline was pinned in USD scale. - function _subscriptionPinnedInUsd( - uint64 serviceId, - address[] memory operators, - Types.Asset memory bondAsset - ) - internal - view - returns (bool) - { - bytes32 bondAssetHash = keccak256(abi.encode(bondAsset.kind, bondAsset.token)); - uint256 n = operators.length; - for (uint256 i = 0; i < n;) { - address op = operators[i]; - Types.AssetSecurityCommitment[] storage commitments = _serviceSecurityCommitments[serviceId][op]; - uint256 m = commitments.length; - if (m == 0) { - if (_baselinePriceByOpAsset[serviceId][op][bondAssetHash] != 0) return true; - } else { - for (uint256 j = 0; j < m;) { - bytes32 assetHash = keccak256(abi.encode(commitments[j].asset.kind, commitments[j].asset.token)); - if (_baselinePriceByOpAsset[serviceId][op][assetHash] != 0) return true; - unchecked { - ++j; - } - } - } - unchecked { - ++i; - } - } - return false; - } - /// @notice Resolve the per-period bill adjustment from the blueprint's manager hook. /// @dev Best-effort with a hard gas cap (the manager hook gas budget). Any revert / /// out-of-range return / zero manager yields a full-bill (10_000 bps) result. diff --git a/src/core/ServicesLifecycle.sol b/src/core/ServicesLifecycle.sol index 9a057f82..e4bf0cca 100644 --- a/src/core/ServicesLifecycle.sol +++ b/src/core/ServicesLifecycle.sol @@ -728,6 +728,26 @@ abstract contract ServicesLifecycle is Base { Types.AssetSecurityCommitment[] storage joinerCommitments = _serviceSecurityCommitments[serviceId][msg.sender]; uint256 commitmentCount = joinerCommitments.length; address oracleAddr = _priceOracle; + + // A new operator joining during an admin oracle-off window (`oracleAddr == 0`) gets + // NO join-time snapshot from `_snapshotJoinPrice`. If the subscription was ALREADY + // pinned in USD at activation, the bill-time fail-closed branch (PaymentsBilling) + // zeroes any leg that is USD-pinned yet has no snapshot and no live oracle — which + // would strip this legitimate joiner's pay. Seed the identity sentinel (1 ether) + // for the joiner so their leg routes through the `snapshot != 0` identity branch + // (contribution * 1e18 / 1e18 = raw) instead of the zero branch, mirroring how a + // reverting / disabled oracle is already seeded below and in PaymentsDistribution. + // Gate strictly on the service already being USD-pinned: `_baselinePriceByOpAsset` + // is the durable witness for `_subscriptionPinnedInUsd`, so seeding a snapshot on a + // genuinely oracle-off / non-USD service would corrupt that witness and silently + // flip the whole service into USD scale. The O(n) pinned check only runs on the + // oracle-off path — when a live oracle exists, `_snapshotJoinPrice` seeds directly. + bool seedIdentityWhenPinned = false; + if (oracleAddr == address(0)) { + seedIdentityWhenPinned = + _subscriptionPinnedInUsd(serviceId, _serviceOperatorSet[serviceId].values(), _bondAssetForBilling()); + } + if (commitmentCount == 0) { // Fallback: single bond-asset cursor (matches `_initSubscriptionBaseline`). Types.Asset memory bondAsset = _bondAssetForBilling(); @@ -735,7 +755,7 @@ abstract contract ServicesLifecycle is Base { (uint256 cumOpAtJoin,,) = _staking.getCumStakeSeconds(msg.sender, bondAsset); _twapCursorByOpAsset[serviceId][msg.sender][assetHash] = cumOpAtJoin == 0 ? 1 : cumOpAtJoin; address token = bondAsset.kind == Types.AssetKind.Native ? address(0) : bondAsset.token; - _snapshotJoinPrice(serviceId, msg.sender, assetHash, oracleAddr, token); + _snapshotJoinPrice(serviceId, msg.sender, assetHash, oracleAddr, token, seedIdentityWhenPinned); } else { for (uint256 k = 0; k < commitmentCount;) { Types.AssetSecurityCommitment storage c = joinerCommitments[k]; @@ -743,7 +763,7 @@ abstract contract ServicesLifecycle is Base { (uint256 cumOpAtJoin,,) = _staking.getCumStakeSeconds(msg.sender, c.asset); _twapCursorByOpAsset[serviceId][msg.sender][assetHash] = cumOpAtJoin == 0 ? 1 : cumOpAtJoin; address token = c.asset.kind == Types.AssetKind.Native ? address(0) : c.asset.token; - _snapshotJoinPrice(serviceId, msg.sender, assetHash, oracleAddr, token); + _snapshotJoinPrice(serviceId, msg.sender, assetHash, oracleAddr, token, seedIdentityWhenPinned); unchecked { ++k; } @@ -771,17 +791,30 @@ abstract contract ServicesLifecycle is Base { /// absence cannot game the rejoin). A reverting / disabled oracle stores the /// identity scale (1 ether), which the bill-time formula treats as raw /// token-second weighting for that (op, asset). + /// @param seedIdentityWhenOracleOff When true and no oracle is configured, seed the + /// identity sentinel (1 ether) instead of skipping. The caller sets this ONLY + /// when the subscription is already USD-pinned (`_subscriptionPinnedInUsd`), so + /// an operator joining during an admin oracle-off window is billed at identity + /// scale rather than being zeroed by the bill-time fail-closed branch. When the + /// service is NOT USD-pinned the caller passes false, so a non-USD leg gets no + /// snapshot and the `_subscriptionPinnedInUsd` witness stays uncorrupted. function _snapshotJoinPrice( uint64 serviceId, address op, bytes32 assetHash, address oracleAddr, - address token + address token, + bool seedIdentityWhenOracleOff ) internal { - if (oracleAddr == address(0)) return; if (_baselinePriceByOpAsset[serviceId][op][assetHash] != 0) return; + if (oracleAddr == address(0)) { + if (seedIdentityWhenOracleOff) { + _baselinePriceByOpAsset[serviceId][op][assetHash] = 1 ether; + } + return; + } uint256 priceUsd = _safeToUSDView(oracleAddr, token, 1 ether); _baselinePriceByOpAsset[serviceId][op][assetHash] = priceUsd == 0 ? 1 ether : priceUsd; } diff --git a/test/FacetSize.t.sol b/test/FacetSize.t.sol index cbd27dc2..d65a4879 100644 --- a/test/FacetSize.t.sol +++ b/test/FacetSize.t.sol @@ -29,6 +29,13 @@ import { StakingOperatorsFacet } from "../src/facets/staking/StakingOperatorsFac import { StakingSlashingFacet } from "../src/facets/staking/StakingSlashingFacet.sol"; import { StakingViewsFacet } from "../src/facets/staking/StakingViewsFacet.sol"; +// Standalone (non-facet) deployable impls that sit near the size ceiling — gated for Tempo too. +import { ServiceFeeDistributor } from "../src/rewards/ServiceFeeDistributor.sol"; +import { InflationPool } from "../src/rewards/InflationPool.sol"; +import { RewardVaults } from "../src/rewards/RewardVaults.sol"; +import { StreamingPaymentManager } from "../src/rewards/StreamingPaymentManager.sol"; +import { ValidatorPodManager } from "../src/beacon/ValidatorPodManager.sol"; + /// @notice EIP-170 gate: every deployable facet's runtime bytecode must fit under the /// 24576-byte limit enforced by mainnet, Base, Arbitrum, Optimism, and every /// other EIP-170 chain. Anvil runs with `--disable-code-size-limit` and will @@ -44,6 +51,16 @@ contract FacetSizeTest is Test { /// @dev EIP-170 contract size limit (24576 bytes). uint256 internal constant CODE_SIZE_LIMIT = 24_576; + /// @dev Tempo (chain 42431) per-transaction gas cap is 30M and it meters code deposit ~1,372 + /// gas/runtime-byte, so a contract's CREATE cost is ~1,372*bytes + init overhead. The + /// deploy ceiling is therefore ~21,870 B (30M/1,372) — STRICTER than EIP-170's 24,576. + /// We gate at 21,500 to keep ~370 B (~500k gas) of margin for constructor/init overhead and + /// forge's gas-estimate variance, so a contract can never silently grow past the point where + /// `forge script ... --broadcast` to Tempo would fail with "tx gas limit > cap". This is the + /// fence that caught the 0.19 deploy (StakingDelegationsFacet + ServiceFeeDistributor both + /// exceeded it and were split / library-extracted). + uint256 internal constant TEMPO_DEPLOY_CAP = 21_500; + function _assertUnderLimit(address facet, string memory name) internal view { uint256 size = facet.code.length; if (size > CODE_SIZE_LIMIT) { @@ -62,6 +79,62 @@ contract FacetSizeTest is Test { } } + function _assertUnderTempoCap(address c, string memory name) internal view { + uint256 size = c.code.length; + if (size > TEMPO_DEPLOY_CAP) { + revert( + string.concat( + name, + ": ", + vm.toString(size), + " bytes exceeds the Tempo deploy ceiling of ", + vm.toString(TEMPO_DEPLOY_CAP), + " bytes (overage: ", + vm.toString(size - TEMPO_DEPLOY_CAP), + ") - its CREATE would exceed Tempo's 30M per-tx gas cap; split it or extract a library" + ) + ); + } + } + + /// @notice Tempo deploy-cap gate: every deployable contract (facets + near-ceiling standalone + /// impls) must fit under the ~21,870 B Tempo CREATE ceiling. Runs under the optimized + /// (default) profile that production builds use. + function test_DeployablesUnderTempoDeployCap() public { + // Tangle facets + _assertUnderTempoCap(address(new TangleBlueprintsFacet()), "TangleBlueprintsFacet"); + _assertUnderTempoCap(address(new TangleBlueprintsManagementFacet()), "TangleBlueprintsManagementFacet"); + _assertUnderTempoCap(address(new TangleJobsAggregationFacet()), "TangleJobsAggregationFacet"); + _assertUnderTempoCap(address(new TangleJobsFacet()), "TangleJobsFacet"); + _assertUnderTempoCap(address(new TangleJobsRFQFacet()), "TangleJobsRFQFacet"); + _assertUnderTempoCap(address(new TangleOperatorsFacet()), "TangleOperatorsFacet"); + _assertUnderTempoCap(address(new TanglePaymentsDistributionFacet()), "TanglePaymentsDistributionFacet"); + _assertUnderTempoCap(address(new TanglePaymentsFacet()), "TanglePaymentsFacet"); + _assertUnderTempoCap(address(new TanglePaymentsRewardsFacet()), "TanglePaymentsRewardsFacet"); + _assertUnderTempoCap(address(new TangleQuotesExtensionFacet()), "TangleQuotesExtensionFacet"); + _assertUnderTempoCap(address(new TangleQuotesFacet()), "TangleQuotesFacet"); + _assertUnderTempoCap(address(new TangleServicesFacet()), "TangleServicesFacet"); + _assertUnderTempoCap(address(new TangleServicesLifecycleFacet()), "TangleServicesLifecycleFacet"); + _assertUnderTempoCap(address(new TangleServicesRequestsFacet()), "TangleServicesRequestsFacet"); + _assertUnderTempoCap(address(new TangleServicesViewsFacet()), "TangleServicesViewsFacet"); + _assertUnderTempoCap(address(new TangleSlashingFacet()), "TangleSlashingFacet"); + // Staking facets + _assertUnderTempoCap(address(new StakingAdminFacet()), "StakingAdminFacet"); + _assertUnderTempoCap(address(new StakingAssetsFacet()), "StakingAssetsFacet"); + _assertUnderTempoCap(address(new StakingDelegationsFacet()), "StakingDelegationsFacet"); + _assertUnderTempoCap(address(new StakingUnstakeWithdrawFacet()), "StakingUnstakeWithdrawFacet"); + _assertUnderTempoCap(address(new StakingDepositsFacet()), "StakingDepositsFacet"); + _assertUnderTempoCap(address(new StakingOperatorsFacet()), "StakingOperatorsFacet"); + _assertUnderTempoCap(address(new StakingSlashingFacet()), "StakingSlashingFacet"); + _assertUnderTempoCap(address(new StakingViewsFacet()), "StakingViewsFacet"); + // Standalone impls that sit near the ceiling + _assertUnderTempoCap(address(new ServiceFeeDistributor()), "ServiceFeeDistributor"); + _assertUnderTempoCap(address(new InflationPool()), "InflationPool"); + _assertUnderTempoCap(address(new RewardVaults()), "RewardVaults"); + _assertUnderTempoCap(address(new StreamingPaymentManager()), "StreamingPaymentManager"); + _assertUnderTempoCap(address(new ValidatorPodManager(address(0x1), 0)), "ValidatorPodManager"); + } + function test_TangleFacetsUnderEip170() public { _assertUnderLimit(address(new TangleBlueprintsFacet()), "TangleBlueprintsFacet"); _assertUnderLimit(address(new TangleBlueprintsManagementFacet()), "TangleBlueprintsManagementFacet"); diff --git a/test/audit/medlow/PaymentsBilling.t.sol b/test/audit/medlow/PaymentsBilling.t.sol index 9e9fe9cb..f7456ce6 100644 --- a/test/audit/medlow/PaymentsBilling.t.sol +++ b/test/audit/medlow/PaymentsBilling.t.sol @@ -220,9 +220,7 @@ contract PaymentsBillingMedLowTest is BaseTest { uint256 chargedAfterEnable = _billOnceAndMeasure(); // Scale stays raw (matching the raw baseline): clean nominal, no flip. - assertApproxEqAbs( - chargedAfterEnable, SUB_RATE, 1, "bill after oracle enable stays at nominal (no scale flip)" - ); + assertApproxEqAbs(chargedAfterEnable, SUB_RATE, 1, "bill after oracle enable stays at nominal (no scale flip)"); } // ───────────────────────────────────────────────────────────────────────── @@ -247,4 +245,243 @@ contract PaymentsBillingMedLowTest is BaseTest { vm.warp(block.timestamp + SUB_INTERVAL); assertApproxEqAbs(_billOnceAndMeasure(), SUB_RATE, 1, "period 3 == nominal"); } + + // ───────────────────────────────────────────────────────────────────────── + // LOW FINDING: joiner on an already-USD-pinned subscription during an admin + // oracle-off window must NOT be billed at zero (fail-closed + // branch would strip their pay). Fix: `_finalizeJoin` seeds the + // identity sentinel (1 ether) for the joiner IFF the service is + // already USD-pinned, routing their leg through the identity + // branch instead of the zero branch. + // ───────────────────────────────────────────────────────────────────────── + + uint16 internal constant JOIN_EXPOSURE = 5000; // 50% + + /// @dev Dynamic-membership subscription seeded with a single USD-snapshotted operator + /// (operator1) plus two registered-but-not-yet-joined operators (operator2, + /// operator3). Mirrors `_setUpSubscriptionWithOracle` but uses Dynamic membership + /// so operators can join mid-subscription, and registers all three operators on + /// the blueprint up front so the later `joinService` calls pass registration. + function _setUpDynamicSubscription(address oracle) internal { + if (oracle != address(0)) { + vm.prank(admin); + tangle.setPriceOracle(oracle); + } + + Types.BlueprintConfig memory config = Types.BlueprintConfig({ + membership: Types.MembershipModel.Dynamic, + pricing: Types.PricingModel.Subscription, + minOperators: 1, + maxOperators: 5, + subscriptionRate: SUB_RATE, + subscriptionInterval: SUB_INTERVAL, + eventRate: 0 + }); + + vm.prank(developer); + blueprintId = _createBlueprintWithConfigAsSender("ipfs://sub-dynamic", address(0), config); + + // Register operator1 (activates the service) plus operator2/operator3 (join later). + address[3] memory ops = [operator1, operator2, operator3]; + for (uint256 i = 0; i < ops.length; ++i) { + vm.prank(ops[i]); + staking.registerOperator{ value: BASE_OP_STAKE }(); + vm.prank(ops[i]); + staking.setDelegationMode(Types.DelegationMode.Open); + _directRegisterOperator(ops[i], blueprintId, ""); + } + + // Give the joiners some delegated stake so their post-join stake-seconds accrue a + // non-zero raw delta by bill time (otherwise their weight would be zero regardless + // of the snapshot, and the test could not distinguish "zeroed by fail-closed" from + // "zeroed by no stake"). + vm.startPrank(delegator1); + staking.deposit{ value: DELEGATOR_BASELINE }(); + staking.delegate(operator1, DELEGATOR_BASELINE); + vm.stopPrank(); + vm.startPrank(delegator2); + staking.deposit{ value: DELEGATOR_BASELINE * 2 }(); + staking.delegate(operator2, DELEGATOR_BASELINE); + staking.delegate(operator3, DELEGATOR_BASELINE); + vm.stopPrank(); + + uint256 escrow = SUB_RATE * 24; + address[] memory operators = new address[](1); + operators[0] = operator1; + uint16[] memory exposures = new uint16[](1); + exposures[0] = 10_000; // 100% + address[] memory callers = new address[](0); + vm.deal(address(tangle), 400 ether); + + vm.prank(user1); + uint64 requestId = tangle.requestServiceWithExposure{ value: escrow }( + blueprintId, operators, exposures, "", callers, 0, address(0), escrow, Types.ConfidentialityPolicy.Any + ); + + vm.prank(operator1); + tangle.approveService(_approve(requestId)); + + serviceId = tangle.serviceCount() - 1; + assertTrue(tangle.isServiceActive(serviceId), "service active"); + } + + /// @notice An operator joining an ALREADY-USD-pinned subscription while the oracle is + /// OFF must still be billed (their leg routes through the identity branch, not + /// the fail-closed zero branch). This is the LOW-finding regression. + /// @dev Setup: activate with operator1 while the oracle is ON, so the service is + /// USD-pinned (operator1 carries a non-zero activation snapshot). Then admin + /// disables the oracle. operator2 joins (index 1 of 3 — NOT the last-index + /// remainder recipient, so a zeroed weight is directly observable as a zero + /// reward share). After a bill, operator2's pending reward MUST be non-zero. + /// Pre-fix, `_snapshotJoinPrice` early-returned on the zero oracle, leaving + /// operator2 with no snapshot; the bill-time fail-closed branch then forced + /// operator2's contribution to 0 and stripped their entire share. + function test_JoinDuringOracleOff_OnPinnedService_IsBilledAtIdentityScale() public { + uint256 t0 = 4_000_000; + vm.warp(t0); + + FlatUsdOracle oracle = new FlatUsdOracle(ORACLE_PRICE_USD); + _setUpDynamicSubscription(address(oracle)); + + // Service is USD-pinned: bill once with the oracle on to establish the nominal. + vm.warp(t0 + SUB_INTERVAL); + assertApproxEqAbs(_billOnceAndMeasure(), SUB_RATE, 1, "activation bill (oracle on) == nominal"); + + // Admin disables the oracle mid-subscription. + vm.prank(admin); + tangle.setPriceOracle(address(0)); + assertEq(tangle.priceOracle(), address(0), "oracle disabled"); + + // operator2 joins while the oracle is off. Because the service is already + // USD-pinned, `_finalizeJoin` must seed operator2's identity sentinel. + vm.prank(operator2); + tangle.joinService(serviceId, JOIN_EXPOSURE); + // operator3 joins last so operator2 is a non-last index (operator3 absorbs the + // per-bill rounding remainder in `_payOperatorPoolByWeight`, which would otherwise + // mask a zeroed weight on the last operator). + vm.prank(operator3); + tangle.joinService(serviceId, JOIN_EXPOSURE); + + // Bill a full period after the joins so operator2 accrues raw stake-seconds. + uint256 op2Before = tangle.pendingRewards(operator2); + vm.warp(block.timestamp + SUB_INTERVAL); + tangle.billSubscription(serviceId); + uint256 op2After = tangle.pendingRewards(operator2); + + assertGt( + op2After - op2Before, + 0, + "joiner on a USD-pinned service during oracle-off must be billed (identity scale), not zeroed" + ); + } + + /// @notice Fail-closed lock-in: on a genuinely oracle-off, NON-USD service a leg with + /// no snapshot and no live oracle still contributes zero at bill time. This + /// asserts the intended #204 behavior so a future change cannot silently + /// reintroduce identity-scaling (which under-bills the customer). Here the + /// WHOLE service is raw-scale, so `_subscriptionPinnedInUsd` is false and the + /// joiner must NOT be seeded — the invariant (a non-USD service never gains a + /// snapshot from a join) is covered by the assertion below. + /// @dev Enabling an oracle AFTER a join on a raw-scale service must not flip the bill + /// scale. If `_finalizeJoin` had wrongly seeded the joiner's snapshot, the + /// `_subscriptionPinnedInUsd` witness would flip to true and the subsequent + /// oracle-on bill would scale the numerator by ~2000x against a raw denominator — + /// corrupting the bill. A stable nominal bill proves the witness stayed false. + function test_JoinOnNonUsdService_DoesNotSeedSnapshot_NorFlipScale() public { + uint256 t0 = 5_000_000; + vm.warp(t0); + + _setUpDynamicSubscription(address(0)); // raw-scale baseline, no oracle ever configured + + // Raw-scale first bill establishes the nominal. + vm.warp(t0 + SUB_INTERVAL); + assertApproxEqAbs(_billOnceAndMeasure(), SUB_RATE, 1, "raw-scale activation bill == nominal"); + + // operator2 joins while there is NO oracle and the service is NOT USD-pinned. The + // joiner must NOT receive a seeded snapshot (that would corrupt the pinned witness). + vm.prank(operator2); + tangle.joinService(serviceId, JOIN_EXPOSURE); + vm.prank(operator3); + tangle.joinService(serviceId, JOIN_EXPOSURE); + + // A bill while still raw-scale stays nominal (joiner participates at raw scale). + vm.warp(block.timestamp + SUB_INTERVAL); + assertApproxEqAbs(_billOnceAndMeasure(), SUB_RATE, 1, "raw-scale bill after non-USD join stays at nominal"); + + // Now enable an oracle. If the join had corrupted the witness to USD, the numerator + // would flip ~2000x against the raw denominator. It must stay raw → nominal. + FlatUsdOracle oracle = new FlatUsdOracle(ORACLE_PRICE_USD); + vm.prank(admin); + tangle.setPriceOracle(address(oracle)); + + vm.warp(block.timestamp + SUB_INTERVAL); + assertApproxEqAbs( + _billOnceAndMeasure(), + SUB_RATE, + 1, + "enabling an oracle after a non-USD join must not flip the bill scale (witness stayed raw)" + ); + } + + /// @dev `_baselinePriceByOpAsset` is a private triple-nested mapping + /// (uint64 svc => address op => bytes32 assetHash => uint256), so its base slot is not + /// exposed and shifts whenever storage is reordered. Rather than hardcode it (brittle), + /// LOCATE it: scan candidate base slots and return the (svc,op,assetHash) entry that + /// currently holds op's non-zero activation snapshot. Robust to any storage reorder; if + /// the wrong slot were ever returned, wiping it would not zero the snapshot and the + /// caller's terminal `charged == 0` assertion would fail loudly. + function _findBaselinePriceSlot(uint64 svcId, address op, bytes32 assetHash) internal view returns (bytes32) { + for (uint256 base = 0; base < 512; base++) { + bytes32 s1 = keccak256(abi.encode(uint256(svcId), base)); + bytes32 s2 = keccak256(abi.encode(op, s1)); + bytes32 slot = keccak256(abi.encode(assetHash, s2)); + if (uint256(vm.load(address(tangle), slot)) != 0) return slot; + } + revert("baseline price slot not found in [0,512) - storage layout changed"); + } + + /// @notice Direct fail-closed assertion: a USD-pinned subscription whose oracle is off + /// and whose leg has NO price snapshot contributes ZERO for that leg — the bill + /// collapses toward zero rather than silently scaling at 1x. Locks in the + /// intended #204 fail-closed behavior so a future change cannot revert it to + /// identity-scaling (which would under-bill the customer indefinitely). + /// @dev The join-seed fix makes this state unreachable through any public path, so we + /// construct it directly: activate USD-pinned with the oracle on (operator1 gets a + /// snapshot), disable the oracle, then `vm.store`-zero operator1's snapshot. The + /// service stays USD-pinned only if some snapshot remains — so we keep the witness + /// alive by leaving the `subscriptionBaselineStake` (USD-scale denominator) intact + /// and confirm the now-snapshot-less leg drops to ~0. A guard assert first proves + /// the storage slot is correct (reads the known non-zero snapshot) so a slot drift + /// cannot produce a false green. + function test_FailClosed_UsdPinnedLegWithoutSnapshot_ContributesZero() public { + uint256 t0 = 6_000_000; + vm.warp(t0); + + FlatUsdOracle oracle = new FlatUsdOracle(ORACLE_PRICE_USD); + _setUpSubscriptionWithOracle(address(oracle)); // single-operator (operator1), USD-pinned + + // First bill (oracle on) establishes the USD-scale nominal. + vm.warp(t0 + SUB_INTERVAL); + assertApproxEqAbs(_billOnceAndMeasure(), SUB_RATE, 1, "activation bill (oracle on) == nominal"); + + // operator1's bond-asset snapshot: bond asset is native here (no TNT set in tests). + bytes32 assetHash = keccak256(abi.encode(Types.AssetKind.Native, address(0))); + // Locate operator1's activation snapshot slot dynamically (survives storage reorders). + bytes32 slot = _findBaselinePriceSlot(serviceId, operator1, assetHash); + assertGt(uint256(vm.load(address(tangle), slot)), 0, "located slot holds operator1's non-zero snapshot"); + + // Disable the oracle (service stays USD-pinned via the surviving denominator) and + // wipe operator1's snapshot to reconstruct the fail-closed condition: USD-pinned, + // no live oracle, no per-pair snapshot. + vm.prank(admin); + tangle.setPriceOracle(address(0)); + vm.store(address(tangle), slot, bytes32(uint256(0))); + assertEq(uint256(vm.load(address(tangle), slot)), 0, "operator1 snapshot wiped"); + + // The now-snapshot-less leg on a USD-pinned/oracle-off service must contribute zero, + // collapsing the bill (the whole point of #204's fail-closed branch). + vm.warp(block.timestamp + SUB_INTERVAL); + uint256 charged = _billOnceAndMeasure(); + assertEq(charged, 0, "fail-closed: USD-pinned leg with no snapshot and no oracle contributes zero"); + } }