diff --git a/contracts/utils/BuyGDClone.sol b/contracts/utils/BuyGDClone.sol index 08f8659c..5089f0a4 100644 --- a/contracts/utils/BuyGDClone.sol +++ b/contracts/utils/BuyGDClone.sol @@ -3,57 +3,111 @@ pragma solidity >=0.8; import "@openzeppelin/contracts-upgradeable/proxy/ClonesUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts/utils/math/Math.sol"; import "@mean-finance/uniswap-v3-oracle/solidity/interfaces/IStaticOracle.sol"; import "../Interfaces.sol"; +import "../MentoInterfaces.sol"; +/** + * @dev This struct is used to store the Uniswap path for a given token. + */ +struct UniswapPath { + address[] tokens; + uint24[] fees; +} /* * @title BuyGDClone - * @notice This contract allows users to swap Celo or cUSD for GoodDollar (GD) tokens. + * @notice This contract allows users to swap Celo or stable for GoodDollar (GD) tokens. * @dev This contract is a clone of the BuyGD contract, which is used to buy GD tokens on the GoodDollar platform. * @dev This contract uses the SwapRouter contract to perform the swaps. */ -contract BuyGDClone is Initializable { +contract BuyGDCloneV2 is Initializable { error REFUND_FAILED(uint256); error NO_BALANCE(); + error MENTO_NOT_CONFIGURED(); event Bought(address inToken, uint256 inAmount, uint256 outAmount); + event BoughtFromMento(address inToken, uint256 inAmount, uint256 outAmount); + event BoughtFromUniswap(address inToken, uint256 inAmount, uint256 outAmount); ISwapRouter public immutable router; address public constant celo = 0x471EcE3750Da237f93B8E339c536989b8978a438; + address public constant CUSD = 0x765DE816845861e75A25fCA122bb6898B8B1282a; + address public constant USDC = 0xcebA9300f2b948710d2653dD7B07f33A8B32118C; + address public constant GLOUSD = 0x4F604735c1cF31399C6E711D5962b2B3E0225AD3; + + uint24 public constant GD_FEE_TIER = 500; uint32 public immutable twapPeriod; - address public immutable cusd; + address public immutable stable; address public immutable gd; IStaticOracle public immutable oracle; + IQuoterV2 public immutable quoter; + + // Mento reserve configuration (optional) + IBroker public immutable mentoBroker; + address public immutable mentoExchangeProvider; + bytes32 public immutable mentoExchangeId; address public owner; + // Hardcoded default paths for Uniswap swaps + UniswapPath internal cusdPath; + UniswapPath internal celoPath; + + /** Gas cost reserve when refundGas != owner (0.1$) */ + uint256 public constant CUSD_GAS_COSTS = 1e17; + receive() external payable {} constructor( ISwapRouter _router, - address _cusd, + address _stable, address _gd, - IStaticOracle _oracle + IStaticOracle _oracle, + IQuoterV2 _quoter, + IBroker _mentoBroker, + address _mentoExchangeProvider, + bytes32 _mentoExchangeId ) { router = _router; - cusd = _cusd; + stable = _stable; gd = _gd; oracle = _oracle; + quoter = _quoter; twapPeriod = 300; //5 minutes + mentoBroker = _mentoBroker; + mentoExchangeProvider = _mentoExchangeProvider; + mentoExchangeId = _mentoExchangeId; } /** * @notice Initializes the contract with the owner's address. * @param _owner The address of the owner of the contract. */ - function initialize(address _owner) external initializer { + function initialize(address _owner, UniswapPath memory _cusdPath, UniswapPath memory _celoPath) + external + initializer { owner = _owner; + + // Initialize hardcoded default paths + cusdPath = _cusdPath; + celoPath = _celoPath; + } + + function getSwapPath(address[] memory tokens, uint24[] memory fees) public pure returns (bytes memory path) { + require(tokens.length == fees.length + 1 && fees.length > 0, "wrong input parameters"); + path = abi.encodePacked(tokens[0]); + for (uint256 i = 0; i < fees.length; i++) { + path = abi.encodePacked(path, fees[i], tokens[i + 1]); + } + path = abi.encodePacked(path, tokens[tokens.length - 1]); + return path; } /** - * @notice Swaps either Celo or cUSD for GD tokens. + * @notice Swaps either Celo or stable for GD tokens. * @dev If the contract has a balance of Celo, it will swap Celo for GD tokens. - * @dev If the contract has a balance of cUSD, it will swap cUSD for GD tokens. + * @dev If the contract has a balance of stable, it will swap stable for GD tokens. * @param _minAmount The minimum amount of GD tokens to receive from the swap. */ function swap( @@ -67,7 +121,7 @@ contract BuyGDClone is Initializable { emit Bought(celo, balance, bought); return bought; } - balance = ERC20(cusd).balanceOf(address(this)); + balance = ERC20(CUSD).balanceOf(address(this)); if (balance > 0) { bought = swapCusd(_minAmount, refundGas); emit Bought(celo, balance, bought); @@ -78,67 +132,214 @@ contract BuyGDClone is Initializable { } /** - * @notice Swaps Celo for GD tokens. - * @param _minAmount The minimum amount of GD tokens to receive from the swap. + * @notice Swaps Celo for GD tokens using the default path. */ function swapCelo( uint256 _minAmount, address payable refundGas ) public payable returns (uint256 bought) { + return swapCeloWithPath(_minAmount, refundGas, celoPath); + } + + /** + * @notice Swaps Celo for GD tokens using a custom Uniswap path. + */ + function swapCeloWithPath( + uint256 _minAmount, + address payable refundGas, + UniswapPath memory _path + ) public payable returns (uint256 bought) { + return _swapCeloViaUniswap(_minAmount, refundGas, _path); + } + + /** + * @notice Swaps Celo for GD tokens via Uniswap using the given path. + * @dev Uses quoter (same as getExpectedReturnFromUniswapPath) for expected output; no oracle. + */ + function _swapCeloViaUniswap( + uint256 _minAmount, + address payable refundGas, + UniswapPath memory _path + ) internal returns (uint256 bought) { uint256 gasCosts; + uint24[] memory fees = new uint24[](1); + fees[0] = 500; if (refundGas != owner) { - (gasCosts, ) = oracle.quoteAllAvailablePoolsWithTimePeriod( - 1e17, //0.1$ - cusd, - celo, - 60 - ); + (gasCosts, ) = oracle.quoteSpecificFeeTiersWithTimePeriod(1e17, stable, celo, fees, 60); } - uint256 amountIn = address(this).balance - gasCosts; - (uint256 minByTwap, ) = minAmountByTWAP(amountIn, celo, twapPeriod); - _minAmount = _minAmount > minByTwap ? _minAmount : minByTwap; + bytes memory path = getSwapPath(_path.tokens, _path.fees); + uint256 exactQuote = getExpectedReturnFromUniswapPath(amountIn, _path); + uint256 minOut = _minAmount > exactQuote ? _minAmount : exactQuote; ERC20(celo).approve(address(router), amountIn); - ISwapRouter.ExactInputParams memory params = ISwapRouter.ExactInputParams({ - path: abi.encodePacked(celo, uint24(3000), cusd, uint24(10000), gd), - recipient: owner, - amountIn: amountIn, - amountOutMinimum: _minAmount - }); - bought = router.exactInput(params); + bought = router.exactInput( + ISwapRouter.ExactInputParams({ + path: path, + recipient: owner, + amountIn: amountIn, + amountOutMinimum: minOut + }) + ); if (refundGas != owner) { (bool sent, ) = refundGas.call{ value: gasCosts }(""); if (!sent) revert REFUND_FAILED(gasCosts); } + emit BoughtFromUniswap(celo, amountIn, bought); } /** - * @notice Swaps cUSD for GD tokens. - * @param _minAmount The minimum amount of GD tokens to receive from the swap. + * @notice Swaps cUSD for GD tokens, choosing the best route between Uniswap (default path) and Mento. */ function swapCusd( uint256 _minAmount, address refundGas ) public returns (uint256 bought) { - uint256 gasCosts = refundGas != owner ? 1e17 : 0; //fixed 0.1$ - uint256 amountIn = ERC20(cusd).balanceOf(address(this)) - gasCosts; - - (uint256 minByTwap, ) = minAmountByTWAP(amountIn, cusd, twapPeriod); - _minAmount = _minAmount > minByTwap ? _minAmount : minByTwap; - - ERC20(cusd).approve(address(router), amountIn); - ISwapRouter.ExactInputParams memory params = ISwapRouter.ExactInputParams({ - path: abi.encodePacked(cusd, uint24(10000), gd), - recipient: owner, - amountIn: amountIn, - amountOutMinimum: _minAmount - }); - bought = router.exactInput(params); + return swapCusdWithPath(_minAmount, refundGas, cusdPath); + } + + /** + * @notice Swaps cUSD for GD tokens, choosing the best route between Uniswap (custom path) and Mento. + * @param _path The custom Uniswap path to use when Uniswap is chosen. + */ + function swapCusdWithPath( + uint256 _minAmount, + address refundGas, + UniswapPath memory _path + ) public returns (uint256 bought) { + return _swapCusdChooseRoute(_minAmount, refundGas, _path); + } + + /** + @dev internal function to swap cUSD for GD tokens, choosing the best route between Uniswap (custom path) and Mento. + */ + function _swapCusdChooseRoute( + uint256 _minAmount, + address refundGas, + UniswapPath memory _path + ) internal returns (uint256 bought) { + uint256 amountIn = ERC20(CUSD).balanceOf(address(this)) - (refundGas != owner ? CUSD_GAS_COSTS : 0); + require(amountIn > 0, "No cUSD balance"); + + uint256 uniswapExpected = getExpectedReturnFromUniswapPath(amountIn, _path); + uint256 mentoExpected = 0; + bool mentoAvailable = address(mentoBroker) != address(0) && + mentoExchangeProvider != address(0) && + mentoExchangeId != bytes32(0); + if (mentoAvailable) { + mentoExpected = getExpectedReturnFromMento(amountIn); + } + uint256 maxExpected = Math.max(_minAmount, Math.max(uniswapExpected, mentoExpected)); + if (mentoExpected > uniswapExpected) { + bought = _swapCusdFromMento(maxExpected, refundGas); + } else { + bought = _swapCUSDfromUniswap(maxExpected, refundGas, _path); + } + } + + /** + * @notice Swaps cUSD for GD tokens via Uniswap using the given path. + */ + function _swapCUSDfromUniswap( + uint256 _minAmount, + address refundGas, + UniswapPath memory _path + ) internal returns (uint256 bought) { + uint256 gasCosts = refundGas != owner ? CUSD_GAS_COSTS : 0; + uint256 amountIn = ERC20(CUSD).balanceOf(address(this)) - gasCosts; + + ERC20(CUSD).approve(address(router), amountIn); + bytes memory path = getSwapPath(_path.tokens, _path.fees); + bought = router.exactInput( + ISwapRouter.ExactInputParams({ + path: path, + recipient: owner, + amountIn: amountIn, + amountOutMinimum: _minAmount + }) + ); if (refundGas != owner) { - ERC20(cusd).transfer(refundGas, gasCosts); + ERC20(CUSD).transfer(refundGas, gasCosts); } + emit BoughtFromUniswap(CUSD, amountIn, bought); + } + + /** + * @notice Swaps cUSD for G$ tokens using Mento reserve. + * @dev Requires Mento broker, exchange provider, and exchange ID to be configured. + * @param _minAmount The minimum amount of G$ tokens to receive from the swap. + * @param refundGas The address to refund gas costs to (if not owner). + * @return bought The amount of G$ tokens received. + */ + function _swapCusdFromMento( + uint256 _minAmount, + address refundGas + ) internal returns (uint256 bought) { + if (address(mentoBroker) == address(0) || mentoExchangeProvider == address(0) || mentoExchangeId == bytes32(0)) { + revert MENTO_NOT_CONFIGURED(); + } + + uint256 gasCosts = refundGas != owner ? CUSD_GAS_COSTS : 0; + uint256 amountIn = ERC20(CUSD).balanceOf(address(this)) - gasCosts; + require(amountIn > 0, "No cUSD balance"); + + ERC20(CUSD).approve(address(mentoBroker), amountIn); + + // Execute swap through Mento broker + bought = mentoBroker.swapIn( + mentoExchangeProvider, + mentoExchangeId, + CUSD, + gd, + amountIn, + _minAmount + ); + + // Transfer G$ to owner + ERC20(gd).transfer(owner, bought); + + // Refund gas costs if needed + if (refundGas != owner && gasCosts > 0) { + ERC20(CUSD).transfer(refundGas, gasCosts); + } + + emit BoughtFromMento(CUSD, amountIn, bought); + } + + function getExpectedReturnFromUniswapPath( + uint256 amountIn, + UniswapPath memory _path + ) public returns (uint256 expectedReturn) { + require( + _path.tokens.length == _path.fees.length + 1 && _path.fees.length > 0, + "getExpectedReturnFromUniswapPath: invalid path" + ); + bytes memory path = getSwapPath(_path.tokens, _path.fees); + (expectedReturn, , , ) = quoter.quoteExactInput(path, amountIn); + return expectedReturn; + } + + /** + * @notice Calculates the expected return of G$ tokens for a given amount of cUSD using Mento reserve. + * @dev This is a view function that queries the Mento broker for the expected output. + * @param cusdAmount The amount of cUSD to swap. + * @return expectedReturn The expected amount of G$ tokens to receive. + */ + function getExpectedReturnFromMento( + uint256 cusdAmount + ) public view returns (uint256 expectedReturn) { + if (address(mentoBroker) == address(0) || mentoExchangeProvider == address(0) || mentoExchangeId == bytes32(0)) { + revert MENTO_NOT_CONFIGURED(); + } + + expectedReturn = mentoBroker.getAmountOut( + mentoExchangeProvider, + mentoExchangeId, + CUSD, + gd, + cusdAmount + ); } /** @@ -154,21 +355,34 @@ contract BuyGDClone is Initializable { uint32 period ) public view returns (uint256 minTwap, uint256 quote) { uint24[] memory fees = new uint24[](1); - fees[0] = 10000; uint128 toConvert = uint128(baseAmount); if (baseToken == celo) { - (quote, ) = oracle.quoteAllAvailablePoolsWithTimePeriod( + /// Set the fee to 500 since there is no pool with a 100 fee tier + fees[0] = 500; + (quote, ) = oracle.quoteSpecificFeeTiersWithTimePeriod( + toConvert, + baseToken, + stable, + fees, + period + ); + toConvert = uint128(quote); + } else if (baseToken == CUSD && stable != CUSD) { + fees[0] = 100; + (quote, ) = oracle.quoteSpecificFeeTiersWithTimePeriod( toConvert, baseToken, - cusd, + stable, + fees, period ); toConvert = uint128(quote); } + fees[0] = GD_FEE_TIER; (quote, ) = oracle.quoteSpecificFeeTiersWithTimePeriod( toConvert, - cusd, + stable, gd, fees, period @@ -191,7 +405,7 @@ contract BuyGDClone is Initializable { } } -contract DonateGDClone is BuyGDClone { +contract DonateGDClone is BuyGDCloneV2 { error EXEC_FAILED(bytes error); event Donated( @@ -206,10 +420,14 @@ contract DonateGDClone is BuyGDClone { constructor( ISwapRouter _router, - address _cusd, + address _stable, address _gd, - IStaticOracle _oracle - ) BuyGDClone(_router, _cusd, _gd, _oracle) {} + IStaticOracle _oracle, + IQuoterV2 _quoter, + IBroker _mentoBroker, + address _mentoExchangeProvider, + bytes32 _mentoExchangeId + ) BuyGDCloneV2(_router, _stable, _gd, _oracle, _quoter, _mentoBroker, _mentoExchangeProvider, _mentoExchangeId) {} /** * @notice Initializes the contract with the owner's address. @@ -248,13 +466,13 @@ contract DonateGDClone is BuyGDClone { //now exec if (callData.length > 0) { // approve spend of the different possible tokens, before calling the target contract - uint256 cusdBalance = ERC20(cusd).balanceOf(address(this)); + uint256 cusdBalance = ERC20(CUSD).balanceOf(address(this)); uint256 gdBalance = ERC20(gd).balanceOf(address(this)); uint256 celoBalance = address(this).balance; if (cusdBalance > 0) { - ERC20(cusd).approve(address(owner), cusdBalance); - token = cusd; + ERC20(CUSD).approve(address(owner), cusdBalance); + token = CUSD; donated = cusdBalance; } if (gdBalance > 0) { @@ -302,11 +520,14 @@ contract BuyGDCloneFactory { IQuoterV2 public constant quoter = IQuoterV2(0x82825d0554fA07f7FC52Ab63c961F330fdEFa8E8); // celo quoter + address public constant CUSD = 0x765DE816845861e75A25fCA122bb6898B8B1282a; + address public constant celo = 0x471EcE3750Da237f93B8E339c536989b8978a438; + uint24 public constant PERIOD = 600; address public immutable impl; address public immutable donateImpl; address public immutable gd; - address public immutable cusd; + address public immutable stable; IStaticOracle public immutable oracle; ISwapRouter public immutable router; @@ -318,28 +539,71 @@ contract BuyGDCloneFactory { bytes note ); + // Mento configuration (optional) + IBroker public immutable mentoBroker; + address public immutable mentoExchangeProvider; + bytes32 public immutable mentoExchangeId; + + UniswapPath internal cusdPath; + UniswapPath internal celoPath; + /** * @notice Initializes the BuyGDCloneFactory contract with the provided parameters. * @param _router The address of the SwapRouter contract. - * @param _cusd The address of the cUSD token contract. + * @param _stable The address of the stable token contract. * @param _gd The address of the GD token contract. * @param _oracle The address of the StaticOracle contract. + * @param _quoter The address of the QuoterV2 contract for exact price quotes. + * @param _mentoBroker The address of the Mento broker contract (optional, can be address(0)). + * @param _mentoExchangeProvider The address of the Mento exchange provider (optional, can be address(0)). + * @param _mentoExchangeId The exchange ID for the Mento G$/cUSD exchange (optional, can be bytes32(0)). */ constructor( ISwapRouter _router, - address _cusd, + address _stable, address _gd, - IStaticOracle _oracle + IStaticOracle _oracle, + IQuoterV2 _quoter, + IBroker _mentoBroker, + address _mentoExchangeProvider, + bytes32 _mentoExchangeId, + UniswapPath memory _cusdPath, + UniswapPath memory _celoPath ) { - impl = address(new BuyGDClone(_router, _cusd, _gd, _oracle)); - donateImpl = address(new DonateGDClone(_router, _cusd, _gd, _oracle)); + impl = address(new BuyGDCloneV2(_router, _stable, _gd, _oracle, _quoter, _mentoBroker, _mentoExchangeProvider, _mentoExchangeId)); + donateImpl = address(new DonateGDClone(_router, _stable, _gd, _oracle, _quoter, _mentoBroker, _mentoExchangeProvider, _mentoExchangeId)); gd = _gd; - cusd = _cusd; + stable = _stable; oracle = _oracle; router = _router; - _oracle.prepareAllAvailablePoolsWithTimePeriod(_gd, _cusd, 600); + + mentoBroker = _mentoBroker; + mentoExchangeProvider = _mentoExchangeProvider; + mentoExchangeId = _mentoExchangeId; + + cusdPath = _cusdPath; + celoPath = _celoPath; + for (uint256 i = 0; i < cusdPath.tokens.length - 2; i++) { + _oracle.prepareAllAvailablePoolsWithTimePeriod(cusdPath.tokens[i], cusdPath.tokens[i + 1], PERIOD); //cusd/stable pools + } + for (uint256 i = 0; i < celoPath.tokens.length - 2; i++) { + _oracle.prepareAllAvailablePoolsWithTimePeriod(celoPath.tokens[i], celoPath.tokens[i + 1], PERIOD); //celo/stable pools + } + _oracle.prepareAllAvailablePoolsWithTimePeriod(stable, gd, PERIOD); //cusd/stable pools } + /** + @dev Returns the Uniswap path for cUSD. + */ + function getCUsdPath() external view returns (UniswapPath memory) { + return cusdPath; + } + /** + @dev Returns the Uniswap path for Celo. + */ + function getCeloPath() external view returns (UniswapPath memory) { + return celoPath; + } /** * @notice Creates a new clone of the BuyGDClone contract with the provided owner address. * @param owner The address of the owner of the new BuyGDClone contract. @@ -348,7 +612,7 @@ contract BuyGDCloneFactory { function create(address owner) public returns (address) { bytes32 salt = keccak256(abi.encode(owner)); address clone = ClonesUpgradeable.cloneDeterministic(impl, salt); - BuyGDClone(payable(clone)).initialize(owner); + BuyGDCloneV2(payable(clone)).initialize(owner, cusdPath, celoPath); return clone; } @@ -376,7 +640,7 @@ contract BuyGDCloneFactory { uint256 minAmount ) external returns (address) { address clone = create(owner); - BuyGDClone(payable(clone)).swap(minAmount, payable(msg.sender)); + BuyGDCloneV2(payable(clone)).swap(minAmount, payable(msg.sender)); return clone; } @@ -432,134 +696,134 @@ contract BuyGDCloneFactory { return block.basefee; } - function onTokenTransfer( - address from, - uint256 amount, - bytes calldata data - ) external returns (bool) { - if (msg.sender != gd) revert NOT_GD_TOKEN(); - (address to, uint256 minAmount, bytes memory note) = abi.decode( - data, - (address, uint256, bytes) - ); - if (to == address(0)) revert RECIPIENT_ZERO(); - - uint256 amountIn = ERC20(gd).balanceOf(address(this)); - - uint256 amountReceived = swapToCusd(amountIn, minAmount, to); - emit GDSwapToCusd(from, to, amount, amountReceived, note); - return true; - } - - /** - * @notice Swaps cUSD for GD tokens. - * @param _minAmount The minimum amount of GD tokens to receive from the swap. - */ - function swapToCusd( - uint256 amountIn, - uint256 _minAmount, - address recipient - ) public returns (uint256) { - if (msg.sender != gd) { - ERC20(gd).transferFrom(msg.sender, address(this), amountIn); - } - - if (_minAmount == 0) - (_minAmount, ) = minAmountByTWAP(amountIn, gd, cusd, 60); - - ERC20(gd).approve(address(router), amountIn); - ISwapRouter.ExactInputParams memory params = ISwapRouter.ExactInputParams({ - path: abi.encodePacked(gd, uint24(10000), cusd), - recipient: recipient, - amountIn: amountIn, - amountOutMinimum: _minAmount - }); - return router.exactInput(params); - } - - /** - * @notice Swaps cUSD for GD tokens. - * @param _minAmount The minimum amount of GD tokens to receive from the swap. - */ - function swapFromCusd( - uint256 amountIn, - uint256 _minAmount, - address recipient - ) public returns (uint256) { - ERC20(cusd).transferFrom(msg.sender, address(this), amountIn); - - if (_minAmount == 0) - (_minAmount, ) = minAmountByTWAP(amountIn, cusd, gd, 60); - - ERC20(cusd).approve(address(router), amountIn); - ISwapRouter.ExactInputParams memory params = ISwapRouter.ExactInputParams({ - path: abi.encodePacked(cusd, uint24(10000), gd), - recipient: recipient, - amountIn: amountIn, - amountOutMinimum: _minAmount - }); - return router.exactInput(params); - } - - /** - * @notice Calculates the minimum amount of tokens that can be received for a given amount of base tokens, - * based on the time-weighted average price (TWAP) of the token pair over a specified period of time. - * @param baseAmount The amount of base tokens to swap. - * @param baseToken The address of the base token. - * @param qtToken The address of the quote token. - - * @return minTwap The minimum amount of G$ expected to receive by twap - */ - function minAmountByTWAP( - uint256 baseAmount, - address baseToken, - address qtToken, - uint32 period - ) public view returns (uint256 minTwap, uint256 quote) { - uint24[] memory fees = new uint24[](1); - fees[0] = 10000; - uint128 toConvert = uint128(baseAmount); - (quote, ) = oracle.quoteSpecificFeeTiersWithTimePeriod( - toConvert, - baseToken, - qtToken, - fees, - period - ); - - (uint256 curPrice, ) = oracle.quoteSpecificFeeTiersWithTimePeriod( - toConvert, - baseToken, - qtToken, - fees, - 0 - ); - - // (ie we dont expect price movement > 2% in timePeriod) - if ((quote * 98) / 100 > curPrice) { - revert INVALID_TWAP(); - } - //minAmount should not be 2% under curPrice (including slippage and price impact) - //this is just a guesstimate, for accurate results use uniswap sdk to get price quote - //v3 price quote is not available on chain - return ((curPrice * 980) / 1000, quote); - } - - function quoteCusd(uint256 amountIn) external returns (uint256 amountOut) { - return quoteToken(amountIn, 10000, cusd); - } - - function quoteToken( - uint256 amountIn, - uint24 fee, - address targetToken - ) public returns (uint256 amountOut) { - IQuoterV2.QuoteExactInputSingleParams memory params; - params.amountIn = amountIn; - params.tokenIn = gd; - params.tokenOut = targetToken; - params.fee = fee; - - (amountOut, , , ) = quoter.quoteExactInputSingle(params); - } + // function onTokenTransfer( + // address from, + // uint256 amount, + // bytes calldata data + // ) external returns (bool) { + // if (msg.sender != gd) revert NOT_GD_TOKEN(); + // (address to, uint256 minAmount, bytes memory note) = abi.decode( + // data, + // (address, uint256, bytes) + // ); + // if (to == address(0)) revert RECIPIENT_ZERO(); + + // uint256 amountIn = ERC20(gd).balanceOf(address(this)); + + // uint256 amountReceived = swapToCusd(amountIn, minAmount, to); + // emit GDSwapToCusd(from, to, amount, amountReceived, note); + // return true; + // } + + // /** + // * @notice Swaps cUSD for GD tokens. + // * @param _minAmount The minimum amount of GD tokens to receive from the swap. + // */ + // function swapToCusd( + // uint256 amountIn, + // uint256 _minAmount, + // address recipient + // ) public returns (uint256) { + // if (msg.sender != gd) { + // ERC20(gd).transferFrom(msg.sender, address(this), amountIn); + // } + + // if (_minAmount == 0) + // (_minAmount, ) = minAmountByTWAP(amountIn, gd, cusd, 60); + + // ERC20(gd).approve(address(router), amountIn); + // ISwapRouter.ExactInputParams memory params = ISwapRouter.ExactInputParams({ + // path: abi.encodePacked(gd, uint24(10000), cusd), + // recipient: recipient, + // amountIn: amountIn, + // amountOutMinimum: _minAmount + // }); + // return router.exactInput(params); + // } + + // /** + // * @notice Swaps cUSD for GD tokens. + // * @param _minAmount The minimum amount of GD tokens to receive from the swap. + // */ + // function swapFromCusd( + // uint256 amountIn, + // uint256 _minAmount, + // address recipient + // ) public returns (uint256) { + // ERC20(cusd).transferFrom(msg.sender, address(this), amountIn); + + // if (_minAmount == 0) + // (_minAmount, ) = minAmountByTWAP(amountIn, cusd, gd, 60); + + // ERC20(cusd).approve(address(router), amountIn); + // ISwapRouter.ExactInputParams memory params = ISwapRouter.ExactInputParams({ + // path: abi.encodePacked(cusd, uint24(10000), gd), + // recipient: recipient, + // amountIn: amountIn, + // amountOutMinimum: _minAmount + // }); + // return router.exactInput(params); + // } + + // /** + // * @notice Calculates the minimum amount of tokens that can be received for a given amount of base tokens, + // * based on the time-weighted average price (TWAP) of the token pair over a specified period of time. + // * @param baseAmount The amount of base tokens to swap. + // * @param baseToken The address of the base token. + // * @param qtToken The address of the quote token. + + // * @return minTwap The minimum amount of G$ expected to receive by twap + // */ + // function minAmountByTWAP( + // uint256 baseAmount, + // address baseToken, + // address qtToken, + // uint32 period + // ) public view returns (uint256 minTwap, uint256 quote) { + // uint24[] memory fees = new uint24[](1); + // fees[0] = 10000; + // uint128 toConvert = uint128(baseAmount); + // (quote, ) = oracle.quoteSpecificFeeTiersWithTimePeriod( + // toConvert, + // baseToken, + // qtToken, + // fees, + // period + // ); + + // (uint256 curPrice, ) = oracle.quoteSpecificFeeTiersWithTimePeriod( + // toConvert, + // baseToken, + // qtToken, + // fees, + // 0 + // ); + + // // (ie we dont expect price movement > 2% in timePeriod) + // if ((quote * 98) / 100 > curPrice) { + // revert INVALID_TWAP(); + // } + // //minAmount should not be 2% under curPrice (including slippage and price impact) + // //this is just a guesstimate, for accurate results use uniswap sdk to get price quote + // //v3 price quote is not available on chain + // return ((curPrice * 980) / 1000, quote); + // } + + // function quoteCusd(uint256 amountIn) external returns (uint256 amountOut) { + // return quoteToken(amountIn, 10000, cusd); + // } + + // function quoteToken( + // uint256 amountIn, + // uint24 fee, + // address targetToken + // ) public returns (uint256 amountOut) { + // IQuoterV2.QuoteExactInputSingleParams memory params; + // params.amountIn = amountIn; + // params.tokenIn = gd; + // params.tokenOut = targetToken; + // params.fee = fee; + + // (amountOut, , , ) = quoter.quoteExactInputSingle(params); + // } } diff --git a/package.json b/package.json index 7be2722d..3b124dfc 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "@jsier/retrier": "^1.2.4", "@mean-finance/uniswap-v3-oracle": "^1.0.3", "@nomicfoundation/hardhat-chai-matchers": "1", - "@nomicfoundation/hardhat-network-helpers": "1.*", + "@nomicfoundation/hardhat-network-helpers": "^1.1.2", "@nomicfoundation/hardhat-verify": "^2.1.0", "@nomiclabs/hardhat-ethers": "^2.2.1", "@nomiclabs/hardhat-waffle": "^2.0.6", diff --git a/test/ubi/UBIScheme.e2e.test.ts b/test/ubi/UBIScheme.e2e.test.ts index 78e632dd..964f8787 100644 --- a/test/ubi/UBIScheme.e2e.test.ts +++ b/test/ubi/UBIScheme.e2e.test.ts @@ -207,7 +207,7 @@ describe("UBIScheme - network e2e tests", () => { founder.address, ethers.utils.parseEther("1000") ); - dai.approve(simpleStaking.address, ethers.utils.parseEther("1000")); + await dai.approve(simpleStaking.address, ethers.utils.parseEther("1000")); await simpleStaking.stake(ethers.utils.parseEther("1000"), 0, false); await cDAI["mint(address,uint256)"]( founder.address, diff --git a/test/utils/BuyGDClone.test.ts b/test/utils/BuyGDClone.test.ts new file mode 100644 index 00000000..97a97dc0 --- /dev/null +++ b/test/utils/BuyGDClone.test.ts @@ -0,0 +1,693 @@ +/** + * @file E2E test for BuyGDClone contract on Celo fork + * + * This test suite verifies the BuyGDClone contract functionality on a Celo mainnet fork. + * It tests the cUSD -> GLOUSD -> G$ swap path as specified in the GitHub issue. + * + * To run this test: + * 1. Make sure you have a Celo RPC endpoint available (or use public forno.celo.org) + * 2. Run: npx hardhat test test/utils/BuyGDClone.celo-fork.test.ts + * + * Note: This test forks Celo mainnet, so it requires network access and may take longer to run. + */ + +import { ethers, network } from "hardhat"; +import { expect } from "chai"; +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; +import { BuyGDCloneV2, BuyGDCloneFactory } from "../../types"; +import deployments from "../../releases/deployment.json"; +import * as networkHelpers from "@nomicfoundation/hardhat-network-helpers"; + +// Celo mainnet addresses +const CELO_MAINNET_RPC = "https://forno.celo.org"; +const CELO_CHAIN_ID = 42220; + +// Production Celo addresses from deployment.json (used for existing contracts on fork) +const PRODUCTION_CELO = deployments["production-celo"]; +const GOODDOLLAR = PRODUCTION_CELO.GoodDollar; +const CUSD = PRODUCTION_CELO.CUSD; +const UNISWAP_V3_ROUTER = PRODUCTION_CELO.UniswapV3Router; +const STATIC_ORACLE = PRODUCTION_CELO.StaticOracle; +const MENTO_BROKER = PRODUCTION_CELO.MentoBroker; +const MENTO_EXCHANGE_PROVIDER = PRODUCTION_CELO.MentoExchangeProvider; +const MENTO_EXCHANGE_ID = PRODUCTION_CELO.CUSDExchangeId; +const CELO = "0x471EcE3750Da237f93B8E339c536989b8978a438"; +const QUOTE = "0x82825d0554fA07f7FC52Ab63c961F330fdEFa8E8"; +const USDC = "0xceba9300f2b948710d2653dd7b07f33a8b32118c"; + +// GLOUSD address on Celo mainnet +const GLOUSD_REFERENCE = "0x4F604735c1cF31399C6E711D5962b2B3E0225AD3"; // Common GLOUSD address + +// Account with cUSD balance on Celo (for impersonation) +const CUSD_WHALE = "0x167030Be27a0383b14E645884BB3786Ee7f5d0a8"; // Example whale address +const cusdPath = {tokens: [CUSD, USDC, GLOUSD_REFERENCE, GOODDOLLAR], fees: [100, 100, 500]}; +const celoPath = {tokens: [CELO, GLOUSD_REFERENCE, GOODDOLLAR], fees: [500, 500]}; + +describe("BuyGDClone - Celo Fork E2E", function () { + // Increase timeout for fork tests + this.timeout(600000); + + this.afterAll(async function () { + await networkHelpers.reset(); + }); + before(async function () { + await networkHelpers.reset(CELO_MAINNET_RPC); + }); + + async function forkCelo() { + const [deployer, user] = await ethers.getSigners(); + + // Get existing contracts from Celo (for router, oracle, tokens) + const router = await ethers.getContractAt("contracts/Interfaces.sol:ISwapRouter", UNISWAP_V3_ROUTER); + const oracleAddress = STATIC_ORACLE; + const gdToken = await ethers.getContractAt("contracts/Interfaces.sol:ERC20", GOODDOLLAR); + const cusdToken = await ethers.getContractAt("contracts/Interfaces.sol:ERC20", CUSD); + const celoToken = await ethers.getContractAt("contracts/Interfaces.sol:ERC20", CELO); + + const stableAddress = process.env.GLOUSD_ADDRESS || GLOUSD_REFERENCE; + console.log("Using stable token (GLOUSD):", stableAddress); + + // Deploy BuyGDCloneFactory + const BuyGDCloneFactoryFactory = await ethers.getContractFactory("BuyGDCloneFactory"); + const factory = (await BuyGDCloneFactoryFactory.deploy( + router.address, + stableAddress, + GOODDOLLAR, + oracleAddress, + QUOTE, + MENTO_BROKER, + MENTO_EXCHANGE_PROVIDER, + MENTO_EXCHANGE_ID, + cusdPath, + celoPath, + {gasLimit: 25000000} + )) as BuyGDCloneFactory; + + await factory.deployed(); + console.log("✓ BuyGDCloneFactory deployed at:", factory.address); + + // Verify the stable token in the factory + const factoryStable = await factory.stable(); + expect(factoryStable.toLowerCase()).to.equal(stableAddress.toLowerCase()); + console.log("✓ Factory stable token verified:", factoryStable); + + // Impersonate a whale account to get cUSD + const whale = await ethers.getImpersonatedSigner(CUSD_WHALE); + await ethers.provider.send("hardhat_setBalance", [ + CUSD_WHALE, + "0x1000000000000000000", + ]); + + return { + deployer, + user, + factory, + gdToken, + cusdToken, + celoToken, + stableAddress, + whale, + router, + oracleAddress, + MENTO_BROKER, + MENTO_EXCHANGE_PROVIDER, + MENTO_EXCHANGE_ID + }; + } + + describe("cUSD Swap Tests", function () { + it("Should use Uniswap when Mento is not configured", async function () { + const { deployer, user, gdToken, cusdToken, whale, router, oracleAddress } = await loadFixture(forkCelo); + + // Create factory without Mento configuration + const BuyGDCloneFactoryFactory = await ethers.getContractFactory("BuyGDCloneFactory"); + const factoryWithoutMento = (await BuyGDCloneFactoryFactory.deploy( + router.address, + process.env.GLOUSD_ADDRESS || GLOUSD_REFERENCE, + GOODDOLLAR, + oracleAddress, + QUOTE, + ethers.constants.AddressZero, + ethers.constants.AddressZero, + ethers.constants.HashZero, + cusdPath, + celoPath, + )) as BuyGDCloneFactory; + + await factoryWithoutMento.create(user.address); + const cloneAddress = await factoryWithoutMento.predict(user.address); + const clone = (await ethers.getContractAt( + "BuyGDCloneV2", + cloneAddress + )) as BuyGDCloneV2; + + const swapAmount = ethers.utils.parseEther("5"); + const whaleBalance = await cusdToken.balanceOf(whale.address); + + if (whaleBalance.lt(swapAmount)) { + throw new Error(`Whale doesn't have enough cUSD. Balance: ${ethers.utils.formatEther(whaleBalance)}, Required: ${ethers.utils.formatEther(swapAmount)}`); + } + + await cusdToken.connect(whale).transfer(cloneAddress, swapAmount); + + // Should be able to get Uniswap expected return + const uniswapExpected = await clone.callStatic.getExpectedReturnFromUniswapPath(swapAmount, cusdPath); + expect(uniswapExpected).to.be.gt(0); + + // Should revert when trying to get Mento expected return + await expect(clone.getExpectedReturnFromMento(swapAmount)).to.be.revertedWithCustomError( + clone, + "MENTO_NOT_CONFIGURED" + ); + + const initialGdBalance = await gdToken.balanceOf(user.address); + const [minByTwap] = await clone.minAmountByTWAP(swapAmount, CUSD, 300); + const minAmount = minByTwap; + + // swapCusd should use Uniswap (only option) + const swapTx = await clone.swapCusd(minAmount, user.address); + const swapReceipt = await swapTx.wait(); + + // Should emit BoughtFromUniswap event + const uniswapEvent = swapReceipt.events?.find( + (e: any) => e.event === "BoughtFromUniswap" + ); + expect(uniswapEvent).to.not.be.undefined; + console.log("✓ BoughtFromUniswap event emitted:", { + inToken: uniswapEvent?.args?.inToken, + inAmount: ethers.utils.formatEther(uniswapEvent?.args?.inAmount), + outAmount: ethers.utils.formatEther(uniswapEvent?.args?.outAmount), + }); + + // Should not emit BoughtFromMento event + const mentoEvent = swapReceipt.events?.find( + (e: any) => e.event === "BoughtFromMento" + ); + expect(mentoEvent).to.be.undefined; + + const finalGdBalance = await gdToken.balanceOf(user.address); + const gdReceived = finalGdBalance.sub(initialGdBalance); + expect(gdReceived).to.be.gt(0); + expect(gdReceived).to.be.gte(minAmount); + console.log("✓ Used Uniswap when Mento not configured, received:", ethers.utils.formatEther(gdReceived), "G$"); + }); + + it("Should use swapCusdWithPath with custom path when Mento is not configured", async function () { + const { deployer, user, gdToken, cusdToken, whale, router, oracleAddress } = await loadFixture(forkCelo); + + const BuyGDCloneFactoryFactory = await ethers.getContractFactory("BuyGDCloneFactory"); + const factoryWithoutMento = (await BuyGDCloneFactoryFactory.deploy( + router.address, + process.env.GLOUSD_ADDRESS || GLOUSD_REFERENCE, + GOODDOLLAR, + oracleAddress, + QUOTE, + ethers.constants.AddressZero, + ethers.constants.AddressZero, + ethers.constants.HashZero, + cusdPath, + celoPath, + )) as BuyGDCloneFactory; + + await factoryWithoutMento.create(user.address); + const cloneAddress = await factoryWithoutMento.predict(user.address); + const clone = (await ethers.getContractAt( + "BuyGDCloneV2", + cloneAddress + )) as BuyGDCloneV2; + + const swapAmount = ethers.utils.parseEther("5"); + const whaleBalance = await cusdToken.balanceOf(whale.address); + if (whaleBalance.lt(swapAmount)) { + throw new Error(`Whale doesn't have enough cUSD. Balance: ${ethers.utils.formatEther(whaleBalance)}, Required: ${ethers.utils.formatEther(swapAmount)}`); + } + await cusdToken.connect(whale).transfer(cloneAddress, swapAmount); + + const initialGdBalance = await gdToken.balanceOf(user.address); + const [minByTwap] = await clone.minAmountByTWAP(swapAmount, CUSD, 300); + const minAmount = minByTwap; + + const swapTx = await clone.swapCusdWithPath(minAmount, user.address, cusdPath); + const swapReceipt = await swapTx.wait(); + + const uniswapEvent = swapReceipt.events?.find((e: any) => e.event === "BoughtFromUniswap"); + expect(uniswapEvent).to.not.be.undefined; + const mentoEvent = swapReceipt.events?.find((e: any) => e.event === "BoughtFromMento"); + expect(mentoEvent).to.be.undefined; + + const finalGdBalance = await gdToken.balanceOf(user.address); + const gdReceived = finalGdBalance.sub(initialGdBalance); + expect(gdReceived).to.be.gt(0); + expect(gdReceived).to.be.gte(minAmount); + console.log("✓ swapCusdWithPath used Uniswap with custom path, received:", ethers.utils.formatEther(gdReceived), "G$"); + }); + + it("Should compare Uniswap vs Mento and choose better route", async function () { + const { factory, user, gdToken, cusdToken, whale } = await loadFixture(forkCelo); + + await factory.create(user.address); + const cloneAddress = await factory.predict(user.address); + const clone = (await ethers.getContractAt( + "BuyGDCloneV2", + cloneAddress + )) as BuyGDCloneV2; + + const swapAmount = ethers.utils.parseEther("5"); + const whaleBalance = await cusdToken.balanceOf(whale.address); + + if (whaleBalance.lt(swapAmount)) { + throw new Error(`Whale doesn't have enough cUSD. Balance: ${ethers.utils.formatEther(whaleBalance)}, Required: ${ethers.utils.formatEther(swapAmount)}`); + } + + await cusdToken.connect(whale).transfer(cloneAddress, swapAmount); + + // Get expected returns + const uniswapExpected = await clone.callStatic.getExpectedReturnFromUniswapPath(swapAmount, cusdPath); + const mentoExpected = await clone.getExpectedReturnFromMento(swapAmount); + + console.log("Route comparison:"); + console.log(" Uniswap expected:", ethers.utils.formatEther(uniswapExpected), "G$"); + console.log(" Mento expected:", ethers.utils.formatEther(mentoExpected), "G$"); + + const initialGdBalance = await gdToken.balanceOf(user.address); + const [minByTwap] = await clone.minAmountByTWAP(swapAmount, CUSD, 300); + const minAmount = minByTwap; + + // Call swapCusd which should choose the better route + const swapTx = await clone.swapCusd(minAmount, user.address); + const swapReceipt = await swapTx.wait(); + + const finalGdBalance = await gdToken.balanceOf(user.address); + const gdReceived = finalGdBalance.sub(initialGdBalance); + + expect(gdReceived).to.be.gt(0); + expect(gdReceived).to.be.gte(minAmount); + + // Check which route was used based on event + const mentoEvent = swapReceipt.events?.find( + (e: any) => e.event === "BoughtFromMento" + ); + const uniswapEvent = swapReceipt.events?.find( + (e: any) => e.event === "BoughtFromUniswap" + ); + const usedMento = mentoEvent !== undefined; + const usedUniswap = uniswapEvent !== undefined; + + if (mentoExpected.gt(uniswapExpected)) { + expect(usedMento).to.be.true; + expect(usedUniswap).to.be.false; + console.log("✓ Correctly chose Mento (better return)"); + console.log("✓ BoughtFromMento event emitted"); + } else { + expect(usedMento).to.be.false; + expect(usedUniswap).to.be.true; + console.log("✓ Correctly chose Uniswap (better return)"); + console.log("✓ BoughtFromUniswap event emitted"); + } + + console.log("✓ Received:", ethers.utils.formatEther(gdReceived), "G$"); + }); + + it("Should use swapCusdWithPath and choose better route (Uniswap vs Mento)", async function () { + const { factory, user, gdToken, cusdToken, whale } = await loadFixture(forkCelo); + + await factory.create(user.address); + const cloneAddress = await factory.predict(user.address); + const clone = (await ethers.getContractAt( + "BuyGDCloneV2", + cloneAddress + )) as BuyGDCloneV2; + + const swapAmount = ethers.utils.parseEther("5"); + const whaleBalance = await cusdToken.balanceOf(whale.address); + if (whaleBalance.lt(swapAmount)) { + throw new Error(`Whale doesn't have enough cUSD. Balance: ${ethers.utils.formatEther(whaleBalance)}, Required: ${ethers.utils.formatEther(swapAmount)}`); + } + await cusdToken.connect(whale).transfer(cloneAddress, swapAmount); + + const initialGdBalance = await gdToken.balanceOf(user.address); + const [minByTwap] = await clone.minAmountByTWAP(swapAmount, CUSD, 300); + const minAmount = minByTwap; + + const swapTx = await clone.swapCusdWithPath(minAmount, user.address, cusdPath); + const swapReceipt = await swapTx.wait(); + + const finalGdBalance = await gdToken.balanceOf(user.address); + const gdReceived = finalGdBalance.sub(initialGdBalance); + expect(gdReceived).to.be.gt(0); + expect(gdReceived).to.be.gte(minAmount); + + const mentoEvent = swapReceipt.events?.find((e: any) => e.event === "BoughtFromMento"); + const uniswapEvent = swapReceipt.events?.find((e: any) => e.event === "BoughtFromUniswap"); + expect(mentoEvent !== undefined || uniswapEvent !== undefined).to.be.true; + console.log("✓ swapCusdWithPath chose route, received:", ethers.utils.formatEther(gdReceived), "G$"); + }); + + it("Should force Mento usage with large swap amount", async function () { + const { factory, user, gdToken, cusdToken, whale } = await loadFixture(forkCelo); + + await factory.create(user.address); + const cloneAddress = await factory.predict(user.address); + const clone = (await ethers.getContractAt( + "BuyGDCloneV2", + cloneAddress + )) as BuyGDCloneV2; + + // Use a large swap amount to force Mento (large amounts favor Mento due to lower slippage) + const swapAmount = ethers.utils.parseEther("10000"); // 10,000 cUSD + const whaleBalance = await cusdToken.balanceOf(whale.address); + + if (whaleBalance.lt(swapAmount)) { + throw new Error(`Whale doesn't have enough cUSD. Balance: ${ethers.utils.formatEther(whaleBalance)}, Required: ${ethers.utils.formatEther(swapAmount)}`); + } + + await cusdToken.connect(whale).transfer(cloneAddress, swapAmount); + + // Get expected returns + const uniswapExpected = await clone.callStatic.getExpectedReturnFromUniswapPath(swapAmount, cusdPath); + const mentoExpected = await clone.getExpectedReturnFromMento(swapAmount); + + console.log("Large swap route comparison:"); + console.log(" Swap amount:", ethers.utils.formatEther(swapAmount), "cUSD"); + console.log(" Uniswap expected:", ethers.utils.formatEther(uniswapExpected), "G$"); + console.log(" Mento expected:", ethers.utils.formatEther(mentoExpected), "G$"); + + // For large amounts, Mento should provide better returns + expect(mentoExpected).to.be.gt(uniswapExpected); + console.log("✓ Mento provides better return for large swap"); + const initialGdBalance = await gdToken.balanceOf(user.address); + + // Call swapCusd which should choose Mento + const swapTx = await clone.swapCusd(mentoExpected, user.address); + const swapReceipt = await swapTx.wait(); + + const finalGdBalance = await gdToken.balanceOf(user.address); + const gdReceived = finalGdBalance.sub(initialGdBalance); + + expect(gdReceived).to.be.gt(0); + expect(gdReceived).to.be.gte(mentoExpected); + + // Verify Mento was used + const mentoEvent = swapReceipt.events?.find( + (e: any) => e.event === "BoughtFromMento" + ); + const uniswapEvent = swapReceipt.events?.find( + (e: any) => e.event === "BoughtFromUniswap" + ); + + expect(mentoEvent).to.not.be.undefined; + expect(uniswapEvent).to.be.undefined; + + console.log("✓ BoughtFromMento event emitted:", { + inToken: mentoEvent?.args?.inToken, + inAmount: ethers.utils.formatEther(mentoEvent?.args?.inAmount), + outAmount: ethers.utils.formatEther(mentoEvent?.args?.outAmount), + }); + console.log("✓ Received:", ethers.utils.formatEther(gdReceived), "G$"); + }); + }); + + describe("CELO Swap Tests", function () { + it("Should swap Celo -> GLOUSD -> G$ via clone", async function () { + /// Skip test because forking does not fork the precompiled contracts from celo mainnet + if(network.name === 'hardhat') { + this.skip(); + return; + } + const { factory, user, gdToken, celoToken, whale } = await loadFixture(forkCelo); + + // Create clone + await factory.create(user.address); + const cloneAddress = await factory.predict(user.address); + const clone = (await ethers.getContractAt( + "BuyGDCloneV2", + cloneAddress + )) as BuyGDCloneV2; + + // Transfer CELO to clone (simulating onramp service) + const swapAmount = ethers.utils.parseEther("1000"); + const whaleCeloBalance = await celoToken.balanceOf(whale.address); + + if (whaleCeloBalance.lt(swapAmount)) { + throw new Error(`Whale doesn't have enough CELO. Balance: ${ethers.utils.formatEther(whaleCeloBalance)}, Required: ${ethers.utils.formatEther(swapAmount)}`); + } + + // Transfer CELO from whale to clone + // await celoToken.connect(whale).transfer(cloneAddress, swapAmount); + await whale.sendTransaction({ + to: cloneAddress, + value: swapAmount, + }); + + const cloneCeloBalance = await celoToken.balanceOf(cloneAddress); + expect(cloneCeloBalance).to.equal(swapAmount); + console.log("✓ CELO transferred to clone:", ethers.utils.formatEther(swapAmount)); + + // Get initial G$ balance + const initialGdBalance = await gdToken.balanceOf(user.address); + console.log("Initial G$ balance:", ethers.utils.formatEther(initialGdBalance)); + + // Calculate min amount using TWAP + const [minByTwap] = await clone.minAmountByTWAP( + swapAmount, + CELO, + 300 // 5 minutes + ); + console.log("Min amount by TWAP:", ethers.utils.formatEther(minByTwap)); + + // Perform swap - minTwap is already 98% of quote + const minAmount = minByTwap; + console.log("Using minAmount:", ethers.utils.formatEther(minAmount)); + + const swapTx = await clone.swap(minAmount, user.address); + const swapReceipt = await swapTx.wait(); + + // Check for Bought event + const boughtEvent = swapReceipt.events?.find( + (e: any) => e.event === "Bought" + ); + expect(boughtEvent).to.not.be.undefined; + console.log("✓ Bought event emitted:", { + inToken: boughtEvent?.args?.inToken, + inAmount: ethers.utils.formatEther(boughtEvent?.args?.inAmount), + outAmount: ethers.utils.formatEther(boughtEvent?.args?.outAmount), + }); + + // Check final G$ balance + const finalGdBalance = await gdToken.balanceOf(user.address); + const gdReceived = finalGdBalance.sub(initialGdBalance); + expect(gdReceived).to.be.gt(0); + console.log("✓ G$ received:", ethers.utils.formatEther(gdReceived)); + console.log("Final G$ balance:", ethers.utils.formatEther(finalGdBalance)); + + // Verify minimum amount + expect(gdReceived).to.be.gte(minAmount); + console.log("✓ Received amount >= minAmount"); + }); + + it("Should swap CELO via swapCeloWithPath with custom path", async function () { + if (network.name === "hardhat") { + this.skip(); + return; + } + const { factory, user, gdToken, celoToken, whale } = await loadFixture(forkCelo); + + await factory.create(user.address); + const cloneAddress = await factory.predict(user.address); + const clone = (await ethers.getContractAt( + "BuyGDCloneV2", + cloneAddress + )) as BuyGDCloneV2; + + const swapAmount = ethers.utils.parseEther("1000"); + const whaleCeloBalance = await celoToken.balanceOf(whale.address); + if (whaleCeloBalance.lt(swapAmount)) { + throw new Error(`Whale doesn't have enough CELO. Balance: ${ethers.utils.formatEther(whaleCeloBalance)}, Required: ${ethers.utils.formatEther(swapAmount)}`); + } + + await whale.sendTransaction({ + to: cloneAddress, + value: swapAmount, + }); + + const initialGdBalance = await gdToken.balanceOf(user.address); + const [minByTwap] = await clone.minAmountByTWAP(swapAmount, CELO, 300); + const minAmount = minByTwap; + + const swapTx = await clone.swapCeloWithPath(minAmount, user.address, celoPath); + const swapReceipt = await swapTx.wait(); + + const uniswapEvent = swapReceipt.events?.find((e: any) => e.event === "BoughtFromUniswap"); + expect(uniswapEvent).to.not.be.undefined; + + const finalGdBalance = await gdToken.balanceOf(user.address); + const gdReceived = finalGdBalance.sub(initialGdBalance); + expect(gdReceived).to.be.gt(0); + expect(gdReceived).to.be.gte(minAmount); + console.log("✓ swapCeloWithPath completed, received:", ethers.utils.formatEther(gdReceived), "G$"); + }); + }); + + describe("TWAP and Price Comparison Tests", function () { + it("Should compare TWAP quote vs actual pool price", async function () { + const { factory, user, router } = await loadFixture(forkCelo); + + // Create clone + await factory.create(user.address); + const cloneAddress = await factory.predict(user.address); + const clone = (await ethers.getContractAt( + "BuyGDCloneV2", + cloneAddress + )) as BuyGDCloneV2; + + const testAmount = ethers.utils.parseEther("10"); // 10 cUSD + const stableAddress = await clone.stable(); + const gdAddress = await clone.gd(); + + // Get TWAP quote from oracle + const [minTwap, twapQuote] = await clone.minAmountByTWAP( + testAmount, + CUSD, + 300 // 5 minutes + ); + + console.log("TWAP Oracle Quote:"); + console.log(" Input:", ethers.utils.formatEther(testAmount), "cUSD"); + console.log(" Min TWAP (98%):", ethers.utils.formatEther(minTwap), "G$"); + console.log(" TWAP Quote:", ethers.utils.formatEther(twapQuote), "G$"); + + // Get actual pool price using QuoterV2 + const quoter = await ethers.getContractAt("contracts/Interfaces.sol:IQuoterV2", QUOTE); + + // Build path: CUSD -> stable -> G$ (using same encoding as contract) + let path: string; + if (stableAddress.toLowerCase() === CUSD.toLowerCase()) { + path = ethers.utils.solidityPack( + ["address", "uint24", "address"], + [CUSD, 500, gdAddress] // GD_FEE_TIER = 500 + ); + } else { + path = ethers.utils.solidityPack( + ["address", "uint24", "address", "uint24", "address"], + [CUSD, 100, stableAddress, 500, gdAddress] // 100 for CUSD->stable, 500 for stable->G$ + ); + } + + // Get quote from actual pool + const [actualAmountOut] = await quoter.callStatic.quoteExactInput(path, testAmount); + + console.log("Actual Pool Price:"); + console.log(" Input:", ethers.utils.formatEther(testAmount), "cUSD"); + console.log(" Actual Output:", ethers.utils.formatEther(actualAmountOut), "G$"); + + // Compare TWAP vs actual + const twapVsActual = twapQuote.mul(100).div(actualAmountOut); + const minTwapVsActual = minTwap.mul(100).div(actualAmountOut); + + console.log("Comparison:"); + console.log(" TWAP Quote vs Actual:", twapVsActual.toString(), "%"); + console.log(" Min TWAP vs Actual:", minTwapVsActual.toString(), "%"); + + // Min TWAP should be less than or equal to actual + // But allow some tolerance for price movement + expect(minTwap).to.be.lte(actualAmountOut); + expect(minTwap).to.be.gte(actualAmountOut.mul(98).div(100)); + + console.log("✓ TWAP quote comparison completed"); + }); + + it("Should revert when minAmount is more than quote", async function () { + const { factory, user, cusdToken, whale } = await loadFixture(forkCelo); + + // Create clone + await factory.create(user.address); + const cloneAddress = await factory.predict(user.address); + const clone = (await ethers.getContractAt( + "BuyGDCloneV2", + cloneAddress + )) as BuyGDCloneV2; + + // Transfer cUSD to clone + const swapAmount = ethers.utils.parseEther("5"); + const whaleBalance = await cusdToken.balanceOf(whale.address); + + if (whaleBalance.lt(swapAmount)) { + throw new Error(`Whale doesn't have enough cUSD. Balance: ${ethers.utils.formatEther(whaleBalance)}, Required: ${ethers.utils.formatEther(swapAmount)}`); + } + + await cusdToken.connect(whale).transfer(cloneAddress, swapAmount); + + // Get TWAP quote + const [, twapQuote] = await clone.minAmountByTWAP( + swapAmount, + CUSD, + 300 + ); + + console.log("TWAP values:"); + console.log(" TWAP Quote:", ethers.utils.formatEther(twapQuote), "G$"); + + // Use minAmount = 102% of quote + const excessiveMinAmount = twapQuote.mul(102).div(100); + console.log("Using excessive minAmount (102% of quote):", ethers.utils.formatEther(excessiveMinAmount)); + + // The swap should revert because excessiveMinAmount > actual pool output + // The contract enforces: amountOutMinimum = excessiveMinAmount + // But the pool likely can't provide that much due to slippage/price impact + await expect( + clone.swap(excessiveMinAmount, user.address) + ).to.be.reverted; // Should revert with Uniswap "STF" (insufficient output amount) or similar + + console.log("✓ Swap correctly reverts when minAmount = 102% of TWAP quote"); + }); + }); + + describe("Factory Helper Functions", function () { + it("Should handle createAndSwap in one transaction", async function () { + const { factory, user, deployer, gdToken, cusdToken, whale } = await loadFixture( + forkCelo + ); + + const swapAmount = ethers.utils.parseEther("5"); + const whaleBalance = await cusdToken.balanceOf(whale.address); + + if (whaleBalance.lt(swapAmount)) { + throw new Error(`Whale doesn't have enough cUSD. Balance: ${ethers.utils.formatEther(whaleBalance)}, Required: ${ethers.utils.formatEther(swapAmount)}`); + } + + // Get initial G$ balance + const initialGdBalance = await gdToken.balanceOf(user.address); + + // Create clone and get address + await factory.create(deployer.address); + const cloneAddress = await factory.predict(deployer.address); + const clone = (await ethers.getContractAt( + "BuyGDCloneV2", + cloneAddress + )) as BuyGDCloneV2; + + // Calculate min amount + const [minByTwap] = await clone.minAmountByTWAP( + swapAmount, + CUSD, + 300 + ); + const minAmount = minByTwap; + + const predictedAddress = await factory.predict(user.address); + cusdToken.connect(whale).transfer(predictedAddress, swapAmount); + // Use createAndSwap + await cusdToken.connect(user).approve(factory.address, swapAmount); + const tx = await factory.connect(user).createAndSwap(user.address, minAmount); + const receipt = await tx.wait(); + + // Check final balance + const finalGdBalance = await gdToken.balanceOf(user.address); + const gdReceived = finalGdBalance.sub(initialGdBalance); + + expect(gdReceived).to.be.gte(minAmount); + console.log("✓ createAndSwap successful, G$ received:", ethers.utils.formatEther(gdReceived)); + }); + }); +}); + diff --git a/yarn.lock b/yarn.lock index 7b66bc5e..95ea455c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2792,7 +2792,7 @@ __metadata: "@jsier/retrier": ^1.2.4 "@mean-finance/uniswap-v3-oracle": ^1.0.3 "@nomicfoundation/hardhat-chai-matchers": 1 - "@nomicfoundation/hardhat-network-helpers": 1.* + "@nomicfoundation/hardhat-network-helpers": ^1.1.2 "@nomicfoundation/hardhat-verify": ^2.1.0 "@nomiclabs/hardhat-ethers": ^2.2.1 "@nomiclabs/hardhat-waffle": ^2.0.6 @@ -3246,7 +3246,7 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/hardhat-network-helpers@npm:1.*": +"@nomicfoundation/hardhat-network-helpers@npm:^1.1.2": version: 1.1.2 resolution: "@nomicfoundation/hardhat-network-helpers@npm:1.1.2" dependencies: