From 0a5aa99ef7d9ebf86024a9e59f02c9e0ae7d557c Mon Sep 17 00:00:00 2001 From: wadealexc Date: Wed, 11 Jun 2025 17:22:51 +0000 Subject: [PATCH 01/16] test(wip): basic harness for precision loss analysis --- .gitignore | 3 +- src/test/integration/tests/Rounding.t.sol | 121 ++++++++++++++++++++++ src/test/integration/users/User.t.sol | 6 ++ 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 src/test/integration/tests/Rounding.t.sol diff --git a/.gitignore b/.gitignore index cf968e92e4..ba2011100f 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,5 @@ deployed_strategies.json populate_src* # cerota -.certora_internal/* \ No newline at end of file +.certora_internal/* +/snapshots/ diff --git a/src/test/integration/tests/Rounding.t.sol b/src/test/integration/tests/Rounding.t.sol new file mode 100644 index 0000000000..a3a05929be --- /dev/null +++ b/src/test/integration/tests/Rounding.t.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import "src/test/integration/IntegrationChecks.t.sol"; + +contract Integration_Rounding is IntegrationCheckUtils { + using ArrayLib for *; + using StdStyle for *; + + User attacker; + AVS badAVS; + IStrategy strategy; + IERC20Metadata token; + + OperatorSet mOpSet; // "manipOpSet" used for magnitude manipulation + OperatorSet rOpSet; // redistributable opset used to exploit precision loss and trigger redistribution + + function _init() internal override { + _configAssetTypes(HOLDS_LST); + + attacker = new User("Attacker"); + badAVS = new AVS("BadAVS"); + strategy = lstStrats[0]; + token = IERC20Metadata(address(strategy.underlyingToken())); + + // Register attacker as operator and create attacker-controller AVS/OpSets + attacker.registerAsOperator(0); + rollForward({blocks: ALLOCATION_CONFIGURATION_DELAY + 1}); + badAVS.updateAVSMetadataURI("https://example.com"); + mOpSet = badAVS.createOperatorSet(strategy.toArray()); + rOpSet = badAVS.createRedistributingOperatorSet(strategy.toArray(), address(attacker)); + + // Register for both opsets + attacker.registerForOperatorSet(mOpSet); + attacker.registerForOperatorSet(rOpSet); + + _print("setup"); + } + + function test_rounding() public rand(0) { + _magnitudeManipulation(); + _setupFinal(); + _final(); + } + + // TODO - another way to mess with rounding/precision loss is to manipulate DSF + function _magnitudeManipulation() internal { + attacker.modifyAllocations(AllocateParams({ + operatorSet: mOpSet, + strategies: strategy.toArray(), + newMagnitudes: WAD.toArrayU64() + })); + + _print("allocate"); + + badAVS.slashOperator(SlashingParams({ + operator: address(attacker), + operatorSetId: mOpSet.id, + strategies: strategy.toArray(), + wadsToSlash: uint(WAD-1).toArrayU256(), + description: "" + })); + + _print("slash"); + } + + function _setupFinal() internal { + // create redistributable opset + // deposit assets + } + + function _final() internal { + // perform final slash on redistributable opset and check for profit + } + + function _print(string memory phaseName) internal { + address a = address(attacker); + + console.log(""); + console.log("===Attacker Info: %s phase===".cyan(), phaseName); + + { + console.log("\nRaw Assets:".magenta()); + console.log(" - token: %s", token.symbol()); + console.log(" - held balance: %d", token.balanceOf(a)); + // TODO - amt deposited, possibly keep track of this separately? + } + + { + console.log("\nShares:".magenta()); + + (uint[] memory withdrawableArr, uint[] memory depositArr) + = delegationManager.getWithdrawableShares(a, strategy.toArray()); + uint withdrawableShares = withdrawableArr.length == 0 ? 0 : withdrawableArr[0]; + uint depositShares = depositArr.length == 0 ? 0 : depositArr[0]; + console.log(" - deposit shares: %d", depositShares); + console.log(" - withdrawable shares: %d", withdrawableShares); + console.log(" - operator shares: %d", delegationManager.operatorShares(a, strategy)); + } + + { + console.log("\nScaling:".magenta()); + + Allocation memory mAlloc = allocationManager.getAllocation(a, mOpSet, strategy); + Allocation memory rAlloc = allocationManager.getAllocation(a, rOpSet, strategy); + + console.log(" - Init Mag: %d", WAD); + console.log( + " - Max Mag: %d\n -- Total Allocated: %d\n -- Total Available: %d", + allocationManager.getMaxMagnitude(a, strategy), + allocationManager.getEncumberedMagnitude(a, strategy), + allocationManager.getAllocatableMagnitude(a, strategy) + ); + console.log(" - Allocated to mOpSet: %d", mAlloc.currentMagnitude); + console.log(" - Allocated to rOpSet: %d", rAlloc.currentMagnitude); + console.log(" - DSF: %d", delegationManager.depositScalingFactor(a, strategy)); + } + + console.log("\n ===\n".cyan()); + } +} diff --git a/src/test/integration/users/User.t.sol b/src/test/integration/users/User.t.sol index f97904acfc..83f59e5654 100644 --- a/src/test/integration/users/User.t.sol +++ b/src/test/integration/users/User.t.sol @@ -166,6 +166,12 @@ contract User is Logger, IDelegationManagerTypes, IAllocationManagerTypes { print.gasUsed(); } + function registerAsOperator(uint32 allocDelay) public virtual createSnapshot { + print.method("registerAsOperator"); + delegationManager.registerAsOperator(address(0), allocDelay, "metadata"); + print.gasUsed(); + } + /// @dev Delegate to the operator without a signature function delegateTo(User operator) public virtual createSnapshot { print.method("delegateTo", operator.NAME_COLORED()); From 622e86b8e349e933eb03127cddb941ec97b7888c Mon Sep 17 00:00:00 2001 From: Nadir Akhtar Date: Wed, 11 Jun 2025 12:10:53 -0700 Subject: [PATCH 02/16] test(rounding): implement integration rounding test --- src/test/integration/tests/Rounding.t.sol | 95 +++++++++++++++++++---- 1 file changed, 80 insertions(+), 15 deletions(-) diff --git a/src/test/integration/tests/Rounding.t.sol b/src/test/integration/tests/Rounding.t.sol index a3a05929be..d47ca196cb 100644 --- a/src/test/integration/tests/Rounding.t.sol +++ b/src/test/integration/tests/Rounding.t.sol @@ -11,6 +11,7 @@ contract Integration_Rounding is IntegrationCheckUtils { AVS badAVS; IStrategy strategy; IERC20Metadata token; + User goodStaker; OperatorSet mOpSet; // "manipOpSet" used for magnitude manipulation OperatorSet rOpSet; // redistributable opset used to exploit precision loss and trigger redistribution @@ -18,59 +19,123 @@ contract Integration_Rounding is IntegrationCheckUtils { function _init() internal override { _configAssetTypes(HOLDS_LST); - attacker = new User("Attacker"); + attacker = new User("Attacker"); // attacker can be both operator and staker badAVS = new AVS("BadAVS"); strategy = lstStrats[0]; token = IERC20Metadata(address(strategy.underlyingToken())); - + deal(address(token), address(attacker), uint256(1000000000000000000)); // TODO: make the balance a fuzzing param + + // good staker and operator setup + goodStaker = new User("goodStaker"); + deal(address(token), address(goodStaker), uint256(1000000000000000000)); // TODO: make the balance a fuzzing param + // Register attacker as operator and create attacker-controller AVS/OpSets attacker.registerAsOperator(0); rollForward({blocks: ALLOCATION_CONFIGURATION_DELAY + 1}); badAVS.updateAVSMetadataURI("https://example.com"); - mOpSet = badAVS.createOperatorSet(strategy.toArray()); - rOpSet = badAVS.createRedistributingOperatorSet(strategy.toArray(), address(attacker)); + mOpSet = badAVS.createOperatorSet(strategy.toArray()); // setup low mag operator + rOpSet = badAVS.createRedistributingOperatorSet(strategy.toArray(), address(attacker)); // execute exploit // Register for both opsets attacker.registerForOperatorSet(mOpSet); attacker.registerForOperatorSet(rOpSet); + + // TODO: considerations + // - assets of other users within the opSet + // - calculate total assets entering and leaving the protocol, estimate precision loss (+ or -) _print("setup"); } - function test_rounding() public rand(0) { - _magnitudeManipulation(); - _setupFinal(); - _final(); + // TODO: parameterize deposits + // TODO: consider manual fuzzing from 1 up to WAD - 1 + function test_rounding(uint64 wadToSlash) public rand(0) { + // bound wadToSlash to 1 < wadToSlash < WAD - 1 + wadToSlash = uint64(bound(wadToSlash, 1, WAD - 1)); + + _magnitudeManipulation(wadToSlash); // get operator to low mag + _setupFinal(wadToSlash); // + _final(wadToSlash); } // TODO - another way to mess with rounding/precision loss is to manipulate DSF - function _magnitudeManipulation() internal { + function _magnitudeManipulation(uint64 wadToSlash) internal { + + // allocate magnitude to operator set attacker.modifyAllocations(AllocateParams({ operatorSet: mOpSet, strategies: strategy.toArray(), newMagnitudes: WAD.toArrayU64() })); + + // TODO: print "newMagnitudes" _print("allocate"); + // slash operator to low mag badAVS.slashOperator(SlashingParams({ operator: address(attacker), operatorSetId: mOpSet.id, strategies: strategy.toArray(), - wadsToSlash: uint(WAD-1).toArrayU256(), - description: "" + wadsToSlash: uint(wadToSlash).toArrayU256(), // TODO: Make WAD-1 a fuzzing param + description: "manipulation!" })); + + // TODO: print "wadsToSlash" _print("slash"); + + // deallocate magnitude from operator set + attacker.modifyAllocations(AllocateParams({ + operatorSet: mOpSet, + strategies: strategy.toArray(), + newMagnitudes: 0.toArrayU64() + })); + + rollForward({blocks: DEALLOCATION_DELAY + 1}); + + _print("deallocate"); } - function _setupFinal() internal { - // create redistributable opset - // deposit assets + function _setupFinal(uint64 wadToSlash) internal { + // allocate to redistributable opset + attacker.modifyAllocations(AllocateParams({ + operatorSet: rOpSet, + strategies: strategy.toArray(), + newMagnitudes: (WAD - wadToSlash).toArrayU64() + })); + + // deposit assets and delegate to attacker + attacker.depositIntoEigenlayer(strategy.toArray(), token.balanceOf(address(attacker)).toArrayU256()); + + + goodStaker.depositIntoEigenlayer(strategy.toArray(), token.balanceOf(address(goodStaker)).toArrayU256()); + + // TODO: print "initTokenBalances" + + _print("deposit"); } - function _final() internal { + function _final(uint64 wadToSlash) internal { // perform final slash on redistributable opset and check for profit + badAVS.slashOperator(SlashingParams({ + operator: address(attacker), + operatorSetId: rOpSet.id, + strategies: strategy.toArray(), + wadsToSlash: uint(WAD - wadToSlash).toArrayU256(), + description: "final slash" + })); + + _print("slash"); + + // roll forward past the escrow delay + rollForward({blocks: slashEscrowFactory.getGlobalEscrowDelay() + 1}); + + // release funds + vm.prank(address(attacker)); + slashEscrowFactory.releaseSlashEscrow(rOpSet, 1); // 1 is used as it's the first slashId + + _print("release"); } function _print(string memory phaseName) internal { From 7df70691de5c30663fad2aa4adc1f06d9c5eb8c8 Mon Sep 17 00:00:00 2001 From: Nadir Akhtar Date: Thu, 12 Jun 2025 11:12:23 -0700 Subject: [PATCH 03/16] test: make initTokenBalance a fuzz parameter --- src/test/integration/tests/Rounding.t.sol | 61 ++++++++++++++--------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/src/test/integration/tests/Rounding.t.sol b/src/test/integration/tests/Rounding.t.sol index d47ca196cb..e3761bfdb9 100644 --- a/src/test/integration/tests/Rounding.t.sol +++ b/src/test/integration/tests/Rounding.t.sol @@ -12,24 +12,25 @@ contract Integration_Rounding is IntegrationCheckUtils { IStrategy strategy; IERC20Metadata token; User goodStaker; + uint64 initTokenBalance; OperatorSet mOpSet; // "manipOpSet" used for magnitude manipulation - OperatorSet rOpSet; // redistributable opset used to exploit precision loss and trigger redistribution + OperatorSet rOpSet; // Redistributable opset used to exploit precision loss and trigger redistribution function _init() internal override { _configAssetTypes(HOLDS_LST); - attacker = new User("Attacker"); // attacker can be both operator and staker - badAVS = new AVS("BadAVS"); + attacker = new User("Attacker"); // Attacker serves as both operator and staker + badAVS = new AVS("BadAVS"); // AVS is also attacker-controlled strategy = lstStrats[0]; token = IERC20Metadata(address(strategy.underlyingToken())); - deal(address(token), address(attacker), uint256(1000000000000000000)); // TODO: make the balance a fuzzing param - // good staker and operator setup - goodStaker = new User("goodStaker"); - deal(address(token), address(goodStaker), uint256(1000000000000000000)); // TODO: make the balance a fuzzing param + // Prepares to add non-attacker stake into the protocol. Can be any amount > 0. + // Note that the honest stake does not need to be allocated anywhere, so long as it's in the same strategy. + goodStaker = new User("GoodStaker"); + deal(address(token), address(goodStaker), uint256(1e18)); - // Register attacker as operator and create attacker-controller AVS/OpSets + // Register attacker as operator and create attacker-controlled AVS/OpSets attacker.registerAsOperator(0); rollForward({blocks: ALLOCATION_CONFIGURATION_DELAY + 1}); badAVS.updateAVSMetadataURI("https://example.com"); @@ -40,28 +41,40 @@ contract Integration_Rounding is IntegrationCheckUtils { attacker.registerForOperatorSet(mOpSet); attacker.registerForOperatorSet(rOpSet); - // TODO: considerations - // - assets of other users within the opSet - // - calculate total assets entering and leaving the protocol, estimate precision loss (+ or -) - _print("setup"); } - // TODO: parameterize deposits - // TODO: consider manual fuzzing from 1 up to WAD - 1 - function test_rounding(uint64 wadToSlash) public rand(0) { - // bound wadToSlash to 1 < wadToSlash < WAD - 1 + // TODO: consider incremental manual fuzzing from 1 up to WAD - 1 + function test_rounding(uint64 wadToSlash, uint64 _initTokenBalance) public rand(0) { + // We do two slashes, the sum of which slash 1 WAD (all operator magnitude) in total. + // Each slash requires at least 1 mag. As such, we need to bound wadToSlash to 1 <= wadToSlash <= WAD - 1. wadToSlash = uint64(bound(wadToSlash, 1, WAD - 1)); - _magnitudeManipulation(wadToSlash); // get operator to low mag - _setupFinal(wadToSlash); // - _final(wadToSlash); + // Bound initTokenBalance to a reasonable range to avoid overflow, with at least 1 token. + // Using ~18.45 quintillion tokens max (should be enough for any realistic test). + initTokenBalance = uint64(bound(_initTokenBalance, 1, type(uint64).max)); + deal(address(token), address(attacker), initTokenBalance); + + _magnitudeManipulation(wadToSlash); // Manipulate operator magnitude for a given strategy. + _setupFinal(wadToSlash); // Setup operator with new opSet as well as honest stake in same strategy. + _final(wadToSlash); // Perform slash to attempt to extract surplus value. + + // Check for any surplus value extracted by attacker. If found, we've proven the existence of the exploit. + // Unchecked to avoid overflow reverting. Safe because token balances are bounded by uint64. + // Negative diff means attacker lost money, positive diff means attacker gained money. + int diff; + unchecked { + diff = int(token.balanceOf(address(attacker)) - initTokenBalance); + } + console.log("Difference in tokens: %d", diff); + if (diff > 0) { + revert("Rounding error exploit found!"); + } } // TODO - another way to mess with rounding/precision loss is to manipulate DSF function _magnitudeManipulation(uint64 wadToSlash) internal { - - // allocate magnitude to operator set + // Allocate magnitude to operator set. attacker.modifyAllocations(AllocateParams({ operatorSet: mOpSet, strategies: strategy.toArray(), @@ -72,12 +85,12 @@ contract Integration_Rounding is IntegrationCheckUtils { _print("allocate"); - // slash operator to low mag + // Slash operator to low mag. badAVS.slashOperator(SlashingParams({ operator: address(attacker), operatorSetId: mOpSet.id, strategies: strategy.toArray(), - wadsToSlash: uint(wadToSlash).toArrayU256(), // TODO: Make WAD-1 a fuzzing param + wadsToSlash: uint(wadToSlash).toArrayU256(), description: "manipulation!" })); @@ -85,7 +98,7 @@ contract Integration_Rounding is IntegrationCheckUtils { _print("slash"); - // deallocate magnitude from operator set + // Deallocate magnitude from operator set. attacker.modifyAllocations(AllocateParams({ operatorSet: mOpSet, strategies: strategy.toArray(), From 2dcaf2c383df39499d173f0ee74394d3daad2688 Mon Sep 17 00:00:00 2001 From: Nadir Akhtar Date: Thu, 12 Jun 2025 11:53:19 -0700 Subject: [PATCH 04/16] test: fix calc error --- src/test/integration/tests/Rounding.t.sol | 25 +++++++++++------------ 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/test/integration/tests/Rounding.t.sol b/src/test/integration/tests/Rounding.t.sol index e3761bfdb9..5d4225a58c 100644 --- a/src/test/integration/tests/Rounding.t.sol +++ b/src/test/integration/tests/Rounding.t.sol @@ -45,6 +45,7 @@ contract Integration_Rounding is IntegrationCheckUtils { } // TODO: consider incremental manual fuzzing from 1 up to WAD - 1 + // TODO: consider adding number of slashes as a fuzzing param function test_rounding(uint64 wadToSlash, uint64 _initTokenBalance) public rand(0) { // We do two slashes, the sum of which slash 1 WAD (all operator magnitude) in total. // Each slash requires at least 1 mag. As such, we need to bound wadToSlash to 1 <= wadToSlash <= WAD - 1. @@ -57,7 +58,7 @@ contract Integration_Rounding is IntegrationCheckUtils { _magnitudeManipulation(wadToSlash); // Manipulate operator magnitude for a given strategy. _setupFinal(wadToSlash); // Setup operator with new opSet as well as honest stake in same strategy. - _final(wadToSlash); // Perform slash to attempt to extract surplus value. + _final(); // Perform slash to attempt to extract surplus value. // Check for any surplus value extracted by attacker. If found, we've proven the existence of the exploit. // Unchecked to avoid overflow reverting. Safe because token balances are bounded by uint64. @@ -74,7 +75,7 @@ contract Integration_Rounding is IntegrationCheckUtils { // TODO - another way to mess with rounding/precision loss is to manipulate DSF function _magnitudeManipulation(uint64 wadToSlash) internal { - // Allocate magnitude to operator set. + // Allocate all magnitude to operator set. attacker.modifyAllocations(AllocateParams({ operatorSet: mOpSet, strategies: strategy.toArray(), @@ -85,7 +86,7 @@ contract Integration_Rounding is IntegrationCheckUtils { _print("allocate"); - // Slash operator to low mag. + // Slash operator to arbitrary mag. badAVS.slashOperator(SlashingParams({ operator: address(attacker), operatorSetId: mOpSet.id, @@ -111,40 +112,38 @@ contract Integration_Rounding is IntegrationCheckUtils { } function _setupFinal(uint64 wadToSlash) internal { - // allocate to redistributable opset + // Allocate all remaining magnitude to redistributable opset. attacker.modifyAllocations(AllocateParams({ operatorSet: rOpSet, strategies: strategy.toArray(), newMagnitudes: (WAD - wadToSlash).toArrayU64() })); - // deposit assets and delegate to attacker + // Deposit all attacker assets into Eigenlayer. attacker.depositIntoEigenlayer(strategy.toArray(), token.balanceOf(address(attacker)).toArrayU256()); - + // Deposit all honest stake into Eigenlayer. goodStaker.depositIntoEigenlayer(strategy.toArray(), token.balanceOf(address(goodStaker)).toArrayU256()); - // TODO: print "initTokenBalances" - _print("deposit"); } - function _final(uint64 wadToSlash) internal { - // perform final slash on redistributable opset and check for profit + function _final() internal { + // Perform final slash on redistributable opset and check for profit. Slash all operator magnitude. badAVS.slashOperator(SlashingParams({ operator: address(attacker), operatorSetId: rOpSet.id, strategies: strategy.toArray(), - wadsToSlash: uint(WAD - wadToSlash).toArrayU256(), + wadsToSlash: uint(WAD).toArrayU256(), description: "final slash" })); _print("slash"); - // roll forward past the escrow delay + // Roll forward past the escrow delay. rollForward({blocks: slashEscrowFactory.getGlobalEscrowDelay() + 1}); - // release funds + // Release funds. vm.prank(address(attacker)); slashEscrowFactory.releaseSlashEscrow(rOpSet, 1); // 1 is used as it's the first slashId From 640dadc211e40db80d057003fe315f6b69b1702e Mon Sep 17 00:00:00 2001 From: xyz <2523269+antojoseph@users.noreply.github.com> Date: Thu, 12 Jun 2025 19:27:10 +0000 Subject: [PATCH 05/16] test: fuzz with single opset --- .../integration/tests/RoundingSingleOpset.sol | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 src/test/integration/tests/RoundingSingleOpset.sol diff --git a/src/test/integration/tests/RoundingSingleOpset.sol b/src/test/integration/tests/RoundingSingleOpset.sol new file mode 100644 index 0000000000..6ad14093cf --- /dev/null +++ b/src/test/integration/tests/RoundingSingleOpset.sol @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import "src/test/integration/IntegrationChecks.t.sol"; + +contract Integration_Rounding is IntegrationCheckUtils { + using ArrayLib for *; + using StdStyle for *; + + User attacker; + AVS badAVS; + IStrategy strategy; + IERC20Metadata token; + User goodStaker; + uint64 initTokenBalance; + + OperatorSet rOpSet; // Redistributable opset used to exploit precision loss and trigger redistribution + + function _init() internal override { + _configAssetTypes(HOLDS_LST); + + attacker = new User("Attacker"); // Attacker serves as both operator and staker + badAVS = new AVS("BadAVS"); // AVS is also attacker-controlled + strategy = lstStrats[0]; + token = IERC20Metadata(address(strategy.underlyingToken())); + + // Prepares to add non-attacker stake into the protocol. Can be any amount > 0. + // Note that the honest stake does not need to be allocated anywhere, so long as it's in the same strategy. + goodStaker = new User("GoodStaker"); + deal(address(token), address(goodStaker), uint256(1e18)); + + // Register attacker as operator and create attacker-controlled AVS/OpSets + attacker.registerAsOperator(0); + rollForward({blocks: ALLOCATION_CONFIGURATION_DELAY + 1}); + badAVS.updateAVSMetadataURI("https://example.com"); + + //mOpSet = badAVS.createOperatorSet(strategy.toArray()); // setup low mag operator + rOpSet = badAVS.createRedistributingOperatorSet(strategy.toArray(), address(attacker)); // execute exploit + + // Register for both opsets + //attacker.registerForOperatorSet(mOpSet); + attacker.registerForOperatorSet(rOpSet); + + _print("setup"); + } + + // TODO: consider incremental manual fuzzing from 1 up to WAD - 1 + // TODO: consider adding number of slashes as a fuzzing param + function test_rounding(uint64 wadToSlash, uint64 _initTokenBalance) public rand(0) { + // We do two slashes, the sum of which slash 1 WAD (all operator magnitude) in total. + // Each slash requires at least 1 mag. As such, we need to bound wadToSlash to 1 <= wadToSlash <= WAD - 1. + wadToSlash = uint64(bound(wadToSlash, 1, WAD - 1)); + + // Bound initTokenBalance to a reasonable range to avoid overflow, with at least 1 token. + // Using ~18.45 quintillion tokens max (should be enough for any realistic test). + initTokenBalance = uint64(bound(_initTokenBalance, 1, type(uint64).max)); + deal(address(token), address(attacker), initTokenBalance); + + _magnitudeManipulation(wadToSlash); // Manipulate operator magnitude for a given strategy. + _setupFinal(wadToSlash); // Setup operator with new opSet as well as honest stake in same strategy. + _final(); // Perform slash to attempt to extract surplus value. + + // Check for any surplus value extracted by attacker. If found, we've proven the existence of the exploit. + // Unchecked to avoid overflow reverting. Safe because token balances are bounded by uint64. + // Negative diff means attacker lost money, positive diff means attacker gained money. + int diff; + unchecked { + diff = int(token.balanceOf(address(attacker)) - initTokenBalance); + } + console.log("Difference in tokens: %d", diff); + if (diff > 0) { + revert("Rounding error exploit found!"); + } + } + + // TODO - another way to mess with rounding/precision loss is to manipulate DSF + function _magnitudeManipulation(uint64 wadToSlash) internal { + // Allocate all magnitude to operator set. + attacker.modifyAllocations(AllocateParams({ + operatorSet: rOpSet, + strategies: strategy.toArray(), + newMagnitudes: WAD.toArrayU64() + })); + + // TODO: print "newMagnitudes" + + _print("allocate"); + + // slash rOpSet + + badAVS.slashOperator(SlashingParams({ + operator: address(attacker), + operatorSetId: rOpSet.id, + strategies: strategy.toArray(), + wadsToSlash: uint(wadToSlash).toArrayU256(), + description: "manipulation!" + })); + + // TODO: print "wadsToSlash" + + _print("slash"); + + } + + function _setupFinal(uint64 wadToSlash) internal { + + // Deposit all attacker assets into Eigenlayer. + attacker.depositIntoEigenlayer(strategy.toArray(), token.balanceOf(address(attacker)).toArrayU256()); + + // Deposit all honest stake into Eigenlayer. + goodStaker.depositIntoEigenlayer(strategy.toArray(), token.balanceOf(address(goodStaker)).toArrayU256()); + + _print("deposit"); + } + + function _final() internal { + // Perform final slash on redistributable opset and check for profit. Slash all operator magnitude. + badAVS.slashOperator(SlashingParams({ + operator: address(attacker), + operatorSetId: rOpSet.id, + strategies: strategy.toArray(), + wadsToSlash: uint(WAD).toArrayU256(), + description: "final slash" + })); + + _print("slash"); + + // Roll forward past the escrow delay. + rollForward({blocks: slashEscrowFactory.getGlobalEscrowDelay() + 1}); + + // Release funds. + vm.prank(address(attacker)); + slashEscrowFactory.releaseSlashEscrow(rOpSet, 2); // 1 is used as it's the first slashId + + _print("release"); + } + + function _print(string memory phaseName) internal { + address a = address(attacker); + + console.log(""); + console.log("===Attacker Info: %s phase===".cyan(), phaseName); + + { + console.log("\nRaw Assets:".magenta()); + console.log(" - token: %s", token.symbol()); + console.log(" - held balance: %d", token.balanceOf(a)); + // TODO - amt deposited, possibly keep track of this separately? + } + + { + console.log("\nShares:".magenta()); + + (uint[] memory withdrawableArr, uint[] memory depositArr) + = delegationManager.getWithdrawableShares(a, strategy.toArray()); + uint withdrawableShares = withdrawableArr.length == 0 ? 0 : withdrawableArr[0]; + uint depositShares = depositArr.length == 0 ? 0 : depositArr[0]; + console.log(" - deposit shares: %d", depositShares); + console.log(" - withdrawable shares: %d", withdrawableShares); + console.log(" - operator shares: %d", delegationManager.operatorShares(a, strategy)); + } + + { + console.log("\nScaling:".magenta()); + + //Allocation memory mAlloc = allocationManager.getAllocation(a, mOpSet, strategy); + Allocation memory rAlloc = allocationManager.getAllocation(a, rOpSet, strategy); + + console.log(" - Init Mag: %d", WAD); + console.log( + " - Max Mag: %d\n -- Total Allocated: %d\n -- Total Available: %d", + allocationManager.getMaxMagnitude(a, strategy), + allocationManager.getEncumberedMagnitude(a, strategy), + allocationManager.getAllocatableMagnitude(a, strategy) + ); + //console.log(" - Allocated to mOpSet: %d", mAlloc.currentMagnitude); + console.log(" - Allocated to rOpSet: %d", rAlloc.currentMagnitude); + console.log(" - DSF: %d", delegationManager.depositScalingFactor(a, strategy)); + } + + console.log("\n ===\n".cyan()); + } +} From 790f0e38052bf846ccbb8f087e6b68467eada2ed Mon Sep 17 00:00:00 2001 From: Nadir Akhtar Date: Thu, 12 Jun 2025 12:54:35 -0700 Subject: [PATCH 06/16] test: add number of slashes as fuzzing param --- src/test/integration/tests/Rounding.t.sol | 33 ++++++++++++++++------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/test/integration/tests/Rounding.t.sol b/src/test/integration/tests/Rounding.t.sol index 5d4225a58c..10dac5ebea 100644 --- a/src/test/integration/tests/Rounding.t.sol +++ b/src/test/integration/tests/Rounding.t.sol @@ -2,11 +2,12 @@ pragma solidity ^0.8.27; import "src/test/integration/IntegrationChecks.t.sol"; +import "src/test/utils/Random.sol"; contract Integration_Rounding is IntegrationCheckUtils { using ArrayLib for *; using StdStyle for *; - + User attacker; AVS badAVS; IStrategy strategy; @@ -46,18 +47,25 @@ contract Integration_Rounding is IntegrationCheckUtils { // TODO: consider incremental manual fuzzing from 1 up to WAD - 1 // TODO: consider adding number of slashes as a fuzzing param - function test_rounding(uint64 wadToSlash, uint64 _initTokenBalance) public rand(0) { + function test_rounding(uint8 slashes, uint64 initialWadToSlash, uint64 _initTokenBalance, uint24 r) public rand(r) { // We do two slashes, the sum of which slash 1 WAD (all operator magnitude) in total. // Each slash requires at least 1 mag. As such, we need to bound wadToSlash to 1 <= wadToSlash <= WAD - 1. - wadToSlash = uint64(bound(wadToSlash, 1, WAD - 1)); + initialWadToSlash = uint64(bound(initialWadToSlash, 1, WAD - 1)); // Bound initTokenBalance to a reasonable range to avoid overflow, with at least 1 token. // Using ~18.45 quintillion tokens max (should be enough for any realistic test). initTokenBalance = uint64(bound(_initTokenBalance, 1, type(uint64).max)); deal(address(token), address(attacker), initTokenBalance); - _magnitudeManipulation(wadToSlash); // Manipulate operator magnitude for a given strategy. - _setupFinal(wadToSlash); // Setup operator with new opSet as well as honest stake in same strategy. + _magnitudeManipulation(initialWadToSlash); // Manipulate operator magnitude for a given strategy. + _deposit(initialWadToSlash); // Setup operator with new opSet as well as honest stake in same strategy. + + // Perform slashes to + for (uint8 i = 0; i < slashes; i++) { + // slash less than total mag to leave some for final slash + uint64 wadToSlash = uint64(_randUint(1, WAD - 1)); + _slash(wadToSlash); + } _final(); // Perform slash to attempt to extract surplus value. // Check for any surplus value extracted by attacker. If found, we've proven the existence of the exploit. @@ -111,7 +119,7 @@ contract Integration_Rounding is IntegrationCheckUtils { _print("deallocate"); } - function _setupFinal(uint64 wadToSlash) internal { + function _deposit(uint64 wadToSlash) internal { // Allocate all remaining magnitude to redistributable opset. attacker.modifyAllocations(AllocateParams({ operatorSet: rOpSet, @@ -127,19 +135,24 @@ contract Integration_Rounding is IntegrationCheckUtils { _print("deposit"); } - - function _final() internal { + + function _slash(uint64 wadToSlash) internal { // Perform final slash on redistributable opset and check for profit. Slash all operator magnitude. badAVS.slashOperator(SlashingParams({ operator: address(attacker), operatorSetId: rOpSet.id, strategies: strategy.toArray(), - wadsToSlash: uint(WAD).toArrayU256(), + wadsToSlash: uint(wadToSlash).toArrayU256(), description: "final slash" })); _print("slash"); - + } + + function _final() internal { + // Slash all operator magnitude. + _slash(WAD); + // Roll forward past the escrow delay. rollForward({blocks: slashEscrowFactory.getGlobalEscrowDelay() + 1}); From 1f2196a53ba9de6488e14cfa730b15352a7fd557 Mon Sep 17 00:00:00 2001 From: Nadir Akhtar Date: Thu, 12 Jun 2025 13:25:17 -0700 Subject: [PATCH 07/16] test: add assert to check for token loss --- src/test/integration/tests/Rounding.t.sol | 32 ++++++++++++++++------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/test/integration/tests/Rounding.t.sol b/src/test/integration/tests/Rounding.t.sol index 10dac5ebea..683ad4ebaf 100644 --- a/src/test/integration/tests/Rounding.t.sol +++ b/src/test/integration/tests/Rounding.t.sol @@ -2,12 +2,11 @@ pragma solidity ^0.8.27; import "src/test/integration/IntegrationChecks.t.sol"; -import "src/test/utils/Random.sol"; contract Integration_Rounding is IntegrationCheckUtils { using ArrayLib for *; using StdStyle for *; - + User attacker; AVS badAVS; IStrategy strategy; @@ -46,8 +45,11 @@ contract Integration_Rounding is IntegrationCheckUtils { } // TODO: consider incremental manual fuzzing from 1 up to WAD - 1 - // TODO: consider adding number of slashes as a fuzzing param - function test_rounding(uint8 slashes, uint64 initialWadToSlash, uint64 _initTokenBalance, uint24 r) public rand(r) { + function test_rounding(uint16 slashes, uint64 initialWadToSlash, uint64 _initTokenBalance, uint24 r) public rand(r) { + // Bound slashes to a reasonable range, with at least 1 slash. + // Note: Runs after 2500 hit OOG errors with default gas. + slashes = uint16(bound(slashes, 1, 2500)); + // We do two slashes, the sum of which slash 1 WAD (all operator magnitude) in total. // Each slash requires at least 1 mag. As such, we need to bound wadToSlash to 1 <= wadToSlash <= WAD - 1. initialWadToSlash = uint64(bound(initialWadToSlash, 1, WAD - 1)); @@ -60,13 +62,13 @@ contract Integration_Rounding is IntegrationCheckUtils { _magnitudeManipulation(initialWadToSlash); // Manipulate operator magnitude for a given strategy. _deposit(initialWadToSlash); // Setup operator with new opSet as well as honest stake in same strategy. - // Perform slashes to - for (uint8 i = 0; i < slashes; i++) { - // slash less than total mag to leave some for final slash + // Perform slashes to gradually whittle down operator magnitude, as well as produce slash escrows. + for (uint16 i = 0; i < slashes; i++) { + // Upper bound is less than WAD to leave some mag for final slash uint64 wadToSlash = uint64(_randUint(1, WAD - 1)); _slash(wadToSlash); } - _final(); // Perform slash to attempt to extract surplus value. + _final(slashes); // Perform slash to attempt to extract surplus value. // Check for any surplus value extracted by attacker. If found, we've proven the existence of the exploit. // Unchecked to avoid overflow reverting. Safe because token balances are bounded by uint64. @@ -79,6 +81,10 @@ contract Integration_Rounding is IntegrationCheckUtils { if (diff > 0) { revert("Rounding error exploit found!"); } + + if (diff < 0) { + revert("Tokens lost!"); + } } // TODO - another way to mess with rounding/precision loss is to manipulate DSF @@ -149,7 +155,7 @@ contract Integration_Rounding is IntegrationCheckUtils { _print("slash"); } - function _final() internal { + function _final(uint64 slashes) internal { // Slash all operator magnitude. _slash(WAD); @@ -157,8 +163,14 @@ contract Integration_Rounding is IntegrationCheckUtils { rollForward({blocks: slashEscrowFactory.getGlobalEscrowDelay() + 1}); // Release funds. + for (uint32 i = 1; i <= slashes; i++) { + vm.prank(address(attacker)); + slashEscrowFactory.releaseSlashEscrow(rOpSet, i); + } + + // Release final escrow. vm.prank(address(attacker)); - slashEscrowFactory.releaseSlashEscrow(rOpSet, 1); // 1 is used as it's the first slashId + slashEscrowFactory.releaseSlashEscrow(rOpSet, uint256(slashes) + 1); _print("release"); } From 6901d708a7fd59f2bf220e4fa6b05aa991858392 Mon Sep 17 00:00:00 2001 From: Nadir Akhtar Date: Thu, 12 Jun 2025 13:33:33 -0700 Subject: [PATCH 08/16] test: remove gas limiting to simulate higher slash count --- src/test/integration/tests/Rounding.t.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/integration/tests/Rounding.t.sol b/src/test/integration/tests/Rounding.t.sol index 683ad4ebaf..11b1fb026e 100644 --- a/src/test/integration/tests/Rounding.t.sol +++ b/src/test/integration/tests/Rounding.t.sol @@ -46,9 +46,10 @@ contract Integration_Rounding is IntegrationCheckUtils { // TODO: consider incremental manual fuzzing from 1 up to WAD - 1 function test_rounding(uint16 slashes, uint64 initialWadToSlash, uint64 _initTokenBalance, uint24 r) public rand(r) { + vm.pauseGasMetering(); // Bound slashes to a reasonable range, with at least 1 slash. // Note: Runs after 2500 hit OOG errors with default gas. - slashes = uint16(bound(slashes, 1, 2500)); + slashes = uint16(bound(slashes, 1, 5000)); // We do two slashes, the sum of which slash 1 WAD (all operator magnitude) in total. // Each slash requires at least 1 mag. As such, we need to bound wadToSlash to 1 <= wadToSlash <= WAD - 1. From f2e52b050338a24b7ac7f34ea4a9955ed0f47e7d Mon Sep 17 00:00:00 2001 From: Nadir Akhtar Date: Thu, 12 Jun 2025 19:35:51 -0700 Subject: [PATCH 09/16] test: add partial mag slashing tests --- src/test/integration/tests/Rounding.t.sol | 65 ++++++++++++++++++++--- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/src/test/integration/tests/Rounding.t.sol b/src/test/integration/tests/Rounding.t.sol index 11b1fb026e..725743a179 100644 --- a/src/test/integration/tests/Rounding.t.sol +++ b/src/test/integration/tests/Rounding.t.sol @@ -45,7 +45,7 @@ contract Integration_Rounding is IntegrationCheckUtils { } // TODO: consider incremental manual fuzzing from 1 up to WAD - 1 - function test_rounding(uint16 slashes, uint64 initialWadToSlash, uint64 _initTokenBalance, uint24 r) public rand(r) { + function test_rounding_allMagsSlashed(uint16 slashes, uint64 initialWadToSlash, uint64 _initTokenBalance, uint24 r) public rand(r) { vm.pauseGasMetering(); // Bound slashes to a reasonable range, with at least 1 slash. // Note: Runs after 2500 hit OOG errors with default gas. @@ -69,7 +69,12 @@ contract Integration_Rounding is IntegrationCheckUtils { uint64 wadToSlash = uint64(_randUint(1, WAD - 1)); _slash(wadToSlash); } - _final(slashes); // Perform slash to attempt to extract surplus value. + + // Perform final slash to attempt to extract surplus value, where WAD represents 100% of operator magnitude. + _slash(WAD); + + // Release all escrows to the redistributionRecipient (attacker). + _release(slashes); // Check for any surplus value extracted by attacker. If found, we've proven the existence of the exploit. // Unchecked to avoid overflow reverting. Safe because token balances are bounded by uint64. @@ -88,6 +93,57 @@ contract Integration_Rounding is IntegrationCheckUtils { } } + function test_rounding_partialMagsSlashed(uint16 slashes, uint64 initialWadToSlash, uint64 _initTokenBalance, uint24 r) public rand(r) { + vm.pauseGasMetering(); + // Bound slashes to a reasonable range, with at least 1 slash. + // Note: Runs after 2500 hit OOG errors with default gas. + slashes = uint16(bound(slashes, 0, 500)); + + // We do two slashes, the sum of which slash 1 WAD (all operator magnitude) in total. + // Each slash requires at least 1 mag. As such, we need to bound wadToSlash to 1 <= wadToSlash <= WAD - 1. + initialWadToSlash = uint64(bound(initialWadToSlash, 1, WAD - 1)); + + // Bound initTokenBalance to a reasonable range to avoid overflow, with at least 1 token. + // Using ~18.45 quintillion tokens max (should be enough for any realistic test). + initTokenBalance = uint64(bound(_initTokenBalance, 1, type(uint64).max)); + deal(address(token), address(attacker), initTokenBalance); + + _magnitudeManipulation(initialWadToSlash); // Manipulate operator magnitude for a given strategy. + _deposit(initialWadToSlash); // Setup operator with new opSet as well as honest stake in same strategy. + + // Perform slashes to gradually whittle down operator magnitude, as well as produce slash escrows. + for (uint16 i = 0; i < slashes; i++) { + // Upper bound is less than WAD to leave some mag for final slash + uint64 wadToSlash = uint64(_randUint(1, WAD - 1)); + _slash(wadToSlash); + } + + // Release all escrows to the redistributionRecipient (attacker). + _release(slashes); + + // Withdraw all attacker deposits. + (, uint256[] memory depositShares) = strategyManager.getDeposits(address(attacker)); + + Withdrawal[] memory withdrawals = attacker.queueWithdrawals(strategy.toArray(), depositShares); + + rollForward({blocks: DELEGATION_MANAGER_MIN_WITHDRAWAL_DELAY_BLOCKS + 1}); + + attacker.completeWithdrawalsAsTokens(withdrawals); + + _print("withdraw"); + + + if (token.balanceOf(address(attacker)) > initTokenBalance) { + uint64 diff = uint64(token.balanceOf(address(attacker))) - initTokenBalance; + console.log("EXCESS of tokens: %d", diff); + revert("Rounding error exploit found!"); + } else if (token.balanceOf(address(attacker)) < initTokenBalance) { + uint64 diff = uint64(initTokenBalance - token.balanceOf(address(attacker))); + console.log("DEFICIT of tokens: %d", diff); + assertLt(diff, 100, "Tokens lost!"); + } + } + // TODO - another way to mess with rounding/precision loss is to manipulate DSF function _magnitudeManipulation(uint64 wadToSlash) internal { // Allocate all magnitude to operator set. @@ -156,10 +212,7 @@ contract Integration_Rounding is IntegrationCheckUtils { _print("slash"); } - function _final(uint64 slashes) internal { - // Slash all operator magnitude. - _slash(WAD); - + function _release(uint64 slashes) internal { // Roll forward past the escrow delay. rollForward({blocks: slashEscrowFactory.getGlobalEscrowDelay() + 1}); From 590031b836b057a9097a7166d716379817188e2c Mon Sep 17 00:00:00 2001 From: wadealexc Date: Fri, 13 Jun 2025 15:39:42 +0000 Subject: [PATCH 10/16] test: clean up and minor refactors --- src/test/integration/tests/Rounding.t.sol | 85 +++++++++++------------ 1 file changed, 40 insertions(+), 45 deletions(-) diff --git a/src/test/integration/tests/Rounding.t.sol b/src/test/integration/tests/Rounding.t.sol index 725743a179..929ddb53c5 100644 --- a/src/test/integration/tests/Rounding.t.sol +++ b/src/test/integration/tests/Rounding.t.sol @@ -17,6 +17,8 @@ contract Integration_Rounding is IntegrationCheckUtils { OperatorSet mOpSet; // "manipOpSet" used for magnitude manipulation OperatorSet rOpSet; // Redistributable opset used to exploit precision loss and trigger redistribution + uint numSlashes = 50; + function _init() internal override { _configAssetTypes(HOLDS_LST); @@ -28,7 +30,8 @@ contract Integration_Rounding is IntegrationCheckUtils { // Prepares to add non-attacker stake into the protocol. Can be any amount > 0. // Note that the honest stake does not need to be allocated anywhere, so long as it's in the same strategy. goodStaker = new User("GoodStaker"); - deal(address(token), address(goodStaker), uint256(1e18)); + deal(address(token), address(goodStaker), uint256(1e18)); + goodStaker.depositIntoEigenlayer(strategy.toArray(), 1e18.toArrayU256()); // Register attacker as operator and create attacker-controlled AVS/OpSets attacker.registerAsOperator(0); @@ -45,36 +48,33 @@ contract Integration_Rounding is IntegrationCheckUtils { } // TODO: consider incremental manual fuzzing from 1 up to WAD - 1 - function test_rounding_allMagsSlashed(uint16 slashes, uint64 initialWadToSlash, uint64 _initTokenBalance, uint24 r) public rand(r) { + function test_rounding_allMagsSlashed( + uint64 initialWadToSlash, + uint64 _initTokenBalance + ) public rand(0) { vm.pauseGasMetering(); - // Bound slashes to a reasonable range, with at least 1 slash. - // Note: Runs after 2500 hit OOG errors with default gas. - slashes = uint16(bound(slashes, 1, 5000)); - - // We do two slashes, the sum of which slash 1 WAD (all operator magnitude) in total. - // Each slash requires at least 1 mag. As such, we need to bound wadToSlash to 1 <= wadToSlash <= WAD - 1. + // Don't slash 100% as we will do multiple slashes initialWadToSlash = uint64(bound(initialWadToSlash, 1, WAD - 1)); - - // Bound initTokenBalance to a reasonable range to avoid overflow, with at least 1 token. - // Using ~18.45 quintillion tokens max (should be enough for any realistic test). - initTokenBalance = uint64(bound(_initTokenBalance, 1, type(uint64).max)); + // Ensure attacker has at least one token + initTokenBalance = _initTokenBalance > 0 ? _initTokenBalance : _initTokenBalance + 1; deal(address(token), address(attacker), initTokenBalance); - _magnitudeManipulation(initialWadToSlash); // Manipulate operator magnitude for a given strategy. - _deposit(initialWadToSlash); // Setup operator with new opSet as well as honest stake in same strategy. + // Use modifyAllocation+slashOperator to arbitrarily set operator max magnitude + _magnitudeManipulation(initialWadToSlash); + _deposit(); // Perform slashes to gradually whittle down operator magnitude, as well as produce slash escrows. - for (uint16 i = 0; i < slashes; i++) { - // Upper bound is less than WAD to leave some mag for final slash - uint64 wadToSlash = uint64(_randUint(1, WAD - 1)); + // Since we're doing multiple slashes, never slash 100%. + for (uint16 i = 0; i < numSlashes; i++) { + uint64 wadToSlash = uint64(cheats.randomUint(1, WAD - 1)); _slash(wadToSlash); } - // Perform final slash to attempt to extract surplus value, where WAD represents 100% of operator magnitude. + // Perform final 100% slash to extract any remaining tokens. _slash(WAD); // Release all escrows to the redistributionRecipient (attacker). - _release(slashes); + _release(); // Check for any surplus value extracted by attacker. If found, we've proven the existence of the exploit. // Unchecked to avoid overflow reverting. Safe because token balances are bounded by uint64. @@ -93,33 +93,30 @@ contract Integration_Rounding is IntegrationCheckUtils { } } - function test_rounding_partialMagsSlashed(uint16 slashes, uint64 initialWadToSlash, uint64 _initTokenBalance, uint24 r) public rand(r) { + function test_rounding_partialMagsSlashed( + uint64 initialWadToSlash, + uint64 _initTokenBalance + ) public rand(0) { vm.pauseGasMetering(); - // Bound slashes to a reasonable range, with at least 1 slash. - // Note: Runs after 2500 hit OOG errors with default gas. - slashes = uint16(bound(slashes, 0, 500)); - - // We do two slashes, the sum of which slash 1 WAD (all operator magnitude) in total. - // Each slash requires at least 1 mag. As such, we need to bound wadToSlash to 1 <= wadToSlash <= WAD - 1. + // Don't slash 100% as we will do multiple slashes initialWadToSlash = uint64(bound(initialWadToSlash, 1, WAD - 1)); - - // Bound initTokenBalance to a reasonable range to avoid overflow, with at least 1 token. - // Using ~18.45 quintillion tokens max (should be enough for any realistic test). - initTokenBalance = uint64(bound(_initTokenBalance, 1, type(uint64).max)); + // Ensure attacker has at least one token + initTokenBalance = _initTokenBalance > 0 ? _initTokenBalance : _initTokenBalance + 1; deal(address(token), address(attacker), initTokenBalance); - _magnitudeManipulation(initialWadToSlash); // Manipulate operator magnitude for a given strategy. - _deposit(initialWadToSlash); // Setup operator with new opSet as well as honest stake in same strategy. + // Use modifyAllocation+slashOperator to arbitrarily set operator max magnitude + _magnitudeManipulation(initialWadToSlash); + _deposit(); // Perform slashes to gradually whittle down operator magnitude, as well as produce slash escrows. - for (uint16 i = 0; i < slashes; i++) { - // Upper bound is less than WAD to leave some mag for final slash - uint64 wadToSlash = uint64(_randUint(1, WAD - 1)); + // Since we're doing multiple slashes, never slash 100%. + for (uint16 i = 0; i < numSlashes; i++) { + uint64 wadToSlash = uint64(cheats.randomUint(1, WAD - 1)); _slash(wadToSlash); } // Release all escrows to the redistributionRecipient (attacker). - _release(slashes); + _release(); // Withdraw all attacker deposits. (, uint256[] memory depositShares) = strategyManager.getDeposits(address(attacker)); @@ -182,20 +179,18 @@ contract Integration_Rounding is IntegrationCheckUtils { _print("deallocate"); } - function _deposit(uint64 wadToSlash) internal { + function _deposit() internal { // Allocate all remaining magnitude to redistributable opset. + uint64 allocatableMagnitude = allocationManager.getAllocatableMagnitude(address(attacker), strategy); attacker.modifyAllocations(AllocateParams({ operatorSet: rOpSet, strategies: strategy.toArray(), - newMagnitudes: (WAD - wadToSlash).toArrayU64() + newMagnitudes: (allocatableMagnitude).toArrayU64() })); // Deposit all attacker assets into Eigenlayer. attacker.depositIntoEigenlayer(strategy.toArray(), token.balanceOf(address(attacker)).toArrayU256()); - // Deposit all honest stake into Eigenlayer. - goodStaker.depositIntoEigenlayer(strategy.toArray(), token.balanceOf(address(goodStaker)).toArrayU256()); - _print("deposit"); } @@ -212,19 +207,19 @@ contract Integration_Rounding is IntegrationCheckUtils { _print("slash"); } - function _release(uint64 slashes) internal { + function _release() internal { // Roll forward past the escrow delay. rollForward({blocks: slashEscrowFactory.getGlobalEscrowDelay() + 1}); // Release funds. - for (uint32 i = 1; i <= slashes; i++) { + for (uint32 i = 1; i <= numSlashes; i++) { vm.prank(address(attacker)); slashEscrowFactory.releaseSlashEscrow(rOpSet, i); } // Release final escrow. vm.prank(address(attacker)); - slashEscrowFactory.releaseSlashEscrow(rOpSet, uint256(slashes) + 1); + slashEscrowFactory.releaseSlashEscrow(rOpSet, uint256(numSlashes) + 1); _print("release"); } @@ -274,4 +269,4 @@ contract Integration_Rounding is IntegrationCheckUtils { console.log("\n ===\n".cyan()); } -} +} \ No newline at end of file From 7a98f6ded679fe1e435df4e5ae086fee7cbe9fb1 Mon Sep 17 00:00:00 2001 From: Nadir Akhtar Date: Fri, 13 Jun 2025 15:11:01 -0700 Subject: [PATCH 11/16] test: improved checks/structure and minor fixes --- src/test/integration/tests/Rounding.t.sol | 157 ++++++++++++---------- 1 file changed, 89 insertions(+), 68 deletions(-) diff --git a/src/test/integration/tests/Rounding.t.sol b/src/test/integration/tests/Rounding.t.sol index 929ddb53c5..2ed4535d72 100644 --- a/src/test/integration/tests/Rounding.t.sol +++ b/src/test/integration/tests/Rounding.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.27; import "src/test/integration/IntegrationChecks.t.sol"; +// TODO: add strategy share manipulation contract Integration_Rounding is IntegrationCheckUtils { using ArrayLib for *; using StdStyle for *; @@ -16,8 +17,8 @@ contract Integration_Rounding is IntegrationCheckUtils { OperatorSet mOpSet; // "manipOpSet" used for magnitude manipulation OperatorSet rOpSet; // Redistributable opset used to exploit precision loss and trigger redistribution - - uint numSlashes = 50; + + uint16 numSlashes = 100; // TODO: transform into envvar function _init() internal override { _configAssetTypes(HOLDS_LST); @@ -26,13 +27,13 @@ contract Integration_Rounding is IntegrationCheckUtils { badAVS = new AVS("BadAVS"); // AVS is also attacker-controlled strategy = lstStrats[0]; token = IERC20Metadata(address(strategy.underlyingToken())); - + // Prepares to add non-attacker stake into the protocol. Can be any amount > 0. // Note that the honest stake does not need to be allocated anywhere, so long as it's in the same strategy. goodStaker = new User("GoodStaker"); deal(address(token), address(goodStaker), uint256(1e18)); goodStaker.depositIntoEigenlayer(strategy.toArray(), 1e18.toArrayU256()); - + // Register attacker as operator and create attacker-controlled AVS/OpSets attacker.registerAsOperator(0); rollForward({blocks: ALLOCATION_CONFIGURATION_DELAY + 1}); @@ -43,117 +44,125 @@ contract Integration_Rounding is IntegrationCheckUtils { // Register for both opsets attacker.registerForOperatorSet(mOpSet); attacker.registerForOperatorSet(rOpSet); - + _print("setup"); } + /** + * + * TESTS + * + */ + // TODO: consider incremental manual fuzzing from 1 up to WAD - 1 function test_rounding_allMagsSlashed( - uint64 initialWadToSlash, + uint64 _initialMaxMag, uint64 _initTokenBalance ) public rand(0) { vm.pauseGasMetering(); // Don't slash 100% as we will do multiple slashes - initialWadToSlash = uint64(bound(initialWadToSlash, 1, WAD - 1)); + _initialMaxMag = uint64(bound(_initialMaxMag, 1, WAD - 1)); // Ensure attacker has at least one token initTokenBalance = _initTokenBalance > 0 ? _initTokenBalance : _initTokenBalance + 1; deal(address(token), address(attacker), initTokenBalance); - + // Use modifyAllocation+slashOperator to arbitrarily set operator max magnitude - _magnitudeManipulation(initialWadToSlash); + _magnitudeManipulation(_initialMaxMag); + + // Deposit all attacker assets into Eigenlayer. _deposit(); - + // Perform slashes to gradually whittle down operator magnitude, as well as produce slash escrows. // Since we're doing multiple slashes, never slash 100%. for (uint16 i = 0; i < numSlashes; i++) { uint64 wadToSlash = uint64(cheats.randomUint(1, WAD - 1)); _slash(wadToSlash); } - + // Perform final 100% slash to extract any remaining tokens. - _slash(WAD); - + _slash(WAD); + // Release all escrows to the redistributionRecipient (attacker). _release(); - - // Check for any surplus value extracted by attacker. If found, we've proven the existence of the exploit. - // Unchecked to avoid overflow reverting. Safe because token balances are bounded by uint64. - // Negative diff means attacker lost money, positive diff means attacker gained money. - int diff; - unchecked { - diff = int(token.balanceOf(address(attacker)) - initTokenBalance); - } - console.log("Difference in tokens: %d", diff); - if (diff > 0) { - revert("Rounding error exploit found!"); - } - - if (diff < 0) { - revert("Tokens lost!"); - } + + // Check for precision loss. + // Note: No precision loss expected after all magnitude is slashed. + checkForPrecisionLoss(0); } - + function test_rounding_partialMagsSlashed( - uint64 initialWadToSlash, + uint64 _initialMaxMag, uint64 _initTokenBalance ) public rand(0) { vm.pauseGasMetering(); // Don't slash 100% as we will do multiple slashes - initialWadToSlash = uint64(bound(initialWadToSlash, 1, WAD - 1)); + _initialMaxMag = uint64(bound(_initialMaxMag, 1, WAD - 1)); // Ensure attacker has at least one token initTokenBalance = _initTokenBalance > 0 ? _initTokenBalance : _initTokenBalance + 1; deal(address(token), address(attacker), initTokenBalance); - + // Use modifyAllocation+slashOperator to arbitrarily set operator max magnitude - _magnitudeManipulation(initialWadToSlash); + _magnitudeManipulation(_initialMaxMag); + + // Deposit all attacker assets into Eigenlayer. _deposit(); - + // Perform slashes to gradually whittle down operator magnitude, as well as produce slash escrows. // Since we're doing multiple slashes, never slash 100%. for (uint16 i = 0; i < numSlashes; i++) { uint64 wadToSlash = uint64(cheats.randomUint(1, WAD - 1)); _slash(wadToSlash); } - + // Release all escrows to the redistributionRecipient (attacker). _release(); - - // Withdraw all attacker deposits. - (, uint256[] memory depositShares) = strategyManager.getDeposits(address(attacker)); - - Withdrawal[] memory withdrawals = attacker.queueWithdrawals(strategy.toArray(), depositShares); - - rollForward({blocks: DELEGATION_MANAGER_MIN_WITHDRAWAL_DELAY_BLOCKS + 1}); - - attacker.completeWithdrawalsAsTokens(withdrawals); - - _print("withdraw"); - - + + // Withdraw all attacker deposits. Necessary as operator has partial mags remaining. + _withdraw(); + + // Note: Precision loss seems to be a consequence of the DSF, rather than slashing precision loss. + // Max precision loss for this test is observed to correspond to residual operator shares. + // TODO: Explore root cause of this precision loss. + uint operatorShares = delegationManager.getOperatorShares(address(attacker), strategy.toArray())[0]; + checkForPrecisionLoss(operatorShares); + } + + /** + * + * INTERNAL FUNCTIONS + * + */ + + // @notice Check for any precision loss. + // First case means attacker gained money. If found, we've proven the existence of an exploit. + // Second case means attacker lost money. This demonstrates precision loss, with maxLoss as the upper bound. + // @dev Reverts if attacker has gained _any_ tokens, or if token loss is greater than maxLoss. + function checkForPrecisionLoss(uint256 maxLoss) internal { if (token.balanceOf(address(attacker)) > initTokenBalance) { uint64 diff = uint64(token.balanceOf(address(attacker))) - initTokenBalance; console.log("EXCESS of tokens: %d", diff); + // ANY tokens gained is an exploit. revert("Rounding error exploit found!"); } else if (token.balanceOf(address(attacker)) < initTokenBalance) { uint64 diff = uint64(initTokenBalance - token.balanceOf(address(attacker))); console.log("DEFICIT of tokens: %d", diff); - assertLt(diff, 100, "Tokens lost!"); + // Check against provided tolerance. + assertLe(diff, maxLoss, "Tokens lost!"); } } - + // TODO - another way to mess with rounding/precision loss is to manipulate DSF - function _magnitudeManipulation(uint64 wadToSlash) internal { + function _magnitudeManipulation(uint64 _initialMaxMag) internal { // Allocate all magnitude to operator set. attacker.modifyAllocations(AllocateParams({ operatorSet: mOpSet, strategies: strategy.toArray(), newMagnitudes: WAD.toArrayU64() })); - - // TODO: print "newMagnitudes" _print("allocate"); + uint64 wadToSlash = WAD - _initialMaxMag; // Slash operator to arbitrary mag. badAVS.slashOperator(SlashingParams({ operator: address(attacker), @@ -162,20 +171,20 @@ contract Integration_Rounding is IntegrationCheckUtils { wadsToSlash: uint(wadToSlash).toArrayU256(), description: "manipulation!" })); - + // TODO: print "wadsToSlash" _print("slash"); - + // Deallocate magnitude from operator set. attacker.modifyAllocations(AllocateParams({ operatorSet: mOpSet, strategies: strategy.toArray(), newMagnitudes: 0.toArrayU64() })); - + rollForward({blocks: DEALLOCATION_DELAY + 1}); - + _print("deallocate"); } @@ -187,13 +196,13 @@ contract Integration_Rounding is IntegrationCheckUtils { strategies: strategy.toArray(), newMagnitudes: (allocatableMagnitude).toArrayU64() })); - + // Deposit all attacker assets into Eigenlayer. attacker.depositIntoEigenlayer(strategy.toArray(), token.balanceOf(address(attacker)).toArrayU256()); - + _print("deposit"); } - + function _slash(uint64 wadToSlash) internal { // Perform final slash on redistributable opset and check for profit. Slash all operator magnitude. badAVS.slashOperator(SlashingParams({ @@ -203,31 +212,43 @@ contract Integration_Rounding is IntegrationCheckUtils { wadsToSlash: uint(wadToSlash).toArrayU256(), description: "final slash" })); - + _print("slash"); } function _release() internal { // Roll forward past the escrow delay. rollForward({blocks: slashEscrowFactory.getGlobalEscrowDelay() + 1}); - + // Release funds. for (uint32 i = 1; i <= numSlashes; i++) { vm.prank(address(attacker)); slashEscrowFactory.releaseSlashEscrow(rOpSet, i); } - + // Release final escrow. vm.prank(address(attacker)); slashEscrowFactory.releaseSlashEscrow(rOpSet, uint256(numSlashes) + 1); - + _print("release"); } + function _withdraw() internal { + (, uint256[] memory depositShares) = strategyManager.getDeposits(address(attacker)); + + Withdrawal[] memory withdrawals = attacker.queueWithdrawals(strategy.toArray(), depositShares); + + rollForward({blocks: DELEGATION_MANAGER_MIN_WITHDRAWAL_DELAY_BLOCKS + 1}); + + attacker.completeWithdrawalsAsTokens(withdrawals); + + _print("withdraw"); + } + function _print(string memory phaseName) internal { address a = address(attacker); - console.log(""); + console.log(""); console.log("===Attacker Info: %s phase===".cyan(), phaseName); { @@ -240,7 +261,7 @@ contract Integration_Rounding is IntegrationCheckUtils { { console.log("\nShares:".magenta()); - (uint[] memory withdrawableArr, uint[] memory depositArr) + (uint[] memory withdrawableArr, uint[] memory depositArr) = delegationManager.getWithdrawableShares(a, strategy.toArray()); uint withdrawableShares = withdrawableArr.length == 0 ? 0 : withdrawableArr[0]; uint depositShares = depositArr.length == 0 ? 0 : depositArr[0]; @@ -257,7 +278,7 @@ contract Integration_Rounding is IntegrationCheckUtils { console.log(" - Init Mag: %d", WAD); console.log( - " - Max Mag: %d\n -- Total Allocated: %d\n -- Total Available: %d", + " - Max Mag: %d\n -- Total Allocated: %d\n -- Total Available: %d", allocationManager.getMaxMagnitude(a, strategy), allocationManager.getEncumberedMagnitude(a, strategy), allocationManager.getAllocatableMagnitude(a, strategy) From 0baf09a4089269aa607bdaf1574df45f05611199 Mon Sep 17 00:00:00 2001 From: Nadir Akhtar Date: Fri, 13 Jun 2025 16:01:35 -0700 Subject: [PATCH 12/16] test: add strategy exchange rate manipulation tests --- src/test/integration/tests/Rounding.t.sol | 135 +++++++++++++++++++++- 1 file changed, 131 insertions(+), 4 deletions(-) diff --git a/src/test/integration/tests/Rounding.t.sol b/src/test/integration/tests/Rounding.t.sol index 2ed4535d72..75348aeaec 100644 --- a/src/test/integration/tests/Rounding.t.sol +++ b/src/test/integration/tests/Rounding.t.sol @@ -55,6 +55,13 @@ contract Integration_Rounding is IntegrationCheckUtils { */ // TODO: consider incremental manual fuzzing from 1 up to WAD - 1 + /** + * @notice Tests rounding behavior when all operator magnitudes are slashed + * @param _initialMaxMag The initial maximum magnitude to set for the operator, bounded between 1 and WAD-1 + * @param _initTokenBalance The initial token balance to give the attacker, must be > 0 + * @dev This test verifies that when an operator's magnitude is gradually reduced through multiple slashes + * and finally completely slashed, there is no precision loss in the redistribution of tokens. + */ function test_rounding_allMagsSlashed( uint64 _initialMaxMag, uint64 _initTokenBalance @@ -70,7 +77,7 @@ contract Integration_Rounding is IntegrationCheckUtils { _magnitudeManipulation(_initialMaxMag); // Deposit all attacker assets into Eigenlayer. - _deposit(); + _depositAll(); // Perform slashes to gradually whittle down operator magnitude, as well as produce slash escrows. // Since we're doing multiple slashes, never slash 100%. @@ -90,6 +97,13 @@ contract Integration_Rounding is IntegrationCheckUtils { checkForPrecisionLoss(0); } + /** + * @notice Tests rounding behavior when operator magnitudes are partially slashed + * @param _initialMaxMag The initial maximum magnitude to set for the operator, bounded between 1 and WAD-1 + * @param _initTokenBalance The initial token balance to give the attacker, must be > 0 + * @dev This test verifies that when an operator's magnitude is gradually reduced through multiple slashes + * and finally completely slashed, there is minimal precision loss. + */ function test_rounding_partialMagsSlashed( uint64 _initialMaxMag, uint64 _initTokenBalance @@ -105,7 +119,7 @@ contract Integration_Rounding is IntegrationCheckUtils { _magnitudeManipulation(_initialMaxMag); // Deposit all attacker assets into Eigenlayer. - _deposit(); + _depositAll(); // Perform slashes to gradually whittle down operator magnitude, as well as produce slash escrows. // Since we're doing multiple slashes, never slash 100%. @@ -127,6 +141,117 @@ contract Integration_Rounding is IntegrationCheckUtils { checkForPrecisionLoss(operatorShares); } + // TODO: consider parameterizing "honest" stake to see how strategy exchange rate is affected + /** + * @notice Tests rounding behavior when the attacker manipulates the strategy's exchange rate with all magnitudes slashed + * @param _initialMaxMag The initial maximum magnitude to set for the operator, bounded between 1 and WAD-1 + * @param _initTokenBalance The initial token balance to give the attacker, must be > 0 + * @dev This test verifies that when the attacker manipulates the strategy's exchange rate, by depositing funds directly into the strategy, that the attacker cannot profit from the manipulation, even when all magnitudes are slashed. + */ + function test_rounding_strategySharesManipulation_allMagsSlashed( + uint64 _initialMaxMag, + uint64 _initTokenBalance + ) public rand(0) { + vm.pauseGasMetering(); + // Don't slash 100% initially as we will do multiple slashes + _initialMaxMag = uint64(bound(_initialMaxMag, 1, WAD - 1)); + // Ensure attacker has at least one token + initTokenBalance = _initTokenBalance > 0 ? _initTokenBalance : _initTokenBalance + 1; + deal(address(token), address(attacker), initTokenBalance); + + // Use modifyAllocation+slashOperator to arbitrarily set operator max magnitude + _magnitudeManipulation(_initialMaxMag); + + // Allocate all remaining magnitude to redistributable opset. + uint64 allocatableMagnitude = allocationManager.getAllocatableMagnitude(address(attacker), strategy); + attacker.modifyAllocations(AllocateParams({ + operatorSet: rOpSet, + strategies: strategy.toArray(), + newMagnitudes: (allocatableMagnitude).toArrayU64() + })); + + // Deposit a random amount of tokens into the strategy. + attacker.depositIntoEigenlayer(strategy.toArray(), cheats.randomUint(1, token.balanceOf(address(attacker))).toArrayU256()); + + // Perform slashes to gradually whittle down operator magnitude, as well as produce slash escrows. + // Since we're doing multiple slashes, never slash 100%. + for (uint16 i = 0; i < numSlashes; i++) { + uint64 wadToSlash = uint64(cheats.randomUint(1, WAD - 1)); + // Randomly decide whether to add funds to the strategy between slashes. + if (cheats.randomBool() && token.balanceOf(address(attacker)) > 0) { + uint fundsToAdd = cheats.randomUint(1, token.balanceOf(address(attacker))); + // Deal funds directly to the strategy to affect the exchange rate. + vm.prank(address(attacker)); + token.transfer(address(strategy), fundsToAdd); + } + _slash(wadToSlash); + } + + // Perform final 100% slash to extract any remaining tokens. + _slash(WAD); + + // Release all escrows to the redistributionRecipient (attacker). + _release(); + + // Note: In this test case, the attacker burns funds to attempt to manipulate the strategy's exchange rate. As such, severe token loss is expected. + checkForPrecisionLoss(initTokenBalance); + } + + /** + * @notice Tests rounding behavior when the attacker manipulates the strategy's exchange rate + * @param _initialMaxMag The initial maximum magnitude to set for the operator, bounded between 1 and WAD-1 + * @param _initTokenBalance The initial token balance to give the attacker, must be > 0 + * @dev This test verifies that when the attacker manipulates the strategy's exchange rate, by depositing funds directly into the strategy, that the attacker cannot profit from the manipulation. + */ + function test_rounding_strategySharesManipulation_partialMagsSlashed( + uint64 _initialMaxMag, + uint64 _initTokenBalance + ) public rand(0) { + vm.pauseGasMetering(); + // Don't slash 100% as we will do multiple slashes + _initialMaxMag = uint64(bound(_initialMaxMag, 1, WAD - 1)); + // Ensure attacker has at least one token + initTokenBalance = _initTokenBalance > 0 ? _initTokenBalance : _initTokenBalance + 1; + deal(address(token), address(attacker), initTokenBalance); + + // Use modifyAllocation+slashOperator to arbitrarily set operator max magnitude + _magnitudeManipulation(_initialMaxMag); + + // Allocate all remaining magnitude to redistributable opset. + uint64 allocatableMagnitude = allocationManager.getAllocatableMagnitude(address(attacker), strategy); + attacker.modifyAllocations(AllocateParams({ + operatorSet: rOpSet, + strategies: strategy.toArray(), + newMagnitudes: (allocatableMagnitude).toArrayU64() + })); + + // Deposit a random amount of tokens into the strategy. + attacker.depositIntoEigenlayer(strategy.toArray(), cheats.randomUint(1, token.balanceOf(address(attacker))).toArrayU256()); + + // Perform slashes to gradually whittle down operator magnitude, as well as produce slash escrows. + // Since we're doing multiple slashes, never slash 100%. + for (uint16 i = 0; i < numSlashes; i++) { + uint64 wadToSlash = uint64(cheats.randomUint(1, WAD - 1)); + // Randomly decide whether to add funds to the strategy between slashes. + if (cheats.randomBool() && token.balanceOf(address(attacker)) > 0) { + uint fundsToAdd = cheats.randomUint(1, token.balanceOf(address(attacker))); + // Deal funds directly to the strategy to affect the exchange rate. + vm.prank(address(attacker)); + token.transfer(address(strategy), fundsToAdd); + } + _slash(wadToSlash); + } + + // Release all escrows to the redistributionRecipient (attacker). + _release(); + + // Withdraw all attacker deposits. Necessary as operator has partial mags remaining. + _withdraw(); + + // Note: In this test case, the attacker burns funds to attempt to manipulate the strategy's exchange rate. As such, severe token loss is expected. + checkForPrecisionLoss(initTokenBalance); + } + /** * * INTERNAL FUNCTIONS @@ -141,11 +266,13 @@ contract Integration_Rounding is IntegrationCheckUtils { if (token.balanceOf(address(attacker)) > initTokenBalance) { uint64 diff = uint64(token.balanceOf(address(attacker))) - initTokenBalance; console.log("EXCESS of tokens: %d", diff); + console.log("maxLoss: %d", maxLoss); // ANY tokens gained is an exploit. revert("Rounding error exploit found!"); } else if (token.balanceOf(address(attacker)) < initTokenBalance) { uint64 diff = uint64(initTokenBalance - token.balanceOf(address(attacker))); console.log("DEFICIT of tokens: %d", diff); + console.log("maxLoss: %d", maxLoss); // Check against provided tolerance. assertLe(diff, maxLoss, "Tokens lost!"); } @@ -188,7 +315,7 @@ contract Integration_Rounding is IntegrationCheckUtils { _print("deallocate"); } - function _deposit() internal { + function _depositAll() internal { // Allocate all remaining magnitude to redistributable opset. uint64 allocatableMagnitude = allocationManager.getAllocatableMagnitude(address(attacker), strategy); attacker.modifyAllocations(AllocateParams({ @@ -200,7 +327,7 @@ contract Integration_Rounding is IntegrationCheckUtils { // Deposit all attacker assets into Eigenlayer. attacker.depositIntoEigenlayer(strategy.toArray(), token.balanceOf(address(attacker)).toArrayU256()); - _print("deposit"); + _print("depositAll"); } function _slash(uint64 wadToSlash) internal { From bdf57ebdcd77eef2669bfc8ec82432edc2019e42 Mon Sep 17 00:00:00 2001 From: Nadir Akhtar Date: Fri, 13 Jun 2025 16:02:21 -0700 Subject: [PATCH 13/16] chore: minor TODO removal --- src/test/integration/tests/Rounding.t.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/integration/tests/Rounding.t.sol b/src/test/integration/tests/Rounding.t.sol index 75348aeaec..18e0106084 100644 --- a/src/test/integration/tests/Rounding.t.sol +++ b/src/test/integration/tests/Rounding.t.sol @@ -3,7 +3,6 @@ pragma solidity ^0.8.27; import "src/test/integration/IntegrationChecks.t.sol"; -// TODO: add strategy share manipulation contract Integration_Rounding is IntegrationCheckUtils { using ArrayLib for *; using StdStyle for *; From 8cb0428991e4ef9b33488337abea4e823dd3eb11 Mon Sep 17 00:00:00 2001 From: xyz <2523269+antojoseph@users.noreply.github.com> Date: Sat, 14 Jun 2025 00:24:25 +0000 Subject: [PATCH 14/16] test: This test creates operator shares with no backing. --- .../RoundingOperatorShareswithNoBacking.t.sol | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 src/test/integration/tests/RoundingOperatorShareswithNoBacking.t.sol diff --git a/src/test/integration/tests/RoundingOperatorShareswithNoBacking.t.sol b/src/test/integration/tests/RoundingOperatorShareswithNoBacking.t.sol new file mode 100644 index 0000000000..8de8ab1cbc --- /dev/null +++ b/src/test/integration/tests/RoundingOperatorShareswithNoBacking.t.sol @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.27; + +import "src/test/integration/IntegrationChecks.t.sol"; + +contract Integration_Rounding is IntegrationCheckUtils { + using ArrayLib for *; + using StdStyle for *; + + User attacker; + // Number of users to create + User[4] users; + AVS badAVS; + IStrategy strategy; + IERC20Metadata token; + User goodStaker; + uint64 initTokenBalance; + + OperatorSet mOpSet; // "manipOpSet" used for magnitude manipulation + OperatorSet rOpSet; // Redistributable opset used to exploit precision loss and trigger redistribution + + uint numSlashes = 1; + + function _init() internal override { + _configAssetTypes(HOLDS_LST); + + attacker = new User("Attacker"); // Attacker serves as both operator and staker + badAVS = new AVS("BadAVS"); // AVS is also attacker-controlled + strategy = lstStrats[0]; + token = IERC20Metadata(address(strategy.underlyingToken())); + + // Create users in a loop and give them some tokens + for (uint256 i = 0; i < users.length; i++) { + users[i] = new User(string(abi.encodePacked("randomUser", vm.toString(i + 1)))); + deal(address(token), address(users[i]), 1e18); + } + // Prepares to add non-attacker stake into the protocol. Can be any amount > 0. + // Note that the honest stake does not need to be allocated anywhere, so long as it's in the same strategy. + goodStaker = new User("GoodStaker"); + deal(address(token), address(goodStaker), uint256(1e18)); + goodStaker.depositIntoEigenlayer(strategy.toArray(), 1e18.toArrayU256()); + + + + + // Register attacker as operator and create attacker-controlled AVS/OpSets + attacker.registerAsOperator(0); + rollForward({blocks: ALLOCATION_CONFIGURATION_DELAY + 1}); + badAVS.updateAVSMetadataURI("https://example.com"); + mOpSet = badAVS.createOperatorSet(strategy.toArray()); // setup low mag operator + rOpSet = badAVS.createRedistributingOperatorSet(strategy.toArray(), address(attacker)); // execute exploit + + // Register for both opsets + attacker.registerForOperatorSet(mOpSet); + attacker.registerForOperatorSet(rOpSet); + + _print("setup"); + } + + // TODO: consider incremental manual fuzzing from 1 up to WAD - 1 + function test_rounding_allMagsSlashed( + uint64 initialWadToSlash, + uint64 _initTokenBalance + ) public rand(0) { + vm.pauseGasMetering(); + // Don't slash 100% as we will do multiple slashes + initialWadToSlash = uint64(bound(initialWadToSlash, 1, WAD - 1)); + // Ensure attacker has at least one token + initTokenBalance = _initTokenBalance > 0 ? _initTokenBalance : _initTokenBalance + 1; + deal(address(token), address(attacker), initTokenBalance); + + // Use modifyAllocation+slashOperator to arbitrarily set operator max magnitude + _magnitudeManipulation(initialWadToSlash); + _deposit(); + + // Perform slashes to gradually whittle down operator magnitude, as well as produce slash escrows. + // Since we're doing multiple slashes, never slash 100%. + for (uint16 i = 0; i < numSlashes; i++) { + uint64 wadToSlash = uint64(cheats.randomUint(1, WAD - 1)); + _slash(wadToSlash); + } + + // Perform final 100% slash to extract any remaining tokens. + _slash(WAD); + + // Release all escrows to the redistributionRecipient (attacker). + _release(); + + // Check for any surplus value extracted by attacker. If found, we've proven the existence of the exploit. + // Unchecked to avoid overflow reverting. Safe because token balances are bounded by uint64. + // Negative diff means attacker lost money, positive diff means attacker gained money. + int diff; + unchecked { + diff = int(token.balanceOf(address(attacker)) - initTokenBalance); + } + console.log("Difference in tokens: %d", diff); + if (diff > 0) { + revert("Rounding error exploit found!"); + } + + if (diff < -1) { + revert("Tokens lost!"); + } + } + + function test_rounding_partialMagsSlashed( + uint64 initialWadToSlash, + uint64 _initTokenBalance + ) public rand(0) { + vm.pauseGasMetering(); + // Don't slash 100% as we will do multiple slashes + initialWadToSlash = uint64(bound(initialWadToSlash, 1, WAD - 1)); + // Ensure attacker has at least one token + initTokenBalance = _initTokenBalance > 0 ? _initTokenBalance : _initTokenBalance + 1; + deal(address(token), address(attacker), initTokenBalance); + + // Use modifyAllocation+slashOperator to arbitrarily set operator max magnitude + _magnitudeManipulation(initialWadToSlash); + _deposit(); + + // Perform slashes to gradually whittle down operator magnitude, as well as produce slash escrows. + // Since we're doing multiple slashes, never slash 100%. + //for (uint16 i = 0; i < numSlashes; i++) { + // uint64 wadToSlash = uint64(cheats.randomUint(1, WAD - 1)); + // _slash(wadToSlash); + //} + + // Release all escrows to the redistributionRecipient (attacker). + //_release(); + + // Withdraw all attacker deposits. + (, uint256[] memory depositShares) = strategyManager.getDeposits(address(attacker)); + + Withdrawal[] memory withdrawals = attacker.queueWithdrawals(strategy.toArray(), depositShares); + + Withdrawal[][] memory userWithdrawals = new Withdrawal[][](users.length); + + for (uint256 i = 0; i < users.length; i++) { + (, uint256[] memory depositShares) = strategyManager.getDeposits(address(users[i])); + userWithdrawals[i] = users[i].queueWithdrawals(strategy.toArray(), depositShares); + } + + + rollForward({blocks: DELEGATION_MANAGER_MIN_WITHDRAWAL_DELAY_BLOCKS + 1}); + + attacker.completeWithdrawalsAsTokens(withdrawals); + for (uint256 i = 0; i < users.length; i++) { + users[i].completeWithdrawalsAsTokens(userWithdrawals[i]); + } + + + _print("withdraw"); + + // Improvements + // check for operator shares greater than 1 + // remove token related checks Line 163 and replace with operator shares check + + if(delegationManager.getOperatorShares(address(attacker), strategy.toArray())[0] > 1) { + revert("Operator shares greater than 1!"); + } + +/* if (token.balanceOf(address(attacker)) > initTokenBalance) { + uint64 diff = uint64(token.balanceOf(address(attacker))) - initTokenBalance; + console.log("EXCESS of tokens: %d", diff); + revert("Rounding error exploit found!"); + } else if (token.balanceOf(address(attacker)) < initTokenBalance) { + uint64 diff = uint64(initTokenBalance - token.balanceOf(address(attacker))); + console.log("DEFICIT of tokens: %d", diff); + assertLe(diff, delegationManager.getOperatorShares(address(attacker), strategy.toArray())[0], "Tokens lost!"); + } */ + } + + // TODO - another way to mess with rounding/precision loss is to manipulate DSF + function _magnitudeManipulation(uint64 wadToSlash) internal { + // Allocate all magnitude to operator set. + attacker.modifyAllocations(AllocateParams({ + operatorSet: mOpSet, + strategies: strategy.toArray(), + newMagnitudes: WAD.toArrayU64() + })); + + // TODO: print "newMagnitudes" + + _print("allocate"); + + // Slash operator to arbitrary mag. + badAVS.slashOperator(SlashingParams({ + operator: address(attacker), + operatorSetId: mOpSet.id, + strategies: strategy.toArray(), + wadsToSlash: uint(wadToSlash).toArrayU256(), + description: "manipulation!" + })); + + // TODO: print "wadsToSlash" + + _print("slash"); + + // Deallocate magnitude from operator set. + attacker.modifyAllocations(AllocateParams({ + operatorSet: mOpSet, + strategies: strategy.toArray(), + newMagnitudes: 0.toArrayU64() + })); + + rollForward({blocks: DEALLOCATION_DELAY + 1}); + + _print("deallocate"); + } + + function _deposit() internal { + // Allocate all remaining magnitude to redistributable opset. + uint64 allocatableMagnitude = allocationManager.getAllocatableMagnitude(address(attacker), strategy); + attacker.modifyAllocations(AllocateParams({ + operatorSet: rOpSet, + strategies: strategy.toArray(), + newMagnitudes: (allocatableMagnitude).toArrayU64() + })); + + // Deposit all attacker assets into Eigenlayer. + attacker.depositIntoEigenlayer(strategy.toArray(), token.balanceOf(address(attacker)).toArrayU256()); + // Deposit all users into Eigenlayer + for (uint256 i = 0; i < users.length; i++) { + users[i].depositIntoEigenlayer(strategy.toArray(), token.balanceOf(address(users[i])).toArrayU256()); + } + + _print("deposit"); + } + + function _slash(uint64 wadToSlash) internal { + // Perform final slash on redistributable opset and check for profit. Slash all operator magnitude. + badAVS.slashOperator(SlashingParams({ + operator: address(attacker), + operatorSetId: rOpSet.id, + strategies: strategy.toArray(), + wadsToSlash: uint(wadToSlash).toArrayU256(), + description: "final slash" + })); + + _print("slash"); + } + + function _release() internal { + // Roll forward past the escrow delay. + rollForward({blocks: slashEscrowFactory.getGlobalEscrowDelay() + 1}); + + // Release funds. + for (uint32 i = 1; i <= numSlashes; i++) { + vm.prank(address(attacker)); + slashEscrowFactory.releaseSlashEscrow(rOpSet, i); + } + + // Release final escrow. + vm.prank(address(attacker)); + slashEscrowFactory.releaseSlashEscrow(rOpSet, uint256(numSlashes) + 1); + + _print("release"); + } + + function _print(string memory phaseName) internal { + address a = address(attacker); + + console.log(""); + console.log("===Attacker Info: %s phase===".cyan(), phaseName); + + { + console.log("\nRaw Assets:".magenta()); + console.log(" - token: %s", token.symbol()); + console.log(" - held balance: %d", token.balanceOf(a)); + // TODO - amt deposited, possibly keep track of this separately? + } + + { + console.log("\nShares:".magenta()); + + (uint[] memory withdrawableArr, uint[] memory depositArr) + = delegationManager.getWithdrawableShares(a, strategy.toArray()); + uint withdrawableShares = withdrawableArr.length == 0 ? 0 : withdrawableArr[0]; + uint depositShares = depositArr.length == 0 ? 0 : depositArr[0]; + console.log(" - deposit shares: %d", depositShares); + console.log(" - withdrawable shares: %d", withdrawableShares); + console.log(" - operator shares: %d", delegationManager.operatorShares(a, strategy)); + } + + { + console.log("\nScaling:".magenta()); + + Allocation memory mAlloc = allocationManager.getAllocation(a, mOpSet, strategy); + Allocation memory rAlloc = allocationManager.getAllocation(a, rOpSet, strategy); + + console.log(" - Init Mag: %d", WAD); + console.log( + " - Max Mag: %d\n -- Total Allocated: %d\n -- Total Available: %d", + allocationManager.getMaxMagnitude(a, strategy), + allocationManager.getEncumberedMagnitude(a, strategy), + allocationManager.getAllocatableMagnitude(a, strategy) + ); + console.log(" - Allocated to mOpSet: %d", mAlloc.currentMagnitude); + console.log(" - Allocated to rOpSet: %d", rAlloc.currentMagnitude); + console.log(" - DSF: %d", delegationManager.depositScalingFactor(a, strategy)); + } + + console.log("\n ===\n".cyan()); + } +} \ No newline at end of file From 076b6a5f4d17e39add944b1cbedaf9e786b162ba Mon Sep 17 00:00:00 2001 From: xyz <2523269+antojoseph@users.noreply.github.com> Date: Wed, 18 Jun 2025 08:20:56 +0000 Subject: [PATCH 15/16] test: updated test that creates unbacked operator shares and logs to a file for longer fuzzing campaigns. --- .../RoundingOperatorShareswithNoBacking.t.sol | 171 ++++++++---------- 1 file changed, 77 insertions(+), 94 deletions(-) diff --git a/src/test/integration/tests/RoundingOperatorShareswithNoBacking.t.sol b/src/test/integration/tests/RoundingOperatorShareswithNoBacking.t.sol index 8de8ab1cbc..9c2d94dc4b 100644 --- a/src/test/integration/tests/RoundingOperatorShareswithNoBacking.t.sol +++ b/src/test/integration/tests/RoundingOperatorShareswithNoBacking.t.sol @@ -7,6 +7,8 @@ contract Integration_Rounding is IntegrationCheckUtils { using ArrayLib for *; using StdStyle for *; + string logFilename; // Store filename + bool headerWritten = false; // Add flag to write header only once User attacker; // Number of users to create User[4] users; @@ -23,6 +25,10 @@ contract Integration_Rounding is IntegrationCheckUtils { function _init() internal override { _configAssetTypes(HOLDS_LST); + // Generate unique filename but don't write header in _init + uint256 uniqueId = uint256(keccak256(abi.encodePacked(block.timestamp, block.difficulty, gasleft()))) % 1000000; + logFilename = string(abi.encodePacked("./fuzz_results_", vm.toString(uniqueId), ".csv")); + // Removed header writing from here attacker = new User("Attacker"); // Attacker serves as both operator and staker badAVS = new AVS("BadAVS"); // AVS is also attacker-controlled @@ -58,56 +64,19 @@ contract Integration_Rounding is IntegrationCheckUtils { } // TODO: consider incremental manual fuzzing from 1 up to WAD - 1 - function test_rounding_allMagsSlashed( + // Removed test that test's rounding with full mags slashed - all interesting results come from partial mags + function test_rounding_partialMagsSlashed( uint64 initialWadToSlash, uint64 _initTokenBalance ) public rand(0) { vm.pauseGasMetering(); - // Don't slash 100% as we will do multiple slashes - initialWadToSlash = uint64(bound(initialWadToSlash, 1, WAD - 1)); - // Ensure attacker has at least one token - initTokenBalance = _initTokenBalance > 0 ? _initTokenBalance : _initTokenBalance + 1; - deal(address(token), address(attacker), initTokenBalance); - // Use modifyAllocation+slashOperator to arbitrarily set operator max magnitude - _magnitudeManipulation(initialWadToSlash); - _deposit(); - - // Perform slashes to gradually whittle down operator magnitude, as well as produce slash escrows. - // Since we're doing multiple slashes, never slash 100%. - for (uint16 i = 0; i < numSlashes; i++) { - uint64 wadToSlash = uint64(cheats.randomUint(1, WAD - 1)); - _slash(wadToSlash); + // Write header only on first fuzz run + if (!headerWritten) { + vm.writeLine(logFilename, "initial_slash,init_balance,final_slash,operator_shares,status"); + headerWritten = true; } - // Perform final 100% slash to extract any remaining tokens. - _slash(WAD); - - // Release all escrows to the redistributionRecipient (attacker). - _release(); - - // Check for any surplus value extracted by attacker. If found, we've proven the existence of the exploit. - // Unchecked to avoid overflow reverting. Safe because token balances are bounded by uint64. - // Negative diff means attacker lost money, positive diff means attacker gained money. - int diff; - unchecked { - diff = int(token.balanceOf(address(attacker)) - initTokenBalance); - } - console.log("Difference in tokens: %d", diff); - if (diff > 0) { - revert("Rounding error exploit found!"); - } - - if (diff < -1) { - revert("Tokens lost!"); - } - } - - function test_rounding_partialMagsSlashed( - uint64 initialWadToSlash, - uint64 _initTokenBalance - ) public rand(0) { - vm.pauseGasMetering(); // Don't slash 100% as we will do multiple slashes initialWadToSlash = uint64(bound(initialWadToSlash, 1, WAD - 1)); // Ensure attacker has at least one token @@ -116,58 +85,41 @@ contract Integration_Rounding is IntegrationCheckUtils { // Use modifyAllocation+slashOperator to arbitrarily set operator max magnitude _magnitudeManipulation(initialWadToSlash); - _deposit(); + _deposit(initTokenBalance); // Perform slashes to gradually whittle down operator magnitude, as well as produce slash escrows. // Since we're doing multiple slashes, never slash 100%. //for (uint16 i = 0; i < numSlashes; i++) { // uint64 wadToSlash = uint64(cheats.randomUint(1, WAD - 1)); - // _slash(wadToSlash); //} - - // Release all escrows to the redistributionRecipient (attacker). - //_release(); - - // Withdraw all attacker deposits. - (, uint256[] memory depositShares) = strategyManager.getDeposits(address(attacker)); - - Withdrawal[] memory withdrawals = attacker.queueWithdrawals(strategy.toArray(), depositShares); - - Withdrawal[][] memory userWithdrawals = new Withdrawal[][](users.length); - - for (uint256 i = 0; i < users.length; i++) { - (, uint256[] memory depositShares) = strategyManager.getDeposits(address(users[i])); - userWithdrawals[i] = users[i].queueWithdrawals(strategy.toArray(), depositShares); - } + uint64 wadToSlash = uint64(cheats.randomUint(1, WAD - 1)); + _slash(wadToSlash); - rollForward({blocks: DELEGATION_MANAGER_MIN_WITHDRAWAL_DELAY_BLOCKS + 1}); - - attacker.completeWithdrawalsAsTokens(withdrawals); - for (uint256 i = 0; i < users.length; i++) { - users[i].completeWithdrawalsAsTokens(userWithdrawals[i]); - } + // Release all slashing escrows to all users and attacker. + _release(); + // Withdraw all user and attacker deposits after withdrawal delay + _withdraw(); + uint256 finalOperatorShares = delegationManager.getOperatorShares(address(attacker), strategy.toArray())[0]; + // Log ONLY the key metrics for each run (one line per test) + string memory runData = string(abi.encodePacked( + vm.toString(initialWadToSlash), ",", + vm.toString(initTokenBalance), ",", + vm.toString(wadToSlash), ",", + vm.toString(finalOperatorShares), ",", + finalOperatorShares > 20 ? "EXPLOIT" : "CLEAN" + )); + vm.writeLine(logFilename, runData); - _print("withdraw"); - - // Improvements - // check for operator shares greater than 1 - // remove token related checks Line 163 and replace with operator shares check - - if(delegationManager.getOperatorShares(address(attacker), strategy.toArray())[0] > 1) { - revert("Operator shares greater than 1!"); + // Only console.log for exploits (will show in terminal) + if(finalOperatorShares > 20) { + console.log("EXPLOIT FOUND!"); + console.log("Params: initial_slash=%d, init_balance=%d, final_slash=%d", + initialWadToSlash, initTokenBalance, wadToSlash); + console.log("Final operator shares: %d", finalOperatorShares); + revert("Operator shares greater than 20!"); } - -/* if (token.balanceOf(address(attacker)) > initTokenBalance) { - uint64 diff = uint64(token.balanceOf(address(attacker))) - initTokenBalance; - console.log("EXCESS of tokens: %d", diff); - revert("Rounding error exploit found!"); - } else if (token.balanceOf(address(attacker)) < initTokenBalance) { - uint64 diff = uint64(initTokenBalance - token.balanceOf(address(attacker))); - console.log("DEFICIT of tokens: %d", diff); - assertLe(diff, delegationManager.getOperatorShares(address(attacker), strategy.toArray())[0], "Tokens lost!"); - } */ } // TODO - another way to mess with rounding/precision loss is to manipulate DSF @@ -208,7 +160,7 @@ contract Integration_Rounding is IntegrationCheckUtils { _print("deallocate"); } - function _deposit() internal { + function _deposit(uint64 initTokenBalance) internal { // Allocate all remaining magnitude to redistributable opset. uint64 allocatableMagnitude = allocationManager.getAllocatableMagnitude(address(attacker), strategy); attacker.modifyAllocations(AllocateParams({ @@ -217,6 +169,15 @@ contract Integration_Rounding is IntegrationCheckUtils { newMagnitudes: (allocatableMagnitude).toArrayU64() })); + // case where we randomly add more tokens to the attacker wallet, + // its a boolean that we randomly set to true + bool addMoreTokens = cheats.randomBool(); + if(addMoreTokens) { + deal(address(token), address(attacker),initTokenBalance); + } + + + // Deposit all attacker assets into Eigenlayer. attacker.depositIntoEigenlayer(strategy.toArray(), token.balanceOf(address(attacker)).toArrayU256()); // Deposit all users into Eigenlayer @@ -239,24 +200,46 @@ contract Integration_Rounding is IntegrationCheckUtils { _print("slash"); } - + // release slashed funds from slashing escrow function _release() internal { // Roll forward past the escrow delay. rollForward({blocks: slashEscrowFactory.getGlobalEscrowDelay() + 1}); + // Release funds. + vm.prank(address(attacker)); + slashEscrowFactory.releaseSlashEscrow(mOpSet, 1); + // Release funds. - for (uint32 i = 1; i <= numSlashes; i++) { - vm.prank(address(attacker)); - slashEscrowFactory.releaseSlashEscrow(rOpSet, i); - } - - // Release final escrow. vm.prank(address(attacker)); - slashEscrowFactory.releaseSlashEscrow(rOpSet, uint256(numSlashes) + 1); - + slashEscrowFactory.releaseSlashEscrow(rOpSet, 1); + _print("release"); } + function _withdraw() internal { + + (, uint256[] memory depositShares) = strategyManager.getDeposits(address(attacker)); + + Withdrawal[] memory withdrawals = attacker.queueWithdrawals(strategy.toArray(), depositShares); + + Withdrawal[][] memory userWithdrawals = new Withdrawal[][](users.length); + + for (uint256 i = 0; i < users.length; i++) { + (, uint256[] memory depositShares) = strategyManager.getDeposits(address(users[i])); + userWithdrawals[i] = users[i].queueWithdrawals(strategy.toArray(), depositShares); + } + + rollForward({blocks: DELEGATION_MANAGER_MIN_WITHDRAWAL_DELAY_BLOCKS + 1}); + + attacker.completeWithdrawalsAsTokens(withdrawals); + for (uint256 i = 0; i < users.length; i++) { + users[i].completeWithdrawalsAsTokens(userWithdrawals[i]); + } + + + _print("withdraw"); + + } function _print(string memory phaseName) internal { address a = address(attacker); From 8a4d0239b5ae82d60bf3af952935fcce33907ad5 Mon Sep 17 00:00:00 2001 From: xyz <2523269+antojoseph@users.noreply.github.com> Date: Mon, 23 Jun 2025 00:34:57 +0000 Subject: [PATCH 16/16] test: added csv logging, specify logfile via command line --- src/test/integration/tests/Rounding.t.sol | 251 ++++++++++++++++------ 1 file changed, 188 insertions(+), 63 deletions(-) diff --git a/src/test/integration/tests/Rounding.t.sol b/src/test/integration/tests/Rounding.t.sol index 18e0106084..d1557079a3 100644 --- a/src/test/integration/tests/Rounding.t.sol +++ b/src/test/integration/tests/Rounding.t.sol @@ -18,6 +18,25 @@ contract Integration_Rounding is IntegrationCheckUtils { OperatorSet rOpSet; // Redistributable opset used to exploit precision loss and trigger redistribution uint16 numSlashes = 100; // TODO: transform into envvar + + // CSV file tracking + string private csvPath; + uint256 private testRunId; + bool private csvInitialized; + + // Struct to hold CSV data to avoid stack too deep + struct CSVData { + uint256 tokenBalance; + uint256 depositShares; + uint256 withdrawableShares; + uint256 operatorShares; + uint256 maxMag; + uint256 totalAllocated; + uint256 totalAvailable; + uint256 allocatedMOpSet; + uint256 allocatedROpSet; + uint256 dsf; + } function _init() internal override { _configAssetTypes(HOLDS_LST); @@ -44,7 +63,30 @@ contract Integration_Rounding is IntegrationCheckUtils { attacker.registerForOperatorSet(mOpSet); attacker.registerForOperatorSet(rOpSet); - _print("setup"); + // Initialize CSV tracking + _initializeCSV(); + + _writePhaseData("setup", 0, 0); + } + + function _initializeCSV() private { + // Check if CSV filename is provided via environment variable + try vm.envString("CSV_FILENAME") returns (string memory customFilename) { + csvPath = customFilename; + } catch { + // Generate unique filename with timestamp and random seed + testRunId = uint256(keccak256(abi.encodePacked(block.timestamp, block.prevrandao))); + csvPath = string(abi.encodePacked( + "rounding_test_results_", + vm.toString(testRunId % 1000000), // Use last 6 digits for shorter filename + ".csv" + )); + } + + // Write CSV headers + string memory headers = "test_name,phase,initial_max_mag,init_token_balance,token_balance,deposit_shares,withdrawable_shares,operator_shares,init_mag,max_mag,total_allocated,total_available,allocated_mOpSet,allocated_rOpSet,dsf,slash_count,precision_loss,test_result"; + vm.writeLine(csvPath, headers); + csvInitialized = true; } /** @@ -93,7 +135,10 @@ contract Integration_Rounding is IntegrationCheckUtils { // Check for precision loss. // Note: No precision loss expected after all magnitude is slashed. - checkForPrecisionLoss(0); + (bool success, uint256 actualLoss) = _checkForPrecisionLoss(0); + + // Write final result to CSV + _writeTestResult("test_rounding_allMagsSlashed", "final", _initialMaxMag, actualLoss, success ? "PASS" : "FAIL"); } /** @@ -137,7 +182,10 @@ contract Integration_Rounding is IntegrationCheckUtils { // Max precision loss for this test is observed to correspond to residual operator shares. // TODO: Explore root cause of this precision loss. uint operatorShares = delegationManager.getOperatorShares(address(attacker), strategy.toArray())[0]; - checkForPrecisionLoss(operatorShares); + (bool success, uint256 actualLoss) = _checkForPrecisionLoss(operatorShares); + + // Write final result to CSV + _writeTestResult("test_rounding_partialMagsSlashed", "final", _initialMaxMag, actualLoss, success ? "PASS" : "FAIL"); } // TODO: consider parameterizing "honest" stake to see how strategy exchange rate is affected @@ -193,7 +241,10 @@ contract Integration_Rounding is IntegrationCheckUtils { _release(); // Note: In this test case, the attacker burns funds to attempt to manipulate the strategy's exchange rate. As such, severe token loss is expected. - checkForPrecisionLoss(initTokenBalance); + (bool success, uint256 actualLoss) = _checkForPrecisionLoss(initTokenBalance); + + // Write final result to CSV + _writeTestResult("test_rounding_strategySharesManipulation_allMagsSlashed", "final", _initialMaxMag, actualLoss, success ? "PASS" : "FAIL"); } /** @@ -248,7 +299,10 @@ contract Integration_Rounding is IntegrationCheckUtils { _withdraw(); // Note: In this test case, the attacker burns funds to attempt to manipulate the strategy's exchange rate. As such, severe token loss is expected. - checkForPrecisionLoss(initTokenBalance); + (bool success, uint256 actualLoss) = _checkForPrecisionLoss(initTokenBalance); + + // Write final result to CSV + _writeTestResult("test_rounding_strategySharesManipulation_partialMagsSlashed", "final", _initialMaxMag, actualLoss, success ? "PASS" : "FAIL"); } /** @@ -260,20 +314,23 @@ contract Integration_Rounding is IntegrationCheckUtils { // @notice Check for any precision loss. // First case means attacker gained money. If found, we've proven the existence of an exploit. // Second case means attacker lost money. This demonstrates precision loss, with maxLoss as the upper bound. - // @dev Reverts if attacker has gained _any_ tokens, or if token loss is greater than maxLoss. - function checkForPrecisionLoss(uint256 maxLoss) internal { + // @dev Returns (success, actualLoss) where success indicates test passed + function _checkForPrecisionLoss(uint256 maxLoss) internal returns (bool success, uint256 actualLoss) { if (token.balanceOf(address(attacker)) > initTokenBalance) { uint64 diff = uint64(token.balanceOf(address(attacker))) - initTokenBalance; - console.log("EXCESS of tokens: %d", diff); - console.log("maxLoss: %d", maxLoss); + actualLoss = 0; // Actually a gain + success = false; // ANY tokens gained is an exploit. revert("Rounding error exploit found!"); } else if (token.balanceOf(address(attacker)) < initTokenBalance) { uint64 diff = uint64(initTokenBalance - token.balanceOf(address(attacker))); - console.log("DEFICIT of tokens: %d", diff); - console.log("maxLoss: %d", maxLoss); + actualLoss = diff; + success = diff <= maxLoss; // Check against provided tolerance. assertLe(diff, maxLoss, "Tokens lost!"); + } else { + actualLoss = 0; + success = true; } } @@ -286,7 +343,7 @@ contract Integration_Rounding is IntegrationCheckUtils { newMagnitudes: WAD.toArrayU64() })); - _print("allocate"); + _writePhaseData("allocate", _initialMaxMag, 0); uint64 wadToSlash = WAD - _initialMaxMag; // Slash operator to arbitrary mag. @@ -298,9 +355,7 @@ contract Integration_Rounding is IntegrationCheckUtils { description: "manipulation!" })); - // TODO: print "wadsToSlash" - - _print("slash"); + _writePhaseData("slash", _initialMaxMag, 1); // Deallocate magnitude from operator set. attacker.modifyAllocations(AllocateParams({ @@ -311,7 +366,7 @@ contract Integration_Rounding is IntegrationCheckUtils { rollForward({blocks: DEALLOCATION_DELAY + 1}); - _print("deallocate"); + _writePhaseData("deallocate", _initialMaxMag, 0); } function _depositAll() internal { @@ -326,7 +381,7 @@ contract Integration_Rounding is IntegrationCheckUtils { // Deposit all attacker assets into Eigenlayer. attacker.depositIntoEigenlayer(strategy.toArray(), token.balanceOf(address(attacker)).toArrayU256()); - _print("depositAll"); + _writePhaseData("depositAll", 0, 0); } function _slash(uint64 wadToSlash) internal { @@ -339,7 +394,7 @@ contract Integration_Rounding is IntegrationCheckUtils { description: "final slash" })); - _print("slash"); + _writePhaseData("slash", 0, 0); } function _release() internal { @@ -356,7 +411,7 @@ contract Integration_Rounding is IntegrationCheckUtils { vm.prank(address(attacker)); slashEscrowFactory.releaseSlashEscrow(rOpSet, uint256(numSlashes) + 1); - _print("release"); + _writePhaseData("release", 0, 0); } function _withdraw() internal { @@ -368,52 +423,122 @@ contract Integration_Rounding is IntegrationCheckUtils { attacker.completeWithdrawalsAsTokens(withdrawals); - _print("withdraw"); + _writePhaseData("withdraw", 0, 0); } - - function _print(string memory phaseName) internal { + + function _collectCSVData() internal returns (CSVData memory data) { address a = address(attacker); + + // Get share data + (uint[] memory withdrawableArr, uint[] memory depositArr) = delegationManager.getWithdrawableShares(a, strategy.toArray()); + data.withdrawableShares = withdrawableArr.length == 0 ? 0 : withdrawableArr[0]; + data.depositShares = depositArr.length == 0 ? 0 : depositArr[0]; + data.operatorShares = delegationManager.operatorShares(a, strategy); + + // Get allocation data + Allocation memory mAlloc = allocationManager.getAllocation(a, mOpSet, strategy); + Allocation memory rAlloc = allocationManager.getAllocation(a, rOpSet, strategy); + data.allocatedMOpSet = mAlloc.currentMagnitude; + data.allocatedROpSet = rAlloc.currentMagnitude; + + // Get other data + data.tokenBalance = token.balanceOf(a); + data.maxMag = allocationManager.getMaxMagnitude(a, strategy); + data.totalAllocated = allocationManager.getEncumberedMagnitude(a, strategy); + data.totalAvailable = allocationManager.getAllocatableMagnitude(a, strategy); + data.dsf = delegationManager.depositScalingFactor(a, strategy); + } - console.log(""); - console.log("===Attacker Info: %s phase===".cyan(), phaseName); - - { - console.log("\nRaw Assets:".magenta()); - console.log(" - token: %s", token.symbol()); - console.log(" - held balance: %d", token.balanceOf(a)); - // TODO - amt deposited, possibly keep track of this separately? - } - - { - console.log("\nShares:".magenta()); - - (uint[] memory withdrawableArr, uint[] memory depositArr) - = delegationManager.getWithdrawableShares(a, strategy.toArray()); - uint withdrawableShares = withdrawableArr.length == 0 ? 0 : withdrawableArr[0]; - uint depositShares = depositArr.length == 0 ? 0 : depositArr[0]; - console.log(" - deposit shares: %d", depositShares); - console.log(" - withdrawable shares: %d", withdrawableShares); - console.log(" - operator shares: %d", delegationManager.operatorShares(a, strategy)); - } - - { - console.log("\nScaling:".magenta()); - - Allocation memory mAlloc = allocationManager.getAllocation(a, mOpSet, strategy); - Allocation memory rAlloc = allocationManager.getAllocation(a, rOpSet, strategy); - - console.log(" - Init Mag: %d", WAD); - console.log( - " - Max Mag: %d\n -- Total Allocated: %d\n -- Total Available: %d", - allocationManager.getMaxMagnitude(a, strategy), - allocationManager.getEncumberedMagnitude(a, strategy), - allocationManager.getAllocatableMagnitude(a, strategy) - ); - console.log(" - Allocated to mOpSet: %d", mAlloc.currentMagnitude); - console.log(" - Allocated to rOpSet: %d", rAlloc.currentMagnitude); - console.log(" - DSF: %d", delegationManager.depositScalingFactor(a, strategy)); - } - - console.log("\n ===\n".cyan()); + function _writePhaseData(string memory phaseName, uint64 _initialMaxMag, uint256 slashCount) internal { + if (!csvInitialized) return; + + // Collect all data in struct + CSVData memory data = _collectCSVData(); + + // Write row part 1 + string memory row = string(abi.encodePacked( + "current_test,", + phaseName, ",", + vm.toString(_initialMaxMag), ",", + vm.toString(initTokenBalance), "," + )); + + // Write row part 2 + row = string(abi.encodePacked( + row, + vm.toString(data.tokenBalance), ",", + vm.toString(data.depositShares), ",", + vm.toString(data.withdrawableShares), ",", + vm.toString(data.operatorShares), "," + )); + + // Write row part 3 + row = string(abi.encodePacked( + row, + vm.toString(WAD), ",", + vm.toString(data.maxMag), ",", + vm.toString(data.totalAllocated), ",", + vm.toString(data.totalAvailable), "," + )); + + // Write row part 4 + row = string(abi.encodePacked( + row, + vm.toString(data.allocatedMOpSet), ",", + vm.toString(data.allocatedROpSet), ",", + vm.toString(data.dsf), ",", + vm.toString(slashCount), ",", + "0,RUNNING" + )); + + vm.writeLine(csvPath, row); + } + + function _writeTestResult( + string memory testName, + string memory phase, + uint64 _initialMaxMag, + uint256 precisionLoss, + string memory result + ) internal { + if (!csvInitialized) return; + + // Collect all data in struct + CSVData memory data = _collectCSVData(); + + // Write row in parts + string memory row = string(abi.encodePacked( + testName, ",", + phase, ",", + vm.toString(_initialMaxMag), ",", + vm.toString(initTokenBalance), "," + )); + + row = string(abi.encodePacked( + row, + vm.toString(data.tokenBalance), ",", + vm.toString(data.depositShares), ",", + vm.toString(data.withdrawableShares), ",", + vm.toString(data.operatorShares), "," + )); + + row = string(abi.encodePacked( + row, + vm.toString(WAD), ",", + vm.toString(data.maxMag), ",", + vm.toString(data.totalAllocated), ",", + vm.toString(data.totalAvailable), "," + )); + + row = string(abi.encodePacked( + row, + "0,0,", // mOpSet and rOpSet allocations (likely 0 at end) + vm.toString(data.dsf), ",", + vm.toString(numSlashes + 1), ",", + vm.toString(precisionLoss), ",", + result + )); + + vm.writeLine(csvPath, row); } } \ No newline at end of file