diff --git a/deployments/tempo/latest.json b/deployments/tempo/latest.json index a0c4a1d..d0a82bf 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 1d436a3..70249ca 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", diff --git a/script/DemoSimulation.s.sol b/script/DemoSimulation.s.sol index 4e91c72..7a0608a 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 5b5e12b..4eec6c4 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"; @@ -293,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(); @@ -344,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)); @@ -459,6 +477,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 30ea87c..d374d6f 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/FullDeploy.s.sol b/script/FullDeploy.s.sol index 9bbafbb..4bb9012 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/script/FullStackScenario.s.sol b/script/FullStackScenario.s.sol index e1306a9..63698bb 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 bc80624..515ecfa 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/beacon/ValidatorPodManager.sol b/src/beacon/ValidatorPodManager.sol index 643e1e7..f4f2a5d 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 0000000..85639ae --- /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 89d309e..00772c1 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 ef00e20..a144ef4 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 9a057f8..e4bf0cc 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/src/facets/staking/StakingDelegationsFacet.sol b/src/facets/staking/StakingDelegationsFacet.sol index 23c790a..436975f 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 0000000..7595140 --- /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 5894c71..23f9091 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 0000000..c66d30e --- /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 e4bddff..e44787c 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 cba6ff4..d65a487 100644 --- a/test/FacetSize.t.sol +++ b/test/FacetSize.t.sol @@ -23,11 +23,19 @@ 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"; 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 @@ -43,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) { @@ -61,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"); @@ -84,6 +158,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 deaf79f..b0e0d20 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 af8cb1c..eb3e19d 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 543004b..85bf6f8 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 b2debf4..8b52f5e 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/PaymentsBilling.t.sol b/test/audit/medlow/PaymentsBilling.t.sol index 9e9fe9c..f7456ce 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"); + } } diff --git a/test/audit/medlow/StakingAdmin.t.sol b/test/audit/medlow/StakingAdmin.t.sol index d4cb847..99fde72 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 9182ba4..9fe9048 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 bb96c27..81c9c54 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 d2d76d2..a173a30 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 f4536ea..6dbf5a3 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 cdd50ca..fd3353c 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 d990143..d8d66f2 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 d1b7ebc..5898562 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 165d749..a91d783 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 0cebf16..9ecd120 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 9772b70..4ed5ed2 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 638f994..59bdfa7 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()));