From efc4d47878fad42f3b87455bc326eb6a0dded8cf Mon Sep 17 00:00:00 2001 From: Ivan Zhelyazkov Date: Thu, 28 Nov 2024 14:52:40 +0200 Subject: [PATCH 1/7] init batch router contract --- contracts/token/Token.sol | 16 ++- contracts/utility/CarbonBatcher.sol | 165 ++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 contracts/utility/CarbonBatcher.sol diff --git a/contracts/token/Token.sol b/contracts/token/Token.sol index 1569b544..9c16653a 100644 --- a/contracts/token/Token.sol +++ b/contracts/token/Token.sol @@ -36,8 +36,10 @@ using { safeTransfer, safeTransferFrom, safeApprove, + forceApprove, safeIncreaseAllowance, - unsafeTransfer + unsafeTransfer, + toIERC20 } for Token global; /* solhint-disable func-visibility */ @@ -135,6 +137,18 @@ function safeApprove(Token token, address spender, uint256 amount) { toIERC20(token).safeApprove(spender, amount); } +/** + * @dev force approves a specific amount of the native token/ERC20 token from a specific holder + * + * note that the function does not perform any action if the native token is provided + */ +function forceApprove(Token token, address spender, uint256 amount) { + if (isNative(token)) { + return; + } + toIERC20(token).forceApprove(spender, amount); +} + /** * @dev atomically increases the allowance granted to `spender` by the caller. * diff --git a/contracts/utility/CarbonBatcher.sol b/contracts/utility/CarbonBatcher.sol new file mode 100644 index 00000000..6177eac3 --- /dev/null +++ b/contracts/utility/CarbonBatcher.sol @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import { IERC721Receiver } from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; + +import { Upgradeable } from "./Upgradeable.sol"; + +import { ICarbonController } from "../carbon/interfaces/ICarbonController.sol"; +import { IVoucher } from "../voucher/interfaces/IVoucher.sol"; + +import { Order } from "../carbon/Strategies.sol"; + +import { Utils } from "../utility/Utils.sol"; + +import { Token, NATIVE_TOKEN } from "../token/Token.sol"; + +struct StrategyData { + Token[2] tokens; + Order[2] orders; +} + +/** + * @dev Contract to batch create carbon controller strategies + */ +contract CarbonBatcher is Upgradeable, Utils, ReentrancyGuardUpgradeable, IERC721Receiver { + error InsufficientNativeTokenSent(); + + ICarbonController private immutable carbonController; + IVoucher private immutable voucher; + + /** + * @dev triggered when tokens have been withdrawn from the carbon batcher + */ + event FundsWithdrawn(Token indexed token, address indexed caller, address indexed target, uint256 amount); + + constructor( + ICarbonController _carbonController, + IVoucher _voucher + ) validAddress(address(_carbonController)) validAddress(address(_voucher)) { + carbonController = _carbonController; + voucher = _voucher; + } + + /** + * @dev fully initializes the contract and its parents + */ + function initialize() public initializer { + __CarbonBatcher_init(); + } + + // solhint-disable func-name-mixedcase + + /** + * @dev initializes the contract and its parents + */ + function __CarbonBatcher_init() internal onlyInitializing { + __Upgradeable_init(); + __ReentrancyGuard_init(); + } + + /** + * @inheritdoc Upgradeable + */ + function version() public pure virtual override(Upgradeable) returns (uint16) { + return 1; + } + + /** + * @dev creates several new strategies, returns the strategies id's + * + * requirements: + * + * - the caller must have approved the tokens with assigned liquidity in the orders + */ + function batchCreate(StrategyData[] calldata strategies) external payable nonReentrant returns (uint256[] memory) { + uint256[] memory strategyIds = new uint256[](strategies.length); + uint256 txValueLeft = msg.value; + + // main loop - transfer funds from user for strategies, + // create strategies and transfer nfts to user + for (uint256 i = 0; i < strategies.length; i++) { + // get tokens for this strategy + Token[2] memory tokens = strategies[i].tokens; + // if any of the tokens is native, send this value with the create strategy tx + uint256 valueToSend = 0; + + // transfer tokens and approve to carbon controller + for (uint256 j = 0; j < 2; j++) { + Token token = strategies[i].tokens[j]; + uint256 amount = strategies[i].orders[j].y; + if (amount == 0) { + continue; + } + if (token.isNative()) { + if (txValueLeft < amount) { + revert InsufficientNativeTokenSent(); + } + valueToSend = amount; + // subtract the native token left sent with the tx + txValueLeft -= amount; + } + + token.safeTransferFrom(msg.sender, address(this), amount); + _setCarbonAllowance(token, amount); + } + + // create strategy on carbon + strategyIds[i] = carbonController.createStrategy{ value: valueToSend }( + tokens[0], + tokens[1], + strategies[i].orders + ); + // transfer nft to user + voucher.safeTransferFrom(address(this), msg.sender, strategyIds[i], ""); + } + // refund user any remaining native token + if (txValueLeft > 0) { + // safe due to nonReentrant modifier (forwards all available gas) + NATIVE_TOKEN.unsafeTransfer(msg.sender, txValueLeft); + } + + return strategyIds; + } + + /** + * @dev withdraws funds held by the contract and sends them to an account + * + * requirements: + * + * - the caller be admin of the contract + */ + function withdrawFunds( + Token token, + address payable target, + uint256 amount + ) external validAddress(target) nonReentrant onlyAdmin { + if (amount == 0) { + return; + } + + // safe due to nonReentrant modifier (forwards all available gas in case of ETH) + token.unsafeTransfer(target, amount); + + emit FundsWithdrawn({ token: token, caller: msg.sender, target: target, amount: amount }); + } + + function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) { + return IERC721Receiver.onERC721Received.selector; + } + + /** + * @dev set carbon controller allowance to 2 ** 256 - 1 if it's less than the input amount + */ + function _setCarbonAllowance(Token token, uint256 inputAmount) private { + if (token.isNative()) { + return; + } + uint256 allowance = token.toIERC20().allowance(address(this), address(carbonController)); + if (allowance < inputAmount) { + // increase allowance to the max amount if allowance < inputAmount + token.forceApprove(address(carbonController), type(uint256).max); + } + } +} From 207b80d5dabc42a154bca85c1e02c1a3ead938ef Mon Sep 17 00:00:00 2001 From: Ivan Zhelyazkov Date: Wed, 11 Dec 2024 14:16:27 +0200 Subject: [PATCH 2/7] carbon batcher - optimizations --- contracts/utility/CarbonBatcher.sol | 129 ++++++++++++++++++++-------- 1 file changed, 95 insertions(+), 34 deletions(-) diff --git a/contracts/utility/CarbonBatcher.sol b/contracts/utility/CarbonBatcher.sol index 6177eac3..b9d9fbda 100644 --- a/contracts/utility/CarbonBatcher.sol +++ b/contracts/utility/CarbonBatcher.sol @@ -1,19 +1,16 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.19; -import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; import { IERC721Receiver } from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; - -import { Upgradeable } from "./Upgradeable.sol"; +import { Address } from "@openzeppelin/contracts/utils/Address.sol"; import { ICarbonController } from "../carbon/interfaces/ICarbonController.sol"; import { IVoucher } from "../voucher/interfaces/IVoucher.sol"; +import { Upgradeable } from "./Upgradeable.sol"; import { Order } from "../carbon/Strategies.sol"; - import { Utils } from "../utility/Utils.sol"; - -import { Token, NATIVE_TOKEN } from "../token/Token.sol"; +import { Token } from "../token/Token.sol"; struct StrategyData { Token[2] tokens; @@ -23,7 +20,9 @@ struct StrategyData { /** * @dev Contract to batch create carbon controller strategies */ -contract CarbonBatcher is Upgradeable, Utils, ReentrancyGuardUpgradeable, IERC721Receiver { +contract CarbonBatcher is Upgradeable, Utils, IERC721Receiver { + using Address for address payable; + error InsufficientNativeTokenSent(); ICarbonController private immutable carbonController; @@ -40,6 +39,7 @@ contract CarbonBatcher is Upgradeable, Utils, ReentrancyGuardUpgradeable, IERC72 ) validAddress(address(_carbonController)) validAddress(address(_voucher)) { carbonController = _carbonController; voucher = _voucher; + _disableInitializers(); } /** @@ -56,7 +56,6 @@ contract CarbonBatcher is Upgradeable, Utils, ReentrancyGuardUpgradeable, IERC72 */ function __CarbonBatcher_init() internal onlyInitializing { __Upgradeable_init(); - __ReentrancyGuard_init(); } /** @@ -73,36 +72,39 @@ contract CarbonBatcher is Upgradeable, Utils, ReentrancyGuardUpgradeable, IERC72 * * - the caller must have approved the tokens with assigned liquidity in the orders */ - function batchCreate(StrategyData[] calldata strategies) external payable nonReentrant returns (uint256[] memory) { + function batchCreate( + StrategyData[] calldata strategies + ) external payable greaterThanZero(strategies.length) returns (uint256[] memory) { uint256[] memory strategyIds = new uint256[](strategies.length); uint256 txValueLeft = msg.value; - // main loop - transfer funds from user for strategies, + // extract unique tokens and amounts + (Token[] memory uniqueTokens, uint256[] memory amounts) = _extractUniqueTokensAndAmounts(strategies); + // transfer funds from user for strategies + for (uint256 i = 0; i < uniqueTokens.length; i = uncheckedInc(i)) { + Token token = uniqueTokens[i]; + uint256 amount = amounts[i]; + if (token.isNative()) { + if (txValueLeft < amount) { + revert InsufficientNativeTokenSent(); + } + txValueLeft -= amount; + continue; + } + token.safeTransferFrom(msg.sender, address(this), amount); + _setCarbonAllowance(token, amount); + } + // create strategies and transfer nfts to user - for (uint256 i = 0; i < strategies.length; i++) { + for (uint256 i = 0; i < strategies.length; i = uncheckedInc(i)) { // get tokens for this strategy Token[2] memory tokens = strategies[i].tokens; // if any of the tokens is native, send this value with the create strategy tx uint256 valueToSend = 0; - - // transfer tokens and approve to carbon controller - for (uint256 j = 0; j < 2; j++) { - Token token = strategies[i].tokens[j]; - uint256 amount = strategies[i].orders[j].y; - if (amount == 0) { - continue; - } - if (token.isNative()) { - if (txValueLeft < amount) { - revert InsufficientNativeTokenSent(); - } - valueToSend = amount; - // subtract the native token left sent with the tx - txValueLeft -= amount; - } - - token.safeTransferFrom(msg.sender, address(this), amount); - _setCarbonAllowance(token, amount); + if (tokens[0].isNative()) { + valueToSend = strategies[i].orders[0].y; + } else if (tokens[1].isNative()) { + valueToSend = strategies[i].orders[1].y; } // create strategy on carbon @@ -116,8 +118,8 @@ contract CarbonBatcher is Upgradeable, Utils, ReentrancyGuardUpgradeable, IERC72 } // refund user any remaining native token if (txValueLeft > 0) { - // safe due to nonReentrant modifier (forwards all available gas) - NATIVE_TOKEN.unsafeTransfer(msg.sender, txValueLeft); + // forwards all available gas + payable(msg.sender).sendValue(txValueLeft); } return strategyIds; @@ -134,12 +136,12 @@ contract CarbonBatcher is Upgradeable, Utils, ReentrancyGuardUpgradeable, IERC72 Token token, address payable target, uint256 amount - ) external validAddress(target) nonReentrant onlyAdmin { + ) external validAddress(target) onlyAdmin { if (amount == 0) { return; } - // safe due to nonReentrant modifier (forwards all available gas in case of ETH) + // forwards all available gas in case of ETH token.unsafeTransfer(target, amount); emit FundsWithdrawn({ token: token, caller: msg.sender, target: target, amount: amount }); @@ -149,6 +151,59 @@ contract CarbonBatcher is Upgradeable, Utils, ReentrancyGuardUpgradeable, IERC72 return IERC721Receiver.onERC721Received.selector; } + /** + * @dev extracts unique tokens and amounts for each token from the strategy data + */ + function _extractUniqueTokensAndAmounts( + StrategyData[] calldata strategies + ) private pure returns (Token[] memory uniqueTokens, uint256[] memory amounts) { + // Maximum possible unique tokens + Token[] memory tempUniqueTokens = new Token[](strategies.length * 2); + uint256[] memory tempAmounts = new uint256[](strategies.length * 2); + uint256 uniqueCount = 0; + + for (uint256 i = 0; i < strategies.length; i = uncheckedInc(i)) { + StrategyData calldata strategy = strategies[i]; + + for (uint256 j = 0; j < 2; j = uncheckedInc(j)) { + Token token = strategy.tokens[j]; + uint128 amount = strategy.orders[j].y; + + // Check if the token is already in the uniqueTokens array + uint256 index = _findInArray(token, tempUniqueTokens, uniqueCount); + if (index == type(uint256).max) { + // If not found, add to the array + tempUniqueTokens[uniqueCount] = token; + tempAmounts[uniqueCount] = amount; + uniqueCount++; + } else { + // If found, aggregate the amount + tempAmounts[index] += amount; + } + } + } + + // Resize the arrays to fit the unique count + uniqueTokens = new Token[](uniqueCount); + amounts = new uint256[](uniqueCount); + + for (uint256 i = 0; i < uniqueCount; i = uncheckedInc(i)) { + uniqueTokens[i] = tempUniqueTokens[i]; + amounts[i] = tempAmounts[i]; + } + + return (uniqueTokens, amounts); + } + + function _findInArray(Token element, Token[] memory array, uint256 arrayLength) private pure returns (uint256) { + for (uint256 i = 0; i < arrayLength; i = uncheckedInc(i)) { + if (array[i] == element) { + return i; + } + } + return type(uint256).max; // Return max value if not found + } + /** * @dev set carbon controller allowance to 2 ** 256 - 1 if it's less than the input amount */ @@ -162,4 +217,10 @@ contract CarbonBatcher is Upgradeable, Utils, ReentrancyGuardUpgradeable, IERC72 token.forceApprove(address(carbonController), type(uint256).max); } } + + function uncheckedInc(uint256 i) private pure returns (uint256 j) { + unchecked { + j = i + 1; + } + } } From 3cef9a1ebe97efd77265923a34cda13de301d338 Mon Sep 17 00:00:00 2001 From: Ivan Zhelyazkov Date: Wed, 18 Dec 2024 12:05:10 +0200 Subject: [PATCH 3/7] Carbon Batcher - optimizations --- contracts/utility/CarbonBatcher.sol | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/contracts/utility/CarbonBatcher.sol b/contracts/utility/CarbonBatcher.sol index b9d9fbda..60cbef4a 100644 --- a/contracts/utility/CarbonBatcher.sol +++ b/contracts/utility/CarbonBatcher.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.19; +import { ReentrancyGuard } from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; import { IERC721Receiver } from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; import { Address } from "@openzeppelin/contracts/utils/Address.sol"; @@ -20,7 +21,7 @@ struct StrategyData { /** * @dev Contract to batch create carbon controller strategies */ -contract CarbonBatcher is Upgradeable, Utils, IERC721Receiver { +contract CarbonBatcher is Upgradeable, Utils, ReentrancyGuard, IERC721Receiver { using Address for address payable; error InsufficientNativeTokenSent(); @@ -74,7 +75,7 @@ contract CarbonBatcher is Upgradeable, Utils, IERC721Receiver { */ function batchCreate( StrategyData[] calldata strategies - ) external payable greaterThanZero(strategies.length) returns (uint256[] memory) { + ) external payable greaterThanZero(strategies.length) nonReentrant returns (uint256[] memory) { uint256[] memory strategyIds = new uint256[](strategies.length); uint256 txValueLeft = msg.value; @@ -99,20 +100,17 @@ contract CarbonBatcher is Upgradeable, Utils, IERC721Receiver { for (uint256 i = 0; i < strategies.length; i = uncheckedInc(i)) { // get tokens for this strategy Token[2] memory tokens = strategies[i].tokens; + Order[2] memory orders = strategies[i].orders; // if any of the tokens is native, send this value with the create strategy tx uint256 valueToSend = 0; if (tokens[0].isNative()) { - valueToSend = strategies[i].orders[0].y; + valueToSend = orders[0].y; } else if (tokens[1].isNative()) { - valueToSend = strategies[i].orders[1].y; + valueToSend = orders[1].y; } // create strategy on carbon - strategyIds[i] = carbonController.createStrategy{ value: valueToSend }( - tokens[0], - tokens[1], - strategies[i].orders - ); + strategyIds[i] = carbonController.createStrategy{ value: valueToSend }(tokens[0], tokens[1], orders); // transfer nft to user voucher.safeTransferFrom(address(this), msg.sender, strategyIds[i], ""); } @@ -130,13 +128,13 @@ contract CarbonBatcher is Upgradeable, Utils, IERC721Receiver { * * requirements: * - * - the caller be admin of the contract + * - the caller is admin of the contract */ function withdrawFunds( Token token, address payable target, uint256 amount - ) external validAddress(target) onlyAdmin { + ) external validAddress(target) onlyAdmin nonReentrant { if (amount == 0) { return; } From d582f10655a0ad0f78895edce955b0340f9d79e0 Mon Sep 17 00:00:00 2001 From: Ivan Zhelyazkov Date: Wed, 18 Dec 2024 12:05:40 +0200 Subject: [PATCH 4/7] Carbon Batcher - tests --- test/forge/CarbonBatcher.t.sol | 331 +++++++++++++++++++++++++++++++++ test/forge/TestFixture.t.sol | 24 +++ 2 files changed, 355 insertions(+) create mode 100644 test/forge/CarbonBatcher.t.sol diff --git a/test/forge/CarbonBatcher.t.sol b/test/forge/CarbonBatcher.t.sol new file mode 100644 index 00000000..2db7c0cf --- /dev/null +++ b/test/forge/CarbonBatcher.t.sol @@ -0,0 +1,331 @@ +// SPDX-License-Identifier: SEE LICENSE IN LICENSE +pragma solidity 0.8.19; + +import { Address } from "@openzeppelin/contracts/utils/Address.sol"; + +import { TestFixture } from "./TestFixture.t.sol"; + +import { Order } from "../../contracts/carbon/Strategies.sol"; + +import { CarbonBatcher, StrategyData } from "../../contracts/utility/CarbonBatcher.sol"; + +import { AccessDenied, InvalidAddress, ZeroValue } from "../../contracts/utility/Utils.sol"; + +import { Token, NATIVE_TOKEN } from "../../contracts/token/Token.sol"; + +contract CarbonBatcherTest is TestFixture { + using Address for address payable; + + /** + * @dev triggered when a strategy is created + */ + event StrategyCreated( + uint256 id, + address indexed owner, + Token indexed token0, + Token indexed token1, + Order order0, + Order order1 + ); + + /** + * @dev Emitted when `value` tokens are moved from one account (`from`) to + * another (`to`). + * + * Note that `value` may be zero. + */ + event Transfer(address indexed from, address indexed to, uint256 value); + + /// @dev function to set up state before tests + function setUp() public virtual { + // Set up tokens and users + systemFixture(); + // Deploy Carbon Controller and Voucher + setupCarbonController(); + // Deploy Carbon Batcher + deployCarbonBatcher(carbonController, voucher); + } + + /** + * @dev construction tests + */ + + function testShouldBeInitializedProperly() public view { + uint256 version = carbonBatcher.version(); + assertEq(version, 1); + } + + /** + * @dev batchCreate tests + */ + + /// @dev test batch create should send strategy NFTs to caller + function testBatchCreateShouldSendStrategyNFTsToCaller() public { + vm.startPrank(user1); + // define strategy data + StrategyData[] memory strategies = new StrategyData[](2); + Order[2] memory orders = [generateTestOrder(), generateTestOrder()]; + Token[2] memory tokens = [token0, token1]; + strategies[0] = StrategyData({ tokens: tokens, orders: orders }); + strategies[1] = StrategyData({ tokens: tokens, orders: orders }); + + // approve batch router + token0.safeApprove(address(carbonBatcher), 1e18); + token1.safeApprove(address(carbonBatcher), 1e18); + + // Create a batch of strategies + uint256[] memory strategyIds = carbonBatcher.batchCreate(strategies); + + // get strategy ids + uint256 strategyId0 = generateStrategyId(1, 1); + uint256 strategyId1 = generateStrategyId(1, 2); + + // Check NFTs have been sent to user1 + uint256[] memory tokenIds = voucher.tokensByOwner(user1, 0, 100); + assertEq(tokenIds.length, 2); + assertEq(tokenIds[0], strategyId0); + assertEq(tokenIds[1], strategyId1); + + assertEq(strategyIds[0], strategyId0); + assertEq(strategyIds[1], strategyId1); + + vm.stopPrank(); + } + + /// @dev test batch create should refund user for unnecessary native token sent + function testBatchCreateETHStrategiesShouldRefundUser() public { + vm.startPrank(user1); + // define strategy data + uint128 liquidity = 10000; + StrategyData[] memory strategies = new StrategyData[](2); + Order[2] memory orders = [generateTestOrder(liquidity), generateTestOrder(liquidity)]; + Token[2] memory tokens = [token0, NATIVE_TOKEN]; + strategies[0] = StrategyData({ tokens: tokens, orders: orders }); + strategies[1] = StrategyData({ tokens: tokens, orders: orders }); + + // approve batch router + token0.safeApprove(address(carbonBatcher), liquidity * 2); + + // send more value than needed + uint256 valueToSend = 1e18; + + uint256 userBalanceBefore = address(user1).balance; + + // Create a batch of strategies + carbonBatcher.batchCreate{ value: valueToSend }(strategies); + + uint256 userBalanceAfter = address(user1).balance; + + // assert user's balance has been refunded + assertEq(userBalanceAfter, userBalanceBefore - (liquidity * 2)); + + vm.stopPrank(); + } + + /// @dev test batch create should return strategy ids + function testBatchCreateShouldReturnStrategyIds() public { + vm.startPrank(user1); + // define strategy data + StrategyData[] memory strategies = new StrategyData[](10); + Order[2] memory orders = [generateTestOrder(), generateTestOrder()]; + Token[2] memory tokens = [token0, token1]; + for (uint256 i = 0; i < 10; i++) { + strategies[i] = StrategyData({ tokens: tokens, orders: orders }); + } + + // approve batch router + token0.safeApprove(address(carbonBatcher), 1e18); + token1.safeApprove(address(carbonBatcher), 1e18); + + // Create a batch of strategies + uint256[] memory strategyIds = carbonBatcher.batchCreate(strategies); + + // get strategy ids + for (uint256 i = 0; i < 10; i++) { + uint256 strategyId = generateStrategyId(1, i + 1); + assertEq(strategyIds[i], strategyId); + } + + vm.stopPrank(); + } + + /// @dev test batch create should emit strategy created events for each strategy created + function testBatchCreateStrategiesShouldEmitStrategyCreatedEvents(uint256 strategyCount) public { + vm.startPrank(user1); + // create 1 to 10 strategies + strategyCount = bound(strategyCount, 1, 10); + // define strategy data + StrategyData[] memory strategies = new StrategyData[](strategyCount); + Order[2] memory orders = [generateTestOrder(), generateTestOrder()]; + Token[2] memory tokens = [token0, token1]; + for (uint256 i = 0; i < strategyCount; ++i) { + strategies[i] = StrategyData({ tokens: tokens, orders: orders }); + } + + // approve batch router + token0.safeApprove(address(carbonBatcher), 1e18); + token1.safeApprove(address(carbonBatcher), 1e18); + + // expect controller strategy created events to be emitted in order + for (uint256 i = 0; i < strategyCount; ++i) { + uint256 strategyId = generateStrategyId(1, i + 1); + vm.expectEmit(); + emit StrategyCreated(strategyId, address(carbonBatcher), token0, token1, orders[0], orders[1]); + } + // Create a batch of strategies + carbonBatcher.batchCreate(strategies); + + vm.stopPrank(); + } + + /// @dev test batch create should transfer funds from user to carbon controller + function testBatchCreateUserShouldTransferFunds(uint128 liquidity0, uint128 liquidity1) public { + liquidity0 = uint128(bound(liquidity0, 1, MAX_SOURCE_AMOUNT)); + liquidity1 = uint128(bound(liquidity1, 1, MAX_SOURCE_AMOUNT)); + vm.startPrank(user1); + // define strategy data + StrategyData[] memory strategies = new StrategyData[](2); + Order[2] memory orders = [generateTestOrder(liquidity0), generateTestOrder(liquidity1)]; + Token[2] memory tokens = [token0, token1]; + strategies[0] = StrategyData({ tokens: tokens, orders: orders }); + strategies[1] = StrategyData({ tokens: tokens, orders: orders }); + + // approve batch router + token0.safeApprove(address(carbonBatcher), liquidity0 * 2); + token1.safeApprove(address(carbonBatcher), liquidity1 * 2); + + uint256 token0BalanceBefore = token0.balanceOf(address(user1)); + uint256 token1BalanceBefore = token1.balanceOf(address(user1)); + + uint256 token0BalanceBeforeCarbon = token0.balanceOf(address(carbonController)); + uint256 token1BalanceBeforeCarbon = token1.balanceOf(address(carbonController)); + + // Create a batch of strategies + carbonBatcher.batchCreate(strategies); + + uint256 token0BalanceAfter = token0.balanceOf(address(user1)); + uint256 token1BalanceAfter = token1.balanceOf(address(user1)); + + uint256 token0BalanceAfterCarbon = token0.balanceOf(address(carbonController)); + uint256 token1BalanceAfterCarbon = token1.balanceOf(address(carbonController)); + + // assert user's balance decreases + assertEq(token0BalanceAfter, token0BalanceBefore - liquidity0 * 2); + assertEq(token1BalanceAfter, token1BalanceBefore - liquidity1 * 2); + + // assert carbon controller's balance increases + assertEq(token0BalanceAfterCarbon, token0BalanceBeforeCarbon + liquidity0 * 2); + assertEq(token1BalanceAfterCarbon, token1BalanceBeforeCarbon + liquidity1 * 2); + + vm.stopPrank(); + } + + /// @dev test batch create should make a single transfer per unique strategy erc-20 token to the carbon batcher + function testBatchCreateShouldMakeASingleTransferPerUniqueStrategyToken() public { + vm.startPrank(user1); + uint128 liquidity = 1000000; + // define strategy data + StrategyData[] memory strategies = new StrategyData[](2); + Order[2] memory orders = [generateTestOrder(liquidity), generateTestOrder(liquidity)]; + Token[2] memory tokens = [token0, token1]; + strategies[0] = StrategyData({ tokens: tokens, orders: orders }); + strategies[1] = StrategyData({ tokens: tokens, orders: orders }); + + // approve batch router + token0.safeApprove(address(carbonBatcher), 1e18); + token1.safeApprove(address(carbonBatcher), 1e18); + + // expect emit of two transfers - one for each unique token + // total strategy amounts are summed up for each token + vm.expectEmit(); + emit Transfer(address(user1), address(carbonBatcher), liquidity * 2); + vm.expectEmit(); + emit Transfer(address(user1), address(carbonBatcher), liquidity * 2); + // Create a batch of strategies + carbonBatcher.batchCreate(strategies); + + vm.stopPrank(); + } + + /// @dev test that batch create reverts if the strategy data is empty + function testBatchCreateShouldRevertIfCreatedWithEmptyData() public { + vm.startPrank(user1); + // define empty strategy data + StrategyData[] memory strategies = new StrategyData[](0); + // Create a batch of strategies + vm.expectRevert(ZeroValue.selector); + carbonBatcher.batchCreate(strategies); + vm.stopPrank(); + } + + /// @dev test that batch create reverts if insufficient native token has been sent with the transaction + function testBatchCreateShouldRevertIfInsufficientETHHasBeenSent() public { + vm.startPrank(user1); + // define strategy data + StrategyData[] memory strategies = new StrategyData[](2); + uint128 liqudity = 1e18; + Order[2] memory orders = [generateTestOrder(liqudity), generateTestOrder(liqudity)]; + Token[2] memory tokens = [token0, NATIVE_TOKEN]; + strategies[0] = StrategyData({ tokens: tokens, orders: orders }); + strategies[1] = StrategyData({ tokens: tokens, orders: orders }); + + // approve batch router + token0.safeApprove(address(carbonBatcher), liqudity * 2); + + // Create a batch of strategies + vm.expectRevert(CarbonBatcher.InsufficientNativeTokenSent.selector); + carbonBatcher.batchCreate{ value: (liqudity * 2) - 1 }(strategies); + + vm.stopPrank(); + } + + /** + * @dev withdrawFunds tests + */ + + /// @dev test should revert when attempting to withdraw funds without the admin role + function testShouldRevertWhenAttemptingToWithdrawFundsWithoutTheAdminRole() public { + vm.prank(user2); + vm.expectRevert(AccessDenied.selector); + carbonBatcher.withdrawFunds(token0, user2, 1000); + } + + /// @dev test should revert when attempting to withdraw funds to an invalid address + function testShouldRevertWhenAttemptingToWithdrawFundsToAnInvalidAddress() public { + vm.prank(admin); + vm.expectRevert(InvalidAddress.selector); + carbonBatcher.withdrawFunds(token0, payable(address(0)), 1000); + } + + /// @dev test admin should be able to withdraw funds + function testAdminShouldBeAbleToWithdrawFunds() public { + vm.prank(user1); + // send funds to carbon batcher + uint256 amount = 1000; + token0.safeTransfer(address(carbonBatcher), amount); + + vm.startPrank(admin); + + uint256 adminBalanceBefore = token0.balanceOf(address(admin)); + + carbonBatcher.withdrawFunds(token0, admin, amount); + + uint256 adminBalanceAfter = token0.balanceOf(address(admin)); + assertEq(adminBalanceAfter, adminBalanceBefore + amount); + vm.stopPrank(); + } + + /// @dev helper function to generate test order + function generateTestOrder() private pure returns (Order memory order) { + return Order({ y: 800000, z: 8000000, A: 736899889, B: 12148001999 }); + } + + /// @dev helper function to generate test order + function generateTestOrder(uint128 liquidity) private pure returns (Order memory order) { + return Order({ y: liquidity, z: liquidity, A: 736899889, B: 12148001999 }); + } + + function generateStrategyId(uint256 pairId, uint256 strategyIndex) private pure returns (uint256) { + return (pairId << 128) | strategyIndex; + } +} diff --git a/test/forge/TestFixture.t.sol b/test/forge/TestFixture.t.sol index d9178b2c..086aa62d 100644 --- a/test/forge/TestFixture.t.sol +++ b/test/forge/TestFixture.t.sol @@ -18,6 +18,7 @@ import { TestVault } from "../../contracts/helpers/TestVault.sol"; import { CarbonVortex } from "../../contracts/vortex/CarbonVortex.sol"; import { CarbonPOL } from "../../contracts/pol/CarbonPOL.sol"; import { TestCarbonController } from "../../contracts/helpers/TestCarbonController.sol"; +import { CarbonBatcher } from "../../contracts/utility/CarbonBatcher.sol"; import { IVoucher } from "../../contracts/voucher/interfaces/IVoucher.sol"; import { ICarbonController } from "../../contracts/carbon/interfaces/ICarbonController.sol"; @@ -42,6 +43,7 @@ contract TestFixture is Test { TestVoucher internal voucher; CarbonPOL internal carbonPOL; CarbonVortex internal carbonVortex; + CarbonBatcher internal carbonBatcher; TestCarbonController internal carbonController; ProxyAdmin internal proxyAdmin; @@ -221,6 +223,28 @@ contract TestFixture is Test { vm.stopPrank(); } + /** + * @dev deploys carbon batch strategies + */ + function deployCarbonBatcher(TestCarbonController _carbonController, TestVoucher _voucher) internal { + // deploy contracts from admin + vm.startPrank(admin); + + // Deploy Carbon Batch Strategies + carbonBatcher = new CarbonBatcher(ICarbonController(address(_carbonController)), IVoucher(address(_voucher))); + + bytes memory initData = abi.encodeWithSelector(carbonBatcher.initialize.selector); + // Deploy Carbon Batcher proxy + address carbonBatcherProxy = address( + new OptimizedTransparentUpgradeableProxy(address(carbonBatcher), payable(address(proxyAdmin)), initData) + ); + + // Set Carbon Batch Strategies address + carbonBatcher = CarbonBatcher(payable(carbonBatcherProxy)); + + vm.stopPrank(); + } + function deployVault() internal returns (address vault) { // deploy contracts from admin vm.prank(admin); From 6510815411b193e02d6887ba9d09d583598f3593 Mon Sep 17 00:00:00 2001 From: Ivan Zhelyazkov Date: Wed, 18 Dec 2024 12:06:28 +0200 Subject: [PATCH 5/7] Carbon Batcher - deployment scripts --- components/Contracts.ts | 2 ++ deploy/scripts/mainnet/0018-CarbonBatcher.ts | 20 ++++++++++++ deploy/tests/mainnet/0018-carbon-batcher.ts | 33 ++++++++++++++++++++ utils/Deploy.ts | 6 ++-- 4 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 deploy/scripts/mainnet/0018-CarbonBatcher.ts create mode 100644 deploy/tests/mainnet/0018-carbon-batcher.ts diff --git a/components/Contracts.ts b/components/Contracts.ts index ad31fcb3..cf0bcb1f 100644 --- a/components/Contracts.ts +++ b/components/Contracts.ts @@ -2,6 +2,7 @@ import { CarbonController__factory, CarbonPOL__factory, CarbonVortex__factory, + CarbonBatcher__factory, ERC20__factory, MockBancorNetworkV3__factory, OptimizedTransparentUpgradeableProxy__factory, @@ -34,6 +35,7 @@ const getContracts = (signer?: Signer) => ({ CarbonController: deployOrAttach('CarbonController', CarbonController__factory, signer), CarbonVortex: deployOrAttach('CarbonVortex', CarbonVortex__factory, signer), CarbonPOL: deployOrAttach('CarbonPOL', CarbonPOL__factory, signer), + CarbonBatcher: deployOrAttach('CarbonBatcher', CarbonBatcher__factory, signer), MockBancorNetworkV3: deployOrAttach('MockBancorNetworkV3', MockBancorNetworkV3__factory, signer), ProxyAdmin: deployOrAttach('ProxyAdmin', ProxyAdmin__factory, signer), Voucher: deployOrAttach('Voucher', Voucher__factory, signer), diff --git a/deploy/scripts/mainnet/0018-CarbonBatcher.ts b/deploy/scripts/mainnet/0018-CarbonBatcher.ts new file mode 100644 index 00000000..1bd8f1d7 --- /dev/null +++ b/deploy/scripts/mainnet/0018-CarbonBatcher.ts @@ -0,0 +1,20 @@ +import { DeployedContracts, deployProxy, InstanceName, setDeploymentMetadata } from '../../../utils/Deploy'; +import { DeployFunction } from 'hardhat-deploy/types'; +import { HardhatRuntimeEnvironment } from 'hardhat/types'; + +const func: DeployFunction = async ({ getNamedAccounts }: HardhatRuntimeEnvironment) => { + const { deployer } = await getNamedAccounts(); + + const carbonController = await DeployedContracts.CarbonController.deployed(); + const voucher = await DeployedContracts.Voucher.deployed(); + + await deployProxy({ + name: InstanceName.CarbonBatcher, + from: deployer, + args: [carbonController.address, voucher.address] + }); + + return true; +}; + +export default setDeploymentMetadata(__filename, func); diff --git a/deploy/tests/mainnet/0018-carbon-batcher.ts b/deploy/tests/mainnet/0018-carbon-batcher.ts new file mode 100644 index 00000000..6c8b9bd9 --- /dev/null +++ b/deploy/tests/mainnet/0018-carbon-batcher.ts @@ -0,0 +1,33 @@ +import { CarbonBatcher, ProxyAdmin } from '../../../components/Contracts'; +import { DeployedContracts, describeDeployment } from '../../../utils/Deploy'; +import { expect } from 'chai'; +import { ethers } from 'hardhat'; + +describeDeployment(__filename, () => { + let proxyAdmin: ProxyAdmin; + let carbonBatcher: CarbonBatcher; + + beforeEach(async () => { + proxyAdmin = await DeployedContracts.ProxyAdmin.deployed(); + carbonBatcher = await DeployedContracts.CarbonBatcher.deployed(); + }); + + it('should deploy and configure the carbon batcher contract', async () => { + expect(await proxyAdmin.getProxyAdmin(carbonBatcher.address)).to.equal(proxyAdmin.address); + expect(await carbonBatcher.version()).to.equal(1); + }); + + it('carbon batcher implementation should be initialized', async () => { + const implementationAddress = await proxyAdmin.getProxyImplementation(carbonBatcher.address); + const carbonBatcherImpl: CarbonBatcher = await ethers.getContractAt('CarbonBatcher', implementationAddress); + // hardcoding gas limit to avoid gas estimation attempts (which get rejected instead of reverted) + const tx = await carbonBatcherImpl.initialize({ gasLimit: 6000000 }); + await expect(tx.wait()).to.be.reverted; + }); + + it('cannot call postUpgrade on carbon batcher', async () => { + // hardcoding gas limit to avoid gas estimation attempts (which get rejected instead of reverted) + const tx = await carbonBatcher.postUpgrade(true, '0x', { gasLimit: 6000000 }); + await expect(tx.wait()).to.be.reverted; + }); +}); diff --git a/utils/Deploy.ts b/utils/Deploy.ts index c0a097b6..97c4aaca 100644 --- a/utils/Deploy.ts +++ b/utils/Deploy.ts @@ -50,7 +50,8 @@ enum NewInstanceName { ProxyAdmin = 'ProxyAdmin', Voucher = 'Voucher', CarbonVortex = 'CarbonVortex', - CarbonPOL = 'CarbonPOL' + CarbonPOL = 'CarbonPOL', + CarbonBatcher = 'CarbonBatcher' } export const LegacyInstanceName = {}; @@ -71,7 +72,8 @@ const DeployedNewContracts = { ProxyAdmin: deployed(InstanceName.ProxyAdmin), Voucher: deployed(InstanceName.Voucher), CarbonVortex: deployed(InstanceName.CarbonVortex), - CarbonPOL: deployed(InstanceName.CarbonPOL) + CarbonPOL: deployed(InstanceName.CarbonPOL), + CarbonBatcher: deployed(InstanceName.CarbonBatcher) }; export const DeployedContracts = { From adebb444665a73c9dacfc10f12a1da854c008254 Mon Sep 17 00:00:00 2001 From: Ivan Zhelyazkov Date: Wed, 18 Dec 2024 12:23:42 +0200 Subject: [PATCH 6/7] Carbon Batcher - minor fixes --- contracts/utility/CarbonBatcher.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/utility/CarbonBatcher.sol b/contracts/utility/CarbonBatcher.sol index 60cbef4a..73925d64 100644 --- a/contracts/utility/CarbonBatcher.sol +++ b/contracts/utility/CarbonBatcher.sol @@ -67,7 +67,7 @@ contract CarbonBatcher is Upgradeable, Utils, ReentrancyGuard, IERC721Receiver { } /** - * @dev creates several new strategies, returns the strategies id's + * @notice creates several new strategies, returns the strategies id's * * requirements: * @@ -124,7 +124,7 @@ contract CarbonBatcher is Upgradeable, Utils, ReentrancyGuard, IERC721Receiver { } /** - * @dev withdraws funds held by the contract and sends them to an account + * @notice withdraws funds held by the contract and sends them to an account * * requirements: * From 62b429ca7233fb28fc4a2e3b3550f166da9cf06b Mon Sep 17 00:00:00 2001 From: Ivan Zhelyazkov Date: Wed, 18 Dec 2024 14:54:00 +0200 Subject: [PATCH 7/7] Carbon Batcher - minor fixes --- utils/Deploy.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/utils/Deploy.ts b/utils/Deploy.ts index 97c4aaca..dd82eb3e 100644 --- a/utils/Deploy.ts +++ b/utils/Deploy.ts @@ -1,5 +1,13 @@ import { ArtifactData } from '../components/ContractBuilder'; -import { CarbonController, CarbonPOL, CarbonVortex, IVersioned, ProxyAdmin, Voucher } from '../components/Contracts'; +import { + CarbonBatcher, + CarbonController, + CarbonPOL, + CarbonVortex, + IVersioned, + ProxyAdmin, + Voucher +} from '../components/Contracts'; import Logger from '../utils/Logger'; import { DeploymentNetwork, ZERO_BYTES } from './Constants'; import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers';