diff --git a/docs/buidl-gsm-converter.md b/docs/buidl-gsm-converter.md new file mode 100644 index 00000000..088ae377 --- /dev/null +++ b/docs/buidl-gsm-converter.md @@ -0,0 +1,21 @@ +# BUIDL GSM Converter + +## Overview + +The BUIDL GSM Converter is designed to allow for integration of the GSM ([GHO Stability Module](https://docs.gho.xyz/developer-docs/gho-stability-module)) with [Blackrock BUIDL fund](https://securitize.io/learn/press/blackrock-launches-first-tokenized-fund-buidl-on-the-ethereum-network) subscriptions and redemptions, adhering to the [BUIDL GSM Temp Check](https://governance.aave.com/t/temp-check-buidl-gsm/18775). + +The combined BUIDL GSM instance with GsmConverter will enable 1:1 fixed-ratio swaps between USDC and GHO, utilizing the USDC surplus to mint BUIDL tokens. The converter architecture as an intermediary allows for minimal required code changes to the GSM, while also providing a standardized smart contract interface for users. Furthermore, BUIDL holders will be able to interact directly with the BUIDL GSM as they do with other existing GSMs. + +### BUIDL GSM ([Gsm.sol](../src/contracts/facilitators/gsm/Gsm.sol)) + +The BUIDL GSM operates like other existing GSM instances, except it specifically facilitates conversions between GHO and BUIDL. BUIDL holders can interact with this contract directly. + +### GSM Converter ([GsmConverter.sol](../src/contracts/facilitators/gsm/converter/GsmConverter.sol)) + +The GSM Converter will act as a middleware between the user and BUIDL GSM, facilitating conversions between USDC and BUIDL under the hood. + +During a `buyAsset` transaction, where users sell GHO to receive USDC, the GsmConverter first interacts with the BUIDL GSM to convert GHO to BUIDL. Then it integrates with the [Circle BUIDL Off-Ramp contract](https://etherscan.io/address/0x31d3f59ad4aac0eee2247c65ebe8bf6e9e470a53#code) in order to redeem USDC in exchange for BUIDL. This USDC is then returned to the user. + +![buyAsset](./img/buyAsset.png) + +During a `sellAsset` transaction, where users sell USDC to receive GHO, ... diff --git a/docs/img/buyAsset.png b/docs/img/buyAsset.png new file mode 100644 index 00000000..4b77c595 Binary files /dev/null and b/docs/img/buyAsset.png differ diff --git a/src/contracts/facilitators/gsm/converter/GsmConverter.sol b/src/contracts/facilitators/gsm/converter/GsmConverter.sol new file mode 100644 index 00000000..a170eec2 --- /dev/null +++ b/src/contracts/facilitators/gsm/converter/GsmConverter.sol @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.10; + +import {GPv2SafeERC20} from '@aave/core-v3/contracts/dependencies/gnosis/contracts/GPv2SafeERC20.sol'; +import {IERC20} from '@aave/core-v3/contracts/dependencies/openzeppelin/contracts/IERC20.sol'; +import {EIP712} from '@openzeppelin/contracts/utils/cryptography/EIP712.sol'; +import {SignatureChecker} from '@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol'; +import {Ownable} from '@openzeppelin/contracts/access/Ownable.sol'; +import {IGhoToken} from '../../../gho/interfaces/IGhoToken.sol'; +import {IGsm} from '../interfaces/IGsm.sol'; +import {IGsmConverter} from './interfaces/IGsmConverter.sol'; +import {IRedemption} from '../dependencies/circle/IRedemption.sol'; +// TODO: replace with proper issuance implementation later +import {MockBUIDLSubscription} from '../../../../test/mocks/MockBUIDLSubscription.sol'; + +import 'forge-std/console2.sol'; + +/** + * @title GsmConverter + * @author Aave + * @notice Converter that facilitates conversions/redemptions of underlying assets. Integrates with GSM to buy/sell to go to/from an underlying asset to/from GHO. + */ +contract GsmConverter is Ownable, EIP712, IGsmConverter { + using GPv2SafeERC20 for IERC20; + + /// @inheritdoc IGsmConverter + bytes32 public constant BUY_ASSET_WITH_SIG_TYPEHASH = + keccak256( + 'BuyAssetWithSig(address originator,uint256 minAmount,address receiver,uint256 nonce,uint256 deadline)' + ); + + /// @inheritdoc IGsmConverter + bytes32 public constant SELL_ASSET_WITH_SIG_TYPEHASH = + keccak256( + 'SellAssetWithSig(address originator,uint256 maxAmount,address receiver,uint256 nonce,uint256 deadline)' + ); + + /// @inheritdoc IGsmConverter + address public immutable GHO_TOKEN; + + /// @inheritdoc IGsmConverter + address public immutable GSM; + + /// @inheritdoc IGsmConverter + address public immutable ISSUED_ASSET; + + /// @inheritdoc IGsmConverter + address public immutable REDEEMED_ASSET; + + /// @inheritdoc IGsmConverter + address public immutable REDEMPTION_CONTRACT; + + /// @inheritdoc IGsmConverter + address public immutable SUBSCRIPTION_CONTRACT; + + /// @inheritdoc IGsmConverter + mapping(address => uint256) public nonces; + + /** + * @dev Constructor + * @param gsm The address of the associated GSM contract + * @param redemptionContract The address of the redemption contract associated with the asset conversion + * @param issuanceReceiverContract The address of the contract receiving the payment associated with the asset conversion + * @param issuedAsset The address of the asset being redeemed + * @param redeemedAsset The address of the asset being received from redemption + */ + constructor( + address admin, + address gsm, + address redemptionContract, + address issuanceReceiverContract, + address issuedAsset, + address redeemedAsset + ) EIP712('GSMConverter', '1') { + require(admin != address(0), 'ZERO_ADDRESS_NOT_VALID'); + require(gsm != address(0), 'ZERO_ADDRESS_NOT_VALID'); + require(redemptionContract != address(0), 'ZERO_ADDRESS_NOT_VALID'); + require(issuanceReceiverContract != address(0), 'ZERO_ADDRESS_NOT_VALID'); + require(issuedAsset != address(0), 'ZERO_ADDRESS_NOT_VALID'); + require(redeemedAsset != address(0), 'ZERO_ADDRESS_NOT_VALID'); + + GSM = gsm; + REDEMPTION_CONTRACT = redemptionContract; + SUBSCRIPTION_CONTRACT = issuanceReceiverContract; + ISSUED_ASSET = issuedAsset; // BUIDL + REDEEMED_ASSET = redeemedAsset; // USDC + GHO_TOKEN = IGsm(GSM).GHO_TOKEN(); + + transferOwnership(admin); + } + + /// @inheritdoc IGsmConverter + function sellAsset(uint256 maxAmount, address receiver) external returns (uint256, uint256) { + require(maxAmount > 0, 'INVALID_MAX_AMOUNT'); + + return _sellAsset(msg.sender, maxAmount, receiver); + } + + /// @inheritdoc IGsmConverter + function buyAsset(uint256 minAmount, address receiver) external returns (uint256, uint256) { + require(minAmount > 0, 'INVALID_MIN_AMOUNT'); + + return _buyAsset(msg.sender, minAmount, receiver); + } + + /// @inheritdoc IGsmConverter + function buyAssetWithSig( + address originator, + uint256 minAmount, + address receiver, + uint256 deadline, + bytes calldata signature + ) external returns (uint256, uint256) { + require(deadline >= block.timestamp, 'SIGNATURE_DEADLINE_EXPIRED'); + bytes32 digest = keccak256( + abi.encode( + '\x19\x01', + _domainSeparatorV4(), + BUY_ASSET_WITH_SIG_TYPEHASH, + abi.encode(originator, minAmount, receiver, nonces[originator]++, deadline) + ) + ); + require( + SignatureChecker.isValidSignatureNow(originator, digest, signature), + 'SIGNATURE_INVALID' + ); + + return _buyAsset(originator, minAmount, receiver); + } + + /// @inheritdoc IGsmConverter + function sellAssetWithSig( + address originator, + uint256 maxAmount, + address receiver, + uint256 deadline, + bytes calldata signature + ) external returns (uint256, uint256) { + require(deadline >= block.timestamp, 'SIGNATURE_DEADLINE_EXPIRED'); + bytes32 digest = keccak256( + abi.encode( + '\x19\x01', + _domainSeparatorV4(), + SELL_ASSET_WITH_SIG_TYPEHASH, + abi.encode(originator, maxAmount, receiver, nonces[originator]++, deadline) + ) + ); + require( + SignatureChecker.isValidSignatureNow(originator, digest, signature), + 'SIGNATURE_INVALID' + ); + + return _sellAsset(originator, maxAmount, receiver); + } + + /// @inheritdoc IGsmConverter + function rescueTokens(address token, address to, uint256 amount) external onlyOwner { + require(amount > 0, 'INVALID_AMOUNT'); + IERC20(token).safeTransfer(to, amount); + emit TokensRescued(token, to, amount); + } + + /// @inheritdoc IGsmConverter + function DOMAIN_SEPARATOR() external view returns (bytes32) { + return _domainSeparatorV4(); + } + + /** + * @notice Buys the GSM underlying asset in exchange for selling GHO, after asset redemption + * @param minAmount The minimum amount of the underlying asset to buy (ie BUIDL) + * @param receiver Recipient address of the underlying asset being purchased + * @return The amount of underlying asset bought, after asset redemption + * @return The amount of GHO sold by the user + */ + function _buyAsset( + address originator, + uint256 minAmount, + address receiver + ) internal returns (uint256, uint256) { + uint256 initialGhoBalance = IGhoToken(GHO_TOKEN).balanceOf(address(this)); + uint256 initialIssuedAssetBalance = IERC20(ISSUED_ASSET).balanceOf(address(this)); + uint256 initialRedeemedAssetBalance = IERC20(REDEEMED_ASSET).balanceOf(address(this)); + + (, uint256 ghoAmount, , ) = IGsm(GSM).getGhoAmountForBuyAsset(minAmount); + + IGhoToken(GHO_TOKEN).transferFrom(originator, address(this), ghoAmount); + IGhoToken(GHO_TOKEN).approve(address(GSM), ghoAmount); + (uint256 boughtAssetAmount, uint256 ghoSold) = IGsm(GSM).buyAsset(minAmount, address(this)); + require(ghoAmount == ghoSold, 'INVALID_GHO_SOLD'); + IGhoToken(GHO_TOKEN).approve(address(GSM), 0); + + IERC20(ISSUED_ASSET).approve(address(REDEMPTION_CONTRACT), boughtAssetAmount); + IRedemption(REDEMPTION_CONTRACT).redeem(boughtAssetAmount); + // Redemption exchanges in 1:1 ratio between BUIDL and USDC + require( + IERC20(REDEEMED_ASSET).balanceOf(address(this)) == + initialRedeemedAssetBalance + boughtAssetAmount, + 'INVALID_REDEMPTION' + ); + IERC20(ISSUED_ASSET).approve(address(REDEMPTION_CONTRACT), 0); + IERC20(REDEEMED_ASSET).safeTransfer(receiver, boughtAssetAmount); + + require( + IGhoToken(GHO_TOKEN).balanceOf(address(this)) == initialGhoBalance, + 'INVALID_REMAINING_GHO_BALANCE' + ); + require( + IERC20(ISSUED_ASSET).balanceOf(address(this)) == initialIssuedAssetBalance, + 'INVALID_REMAINING_ISSUED_ASSET_BALANCE' + ); + + emit BuyAssetThroughRedemption(originator, receiver, boughtAssetAmount, ghoSold); + return (boughtAssetAmount, ghoSold); + } + + /** + * @notice Sells the GSM underlying asset in exchange for buying GHO, after asset conversion + * @param originator The originator of the request + * @param maxAmount The maximum amount of the underlying asset to sell + * @param receiver Recipient address of the GHO being purchased + * @return The amount of underlying asset sold, after asset conversion + * @return The amount of GHO bought by the user + */ + function _sellAsset( + address originator, + uint256 maxAmount, + address receiver + ) internal returns (uint256, uint256) { + uint256 initialGhoBalance = IGhoToken(GHO_TOKEN).balanceOf(address(this)); + uint256 initialIssuedAssetBalance = IERC20(ISSUED_ASSET).balanceOf(address(this)); + uint256 initialRedeemedAssetBalance = IERC20(REDEEMED_ASSET).balanceOf(address(this)); + + (uint256 assetAmount, , , ) = IGsm(GSM).getGhoAmountForSellAsset(maxAmount); // asset is BUIDL + IERC20(REDEEMED_ASSET).transferFrom(originator, address(this), assetAmount); + IERC20(REDEEMED_ASSET).approve(SUBSCRIPTION_CONTRACT, assetAmount); + //TODO: replace with proper issuance implementation later + MockBUIDLSubscription(SUBSCRIPTION_CONTRACT).issuance(assetAmount); + uint256 subscribedAssetAmount = IERC20(ISSUED_ASSET).balanceOf(address(this)) - + initialIssuedAssetBalance; + // TODO: probably will be fees from issuance, so need to adjust the logic + // only use this require only if preview of issuance is possible, otherwise it is redundant + require( + IERC20(ISSUED_ASSET).balanceOf(address(this)) == + initialIssuedAssetBalance + subscribedAssetAmount, + 'INVALID_ISSUANCE' + ); + // reset approval after issuance + IERC20(REDEEMED_ASSET).approve(SUBSCRIPTION_CONTRACT, 0); + + // TODO: account for fees for sellAsset amount param + (assetAmount, , , ) = IGsm(GSM).getGhoAmountForSellAsset(subscribedAssetAmount); // recalculate based on actual issuance amount, < maxAmount + IERC20(ISSUED_ASSET).approve(GSM, assetAmount); + (uint256 soldAssetAmount, uint256 ghoBought) = IGsm(GSM).sellAsset( + subscribedAssetAmount, + receiver + ); + // reset approval after sellAsset + IERC20(ISSUED_ASSET).approve(GSM, 0); + + // by the end of the transaction, this contract should not retain any of the assets + require( + IGhoToken(GHO_TOKEN).balanceOf(address(this)) == initialGhoBalance, + 'INVALID_REMAINING_GHO_BALANCE' + ); + require( + IERC20(REDEEMED_ASSET).balanceOf(address(this)) == initialRedeemedAssetBalance, + 'INVALID_REMAINING_REDEEMED_ASSET_BALANCE' + ); + require( + IERC20(ISSUED_ASSET).balanceOf(address(this)) == initialIssuedAssetBalance, + 'INVALID_REMAINING_ISSUED_ASSET_BALANCE' + ); + + emit SellAssetThroughSubscription(originator, receiver, soldAssetAmount, ghoBought); + return (soldAssetAmount, ghoBought); + } +} diff --git a/src/contracts/facilitators/gsm/converter/interfaces/IGsmConverter.sol b/src/contracts/facilitators/gsm/converter/interfaces/IGsmConverter.sol new file mode 100644 index 00000000..ebc7580d --- /dev/null +++ b/src/contracts/facilitators/gsm/converter/interfaces/IGsmConverter.sol @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.10; + +/** + * @title IGsmConverter + * @author Aave + * @notice Defines the behaviour of GSM Converters for conversions/redemptions between two non-GHO assets + * @dev + */ +interface IGsmConverter { + /** + * @dev Emitted when a user buys an asset (selling GHO) in the GSM after a redemption + * @param originator The address of the buyer originating the request + * @param receiver The address of the receiver of the underlying asset + * @param boughtAssetAmount The amount of the asset bought + * @param ghoAmount The amount of total GHO sold, inclusive of fee + */ + event BuyAssetThroughRedemption( + address indexed originator, + address indexed receiver, + uint256 boughtAssetAmount, + uint256 ghoAmount + ); + + /** + * @dev Emitted when a user sells an asset (buying GHO) in the GSM after an asset subscription + * @param originator The address of the seller originating the request + * @param receiver The address of the receiver of GHO + * @param soldAssetAmount The amount of the asset sold + * @param ghoAmount The amount of GHO bought, inclusive of fee + */ + event SellAssetThroughSubscription( + address indexed originator, + address indexed receiver, + uint256 soldAssetAmount, + uint256 ghoAmount + ); + + /** + * @dev Emitted when tokens are rescued from the GSM converter + * @param tokenRescued The address of the rescued token + * @param recipient The address that received the rescued tokens + * @param amountRescued The amount of token rescued + */ + event TokensRescued( + address indexed tokenRescued, + address indexed recipient, + uint256 amountRescued + ); + + /** + * @notice Buys the GSM underlying asset in exchange for selling GHO, after asset redemption + * @param minAmount The minimum amount of the underlying asset to buy (ie BUIDL) + * @param receiver Recipient address of the underlying asset being purchased + * @return The amount of underlying asset bought, after asset redemption + * @return The amount of GHO sold by the user + */ + function buyAsset(uint256 minAmount, address receiver) external returns (uint256, uint256); + + /** + * @notice Buys the GSM underlying asset in exchange for selling GHO after asset redemption, using an EIP-712 signature + * @dev Use `getAssetAmountForBuyAsset` function to calculate the amount based on the GHO amount to sell + * @param originator The signer of the request + * @param minAmount The minimum amount of the underlying asset to buy + * @param receiver Recipient address of the underlying asset being purchased + * @param deadline Signature expiration deadline + * @param signature Signature data + * @return The amount of underlying asset bought + * @return The amount of GHO sold by the user + */ + function buyAssetWithSig( + address originator, + uint256 minAmount, + address receiver, + uint256 deadline, + bytes calldata signature + ) external returns (uint256, uint256); + + /** + * @notice Sells the GSM underlying asset in exchange for buying GHO after asset issuance, using an EIP-712 signature + * @dev Use `getAssetAmountForSellAsset` function to calculate the amount based on the GHO amount to buy + * @param originator The signer of the request + * @param maxAmount The maximum amount of the underlying asset to sell + * @param receiver Recipient address of the GHO being purchased + * @param deadline Signature expiration deadline + * @param signature Signature data + * @return The amount of underlying asset sold + * @return The amount of GHO bought by the user + */ + function sellAssetWithSig( + address originator, + uint256 maxAmount, + address receiver, + uint256 deadline, + bytes calldata signature + ) external returns (uint256, uint256); + + /** + * @notice Sells the GSM underlying asset in exchange for buying GHO, after asset conversion + * @param maxAmount The maximum amount of the underlying asset to sell + * @param receiver Recipient address of the GHO being purchased + * @return The amount of underlying asset sold, after asset conversion + * @return The amount of GHO bought by the user + */ + function sellAsset(uint256 maxAmount, address receiver) external returns (uint256, uint256); + + /** + * @notice Rescue and transfer tokens locked in this contract + * @param token The address of the token + * @param to The address of the recipient + * @param amount The amount of token to transfer + */ + function rescueTokens(address token, address to, uint256 amount) external; + + /** + * @notice Returns the address of the GHO token + * @return The address of GHO token contract + */ + function GHO_TOKEN() external view returns (address); + + /** + * @notice Returns the address of the GSM contract associated with the converter + * @return The address of the GSM contract + */ + function GSM() external view returns (address); + + /** + * @notice Returns the address of the issued asset (token) associated with the converter + * @return The address of the issued asset + */ + function ISSUED_ASSET() external view returns (address); + + /** + * @notice Returns the address of the redeemed asset (token) associated with the converter + * @return The address of the redeemed asset + */ + function REDEEMED_ASSET() external view returns (address); + + /** + * @notice Returns the address of the redemption contract that manages asset redemptions + * @return The address of the redemption contract + */ + function REDEMPTION_CONTRACT() external view returns (address); + + /** + * @notice Returns the address of the subscription contract that manages asset issuance + * @return The address of the subscription contract + */ + function SUBSCRIPTION_CONTRACT() external view returns (address); + + /** + * @notice Returns the current nonce (for EIP-712 signature methods) of an address + * @param user The address of the user + * @return The current nonce of the user + */ + function nonces(address user) external view returns (uint256); + + /** + * @notice Returns the EIP712 domain separator + * @return The EIP712 domain separator + */ + function DOMAIN_SEPARATOR() external view returns (bytes32); + + /** + * @notice Returns the EIP-712 signature typehash for buyAssetWithSig + * @return The bytes32 signature typehash for buyAssetWithSig + */ + function BUY_ASSET_WITH_SIG_TYPEHASH() external pure returns (bytes32); + + /** + * @notice Returns the EIP-712 signature typehash for sellAssetWithSig + * @return The bytes32 signature typehash for sellAssetWithSig + */ + function SELL_ASSET_WITH_SIG_TYPEHASH() external pure returns (bytes32); +} diff --git a/src/contracts/facilitators/gsm/dependencies/circle/IRedemption.sol b/src/contracts/facilitators/gsm/dependencies/circle/IRedemption.sol new file mode 100644 index 00000000..509ac5a2 --- /dev/null +++ b/src/contracts/facilitators/gsm/dependencies/circle/IRedemption.sol @@ -0,0 +1,42 @@ +/** + * Copyright 2024 Circle Internet Financial, LTD. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +pragma solidity ^0.8.10; + +/** + * @title IRedemption + */ +interface IRedemption { + /** + * @notice The asset being redeemed. + * @return The address of the asset token. + */ + function asset() external view returns (address); + + /** + * @notice The liquidity token that the asset is being redeemed for. + * @return The address of the liquidity token. + */ + function liquidity() external view returns (address); + + /** + * @notice Redeems an amount of asset for liquidity + * @param amount The amount of the asset token to redeem + */ + function redeem(uint256 amount) external; +} diff --git a/src/test/TestGhoBase.t.sol b/src/test/TestGhoBase.t.sol index b382234c..ed376b04 100644 --- a/src/test/TestGhoBase.t.sol +++ b/src/test/TestGhoBase.t.sol @@ -22,6 +22,8 @@ import {MockAclManager} from './mocks/MockAclManager.sol'; import {MockConfigurator} from './mocks/MockConfigurator.sol'; import {MockFlashBorrower} from './mocks/MockFlashBorrower.sol'; import {MockGsmV2} from './mocks/MockGsmV2.sol'; +import {MockGsmFailedBuyAssetRemainingGhoBalance} from './mocks/MockGsmFailedBuyAssetRemainingGhoBalance.sol'; +import {MockGsmFailedSellAssetRemainingGhoBalance} from './mocks/MockGsmFailedSellAssetRemainingGhoBalance.sol'; import {MockPool} from './mocks/MockPool.sol'; import {MockAddressesProvider} from './mocks/MockAddressesProvider.sol'; import {MockERC4626} from './mocks/MockERC4626.sol'; @@ -29,6 +31,12 @@ import {MockUpgradeable} from './mocks/MockUpgradeable.sol'; import {PriceOracle} from '@aave/core-v3/contracts/mocks/oracle/PriceOracle.sol'; import {TestnetERC20} from '@aave/periphery-v3/contracts/mocks/testnet-helpers/TestnetERC20.sol'; import {WETH9Mock} from '@aave/periphery-v3/contracts/mocks/WETH9Mock.sol'; +import {MockRedemption} from './mocks/MockRedemption.sol'; +import {MockRedemptionFailedIssuedAssetAmount} from './mocks/MockRedemptionFailedIssuedAssetAmount.sol'; +import {MockRedemptionFailed} from './mocks/MockRedemptionFailed.sol'; +import {MockBUIDLSubscription} from './mocks/MockBUIDLSubscription.sol'; +import {MockBUIDLSubscriptionFailed} from './mocks/MockBUIDLSubscriptionFailed.sol'; +import {MockBUIDLSubscriptionFailedInvalidUSDCAccepted} from './mocks/MockBUIDLSubscriptionFailedInvalidUSDCAccepted.sol'; import {MockPoolDataProvider} from './mocks/MockPoolDataProvider.sol'; // interfaces @@ -86,6 +94,7 @@ import {RateLimiter} from '../contracts/misc/dependencies/Ccip.sol'; import {IGhoCcipSteward} from '../contracts/misc/interfaces/IGhoCcipSteward.sol'; import {GhoCcipSteward} from '../contracts/misc/GhoCcipSteward.sol'; import {GhoBucketSteward} from '../contracts/misc/GhoBucketSteward.sol'; +import {GsmConverter} from '../contracts/facilitators/gsm/converter/GsmConverter.sol'; contract TestGhoBase is Test, Constants, Events { using WadRayMath for uint256; @@ -110,11 +119,18 @@ contract TestGhoBase is Test, Constants, Events { TestnetERC20 AAVE_TOKEN; IStakedAaveV3 STK_TOKEN; TestnetERC20 USDC_TOKEN; + TestnetERC20 BUIDL_TOKEN; MockERC4626 USDC_4626_TOKEN; MockPool POOL; MockAclManager ACL_MANAGER; MockAddressesProvider PROVIDER; MockConfigurator CONFIGURATOR; + MockRedemption BUIDL_USDC_REDEMPTION; + MockRedemptionFailedIssuedAssetAmount BUIDL_USDC_REDEMPTION_FAILED_ISSUED_ASSET_AMOUNT; + MockRedemptionFailed BUIDL_USDC_REDEMPTION_FAILED; + MockBUIDLSubscription BUIDL_USDC_ISSUANCE; + MockBUIDLSubscriptionFailed BUIDL_USDC_ISSUANCE_FAILED; + MockBUIDLSubscriptionFailedInvalidUSDCAccepted BUIDL_USDC_ISSUANCE_FAILED_INVALID_USDC; PriceOracle PRICE_ORACLE; WETH9Mock WETH; GhoVariableDebtToken GHO_DEBT_TOKEN; @@ -125,7 +141,10 @@ contract TestGhoBase is Test, Constants, Events { MockFlashBorrower FLASH_BORROWER; Gsm GHO_GSM; Gsm4626 GHO_GSM_4626; + Gsm GHO_BUIDL_GSM; + GsmConverter GSM_CONVERTER; FixedPriceStrategy GHO_GSM_FIXED_PRICE_STRATEGY; + FixedPriceStrategy GHO_BUIDL_GSM_FIXED_PRICE_STRATEGY; FixedPriceStrategy4626 GHO_GSM_4626_FIXED_PRICE_STRATEGY; FixedFeeStrategy GHO_GSM_FIXED_FEE_STRATEGY; SampleLiquidator GHO_GSM_LAST_RESORT_LIQUIDATOR; @@ -189,6 +208,12 @@ contract TestGhoBase is Test, Constants, Events { ); STK_TOKEN = IStakedAaveV3(address(stkAaveProxy)); USDC_TOKEN = new TestnetERC20('USD Coin', 'USDC', 6, FAUCET); + BUIDL_TOKEN = new TestnetERC20( + 'BlackRock USD Institutional Digital Liquidity Fund', + 'BUIDL', + 6, + FAUCET + ); USDC_4626_TOKEN = new MockERC4626('USD Coin 4626', '4626', address(USDC_TOKEN)); IPool iPool = IPool(address(POOL)); WETH = new WETH9Mock('Wrapped Ether', 'WETH', FAUCET); @@ -259,6 +284,12 @@ contract TestGhoBase is Test, Constants, Events { address(USDC_4626_TOKEN), 6 ); + GHO_BUIDL_GSM_FIXED_PRICE_STRATEGY = new FixedPriceStrategy( + DEFAULT_FIXED_PRICE, + address(BUIDL_TOKEN), + 6 + ); + GHO_GSM_FIXED_FEE_STRATEGY = new FixedFeeStrategy(DEFAULT_GSM_BUY_FEE, DEFAULT_GSM_SELL_FEE); GHO_GSM_LAST_RESORT_LIQUIDATOR = new SampleLiquidator(); GHO_GSM_SWAP_FREEZER = new SampleSwapFreezer(); Gsm gsm = new Gsm( @@ -281,9 +312,23 @@ contract TestGhoBase is Test, Constants, Events { ); GHO_GSM_4626.initialize(address(this), TREASURY, DEFAULT_GSM_USDC_EXPOSURE); + Gsm buidlGsm = new Gsm( + address(GHO_TOKEN), + address(BUIDL_TOKEN), + address(GHO_BUIDL_GSM_FIXED_PRICE_STRATEGY) + ); + AdminUpgradeabilityProxy buidlGsmProxy = new AdminUpgradeabilityProxy( + address(buidlGsm), + SHORT_EXECUTOR, + '' + ); + GHO_BUIDL_GSM = Gsm(address(buidlGsmProxy)); + GHO_BUIDL_GSM.initialize(address(this), TREASURY, DEFAULT_GSM_BUIDL_EXPOSURE); + GHO_GSM_FIXED_FEE_STRATEGY = new FixedFeeStrategy(DEFAULT_GSM_BUY_FEE, DEFAULT_GSM_SELL_FEE); GHO_GSM.updateFeeStrategy(address(GHO_GSM_FIXED_FEE_STRATEGY)); GHO_GSM_4626.updateFeeStrategy(address(GHO_GSM_FIXED_FEE_STRATEGY)); + GHO_BUIDL_GSM.updateFeeStrategy(address(GHO_GSM_FIXED_FEE_STRATEGY)); GHO_GSM.grantRole(GSM_LIQUIDATOR_ROLE, address(GHO_GSM_LAST_RESORT_LIQUIDATOR)); GHO_GSM.grantRole(GSM_SWAP_FREEZER_ROLE, address(GHO_GSM_SWAP_FREEZER)); @@ -292,8 +337,8 @@ contract TestGhoBase is Test, Constants, Events { GHO_TOKEN.addFacilitator(address(GHO_GSM), 'GSM Facilitator', DEFAULT_CAPACITY); GHO_TOKEN.addFacilitator(address(GHO_GSM_4626), 'GSM 4626 Facilitator', DEFAULT_CAPACITY); - GHO_TOKEN.addFacilitator(FAUCET, 'Faucet Facilitator', type(uint128).max); + GHO_TOKEN.addFacilitator(address(GHO_BUIDL_GSM), 'GSM BUIDL Facilitator', DEFAULT_CAPACITY); GHO_GSM_REGISTRY = new GsmRegistry(address(this)); FIXED_RATE_STRATEGY_FACTORY = new FixedRateStrategyFactory(address(PROVIDER)); @@ -353,6 +398,33 @@ contract TestGhoBase is Test, Constants, Events { }); vm.prank(OWNER); GHO_TOKEN_POOL.applyChainUpdates(chainUpdate); + + BUIDL_USDC_REDEMPTION = new MockRedemption(address(BUIDL_TOKEN), address(USDC_TOKEN)); + BUIDL_USDC_REDEMPTION_FAILED_ISSUED_ASSET_AMOUNT = new MockRedemptionFailedIssuedAssetAmount( + address(BUIDL_TOKEN), + address(USDC_TOKEN) + ); + BUIDL_USDC_REDEMPTION_FAILED = new MockRedemptionFailed( + address(BUIDL_TOKEN), + address(USDC_TOKEN) + ); + BUIDL_USDC_ISSUANCE = new MockBUIDLSubscription(address(BUIDL_TOKEN), address(USDC_TOKEN)); + BUIDL_USDC_ISSUANCE_FAILED = new MockBUIDLSubscriptionFailed( + address(BUIDL_TOKEN), + address(USDC_TOKEN) + ); + BUIDL_USDC_ISSUANCE_FAILED_INVALID_USDC = new MockBUIDLSubscriptionFailedInvalidUSDCAccepted( + address(BUIDL_TOKEN), + address(USDC_TOKEN) + ); + GSM_CONVERTER = new GsmConverter( + address(this), + address(GHO_BUIDL_GSM), + address(BUIDL_USDC_REDEMPTION), + address(BUIDL_USDC_ISSUANCE), + address(BUIDL_TOKEN), + address(USDC_TOKEN) + ); } function ghoFaucet(address to, uint256 amount) public { diff --git a/src/test/TestGhoToken.t.sol b/src/test/TestGhoToken.t.sol index c3f94148..8f297769 100644 --- a/src/test/TestGhoToken.t.sol +++ b/src/test/TestGhoToken.t.sol @@ -43,7 +43,7 @@ contract TestGhoToken is TestGhoBase { function testGetPopulatedFacilitatorsList() public { address[] memory facilitatorList = GHO_TOKEN.getFacilitatorsList(); - assertEq(facilitatorList.length, 6, 'Unexpected number of facilitators'); + assertEq(facilitatorList.length, 7, 'Unexpected number of facilitators'); assertEq(facilitatorList[0], address(GHO_ATOKEN), 'Unexpected address for mock facilitator 1'); assertEq( facilitatorList[1], diff --git a/src/test/TestGsmConverter.t.sol b/src/test/TestGsmConverter.t.sol new file mode 100644 index 00000000..2e7de5df --- /dev/null +++ b/src/test/TestGsmConverter.t.sol @@ -0,0 +1,2474 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import './TestGhoBase.t.sol'; + +contract TestGsmConverter is TestGhoBase { + // using PercentageMath for uint256; + // using PercentageMath for uint128; + + address public gsmConverterSignerAddr; + uint256 public gsmConverterSignerKey; + + function setUp() public {} + + function testConstructor() public { + GsmConverter gsmConverter = new GsmConverter( + address(this), + address(GHO_BUIDL_GSM), + address(BUIDL_USDC_REDEMPTION), + address(BUIDL_USDC_ISSUANCE), + address(BUIDL_TOKEN), + address(USDC_TOKEN) + ); + assertEq(gsmConverter.owner(), address(this), 'Unexpected default admin address'); + assertEq(gsmConverter.GSM(), address(GHO_BUIDL_GSM), 'Unexpected GSM address'); + assertEq( + gsmConverter.REDEMPTION_CONTRACT(), + address(BUIDL_USDC_REDEMPTION), + 'Unexpected redemption contract address' + ); + assertEq( + gsmConverter.SUBSCRIPTION_CONTRACT(), + address(BUIDL_USDC_ISSUANCE), + 'Unexpected issuance receiver contract address' + ); + assertEq(gsmConverter.ISSUED_ASSET(), address(BUIDL_TOKEN), 'Unexpected issued asset address'); + assertEq( + gsmConverter.REDEEMED_ASSET(), + address(USDC_TOKEN), + 'Unexpected redeemed asset address' + ); + } + + function testRevertConstructorZeroAddressParams() public { + vm.expectRevert('ZERO_ADDRESS_NOT_VALID'); + new GsmConverter( + address(0), + address(GHO_BUIDL_GSM), + address(BUIDL_USDC_REDEMPTION), + address(BUIDL_USDC_ISSUANCE), + address(BUIDL_TOKEN), + address(USDC_TOKEN) + ); + + vm.expectRevert('ZERO_ADDRESS_NOT_VALID'); + new GsmConverter( + address(this), + address(0), + address(BUIDL_USDC_REDEMPTION), + address(BUIDL_USDC_ISSUANCE), + address(BUIDL_TOKEN), + address(USDC_TOKEN) + ); + + vm.expectRevert('ZERO_ADDRESS_NOT_VALID'); + new GsmConverter( + address(this), + address(GHO_BUIDL_GSM), + address(0), + address(BUIDL_USDC_ISSUANCE), + address(BUIDL_TOKEN), + address(USDC_TOKEN) + ); + + vm.expectRevert('ZERO_ADDRESS_NOT_VALID'); + new GsmConverter( + address(this), + address(GHO_BUIDL_GSM), + address(BUIDL_USDC_REDEMPTION), + address(0), + address(BUIDL_TOKEN), + address(USDC_TOKEN) + ); + + vm.expectRevert('ZERO_ADDRESS_NOT_VALID'); + new GsmConverter( + address(this), + address(GHO_BUIDL_GSM), + address(BUIDL_USDC_REDEMPTION), + address(BUIDL_USDC_ISSUANCE), + address(0), + address(USDC_TOKEN) + ); + + vm.expectRevert('ZERO_ADDRESS_NOT_VALID'); + new GsmConverter( + address(this), + address(GHO_BUIDL_GSM), + address(BUIDL_USDC_REDEMPTION), + address(BUIDL_USDC_ISSUANCE), + address(BUIDL_TOKEN), + address(0) + ); + } + + function testSellAsset() public { + ( + uint256 expectedIssuedAssetAmount, + uint256 expectedGhoBought, + , + uint256 sellFee + ) = GHO_BUIDL_GSM.getGhoAmountForSellAsset(DEFAULT_GSM_BUIDL_AMOUNT); + + vm.startPrank(FAUCET); + // Supply USDC to buyer + USDC_TOKEN.mint(ALICE, expectedIssuedAssetAmount); + // Supply BUIDL to issuance contract + BUIDL_TOKEN.mint(address(BUIDL_USDC_ISSUANCE), expectedIssuedAssetAmount); + vm.stopPrank(); + + vm.startPrank(ALICE); + USDC_TOKEN.approve(address(GSM_CONVERTER), expectedIssuedAssetAmount); + + vm.expectEmit(true, true, true, true, address(GSM_CONVERTER)); + emit SellAssetThroughSubscription(ALICE, ALICE, expectedIssuedAssetAmount, expectedGhoBought); + (uint256 assetAmount, uint256 ghoBought) = GSM_CONVERTER.sellAsset( + DEFAULT_GSM_BUIDL_AMOUNT, + ALICE + ); + vm.stopPrank(); + + assertEq(ghoBought, expectedGhoBought, 'Unexpected GHO bought amount'); + assertEq(assetAmount, expectedIssuedAssetAmount, 'Unexpected asset amount sold'); + assertEq(USDC_TOKEN.balanceOf(ALICE), 0, 'Unexpected seller final USDC balance'); + assertEq(GHO_TOKEN.balanceOf(ALICE), ghoBought, 'Unexpected seller final GHO balance'); + assertEq( + BUIDL_TOKEN.balanceOf(ALICE), + 0, + 'Unexpected seller final BUIDL (issued asset) balance' + ); + assertEq(USDC_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), 0, 'Unexpected GSM final USDC balance'); + assertEq( + BUIDL_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + assetAmount, + 'Unexpected GSM final BUIDL balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + sellFee, + 'Unexpected GSM final GHO balance' + ); + assertEq( + USDC_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final USDC balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final BUIDL balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + 0, + 'Unexpected Issuance final BUIDL balance' + ); + assertEq( + USDC_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + expectedIssuedAssetAmount, + 'Unexpected Issuance final USDC balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + 0, + 'Unexpected Issuance final GHO balance' + ); + } + + function testSellAssetSendToOther() public { + ( + uint256 expectedIssuedAssetAmount, + uint256 expectedGhoBought, + , + uint256 sellFee + ) = GHO_BUIDL_GSM.getGhoAmountForSellAsset(DEFAULT_GSM_BUIDL_AMOUNT); + + vm.startPrank(FAUCET); + // Supply USDC to buyer + USDC_TOKEN.mint(ALICE, expectedIssuedAssetAmount); + // Supply BUIDL to issuance contract + BUIDL_TOKEN.mint(address(BUIDL_USDC_ISSUANCE), expectedIssuedAssetAmount); + vm.stopPrank(); + + vm.startPrank(ALICE); + USDC_TOKEN.approve(address(GSM_CONVERTER), expectedIssuedAssetAmount); + + vm.expectEmit(true, true, true, true, address(GSM_CONVERTER)); + emit SellAssetThroughSubscription(ALICE, BOB, expectedIssuedAssetAmount, expectedGhoBought); + (uint256 assetAmount, uint256 ghoBought) = GSM_CONVERTER.sellAsset( + DEFAULT_GSM_BUIDL_AMOUNT, + BOB + ); + vm.stopPrank(); + + assertEq(ghoBought, expectedGhoBought, 'Unexpected GHO bought amount'); + assertEq(assetAmount, expectedIssuedAssetAmount, 'Unexpected asset amount sold'); + assertEq(USDC_TOKEN.balanceOf(BOB), 0, 'Unexpected receiver final USDC balance'); + assertEq(GHO_TOKEN.balanceOf(BOB), ghoBought, 'Unexpected receiver final GHO balance'); + assertEq( + BUIDL_TOKEN.balanceOf(BOB), + 0, + 'Unexpected receiver final BUIDL (issued asset) balance' + ); + assertEq(USDC_TOKEN.balanceOf(ALICE), 0, 'Unexpected seller final USDC balance'); + assertEq(GHO_TOKEN.balanceOf(ALICE), 0, 'Unexpected seller final GHO balance'); + assertEq( + BUIDL_TOKEN.balanceOf(ALICE), + 0, + 'Unexpected seller final BUIDL (issued asset) balance' + ); + assertEq(USDC_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), 0, 'Unexpected GSM final USDC balance'); + assertEq( + BUIDL_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + assetAmount, + 'Unexpected GSM final BUIDL balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + sellFee, + 'Unexpected GSM final GHO balance' + ); + assertEq( + USDC_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final USDC balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final BUIDL balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + 0, + 'Unexpected Issuance final BUIDL balance' + ); + assertEq( + USDC_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + expectedIssuedAssetAmount, + 'Unexpected Issuance final USDC balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + 0, + 'Unexpected Issuance final GHO balance' + ); + } + + function testSellAssetDonatedTokens() public { + uint256 sellFee = GHO_GSM_FIXED_FEE_STRATEGY.getSellFee(DEFAULT_GSM_GHO_AMOUNT); + (uint256 expectedIssuedAssetAmount, uint256 expectedGhoBought, , ) = GHO_BUIDL_GSM + .getGhoAmountForSellAsset(DEFAULT_GSM_BUIDL_AMOUNT); + + uint256 donatedAmount = 1e6; + + vm.startPrank(FAUCET); + // Supply USDC to buyer + USDC_TOKEN.mint(ALICE, expectedIssuedAssetAmount); + // Supply BUIDL to issuance contract + BUIDL_TOKEN.mint(address(BUIDL_USDC_ISSUANCE), expectedIssuedAssetAmount); + // donate tokens to the converter + BUIDL_TOKEN.mint(address(GSM_CONVERTER), donatedAmount); + USDC_TOKEN.mint(address(GSM_CONVERTER), donatedAmount); + vm.stopPrank(); + ghoFaucet(address(GSM_CONVERTER), donatedAmount); + + vm.startPrank(ALICE); + USDC_TOKEN.approve(address(GSM_CONVERTER), expectedIssuedAssetAmount); + + vm.expectEmit(true, true, true, true, address(GSM_CONVERTER)); + emit SellAssetThroughSubscription(ALICE, ALICE, expectedIssuedAssetAmount, expectedGhoBought); + (uint256 assetAmount, uint256 ghoBought) = GSM_CONVERTER.sellAsset( + DEFAULT_GSM_BUIDL_AMOUNT, + ALICE + ); + vm.stopPrank(); + + assertEq(ghoBought, expectedGhoBought, 'Unexpected GHO bought amount'); + assertEq(assetAmount, expectedIssuedAssetAmount, 'Unexpected asset amount sold'); + assertEq(USDC_TOKEN.balanceOf(ALICE), 0, 'Unexpected seller final USDC balance'); + assertEq(GHO_TOKEN.balanceOf(ALICE), ghoBought, 'Unexpected seller final GHO balance'); + assertEq( + BUIDL_TOKEN.balanceOf(ALICE), + 0, + 'Unexpected seller final BUIDL (issued asset) balance' + ); + assertEq(USDC_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), 0, 'Unexpected GSM final USDC balance'); + assertEq( + BUIDL_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + assetAmount, + 'Unexpected GSM final BUIDL balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + sellFee, + 'Unexpected GSM final GHO balance' + ); + assertEq( + USDC_TOKEN.balanceOf(address(GSM_CONVERTER)), + donatedAmount, + 'Unexpected GSM_CONVERTER final USDC balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GSM_CONVERTER)), + donatedAmount, + 'Unexpected GSM_CONVERTER final BUIDL balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GSM_CONVERTER)), + donatedAmount, + 'Unexpected GSM_CONVERTER final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + 0, + 'Unexpected Issuance final BUIDL balance' + ); + assertEq( + USDC_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + expectedIssuedAssetAmount, + 'Unexpected Issuance final USDC balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + 0, + 'Unexpected Issuance final GHO balance' + ); + } + + function testFuzzSellAssetMaxAmount(uint256 maxAmount) public { + maxAmount = bound(maxAmount, 1, GHO_BUIDL_GSM.getExposureCap()); + ( + uint256 expectedIssuedAssetAmount, + uint256 expectedGhoBought, + , + uint256 sellFee + ) = GHO_BUIDL_GSM.getGhoAmountForSellAsset(maxAmount); + + vm.startPrank(FAUCET); + // Supply USDC to buyer + USDC_TOKEN.mint(ALICE, expectedIssuedAssetAmount); + // Supply BUIDL to issuance contract + BUIDL_TOKEN.mint(address(BUIDL_USDC_ISSUANCE), expectedIssuedAssetAmount); + vm.stopPrank(); + + vm.startPrank(ALICE); + USDC_TOKEN.approve(address(GSM_CONVERTER), expectedIssuedAssetAmount); + + vm.expectEmit(true, true, true, true, address(GSM_CONVERTER)); + emit SellAssetThroughSubscription(ALICE, ALICE, expectedIssuedAssetAmount, expectedGhoBought); + (uint256 assetAmount, uint256 ghoBought) = GSM_CONVERTER.sellAsset(maxAmount, ALICE); + vm.stopPrank(); + + assertEq(ghoBought, expectedGhoBought, 'Unexpected GHO bought amount'); + assertEq(assetAmount, expectedIssuedAssetAmount, 'Unexpected asset amount sold'); + assertEq(USDC_TOKEN.balanceOf(ALICE), 0, 'Unexpected seller final USDC balance'); + assertEq(GHO_TOKEN.balanceOf(ALICE), ghoBought, 'Unexpected seller final GHO balance'); + assertEq( + BUIDL_TOKEN.balanceOf(ALICE), + 0, + 'Unexpected seller final BUIDL (issued asset) balance' + ); + assertEq(USDC_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), 0, 'Unexpected GSM final USDC balance'); + assertEq( + BUIDL_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + assetAmount, + 'Unexpected GSM final BUIDL balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + sellFee, + 'Unexpected GSM final GHO balance' + ); + assertEq( + USDC_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final USDC balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final BUIDL balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + 0, + 'Unexpected Issuance final BUIDL balance' + ); + assertEq( + USDC_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + expectedIssuedAssetAmount, + 'Unexpected Issuance final USDC balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + 0, + 'Unexpected Issuance final GHO balance' + ); + } + + function testFuzzSellAssetDonatedTokens( + uint256 donatedIssuedAsset, + uint256 donatedRedeemedAsset, + uint256 donatedGHO + ) public { + donatedIssuedAsset = bound(donatedIssuedAsset, 1, 1e27); + donatedRedeemedAsset = bound(donatedRedeemedAsset, 1, 1e27); + (uint256 bucketCapacity, ) = GHO_TOKEN.getFacilitatorBucket(FAUCET); + donatedGHO = bound(donatedGHO, 1, bucketCapacity); + + uint256 sellFee = GHO_GSM_FIXED_FEE_STRATEGY.getSellFee(DEFAULT_GSM_GHO_AMOUNT); + (uint256 expectedIssuedAssetAmount, uint256 expectedGhoBought, , ) = GHO_BUIDL_GSM + .getGhoAmountForSellAsset(DEFAULT_GSM_BUIDL_AMOUNT); + + vm.startPrank(FAUCET); + // Supply USDC to buyer + USDC_TOKEN.mint(ALICE, expectedIssuedAssetAmount); + // Supply BUIDL to issuance contract + BUIDL_TOKEN.mint(address(BUIDL_USDC_ISSUANCE), expectedIssuedAssetAmount); + // donate tokens to the converter + BUIDL_TOKEN.mint(address(GSM_CONVERTER), donatedIssuedAsset); + USDC_TOKEN.mint(address(GSM_CONVERTER), donatedRedeemedAsset); + vm.stopPrank(); + ghoFaucet(address(GSM_CONVERTER), donatedGHO); + + vm.startPrank(ALICE); + USDC_TOKEN.approve(address(GSM_CONVERTER), expectedIssuedAssetAmount); + + vm.expectEmit(true, true, true, true, address(GSM_CONVERTER)); + emit SellAssetThroughSubscription(ALICE, ALICE, expectedIssuedAssetAmount, expectedGhoBought); + (uint256 assetAmount, uint256 ghoBought) = GSM_CONVERTER.sellAsset( + DEFAULT_GSM_BUIDL_AMOUNT, + ALICE + ); + vm.stopPrank(); + + assertEq(ghoBought, expectedGhoBought, 'Unexpected GHO bought amount'); + assertEq(assetAmount, expectedIssuedAssetAmount, 'Unexpected asset amount sold'); + assertEq(USDC_TOKEN.balanceOf(ALICE), 0, 'Unexpected seller final USDC balance'); + assertEq(GHO_TOKEN.balanceOf(ALICE), ghoBought, 'Unexpected seller final GHO balance'); + assertEq( + BUIDL_TOKEN.balanceOf(ALICE), + 0, + 'Unexpected seller final BUIDL (issued asset) balance' + ); + assertEq(USDC_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), 0, 'Unexpected GSM final USDC balance'); + assertEq( + BUIDL_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + assetAmount, + 'Unexpected GSM final BUIDL balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + sellFee, + 'Unexpected GSM final GHO balance' + ); + assertEq( + USDC_TOKEN.balanceOf(address(GSM_CONVERTER)), + donatedRedeemedAsset, + 'Unexpected GSM_CONVERTER final USDC balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GSM_CONVERTER)), + donatedIssuedAsset, + 'Unexpected GSM_CONVERTER final BUIDL balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GSM_CONVERTER)), + donatedGHO, + 'Unexpected GSM_CONVERTER final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + 0, + 'Unexpected Issuance final BUIDL balance' + ); + assertEq( + USDC_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + expectedIssuedAssetAmount, + 'Unexpected Issuance final USDC balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + 0, + 'Unexpected Issuance final GHO balance' + ); + } + + function testRevertSellAssetZeroAmount() public { + vm.prank(ALICE); + vm.expectRevert('INVALID_MAX_AMOUNT'); + GSM_CONVERTER.sellAsset(0, ALICE); + } + + function testRevertSellAssetNoAsset() public { + vm.startPrank(ALICE); + BUIDL_TOKEN.approve(address(GHO_GSM), DEFAULT_GSM_BUIDL_AMOUNT); + vm.expectRevert('ERC20: transfer amount exceeds balance'); + GSM_CONVERTER.sellAsset(DEFAULT_GSM_BUIDL_AMOUNT, ALICE); + vm.stopPrank(); + } + + function testRevertSellAssetNoAllowanceRedeemedAsset() public { + vm.prank(FAUCET); + USDC_TOKEN.mint(ALICE, DEFAULT_GSM_BUIDL_AMOUNT); + + vm.prank(ALICE); + vm.expectRevert('ERC20: transfer amount exceeds allowance'); + GSM_CONVERTER.sellAsset(DEFAULT_GSM_BUIDL_AMOUNT, ALICE); + } + + function testRevertSellAssetInvalidIssuance() public { + GsmConverter gsmConverter = new GsmConverter( + address(this), + address(GHO_BUIDL_GSM), + address(BUIDL_USDC_REDEMPTION), + address(BUIDL_USDC_ISSUANCE_FAILED), + address(BUIDL_TOKEN), + address(USDC_TOKEN) + ); + + vm.startPrank(FAUCET); + USDC_TOKEN.mint(ALICE, DEFAULT_GSM_BUIDL_AMOUNT); + + vm.startPrank(ALICE); + USDC_TOKEN.approve(address(gsmConverter), DEFAULT_GSM_BUIDL_AMOUNT); + vm.expectRevert('INVALID_AMOUNT'); + gsmConverter.sellAsset(DEFAULT_GSM_BUIDL_AMOUNT, ALICE); + vm.stopPrank(); + } + + function testRevertSellAssetInvalidRemainingGhoBalance() public { + _upgradeToGsmFailedSellAssetRemainingGhoBalance(); + + vm.startPrank(FAUCET); + USDC_TOKEN.mint(ALICE, DEFAULT_GSM_BUIDL_AMOUNT); + BUIDL_TOKEN.mint(address(BUIDL_USDC_ISSUANCE), DEFAULT_GSM_BUIDL_AMOUNT); + vm.stopPrank(); + + vm.startPrank(ALICE); + USDC_TOKEN.approve(address(GSM_CONVERTER), DEFAULT_GSM_BUIDL_AMOUNT); + vm.expectRevert('INVALID_REMAINING_GHO_BALANCE'); + GSM_CONVERTER.sellAsset(DEFAULT_GSM_BUIDL_AMOUNT, ALICE); + } + + function testRevertSellAssetInvalidRedeemedAssetBalance() public { + GsmConverter gsmConverter = new GsmConverter( + address(this), + address(GHO_BUIDL_GSM), + address(BUIDL_USDC_REDEMPTION), + address(BUIDL_USDC_ISSUANCE_FAILED_INVALID_USDC), + address(BUIDL_TOKEN), + address(USDC_TOKEN) + ); + + vm.startPrank(FAUCET); + USDC_TOKEN.mint(ALICE, DEFAULT_GSM_BUIDL_AMOUNT); + BUIDL_TOKEN.mint(address(BUIDL_USDC_ISSUANCE_FAILED_INVALID_USDC), DEFAULT_GSM_BUIDL_AMOUNT); + vm.stopPrank(); + + vm.startPrank(ALICE); + USDC_TOKEN.approve(address(gsmConverter), DEFAULT_GSM_BUIDL_AMOUNT); + vm.expectRevert('INVALID_REMAINING_REDEEMED_ASSET_BALANCE'); + gsmConverter.sellAsset(DEFAULT_GSM_BUIDL_AMOUNT, ALICE); + } + + function testSellAssetWithSig() public { + (gsmConverterSignerAddr, gsmConverterSignerKey) = makeAddrAndKey('randomString'); + + uint256 deadline = block.timestamp + 1 hours; + uint256 sellFee = GHO_GSM_FIXED_FEE_STRATEGY.getSellFee(DEFAULT_GSM_GHO_AMOUNT); + (uint256 expectedIssuedAssetAmount, uint256 expectedGhoBought, , ) = GHO_BUIDL_GSM + .getGhoAmountForSellAsset(DEFAULT_GSM_BUIDL_AMOUNT); + + vm.startPrank(FAUCET); + // Supply USDC to buyer + USDC_TOKEN.mint(gsmConverterSignerAddr, expectedIssuedAssetAmount); + // Supply BUIDL to issuance contract + BUIDL_TOKEN.mint(address(BUIDL_USDC_ISSUANCE), expectedIssuedAssetAmount); + vm.stopPrank(); + + vm.prank(gsmConverterSignerAddr); + USDC_TOKEN.approve(address(GSM_CONVERTER), expectedIssuedAssetAmount); + + assertEq( + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + 0, + 'Unexpected before gsmConverterSignerAddr nonce' + ); + + bytes32 digest = keccak256( + abi.encode( + '\x19\x01', + GSM_CONVERTER.DOMAIN_SEPARATOR(), + GSM_CONVERTER_SELL_ASSET_WITH_SIG_TYPEHASH, + abi.encode( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + deadline + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(gsmConverterSignerKey, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + assertTrue(gsmConverterSignerAddr != ALICE, 'Signer is the same as Bob'); + + vm.prank(ALICE); + vm.expectEmit(true, true, true, true, address(GSM_CONVERTER)); + emit SellAssetThroughSubscription( + gsmConverterSignerAddr, + gsmConverterSignerAddr, + expectedIssuedAssetAmount, + expectedGhoBought + ); + (uint256 assetAmount, uint256 ghoBought) = GSM_CONVERTER.sellAssetWithSig( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + deadline, + signature + ); + vm.stopPrank(); + + assertEq(ghoBought, expectedGhoBought, 'Unexpected GHO bought amount'); + assertEq(assetAmount, expectedIssuedAssetAmount, 'Unexpected asset amount sold'); + assertEq( + USDC_TOKEN.balanceOf(gsmConverterSignerAddr), + 0, + 'Unexpected signer final USDC balance' + ); + assertEq( + GHO_TOKEN.balanceOf(gsmConverterSignerAddr), + ghoBought, + 'Unexpected signer final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(gsmConverterSignerAddr), + 0, + 'Unexpected signer final BUIDL (issued asset) balance' + ); + assertEq(USDC_TOKEN.balanceOf(ALICE), 0, 'Unexpected seller final USDC balance'); + assertEq(GHO_TOKEN.balanceOf(ALICE), 0, 'Unexpected seller final GHO balance'); + assertEq( + BUIDL_TOKEN.balanceOf(ALICE), + 0, + 'Unexpected seller final BUIDL (issued asset) balance' + ); + assertEq(USDC_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), 0, 'Unexpected GSM final USDC balance'); + assertEq( + BUIDL_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + assetAmount, + 'Unexpected GSM final BUIDL balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + sellFee, + 'Unexpected GSM final GHO balance' + ); + assertEq( + USDC_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final USDC balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final BUIDL balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + 0, + 'Unexpected Issuance final BUIDL balance' + ); + assertEq( + USDC_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + expectedIssuedAssetAmount, + 'Unexpected Issuance final USDC balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + 0, + 'Unexpected Issuance final GHO balance' + ); + } + + function testSellAssetWithSigExactDeadline() public { + (gsmConverterSignerAddr, gsmConverterSignerKey) = makeAddrAndKey('randomString'); + + uint256 deadline = block.timestamp; + uint256 sellFee = GHO_GSM_FIXED_FEE_STRATEGY.getSellFee(DEFAULT_GSM_GHO_AMOUNT); + (uint256 expectedIssuedAssetAmount, uint256 expectedGhoBought, , ) = GHO_BUIDL_GSM + .getGhoAmountForSellAsset(DEFAULT_GSM_BUIDL_AMOUNT); + + vm.startPrank(FAUCET); + // Supply USDC to buyer + USDC_TOKEN.mint(gsmConverterSignerAddr, expectedIssuedAssetAmount); + // Supply BUIDL to issuance contract + BUIDL_TOKEN.mint(address(BUIDL_USDC_ISSUANCE), expectedIssuedAssetAmount); + vm.stopPrank(); + + vm.prank(gsmConverterSignerAddr); + USDC_TOKEN.approve(address(GSM_CONVERTER), expectedIssuedAssetAmount); + + assertEq( + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + 0, + 'Unexpected before gsmConverterSignerAddr nonce' + ); + + bytes32 digest = keccak256( + abi.encode( + '\x19\x01', + GSM_CONVERTER.DOMAIN_SEPARATOR(), + GSM_CONVERTER_SELL_ASSET_WITH_SIG_TYPEHASH, + abi.encode( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + deadline + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(gsmConverterSignerKey, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + assertTrue(gsmConverterSignerAddr != ALICE, 'Signer is the same as Bob'); + + vm.prank(ALICE); + vm.expectEmit(true, true, true, true, address(GSM_CONVERTER)); + emit SellAssetThroughSubscription( + gsmConverterSignerAddr, + gsmConverterSignerAddr, + expectedIssuedAssetAmount, + expectedGhoBought + ); + (uint256 assetAmount, uint256 ghoBought) = GSM_CONVERTER.sellAssetWithSig( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + deadline, + signature + ); + vm.stopPrank(); + + assertEq(ghoBought, expectedGhoBought, 'Unexpected GHO bought amount'); + assertEq(assetAmount, expectedIssuedAssetAmount, 'Unexpected asset amount sold'); + assertEq( + USDC_TOKEN.balanceOf(gsmConverterSignerAddr), + 0, + 'Unexpected signer final USDC balance' + ); + assertEq( + GHO_TOKEN.balanceOf(gsmConverterSignerAddr), + ghoBought, + 'Unexpected signer final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(gsmConverterSignerAddr), + 0, + 'Unexpected signer final BUIDL (issued asset) balance' + ); + assertEq(USDC_TOKEN.balanceOf(ALICE), 0, 'Unexpected seller final USDC balance'); + assertEq(GHO_TOKEN.balanceOf(ALICE), 0, 'Unexpected seller final GHO balance'); + assertEq( + BUIDL_TOKEN.balanceOf(ALICE), + 0, + 'Unexpected seller final BUIDL (issued asset) balance' + ); + assertEq(USDC_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), 0, 'Unexpected GSM final USDC balance'); + assertEq( + BUIDL_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + assetAmount, + 'Unexpected GSM final BUIDL balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + sellFee, + 'Unexpected GSM final GHO balance' + ); + assertEq( + USDC_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final USDC balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final BUIDL balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + 0, + 'Unexpected Issuance final BUIDL balance' + ); + assertEq( + USDC_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + expectedIssuedAssetAmount, + 'Unexpected Issuance final USDC balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + 0, + 'Unexpected Issuance final GHO balance' + ); + } + + function testFuzzSellAssetWithSigSignature( + string memory randomStr, + uint256 deadlineBuffer + ) public { + deadlineBuffer = bound(deadlineBuffer, 0, 52 weeks); + + (gsmConverterSignerAddr, gsmConverterSignerKey) = makeAddrAndKey(randomStr); + + uint256 deadline = block.timestamp + deadlineBuffer; + uint256 sellFee = GHO_GSM_FIXED_FEE_STRATEGY.getSellFee(DEFAULT_GSM_GHO_AMOUNT); + (uint256 expectedIssuedAssetAmount, uint256 expectedGhoBought, , ) = GHO_BUIDL_GSM + .getGhoAmountForSellAsset(DEFAULT_GSM_BUIDL_AMOUNT); + + vm.startPrank(FAUCET); + // Supply USDC to buyer + USDC_TOKEN.mint(gsmConverterSignerAddr, expectedIssuedAssetAmount); + // Supply BUIDL to issuance contract + BUIDL_TOKEN.mint(address(BUIDL_USDC_ISSUANCE), expectedIssuedAssetAmount); + vm.stopPrank(); + + vm.prank(gsmConverterSignerAddr); + USDC_TOKEN.approve(address(GSM_CONVERTER), expectedIssuedAssetAmount); + + assertEq( + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + 0, + 'Unexpected before gsmConverterSignerAddr nonce' + ); + + bytes32 digest = keccak256( + abi.encode( + '\x19\x01', + GSM_CONVERTER.DOMAIN_SEPARATOR(), + GSM_CONVERTER_SELL_ASSET_WITH_SIG_TYPEHASH, + abi.encode( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + deadline + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(gsmConverterSignerKey, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + assertTrue(gsmConverterSignerAddr != ALICE, 'Signer is the same as Bob'); + + vm.prank(ALICE); + vm.expectEmit(true, true, true, true, address(GSM_CONVERTER)); + emit SellAssetThroughSubscription( + gsmConverterSignerAddr, + gsmConverterSignerAddr, + expectedIssuedAssetAmount, + expectedGhoBought + ); + (uint256 assetAmount, uint256 ghoBought) = GSM_CONVERTER.sellAssetWithSig( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + deadline, + signature + ); + vm.stopPrank(); + + assertEq(ghoBought, expectedGhoBought, 'Unexpected GHO bought amount'); + assertEq(assetAmount, expectedIssuedAssetAmount, 'Unexpected asset amount sold'); + assertEq( + USDC_TOKEN.balanceOf(gsmConverterSignerAddr), + 0, + 'Unexpected signer final USDC balance' + ); + assertEq( + GHO_TOKEN.balanceOf(gsmConverterSignerAddr), + ghoBought, + 'Unexpected signer final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(gsmConverterSignerAddr), + 0, + 'Unexpected signer final BUIDL (issued asset) balance' + ); + assertEq(USDC_TOKEN.balanceOf(ALICE), 0, 'Unexpected seller final USDC balance'); + assertEq(GHO_TOKEN.balanceOf(ALICE), 0, 'Unexpected seller final GHO balance'); + assertEq( + BUIDL_TOKEN.balanceOf(ALICE), + 0, + 'Unexpected seller final BUIDL (issued asset) balance' + ); + assertEq(USDC_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), 0, 'Unexpected GSM final USDC balance'); + assertEq( + BUIDL_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + assetAmount, + 'Unexpected GSM final BUIDL balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + sellFee, + 'Unexpected GSM final GHO balance' + ); + assertEq( + USDC_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final USDC balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final BUIDL balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + 0, + 'Unexpected Issuance final BUIDL balance' + ); + assertEq( + USDC_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + expectedIssuedAssetAmount, + 'Unexpected Issuance final USDC balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(BUIDL_USDC_ISSUANCE)), + 0, + 'Unexpected Issuance final GHO balance' + ); + } + + function testRevertSellAssetWithSigExpiredSignature() public { + (gsmConverterSignerAddr, gsmConverterSignerKey) = makeAddrAndKey('randomString'); + + uint256 deadline = block.timestamp - 1; + (uint256 expectedIssuedAssetAmount, , , ) = GHO_BUIDL_GSM.getGhoAmountForSellAsset( + DEFAULT_GSM_BUIDL_AMOUNT + ); + + vm.startPrank(FAUCET); + // Supply USDC to buyer + USDC_TOKEN.mint(gsmConverterSignerAddr, expectedIssuedAssetAmount); + // Supply BUIDL to issuance contract + BUIDL_TOKEN.mint(address(BUIDL_USDC_ISSUANCE), expectedIssuedAssetAmount); + vm.stopPrank(); + + vm.prank(gsmConverterSignerAddr); + USDC_TOKEN.approve(address(GSM_CONVERTER), expectedIssuedAssetAmount); + + assertEq( + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + 0, + 'Unexpected before gsmConverterSignerAddr nonce' + ); + + bytes32 digest = keccak256( + abi.encode( + '\x19\x01', + GSM_CONVERTER.DOMAIN_SEPARATOR(), + GSM_CONVERTER_SELL_ASSET_WITH_SIG_TYPEHASH, + abi.encode( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + deadline + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(gsmConverterSignerKey, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + assertTrue(gsmConverterSignerAddr != ALICE, 'Signer is the same as Bob'); + + vm.prank(ALICE); + vm.expectRevert('SIGNATURE_DEADLINE_EXPIRED'); + GSM_CONVERTER.sellAssetWithSig( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + deadline, + signature + ); + vm.stopPrank(); + } + + function testRevertSellAssetWithSigInvalidSignature() public { + (gsmConverterSignerAddr, gsmConverterSignerKey) = makeAddrAndKey('randomString'); + + uint256 deadline = block.timestamp + 10; + (uint256 expectedIssuedAssetAmount, , , ) = GHO_BUIDL_GSM.getGhoAmountForSellAsset( + DEFAULT_GSM_BUIDL_AMOUNT + ); + + vm.startPrank(FAUCET); + // Supply USDC to buyer + USDC_TOKEN.mint(gsmConverterSignerAddr, expectedIssuedAssetAmount); + // Supply BUIDL to issuance contract + BUIDL_TOKEN.mint(address(BUIDL_USDC_ISSUANCE), expectedIssuedAssetAmount); + vm.stopPrank(); + + vm.prank(gsmConverterSignerAddr); + USDC_TOKEN.approve(address(GSM_CONVERTER), expectedIssuedAssetAmount); + + assertEq( + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + 0, + 'Unexpected before gsmConverterSignerAddr nonce' + ); + + bytes32 digest = keccak256( + abi.encode( + '\x19\x01', + GSM_CONVERTER.DOMAIN_SEPARATOR(), + GSM_CONVERTER_SELL_ASSET_WITH_SIG_TYPEHASH, + abi.encode( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + deadline + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(gsmConverterSignerKey, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + assertTrue(gsmConverterSignerAddr != ALICE, 'Signer is the same as Bob'); + + vm.prank(ALICE); + vm.expectRevert('SIGNATURE_INVALID'); + GSM_CONVERTER.sellAssetWithSig(ALICE, DEFAULT_GSM_BUIDL_AMOUNT, ALICE, deadline, signature); + vm.stopPrank(); + } + + function testRevertSellAssetWithSigInvalidAmount() public { + (gsmConverterSignerAddr, gsmConverterSignerKey) = makeAddrAndKey('randomString'); + + uint256 deadline = block.timestamp + 10; + (uint256 expectedIssuedAssetAmount, , , ) = GHO_BUIDL_GSM.getGhoAmountForSellAsset( + DEFAULT_GSM_BUIDL_AMOUNT + ); + + vm.startPrank(FAUCET); + // Supply USDC to buyer + USDC_TOKEN.mint(gsmConverterSignerAddr, expectedIssuedAssetAmount); + // Supply BUIDL to issuance contract + BUIDL_TOKEN.mint(address(BUIDL_USDC_ISSUANCE), expectedIssuedAssetAmount); + vm.stopPrank(); + + vm.prank(gsmConverterSignerAddr); + USDC_TOKEN.approve(address(GSM_CONVERTER), expectedIssuedAssetAmount); + + assertEq( + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + 0, + 'Unexpected before gsmConverterSignerAddr nonce' + ); + + bytes32 digest = keccak256( + abi.encode( + '\x19\x01', + GSM_CONVERTER.DOMAIN_SEPARATOR(), + GSM_CONVERTER_SELL_ASSET_WITH_SIG_TYPEHASH, + abi.encode( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT - 1, + gsmConverterSignerAddr, + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + deadline + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(gsmConverterSignerKey, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + assertTrue(gsmConverterSignerAddr != ALICE, 'Signer is the same as Bob'); + + vm.prank(ALICE); + vm.expectRevert('SIGNATURE_INVALID'); + GSM_CONVERTER.sellAssetWithSig( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + deadline, + signature + ); + vm.stopPrank(); + } + + function testRevertSellAssetWithSigInvalidNonce() public { + (gsmConverterSignerAddr, gsmConverterSignerKey) = makeAddrAndKey('randomString'); + + uint256 deadline = block.timestamp + 10; + (uint256 expectedIssuedAssetAmount, , , ) = GHO_BUIDL_GSM.getGhoAmountForSellAsset( + DEFAULT_GSM_BUIDL_AMOUNT + ); + + vm.startPrank(FAUCET); + // Supply USDC to buyer + USDC_TOKEN.mint(gsmConverterSignerAddr, expectedIssuedAssetAmount); + // Supply BUIDL to issuance contract + BUIDL_TOKEN.mint(address(BUIDL_USDC_ISSUANCE), expectedIssuedAssetAmount); + vm.stopPrank(); + + vm.prank(gsmConverterSignerAddr); + USDC_TOKEN.approve(address(GSM_CONVERTER), expectedIssuedAssetAmount); + + assertEq( + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + 0, + 'Unexpected before gsmConverterSignerAddr nonce' + ); + + bytes32 digest = keccak256( + abi.encode( + '\x19\x01', + GSM_CONVERTER.DOMAIN_SEPARATOR(), + GSM_CONVERTER_SELL_ASSET_WITH_SIG_TYPEHASH, + abi.encode( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + GSM_CONVERTER.nonces(gsmConverterSignerAddr) + 1, // invalid nonce + deadline + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(gsmConverterSignerKey, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + assertTrue(gsmConverterSignerAddr != ALICE, 'Signer is the same as Bob'); + + vm.prank(ALICE); + vm.expectRevert('SIGNATURE_INVALID'); + GSM_CONVERTER.sellAssetWithSig( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + deadline, + signature + ); + vm.stopPrank(); + } + + // TODO: testRevertSellAssetWithSigInvalidRemainingGhoBalance + // _upgradeToGsmFailedSellAssetRemainingGhoBalance + + function testBuyAsset() public { + (uint256 expectedRedeemedAssetAmount, uint256 expectedGhoSold, , uint256 buyFee) = GHO_BUIDL_GSM + .getGhoAmountForBuyAsset(DEFAULT_GSM_BUIDL_AMOUNT); + + // Supply BUIDL assets to the BUIDL GSM first + vm.prank(FAUCET); + BUIDL_TOKEN.mint(ALICE, expectedRedeemedAssetAmount); + vm.startPrank(ALICE); + BUIDL_TOKEN.approve(address(GHO_BUIDL_GSM), expectedRedeemedAssetAmount); + (, , , uint256 sellFee) = GHO_BUIDL_GSM.getGhoAmountForSellAsset(DEFAULT_GSM_BUIDL_AMOUNT); + GHO_BUIDL_GSM.sellAsset(DEFAULT_GSM_BUIDL_AMOUNT, ALICE); + vm.stopPrank(); + + // Supply USDC to the Redemption contract + vm.prank(FAUCET); + USDC_TOKEN.mint(address(BUIDL_USDC_REDEMPTION), expectedRedeemedAssetAmount); + + // Supply assets to another user + ghoFaucet(BOB, expectedGhoSold); + vm.startPrank(BOB); + GHO_TOKEN.approve(address(GSM_CONVERTER), expectedGhoSold); + + // Buy assets via Redemption of USDC + vm.expectEmit(true, true, true, true, address(GSM_CONVERTER)); + emit BuyAssetThroughRedemption(BOB, BOB, expectedRedeemedAssetAmount, expectedGhoSold); + (uint256 redeemedUSDCAmount, uint256 ghoSold) = GSM_CONVERTER.buyAsset( + DEFAULT_GSM_BUIDL_AMOUNT, + BOB + ); + vm.stopPrank(); + + assertEq(ghoSold, expectedGhoSold, 'Unexpected GHO sold amount'); + assertEq( + redeemedUSDCAmount, + expectedRedeemedAssetAmount, + 'Unexpected redeemed buyAsset amount' + ); + assertEq( + USDC_TOKEN.balanceOf(BOB), + expectedRedeemedAssetAmount, + 'Unexpected buyer final USDC balance' + ); + assertEq(GHO_TOKEN.balanceOf(address(BOB)), 0, 'Unexpected buyer final GHO balance'); + assertEq(BUIDL_TOKEN.balanceOf(BOB), 0, 'Unexpected buyer final BUIDL balance'); + assertEq(USDC_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), 0, 'Unexpected GSM final USDC balance'); + assertEq( + GHO_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + sellFee + buyFee, + 'Unexpected GSM final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + 0, + 'Unexpected GSM final BUIDL balance' + ); + assertEq( + USDC_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected converter final USDC balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final BUIDL balance' + ); + } + + function testBuyAssetSendToOther() public { + (uint256 expectedRedeemedAssetAmount, uint256 expectedGhoSold, , uint256 buyFee) = GHO_BUIDL_GSM + .getGhoAmountForBuyAsset(DEFAULT_GSM_BUIDL_AMOUNT); + + // Supply BUIDL assets to the BUIDL GSM first + vm.prank(FAUCET); + BUIDL_TOKEN.mint(ALICE, expectedRedeemedAssetAmount); + vm.startPrank(ALICE); + BUIDL_TOKEN.approve(address(GHO_BUIDL_GSM), expectedRedeemedAssetAmount); + (, , , uint256 sellFee) = GHO_BUIDL_GSM.getGhoAmountForSellAsset(DEFAULT_GSM_BUIDL_AMOUNT); + GHO_BUIDL_GSM.sellAsset(DEFAULT_GSM_BUIDL_AMOUNT, ALICE); + vm.stopPrank(); + + // Supply USDC to the Redemption contract + vm.prank(FAUCET); + USDC_TOKEN.mint(address(BUIDL_USDC_REDEMPTION), expectedRedeemedAssetAmount); + + // Supply assets to another user + ghoFaucet(BOB, expectedGhoSold); + vm.startPrank(BOB); + GHO_TOKEN.approve(address(GSM_CONVERTER), expectedGhoSold); + + // Buy assets via Redemption of USDC + vm.expectEmit(true, true, true, true, address(GSM_CONVERTER)); + emit BuyAssetThroughRedemption(BOB, CHARLES, expectedRedeemedAssetAmount, expectedGhoSold); + (uint256 redeemedUSDCAmount, uint256 ghoSold) = GSM_CONVERTER.buyAsset( + DEFAULT_GSM_BUIDL_AMOUNT, + CHARLES + ); + vm.stopPrank(); + + assertEq(ghoSold, expectedGhoSold, 'Unexpected GHO sold amount'); + assertEq( + redeemedUSDCAmount, + expectedRedeemedAssetAmount, + 'Unexpected redeemed buyAsset amount' + ); + assertEq( + USDC_TOKEN.balanceOf(CHARLES), + expectedRedeemedAssetAmount, + 'Unexpected buyer final USDC balance' + ); + assertEq(GHO_TOKEN.balanceOf(address(CHARLES)), 0, 'Unexpected receiver final GHO balance'); + assertEq(BUIDL_TOKEN.balanceOf(CHARLES), 0, 'Unexpected receiver final BUIDL balance'); + assertEq(USDC_TOKEN.balanceOf(BOB), 0, 'Unexpected receiver final USDC balance'); + assertEq(GHO_TOKEN.balanceOf(address(BOB)), 0, 'Unexpected buyer final GHO balance'); + assertEq(BUIDL_TOKEN.balanceOf(BOB), 0, 'Unexpected buyer final BUIDL balance'); + assertEq(USDC_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), 0, 'Unexpected GSM final USDC balance'); + assertEq( + GHO_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + sellFee + buyFee, + 'Unexpected GSM final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + 0, + 'Unexpected GSM final BUIDL balance' + ); + assertEq( + USDC_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected converter final USDC balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final BUIDL balance' + ); + } + + function testFuzzBuyAssetMinAmount(uint256 minAmount) public { + minAmount = bound(minAmount, 1, GHO_BUIDL_GSM.getExposureCap()); + (uint256 expectedRedeemedAssetAmount, uint256 expectedGhoSold, , uint256 buyFee) = GHO_BUIDL_GSM + .getGhoAmountForBuyAsset(minAmount); + + // Supply BUIDL assets to the BUIDL GSM first + vm.prank(FAUCET); + BUIDL_TOKEN.mint(ALICE, expectedRedeemedAssetAmount); + vm.startPrank(ALICE); + BUIDL_TOKEN.approve(address(GHO_BUIDL_GSM), expectedRedeemedAssetAmount); + (, , , uint256 sellFee) = GHO_BUIDL_GSM.getGhoAmountForSellAsset(minAmount); + GHO_BUIDL_GSM.sellAsset(minAmount, ALICE); + vm.stopPrank(); + + // Supply USDC to the Redemption contract + vm.prank(FAUCET); + USDC_TOKEN.mint(address(BUIDL_USDC_REDEMPTION), expectedRedeemedAssetAmount); + + // Supply assets to another user + ghoFaucet(BOB, expectedGhoSold); + vm.startPrank(BOB); + GHO_TOKEN.approve(address(GSM_CONVERTER), expectedGhoSold); + + // Buy assets via Redemption of USDC + vm.expectEmit(true, true, true, true, address(GSM_CONVERTER)); + emit BuyAssetThroughRedemption(BOB, BOB, expectedRedeemedAssetAmount, expectedGhoSold); + (uint256 redeemedUSDCAmount, uint256 ghoSold) = GSM_CONVERTER.buyAsset(minAmount, BOB); + vm.stopPrank(); + + assertEq(ghoSold, expectedGhoSold, 'Unexpected GHO sold amount'); + assertEq( + redeemedUSDCAmount, + expectedRedeemedAssetAmount, + 'Unexpected redeemed buyAsset amount' + ); + assertEq( + USDC_TOKEN.balanceOf(BOB), + expectedRedeemedAssetAmount, + 'Unexpected buyer final USDC balance' + ); + assertEq(GHO_TOKEN.balanceOf(address(BOB)), 0, 'Unexpected buyer final GHO balance'); + assertEq(BUIDL_TOKEN.balanceOf(BOB), 0, 'Unexpected buyer final BUIDL balance'); + assertEq(USDC_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), 0, 'Unexpected GSM final USDC balance'); + assertEq( + GHO_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + sellFee + buyFee, + 'Unexpected GSM final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + 0, + 'Unexpected GSM final BUIDL balance' + ); + assertEq( + USDC_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected converter final USDC balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final BUIDL balance' + ); + } + + function testBuyAssetDonatedTokens() public { + uint256 sellFee = GHO_GSM_FIXED_FEE_STRATEGY.getSellFee(DEFAULT_GSM_GHO_AMOUNT); + uint256 buyFee = GHO_GSM_FIXED_FEE_STRATEGY.getBuyFee(DEFAULT_GSM_GHO_AMOUNT); + (uint256 expectedRedeemedAssetAmount, uint256 expectedGhoSold, , ) = GHO_BUIDL_GSM + .getGhoAmountForBuyAsset(DEFAULT_GSM_BUIDL_AMOUNT); + uint256 donatedAmount = 1e6; + + // Supply BUIDL assets to the BUIDL GSM first + vm.startPrank(FAUCET); + BUIDL_TOKEN.mint(ALICE, expectedRedeemedAssetAmount); + // donate tokens to the converter + BUIDL_TOKEN.mint(address(GSM_CONVERTER), donatedAmount); + USDC_TOKEN.mint(address(GSM_CONVERTER), donatedAmount); + vm.stopPrank(); + ghoFaucet(address(GSM_CONVERTER), donatedAmount); + + // sellAsset to seed GSM + vm.startPrank(ALICE); + BUIDL_TOKEN.approve(address(GHO_BUIDL_GSM), expectedRedeemedAssetAmount); + GHO_BUIDL_GSM.sellAsset(DEFAULT_GSM_BUIDL_AMOUNT, ALICE); + vm.stopPrank(); + + // Supply USDC to the Redemption contract + vm.prank(FAUCET); + USDC_TOKEN.mint(address(BUIDL_USDC_REDEMPTION), expectedRedeemedAssetAmount); + + // Supply assets to another user + ghoFaucet(BOB, DEFAULT_GSM_GHO_AMOUNT + buyFee); + vm.startPrank(BOB); + GHO_TOKEN.approve(address(GSM_CONVERTER), DEFAULT_GSM_GHO_AMOUNT + buyFee); + + // Buy assets via Redemption of USDC + vm.expectEmit(true, true, true, true, address(GSM_CONVERTER)); + emit BuyAssetThroughRedemption(BOB, BOB, expectedRedeemedAssetAmount, expectedGhoSold); + (uint256 redeemedUSDCAmount, uint256 ghoSold) = GSM_CONVERTER.buyAsset( + DEFAULT_GSM_BUIDL_AMOUNT, + BOB + ); + vm.stopPrank(); + + assertEq(ghoSold, expectedGhoSold, 'Unexpected GHO sold amount'); + assertEq( + redeemedUSDCAmount, + expectedRedeemedAssetAmount, + 'Unexpected redeemed buyAsset amount' + ); + assertEq( + USDC_TOKEN.balanceOf(BOB), + expectedRedeemedAssetAmount, + 'Unexpected buyer final USDC balance' + ); + assertEq(GHO_TOKEN.balanceOf(address(BOB)), 0, 'Unexpected buyer final GHO balance'); + assertEq(BUIDL_TOKEN.balanceOf(BOB), 0, 'Unexpected buyer final BUIDL balance'); + assertEq(USDC_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), 0, 'Unexpected GSM final USDC balance'); + assertEq( + GHO_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + sellFee + buyFee, + 'Unexpected GSM final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + 0, + 'Unexpected GSM final BUIDL balance' + ); + assertEq( + USDC_TOKEN.balanceOf(address(GSM_CONVERTER)), + donatedAmount, + 'Unexpected GSM_CONVERTER final USDC balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GSM_CONVERTER)), + donatedAmount, + 'Unexpected GSM_CONVERTER final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GSM_CONVERTER)), + donatedAmount, + 'Unexpected GSM_CONVERTER final BUIDL balance' + ); + } + + function testFuzzBuyAssetDonatedTokens( + uint256 donatedIssuedAsset, + uint256 donatedRedeemedAsset, + uint256 donatedGHO + ) public { + donatedIssuedAsset = bound(donatedIssuedAsset, 1, 1e27); + donatedRedeemedAsset = bound(donatedRedeemedAsset, 1, 1e27); + (uint256 bucketCapacity, ) = GHO_TOKEN.getFacilitatorBucket(FAUCET); + // enough buffer for later minted gho + donatedGHO = bound(donatedGHO, 1, bucketCapacity / 2); + + uint256 sellFee = GHO_GSM_FIXED_FEE_STRATEGY.getSellFee(DEFAULT_GSM_GHO_AMOUNT); + uint256 buyFee = GHO_GSM_FIXED_FEE_STRATEGY.getBuyFee(DEFAULT_GSM_GHO_AMOUNT); + (uint256 expectedRedeemedAssetAmount, uint256 expectedGhoSold, , ) = GHO_BUIDL_GSM + .getGhoAmountForBuyAsset(DEFAULT_GSM_BUIDL_AMOUNT); + + // Supply BUIDL assets to the BUIDL GSM first + vm.startPrank(FAUCET); + BUIDL_TOKEN.mint(ALICE, expectedRedeemedAssetAmount); + // donate tokens to the converter + BUIDL_TOKEN.mint(address(GSM_CONVERTER), donatedIssuedAsset); + USDC_TOKEN.mint(address(GSM_CONVERTER), donatedRedeemedAsset); + vm.stopPrank(); + ghoFaucet(address(GSM_CONVERTER), donatedGHO); + + // sellAsset to seed GSM + vm.startPrank(ALICE); + BUIDL_TOKEN.approve(address(GHO_BUIDL_GSM), expectedRedeemedAssetAmount); + GHO_BUIDL_GSM.sellAsset(DEFAULT_GSM_BUIDL_AMOUNT, ALICE); + vm.stopPrank(); + + // Supply USDC to the Redemption contract + vm.prank(FAUCET); + USDC_TOKEN.mint(address(BUIDL_USDC_REDEMPTION), expectedRedeemedAssetAmount); + + // Supply assets to another user + ghoFaucet(BOB, DEFAULT_GSM_GHO_AMOUNT + buyFee); + vm.startPrank(BOB); + GHO_TOKEN.approve(address(GSM_CONVERTER), DEFAULT_GSM_GHO_AMOUNT + buyFee); + + // Buy assets via Redemption of USDC + vm.expectEmit(true, true, true, true, address(GSM_CONVERTER)); + emit BuyAssetThroughRedemption(BOB, BOB, expectedRedeemedAssetAmount, expectedGhoSold); + (uint256 redeemedUSDCAmount, uint256 ghoSold) = GSM_CONVERTER.buyAsset( + DEFAULT_GSM_BUIDL_AMOUNT, + BOB + ); + vm.stopPrank(); + + assertEq(ghoSold, expectedGhoSold, 'Unexpected GHO sold amount'); + assertEq( + redeemedUSDCAmount, + expectedRedeemedAssetAmount, + 'Unexpected redeemed buyAsset amount' + ); + assertEq( + USDC_TOKEN.balanceOf(BOB), + expectedRedeemedAssetAmount, + 'Unexpected buyer final USDC balance' + ); + assertEq(GHO_TOKEN.balanceOf(address(BOB)), 0, 'Unexpected buyer final GHO balance'); + assertEq(BUIDL_TOKEN.balanceOf(BOB), 0, 'Unexpected buyer final BUIDL balance'); + assertEq(USDC_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), 0, 'Unexpected GSM final USDC balance'); + assertEq( + GHO_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + sellFee + buyFee, + 'Unexpected GSM final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + 0, + 'Unexpected GSM final BUIDL balance' + ); + assertEq( + USDC_TOKEN.balanceOf(address(GSM_CONVERTER)), + donatedRedeemedAsset, + 'Unexpected GSM_CONVERTER final USDC balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GSM_CONVERTER)), + donatedGHO, + 'Unexpected GSM_CONVERTER final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GSM_CONVERTER)), + donatedIssuedAsset, + 'Unexpected GSM_CONVERTER final BUIDL balance' + ); + } + + function testRevertBuyAssetZeroAmount() public { + vm.expectRevert('INVALID_MIN_AMOUNT'); + uint256 invalidAmount = 0; + GSM_CONVERTER.buyAsset(invalidAmount, ALICE); + } + + function testRevertBuyAssetNoGHO() public { + uint256 buyFee = GHO_GSM_FIXED_FEE_STRATEGY.getBuyFee(DEFAULT_GSM_GHO_AMOUNT); + + // Supply BUIDL assets to the BUIDL GSM first + vm.prank(FAUCET); + BUIDL_TOKEN.mint(ALICE, DEFAULT_GSM_BUIDL_AMOUNT); + vm.startPrank(ALICE); + BUIDL_TOKEN.approve(address(GHO_BUIDL_GSM), DEFAULT_GSM_BUIDL_AMOUNT); + GHO_BUIDL_GSM.sellAsset(DEFAULT_GSM_BUIDL_AMOUNT, ALICE); + vm.stopPrank(); + + // Supply USDC to the Redemption contract + vm.prank(FAUCET); + USDC_TOKEN.mint(address(BUIDL_USDC_REDEMPTION), DEFAULT_GSM_BUIDL_AMOUNT); + + // Supply assets to another user + vm.startPrank(BOB); + GHO_TOKEN.approve(address(GSM_CONVERTER), DEFAULT_GSM_GHO_AMOUNT + buyFee); + + // Buy assets via Redemption of USDC + vm.expectRevert(stdError.arithmeticError); + GSM_CONVERTER.buyAsset(DEFAULT_GSM_BUIDL_AMOUNT, CHARLES); + vm.stopPrank(); + } + + function testRevertBuyAssetNoAllowance() public { + // Supply BUIDL assets to the BUIDL GSM first + vm.prank(FAUCET); + BUIDL_TOKEN.mint(ALICE, DEFAULT_GSM_BUIDL_AMOUNT); + vm.startPrank(ALICE); + BUIDL_TOKEN.approve(address(GHO_BUIDL_GSM), DEFAULT_GSM_BUIDL_AMOUNT); + GHO_BUIDL_GSM.sellAsset(DEFAULT_GSM_BUIDL_AMOUNT, ALICE); + vm.stopPrank(); + + // Supply USDC to the Redemption contract + vm.prank(FAUCET); + USDC_TOKEN.mint(address(BUIDL_USDC_REDEMPTION), DEFAULT_GSM_BUIDL_AMOUNT); + + // Buy assets via Redemption of USDC + vm.startPrank(BOB); + vm.expectRevert(stdError.arithmeticError); + GSM_CONVERTER.buyAsset(DEFAULT_GSM_BUIDL_AMOUNT, CHARLES); + vm.stopPrank(); + } + + function testRevertBuyAssetInvalidGhoSold() public { + vm.mockCall( + address(GHO_BUIDL_GSM), + abi.encodeWithSelector( + GHO_BUIDL_GSM.getGhoAmountForBuyAsset.selector, + DEFAULT_GSM_BUIDL_AMOUNT + ), + abi.encode(100000000, 110000000000000000001, 100000000000000000000, 10000000000000000000) + ); + + uint256 buyFee = GHO_GSM_FIXED_FEE_STRATEGY.getBuyFee(DEFAULT_GSM_GHO_AMOUNT); + (, uint256 expectedGhoSold, , ) = GHO_BUIDL_GSM.getGhoAmountForBuyAsset( + DEFAULT_GSM_BUIDL_AMOUNT + ); + + // Supply BUIDL assets to the BUIDL GSM first + vm.prank(FAUCET); + BUIDL_TOKEN.mint(ALICE, DEFAULT_GSM_BUIDL_AMOUNT); + vm.startPrank(ALICE); + BUIDL_TOKEN.approve(address(GHO_BUIDL_GSM), DEFAULT_GSM_BUIDL_AMOUNT); + GHO_BUIDL_GSM.sellAsset(DEFAULT_GSM_BUIDL_AMOUNT, ALICE); + vm.stopPrank(); + + // Supply USDC to the Redemption contract + vm.prank(FAUCET); + USDC_TOKEN.mint(address(BUIDL_USDC_REDEMPTION), DEFAULT_GSM_BUIDL_AMOUNT); + + // Supply assets to another user + ghoFaucet(BOB, expectedGhoSold + buyFee); + vm.startPrank(BOB); + GHO_TOKEN.approve(address(GSM_CONVERTER), expectedGhoSold + buyFee); + + // Buy assets via Redemption of USDC + vm.expectRevert('INVALID_GHO_SOLD'); + GSM_CONVERTER.buyAsset(DEFAULT_GSM_BUIDL_AMOUNT, BOB); + vm.stopPrank(); + } + + function testRevertBuyAssetInvalidRemainingGhoBalance() public { + _upgradeToGsmFailedBuyAssetRemainingGhoBalance(); + + uint256 buyFee = GHO_GSM_FIXED_FEE_STRATEGY.getBuyFee(DEFAULT_GSM_GHO_AMOUNT); + (, uint256 expectedGhoSold, , ) = GHO_BUIDL_GSM.getGhoAmountForBuyAsset( + DEFAULT_GSM_BUIDL_AMOUNT + ); + + // Supply BUIDL assets to the BUIDL GSM first + vm.prank(FAUCET); + BUIDL_TOKEN.mint(ALICE, DEFAULT_GSM_BUIDL_AMOUNT); + vm.startPrank(ALICE); + BUIDL_TOKEN.approve(address(GHO_BUIDL_GSM), DEFAULT_GSM_BUIDL_AMOUNT); + GHO_BUIDL_GSM.sellAsset(DEFAULT_GSM_BUIDL_AMOUNT, ALICE); + vm.stopPrank(); + + // Supply USDC to the Redemption contract + vm.prank(FAUCET); + USDC_TOKEN.mint(address(BUIDL_USDC_REDEMPTION), DEFAULT_GSM_BUIDL_AMOUNT); + + // Supply assets to another user + ghoFaucet(BOB, expectedGhoSold + buyFee); + vm.startPrank(BOB); + GHO_TOKEN.approve(address(GSM_CONVERTER), expectedGhoSold + buyFee); + + // Buy assets via Redemption of USDC + vm.expectRevert('INVALID_REMAINING_GHO_BALANCE'); + GSM_CONVERTER.buyAsset(DEFAULT_GSM_BUIDL_AMOUNT, BOB); + vm.stopPrank(); + } + + function testRevertBuyAssetInvalidRemainingIssuedAssetBalance() public { + GsmConverter gsmConverter = new GsmConverter( + address(this), + address(GHO_BUIDL_GSM), + address(BUIDL_USDC_REDEMPTION_FAILED_ISSUED_ASSET_AMOUNT), + address(BUIDL_USDC_ISSUANCE), + address(BUIDL_TOKEN), + address(USDC_TOKEN) + ); + + uint256 buyFee = GHO_GSM_FIXED_FEE_STRATEGY.getBuyFee(DEFAULT_GSM_GHO_AMOUNT); + (, uint256 expectedGhoSold, , ) = GHO_BUIDL_GSM.getGhoAmountForBuyAsset( + DEFAULT_GSM_BUIDL_AMOUNT + ); + + // Supply BUIDL assets to the BUIDL GSM first + vm.prank(FAUCET); + BUIDL_TOKEN.mint(ALICE, DEFAULT_GSM_BUIDL_AMOUNT); + vm.startPrank(ALICE); + BUIDL_TOKEN.approve(address(GHO_BUIDL_GSM), DEFAULT_GSM_BUIDL_AMOUNT); + GHO_BUIDL_GSM.sellAsset(DEFAULT_GSM_BUIDL_AMOUNT, ALICE); + vm.stopPrank(); + + // Supply USDC to the Redemption contract + vm.prank(FAUCET); + USDC_TOKEN.mint( + address(BUIDL_USDC_REDEMPTION_FAILED_ISSUED_ASSET_AMOUNT), + DEFAULT_GSM_BUIDL_AMOUNT + ); + + // Supply assets to another user + ghoFaucet(BOB, expectedGhoSold + buyFee); + vm.startPrank(BOB); + GHO_TOKEN.approve(address(gsmConverter), expectedGhoSold + buyFee); + + // Buy assets via Redemption of USDC + vm.expectRevert('INVALID_REMAINING_ISSUED_ASSET_BALANCE'); + gsmConverter.buyAsset(DEFAULT_GSM_BUIDL_AMOUNT, BOB); + vm.stopPrank(); + } + + function testRevertBuyAssetInvalidRedemption() public { + GsmConverter gsmConverter = new GsmConverter( + address(this), + address(GHO_BUIDL_GSM), + address(BUIDL_USDC_REDEMPTION_FAILED), + address(BUIDL_USDC_ISSUANCE), + address(BUIDL_TOKEN), + address(USDC_TOKEN) + ); + + uint256 buyFee = GHO_GSM_FIXED_FEE_STRATEGY.getBuyFee(DEFAULT_GSM_GHO_AMOUNT); + (, uint256 expectedGhoSold, , ) = GHO_BUIDL_GSM.getGhoAmountForBuyAsset( + DEFAULT_GSM_BUIDL_AMOUNT + ); + + // Supply BUIDL assets to the BUIDL GSM first + vm.prank(FAUCET); + BUIDL_TOKEN.mint(ALICE, DEFAULT_GSM_BUIDL_AMOUNT); + vm.startPrank(ALICE); + BUIDL_TOKEN.approve(address(GHO_BUIDL_GSM), DEFAULT_GSM_BUIDL_AMOUNT); + GHO_BUIDL_GSM.sellAsset(DEFAULT_GSM_BUIDL_AMOUNT, ALICE); + vm.stopPrank(); + + // Supply USDC to the Redemption contract + vm.prank(FAUCET); + uint256 bufferForAdditionalTransfer = 1000; + USDC_TOKEN.mint( + address(BUIDL_USDC_REDEMPTION_FAILED), + DEFAULT_GSM_BUIDL_AMOUNT + bufferForAdditionalTransfer + ); + + // Supply assets to another user + ghoFaucet(BOB, expectedGhoSold + buyFee); + vm.startPrank(BOB); + GHO_TOKEN.approve(address(gsmConverter), expectedGhoSold + buyFee); + + // Invalid redemption of USDC + vm.expectRevert('INVALID_REDEMPTION'); + gsmConverter.buyAsset(DEFAULT_GSM_BUIDL_AMOUNT, BOB); + vm.stopPrank(); + } + + function testBuyAssetWithSig() public { + (gsmConverterSignerAddr, gsmConverterSignerKey) = makeAddrAndKey('randomString'); + + uint256 deadline = block.timestamp + 1 hours; + + uint256 buyFee = GHO_GSM_FIXED_FEE_STRATEGY.getBuyFee(DEFAULT_GSM_GHO_AMOUNT); + (uint256 expectedRedeemedAssetAmount, uint256 expectedGhoSold, , ) = GHO_BUIDL_GSM + .getGhoAmountForBuyAsset(DEFAULT_GSM_BUIDL_AMOUNT); + + // Supply BUIDL assets to the BUIDL GSM first + vm.prank(FAUCET); + BUIDL_TOKEN.mint(ALICE, DEFAULT_GSM_BUIDL_AMOUNT); + vm.startPrank(ALICE); + BUIDL_TOKEN.approve(address(GHO_BUIDL_GSM), DEFAULT_GSM_BUIDL_AMOUNT); + GHO_BUIDL_GSM.sellAsset(DEFAULT_GSM_BUIDL_AMOUNT, ALICE); + vm.stopPrank(); + + // Supply USDC to the Redemption contract + vm.prank(FAUCET); + USDC_TOKEN.mint(address(BUIDL_USDC_REDEMPTION), DEFAULT_GSM_BUIDL_AMOUNT); + + // Supply assets to another user + ghoFaucet(gsmConverterSignerAddr, DEFAULT_GSM_GHO_AMOUNT + buyFee); + vm.prank(gsmConverterSignerAddr); + GHO_TOKEN.approve(address(GSM_CONVERTER), DEFAULT_GSM_GHO_AMOUNT + buyFee); + + assertEq( + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + 0, + 'Unexpected before gsmConverterSignerAddr nonce' + ); + + bytes32 digest = keccak256( + abi.encode( + '\x19\x01', + GSM_CONVERTER.DOMAIN_SEPARATOR(), + GSM_CONVERTER_BUY_ASSET_WITH_SIG_TYPEHASH, + abi.encode( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + deadline + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(gsmConverterSignerKey, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + assertTrue(gsmConverterSignerAddr != BOB, 'Signer is the same as Bob'); + + vm.prank(BOB); + // Buy assets via Redemption of USDC + vm.expectEmit(true, true, true, true, address(GSM_CONVERTER)); + emit BuyAssetThroughRedemption( + gsmConverterSignerAddr, + gsmConverterSignerAddr, + expectedRedeemedAssetAmount, + expectedGhoSold + ); + (uint256 redeemedUSDCAmount, uint256 ghoSold) = GSM_CONVERTER.buyAssetWithSig( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + deadline, + signature + ); + + assertEq(ghoSold, expectedGhoSold, 'Unexpected GHO sold amount'); + assertEq(redeemedUSDCAmount, DEFAULT_GSM_BUIDL_AMOUNT, 'Unexpected redeemed buyAsset amount'); + assertEq( + USDC_TOKEN.balanceOf(gsmConverterSignerAddr), + DEFAULT_GSM_BUIDL_AMOUNT, + 'Unexpected buyer final USDC balance' + ); + assertEq(USDC_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), 0, 'Unexpected GSM final USDC balance'); + assertEq( + USDC_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected converter final USDC balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(gsmConverterSignerAddr)), + 0, + 'Unexpected buyer final GHO balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + GHO_GSM_FIXED_FEE_STRATEGY.getSellFee(DEFAULT_GSM_GHO_AMOUNT) + buyFee, + 'Unexpected GSM final GHO balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(gsmConverterSignerAddr), + 0, + 'Unexpected buyer final BUIDL balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + 0, + 'Unexpected GSM final BUIDL balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final BUIDL balance' + ); + } + + function testBuyAssetWithSigExactDeadline() public { + // EIP-2612 states the execution must be allowed in case deadline is equal to block.timestamp + (gsmConverterSignerAddr, gsmConverterSignerKey) = makeAddrAndKey('randomString'); + + uint256 deadline = block.timestamp; + + uint256 buyFee = GHO_GSM_FIXED_FEE_STRATEGY.getBuyFee(DEFAULT_GSM_GHO_AMOUNT); + (uint256 expectedRedeemedAssetAmount, uint256 expectedGhoSold, , ) = GHO_BUIDL_GSM + .getGhoAmountForBuyAsset(DEFAULT_GSM_BUIDL_AMOUNT); + + // Supply BUIDL assets to the BUIDL GSM first + vm.prank(FAUCET); + BUIDL_TOKEN.mint(ALICE, DEFAULT_GSM_BUIDL_AMOUNT); + vm.startPrank(ALICE); + BUIDL_TOKEN.approve(address(GHO_BUIDL_GSM), DEFAULT_GSM_BUIDL_AMOUNT); + GHO_BUIDL_GSM.sellAsset(DEFAULT_GSM_BUIDL_AMOUNT, ALICE); + vm.stopPrank(); + + // Supply USDC to the Redemption contract + vm.prank(FAUCET); + USDC_TOKEN.mint(address(BUIDL_USDC_REDEMPTION), DEFAULT_GSM_BUIDL_AMOUNT); + + // Supply assets to another user + ghoFaucet(gsmConverterSignerAddr, DEFAULT_GSM_GHO_AMOUNT + buyFee); + vm.prank(gsmConverterSignerAddr); + GHO_TOKEN.approve(address(GSM_CONVERTER), DEFAULT_GSM_GHO_AMOUNT + buyFee); + + assertEq( + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + 0, + 'Unexpected before gsmConverterSignerAddr nonce' + ); + + bytes32 digest = keccak256( + abi.encode( + '\x19\x01', + GSM_CONVERTER.DOMAIN_SEPARATOR(), + GSM_CONVERTER_BUY_ASSET_WITH_SIG_TYPEHASH, + abi.encode( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + deadline + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(gsmConverterSignerKey, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + assertTrue(gsmConverterSignerAddr != BOB, 'Signer is the same as Bob'); + + vm.prank(BOB); + // Buy assets via Redemption of USDC + vm.expectEmit(true, true, true, true, address(GSM_CONVERTER)); + emit BuyAssetThroughRedemption( + gsmConverterSignerAddr, + gsmConverterSignerAddr, + expectedRedeemedAssetAmount, + expectedGhoSold + ); + (uint256 redeemedUSDCAmount, uint256 ghoSold) = GSM_CONVERTER.buyAssetWithSig( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + deadline, + signature + ); + + assertEq(ghoSold, expectedGhoSold, 'Unexpected GHO sold amount'); + assertEq(redeemedUSDCAmount, DEFAULT_GSM_BUIDL_AMOUNT, 'Unexpected redeemed buyAsset amount'); + assertEq( + USDC_TOKEN.balanceOf(gsmConverterSignerAddr), + DEFAULT_GSM_BUIDL_AMOUNT, + 'Unexpected buyer final USDC balance' + ); + assertEq(USDC_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), 0, 'Unexpected GSM final USDC balance'); + assertEq( + USDC_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected converter final USDC balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(gsmConverterSignerAddr)), + 0, + 'Unexpected buyer final GHO balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + GHO_GSM_FIXED_FEE_STRATEGY.getSellFee(DEFAULT_GSM_GHO_AMOUNT) + buyFee, + 'Unexpected GSM final GHO balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final GHO balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(gsmConverterSignerAddr), + 0, + 'Unexpected buyer final BUIDL balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + 0, + 'Unexpected GSM final BUIDL balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final BUIDL balance' + ); + } + + function testFuzzMinAmountBuyAssetWithSig(uint minAssetAmount) public { + minAssetAmount = bound(minAssetAmount, 1, DEFAULT_GSM_BUIDL_AMOUNT * 1000); + + (gsmConverterSignerAddr, gsmConverterSignerKey) = makeAddrAndKey('randomString'); + + uint256 deadline = block.timestamp + 1 hours; + ( + uint256 expectedRedeemedAssetAmount, + uint256 expectedGhoSold, + uint256 buyFee, + uint256 sellFee + ) = _getBuySellFees(minAssetAmount); + + // Supply BUIDL assets to the BUIDL GSM first + vm.prank(FAUCET); + BUIDL_TOKEN.mint(ALICE, expectedRedeemedAssetAmount); + vm.startPrank(ALICE); + BUIDL_TOKEN.approve(address(GHO_BUIDL_GSM), expectedRedeemedAssetAmount); + GHO_BUIDL_GSM.sellAsset(expectedRedeemedAssetAmount, ALICE); + vm.stopPrank(); + + // Supply USDC to the Redemption contract + vm.prank(FAUCET); + USDC_TOKEN.mint(address(BUIDL_USDC_REDEMPTION), expectedRedeemedAssetAmount); + + // Supply assets to another user + ghoFaucet(gsmConverterSignerAddr, expectedGhoSold); + vm.prank(gsmConverterSignerAddr); + GHO_TOKEN.approve(address(GSM_CONVERTER), expectedGhoSold); + + bytes32 digest = keccak256( + abi.encode( + '\x19\x01', + GSM_CONVERTER.DOMAIN_SEPARATOR(), + GSM_CONVERTER_BUY_ASSET_WITH_SIG_TYPEHASH, + abi.encode( + gsmConverterSignerAddr, + minAssetAmount, + gsmConverterSignerAddr, + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + deadline + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(gsmConverterSignerKey, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + assertTrue(gsmConverterSignerAddr != BOB, 'Signer is the same as Bob'); + + vm.prank(BOB); + // Buy assets via Redemption of USDC + vm.expectEmit(true, true, true, true, address(GSM_CONVERTER)); + emit BuyAssetThroughRedemption( + gsmConverterSignerAddr, + gsmConverterSignerAddr, + expectedRedeemedAssetAmount, + expectedGhoSold + ); + GSM_CONVERTER.buyAssetWithSig( + gsmConverterSignerAddr, + minAssetAmount, + gsmConverterSignerAddr, + deadline, + signature + ); + + assertEq( + USDC_TOKEN.balanceOf(gsmConverterSignerAddr), + minAssetAmount, + 'Unexpected buyer final USDC balance' + ); + assertEq(USDC_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), 0, 'Unexpected GSM final USDC balance'); + assertEq( + USDC_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final USDC balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(gsmConverterSignerAddr)), + 0, + 'Unexpected buyer final GHO balance' + ); + assertEq( + GHO_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + sellFee + buyFee, + 'Unexpected GSM final GHO balance' + ); + assertEq(GHO_TOKEN.balanceOf(address(GSM_CONVERTER)), 0, 'Unexpected GSM final GHO balance'); + assertEq( + BUIDL_TOKEN.balanceOf(gsmConverterSignerAddr), + 0, + 'Unexpected buyer final BUIDL balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GHO_BUIDL_GSM)), + 0, + 'Unexpected GSM final BUIDL balance' + ); + assertEq( + BUIDL_TOKEN.balanceOf(address(GSM_CONVERTER)), + 0, + 'Unexpected GSM_CONVERTER final BUIDL balance' + ); + } + + function testRevertBuyAssetWithSigExpiredSignature() public { + // EIP-2612 states the execution must be allowed in case deadline is equal to block.timestamp + (gsmConverterSignerAddr, gsmConverterSignerKey) = makeAddrAndKey('randomString'); + + uint256 deadline = block.timestamp - 1; + uint256 buyFee = GHO_GSM_FIXED_FEE_STRATEGY.getBuyFee(DEFAULT_GSM_GHO_AMOUNT); + + // Supply BUIDL assets to the BUIDL GSM first + vm.prank(FAUCET); + BUIDL_TOKEN.mint(ALICE, DEFAULT_GSM_BUIDL_AMOUNT); + vm.startPrank(ALICE); + BUIDL_TOKEN.approve(address(GHO_BUIDL_GSM), DEFAULT_GSM_BUIDL_AMOUNT); + GHO_BUIDL_GSM.sellAsset(DEFAULT_GSM_BUIDL_AMOUNT, ALICE); + vm.stopPrank(); + + // Supply USDC to the Redemption contract + vm.prank(FAUCET); + USDC_TOKEN.mint(address(BUIDL_USDC_REDEMPTION), DEFAULT_GSM_BUIDL_AMOUNT); + + // Supply assets to another user + ghoFaucet(gsmConverterSignerAddr, DEFAULT_GSM_GHO_AMOUNT + buyFee); + vm.prank(gsmConverterSignerAddr); + GHO_TOKEN.approve(address(GSM_CONVERTER), DEFAULT_GSM_GHO_AMOUNT + buyFee); + + assertEq( + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + 0, + 'Unexpected before gsmConverterSignerAddr nonce' + ); + + bytes32 digest = keccak256( + abi.encode( + '\x19\x01', + GSM_CONVERTER.DOMAIN_SEPARATOR(), + GSM_CONVERTER_BUY_ASSET_WITH_SIG_TYPEHASH, + abi.encode( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + deadline + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(gsmConverterSignerKey, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + assertTrue(gsmConverterSignerAddr != BOB, 'Signer is the same as Bob'); + + vm.prank(BOB); + vm.expectRevert('SIGNATURE_DEADLINE_EXPIRED'); + GSM_CONVERTER.buyAssetWithSig( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + deadline, + signature + ); + } + + function testRevertBuyAssetWithSigInvalidSignature() public { + // EIP-2612 states the execution must be allowed in case deadline is equal to block.timestamp + (gsmConverterSignerAddr, gsmConverterSignerKey) = makeAddrAndKey('randomString'); + + uint256 deadline = block.timestamp + 1; + uint256 buyFee = GHO_GSM_FIXED_FEE_STRATEGY.getBuyFee(DEFAULT_GSM_GHO_AMOUNT); + + // Supply BUIDL assets to the BUIDL GSM first + vm.prank(FAUCET); + BUIDL_TOKEN.mint(ALICE, DEFAULT_GSM_BUIDL_AMOUNT); + vm.startPrank(ALICE); + BUIDL_TOKEN.approve(address(GHO_BUIDL_GSM), DEFAULT_GSM_BUIDL_AMOUNT); + GHO_BUIDL_GSM.sellAsset(DEFAULT_GSM_BUIDL_AMOUNT, ALICE); + vm.stopPrank(); + + // Supply USDC to the Redemption contract + vm.prank(FAUCET); + USDC_TOKEN.mint(address(BUIDL_USDC_REDEMPTION), DEFAULT_GSM_BUIDL_AMOUNT); + + // Supply assets to another user + ghoFaucet(gsmConverterSignerAddr, DEFAULT_GSM_GHO_AMOUNT + buyFee); + vm.prank(gsmConverterSignerAddr); + GHO_TOKEN.approve(address(GSM_CONVERTER), DEFAULT_GSM_GHO_AMOUNT + buyFee); + + assertEq( + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + 0, + 'Unexpected before gsmConverterSignerAddr nonce' + ); + + bytes32 digest = keccak256( + abi.encode( + '\x19\x01', + GSM_CONVERTER.DOMAIN_SEPARATOR(), + GSM_CONVERTER_BUY_ASSET_WITH_SIG_TYPEHASH, + abi.encode( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + deadline + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(gsmConverterSignerKey, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + assertTrue(gsmConverterSignerAddr != BOB, 'Signer is the same as Bob'); + + vm.prank(BOB); + vm.expectRevert('SIGNATURE_INVALID'); + GSM_CONVERTER.buyAssetWithSig( + BOB, // invalid signer + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + deadline, + signature + ); + } + + function testRevertBuyAssetWithSigInvalidAmount() public { + // EIP-2612 states the execution must be allowed in case deadline is equal to block.timestamp + (gsmConverterSignerAddr, gsmConverterSignerKey) = makeAddrAndKey('randomString'); + + uint256 deadline = block.timestamp + 1; + uint256 buyFee = GHO_GSM_FIXED_FEE_STRATEGY.getBuyFee(DEFAULT_GSM_GHO_AMOUNT); + + // Supply BUIDL assets to the BUIDL GSM first + vm.prank(FAUCET); + BUIDL_TOKEN.mint(ALICE, DEFAULT_GSM_BUIDL_AMOUNT); + vm.startPrank(ALICE); + BUIDL_TOKEN.approve(address(GHO_BUIDL_GSM), DEFAULT_GSM_BUIDL_AMOUNT); + GHO_BUIDL_GSM.sellAsset(DEFAULT_GSM_BUIDL_AMOUNT, ALICE); + vm.stopPrank(); + + // Supply USDC to the Redemption contract + vm.prank(FAUCET); + USDC_TOKEN.mint(address(BUIDL_USDC_REDEMPTION), DEFAULT_GSM_BUIDL_AMOUNT); + + // Supply assets to another user + ghoFaucet(gsmConverterSignerAddr, DEFAULT_GSM_GHO_AMOUNT + buyFee); + vm.prank(gsmConverterSignerAddr); + GHO_TOKEN.approve(address(GSM_CONVERTER), DEFAULT_GSM_GHO_AMOUNT + buyFee); + + assertEq( + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + 0, + 'Unexpected before gsmConverterSignerAddr nonce' + ); + + bytes32 digest = keccak256( + abi.encode( + '\x19\x01', + GSM_CONVERTER.DOMAIN_SEPARATOR(), + GSM_CONVERTER_BUY_ASSET_WITH_SIG_TYPEHASH, + abi.encode( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + deadline + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(gsmConverterSignerKey, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + assertTrue(gsmConverterSignerAddr != BOB, 'Signer is the same as Bob'); + + vm.prank(BOB); + vm.expectRevert('SIGNATURE_INVALID'); + GSM_CONVERTER.buyAssetWithSig( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT + 1, // invalid amount + gsmConverterSignerAddr, + deadline, + signature + ); + } + + function testRevertBuyAssetWithSigInvalidNonce() public { + // EIP-2612 states the execution must be allowed in case deadline is equal to block.timestamp + (gsmConverterSignerAddr, gsmConverterSignerKey) = makeAddrAndKey('randomString'); + + uint256 deadline = block.timestamp + 1; + uint256 buyFee = GHO_GSM_FIXED_FEE_STRATEGY.getBuyFee(DEFAULT_GSM_GHO_AMOUNT); + + // Supply BUIDL assets to the BUIDL GSM first + vm.prank(FAUCET); + BUIDL_TOKEN.mint(ALICE, DEFAULT_GSM_BUIDL_AMOUNT); + vm.startPrank(ALICE); + BUIDL_TOKEN.approve(address(GHO_BUIDL_GSM), DEFAULT_GSM_BUIDL_AMOUNT); + GHO_BUIDL_GSM.sellAsset(DEFAULT_GSM_BUIDL_AMOUNT, ALICE); + vm.stopPrank(); + + // Supply USDC to the Redemption contract + vm.prank(FAUCET); + USDC_TOKEN.mint(address(BUIDL_USDC_REDEMPTION), DEFAULT_GSM_BUIDL_AMOUNT); + + // Supply assets to another user + ghoFaucet(gsmConverterSignerAddr, DEFAULT_GSM_GHO_AMOUNT + buyFee); + vm.prank(gsmConverterSignerAddr); + GHO_TOKEN.approve(address(GSM_CONVERTER), DEFAULT_GSM_GHO_AMOUNT + buyFee); + + assertEq( + GSM_CONVERTER.nonces(gsmConverterSignerAddr), + 0, + 'Unexpected before gsmConverterSignerAddr nonce' + ); + + bytes32 digest = keccak256( + abi.encode( + '\x19\x01', + GSM_CONVERTER.DOMAIN_SEPARATOR(), + GSM_CONVERTER_BUY_ASSET_WITH_SIG_TYPEHASH, + abi.encode( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + GSM_CONVERTER.nonces(gsmConverterSignerAddr) + 1, // invalid nonce + deadline + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(gsmConverterSignerKey, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + assertTrue(gsmConverterSignerAddr != BOB, 'Signer is the same as Bob'); + + vm.prank(BOB); + vm.expectRevert('SIGNATURE_INVALID'); + GSM_CONVERTER.buyAssetWithSig( + gsmConverterSignerAddr, + DEFAULT_GSM_BUIDL_AMOUNT, + gsmConverterSignerAddr, + deadline, + signature + ); + } + + function testRescueTokens() public { + vm.prank(FAUCET); + WETH.mint(address(GSM_CONVERTER), 100e18); + assertEq(WETH.balanceOf(address(GSM_CONVERTER)), 100e18, 'Unexpected GSM WETH before balance'); + assertEq(WETH.balanceOf(ALICE), 0, 'Unexpected target WETH before balance'); + vm.expectEmit(true, true, true, true, address(GSM_CONVERTER)); + emit TokensRescued(address(WETH), ALICE, 100e18); + GSM_CONVERTER.rescueTokens(address(WETH), ALICE, 100e18); + assertEq(WETH.balanceOf(address(GSM_CONVERTER)), 0, 'Unexpected GSM WETH after balance'); + assertEq(WETH.balanceOf(ALICE), 100e18, 'Unexpected target WETH after balance'); + } + + function testRevertRescueTokensZeroAmount() public { + vm.expectRevert('INVALID_AMOUNT'); + GSM_CONVERTER.rescueTokens(address(WETH), ALICE, 0); + } + + function testRevertRescueTokensInsufficientAmount() public { + vm.expectRevert(); + GSM_CONVERTER.rescueTokens(address(WETH), ALICE, 1); + } + + function testRescueGhoTokens() public { + ghoFaucet(address(GSM_CONVERTER), 100e18); + assertEq( + GHO_TOKEN.balanceOf(address(GSM_CONVERTER)), + 100e18, + 'Unexpected GSM GHO before balance' + ); + assertEq(GHO_TOKEN.balanceOf(ALICE), 0, 'Unexpected target GHO before balance'); + vm.expectEmit(true, true, true, true, address(GSM_CONVERTER)); + emit TokensRescued(address(GHO_TOKEN), ALICE, 100e18); + GSM_CONVERTER.rescueTokens(address(GHO_TOKEN), ALICE, 100e18); + assertEq(GHO_TOKEN.balanceOf(address(GSM_CONVERTER)), 0, 'Unexpected GSM GHO after balance'); + assertEq(GHO_TOKEN.balanceOf(ALICE), 100e18, 'Unexpected target GHO after balance'); + } + + function testRescueRedeemedTokens() public { + vm.prank(FAUCET); + USDC_TOKEN.mint(address(GSM_CONVERTER), DEFAULT_GSM_USDC_AMOUNT); + + assertEq(USDC_TOKEN.balanceOf(ALICE), 0, 'Unexpected USDC balance before'); + vm.expectEmit(true, true, true, true, address(GSM_CONVERTER)); + emit TokensRescued(address(USDC_TOKEN), ALICE, DEFAULT_GSM_USDC_AMOUNT); + GSM_CONVERTER.rescueTokens(address(USDC_TOKEN), ALICE, DEFAULT_GSM_USDC_AMOUNT); + assertEq(USDC_TOKEN.balanceOf(ALICE), DEFAULT_GSM_USDC_AMOUNT, 'Unexpected USDC balance after'); + } + + function testRescueIssuedTokens() public { + vm.prank(FAUCET); + BUIDL_TOKEN.mint(address(GSM_CONVERTER), DEFAULT_GSM_USDC_AMOUNT); + + assertEq(BUIDL_TOKEN.balanceOf(ALICE), 0, 'Unexpected BUIDL balance before'); + vm.expectEmit(true, true, true, true, address(GSM_CONVERTER)); + emit TokensRescued(address(BUIDL_TOKEN), ALICE, DEFAULT_GSM_USDC_AMOUNT); + GSM_CONVERTER.rescueTokens(address(BUIDL_TOKEN), ALICE, DEFAULT_GSM_USDC_AMOUNT); + assertEq( + BUIDL_TOKEN.balanceOf(ALICE), + DEFAULT_GSM_USDC_AMOUNT, + 'Unexpected BUIDL balance after' + ); + } + + function _upgradeToGsmFailedBuyAssetRemainingGhoBalance() internal { + address gsmFailed = address( + new MockGsmFailedBuyAssetRemainingGhoBalance( + address(GHO_TOKEN), + address(BUIDL_TOKEN), + address(GHO_BUIDL_GSM_FIXED_PRICE_STRATEGY) + ) + ); + bytes memory data = abi.encodeWithSelector( + MockGsmFailedBuyAssetRemainingGhoBalance.initialize.selector, + address(this), + TREASURY, + DEFAULT_GSM_USDC_EXPOSURE + ); + + vm.prank(SHORT_EXECUTOR); + AdminUpgradeabilityProxy(payable(address(GHO_BUIDL_GSM))).upgradeToAndCall(gsmFailed, data); + } + + function _upgradeToGsmFailedSellAssetRemainingGhoBalance() internal { + address gsmFailed = address( + new MockGsmFailedSellAssetRemainingGhoBalance( + address(GHO_TOKEN), + address(BUIDL_TOKEN), + address(GHO_BUIDL_GSM_FIXED_PRICE_STRATEGY) + ) + ); + bytes memory data = abi.encodeWithSelector( + MockGsmFailedSellAssetRemainingGhoBalance.initialize.selector, + address(this), + TREASURY, + DEFAULT_GSM_USDC_EXPOSURE + ); + + vm.prank(SHORT_EXECUTOR); + AdminUpgradeabilityProxy(payable(address(GHO_BUIDL_GSM))).upgradeToAndCall(gsmFailed, data); + } + + function _getBuySellFees( + uint256 amount + ) + internal + returns ( + uint256 expectedRedeemedAssetAmount, + uint256 expectedGhoSold, + uint256 buyFee, + uint256 sell + ) + { + (expectedRedeemedAssetAmount, expectedGhoSold, , buyFee) = GHO_BUIDL_GSM + .getGhoAmountForBuyAsset(amount); + (, , , sell) = GHO_BUIDL_GSM.getGhoAmountForSellAsset(amount); + } +} diff --git a/src/test/helpers/Constants.sol b/src/test/helpers/Constants.sol index 17b9c5b7..7948059f 100644 --- a/src/test/helpers/Constants.sol +++ b/src/test/helpers/Constants.sol @@ -36,6 +36,16 @@ contract Constants { 'SellAssetWithSig(address originator,uint256 maxAmount,address receiver,uint256 nonce,uint256 deadline)' ); + // signature typehash for GSM Converter + bytes32 public constant GSM_CONVERTER_BUY_ASSET_WITH_SIG_TYPEHASH = + keccak256( + 'BuyAssetWithSig(address originator,uint256 minAmount,address receiver,uint256 nonce,uint256 deadline)' + ); + bytes32 public constant GSM_CONVERTER_SELL_ASSET_WITH_SIG_TYPEHASH = + keccak256( + 'SellAssetWithSig(address originator,uint256 maxAmount,address receiver,uint256 nonce,uint256 deadline)' + ); + // defaults used in test environment uint256 constant DEFAULT_FLASH_FEE = 0.0009e4; // 0.09% uint128 constant DEFAULT_CAPACITY = 100_000_000e18; @@ -46,7 +56,9 @@ contract Constants { uint256 constant DEFAULT_GSM_BUY_FEE = 0.1e4; // 10% uint256 constant DEFAULT_GSM_SELL_FEE = 0.1e4; // 10% uint128 constant DEFAULT_GSM_USDC_EXPOSURE = 100_000_000e6; // 6 decimals for USDC + uint128 constant DEFAULT_GSM_BUIDL_EXPOSURE = 100_000_000e6; // 6 decimals for BUIDL uint128 constant DEFAULT_GSM_USDC_AMOUNT = 100e6; // 6 decimals for USDC + uint128 constant DEFAULT_GSM_BUIDL_AMOUNT = 100e6; // 6 decimals for BUIDL uint128 constant DEFAULT_GSM_GHO_AMOUNT = 100e18; // Gho Stewards diff --git a/src/test/helpers/Events.sol b/src/test/helpers/Events.sol index c4fdc9fb..c6e58759 100644 --- a/src/test/helpers/Events.sol +++ b/src/test/helpers/Events.sol @@ -111,6 +111,20 @@ interface Events { uint256 amount ); + // GsmConverter events + event BuyAssetThroughRedemption( + address indexed originator, + address indexed receiver, + uint256 issuedAssetAmount, + uint256 ghoAmount + ); + event SellAssetThroughSubscription( + address indexed originator, + address indexed receiver, + uint256 redeemedAssetAmount, + uint256 ghoAmount + ); + // FixedRateStrategyFactory event RateStrategyCreated(address indexed strategy, uint256 indexed rate); diff --git a/src/test/mocks/MockBUIDLSubscription.sol b/src/test/mocks/MockBUIDLSubscription.sol new file mode 100644 index 00000000..b6e16f96 --- /dev/null +++ b/src/test/mocks/MockBUIDLSubscription.sol @@ -0,0 +1,36 @@ +pragma solidity ^0.8.10; + +import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import {SafeERC20} from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; + +/** + * @title MockBUIDLSubscription + */ +contract MockBUIDLSubscription { + using SafeERC20 for IERC20; + + address public immutable asset; + address public immutable liquidity; + + /** + * @param _asset Address of asset token, ie BUIDL + * @param _liquidity Address of liquidity token, ie USDC + */ + constructor(address _asset, address _liquidity) { + asset = _asset; + liquidity = _liquidity; + } + + function test_coverage_ignore() public virtual { + // Intentionally left blank. + // Excludes contract from coverage. + } + + /** + * @notice Issue the asset token in exchange for selling the liquidity token + */ + function issuance(uint256 amount) external { + IERC20(liquidity).safeTransferFrom(msg.sender, address(this), amount); + IERC20(asset).safeTransfer(msg.sender, amount); + } +} diff --git a/src/test/mocks/MockBUIDLSubscriptionFailed.sol b/src/test/mocks/MockBUIDLSubscriptionFailed.sol new file mode 100644 index 00000000..a73f1ab5 --- /dev/null +++ b/src/test/mocks/MockBUIDLSubscriptionFailed.sol @@ -0,0 +1,37 @@ +pragma solidity ^0.8.10; + +import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import {SafeERC20} from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; + +/** + * @title MockBUIDLSubscriptionFailed + */ +contract MockBUIDLSubscriptionFailed { + using SafeERC20 for IERC20; + + address public immutable asset; + address public immutable liquidity; + + /** + * @param _asset Address of asset token, ie BUIDL + * @param _liquidity Address of liquidity token, ie USDC + */ + constructor(address _asset, address _liquidity) { + asset = _asset; + liquidity = _liquidity; + } + + function test_coverage_ignore() public virtual { + // Intentionally left blank. + // Excludes contract from coverage. + } + + /** + * @notice Issue the asset token in exchange for selling the liquidity token + */ + function issuance(uint256 amount) external { + IERC20(liquidity).safeTransferFrom(msg.sender, address(this), amount); + // TRIGGER ERROR: no asset issued to msg.sender + // IERC20(asset).safeTransfer(msg.sender, amount); + } +} diff --git a/src/test/mocks/MockBUIDLSubscriptionFailedInvalidUSDCAccepted.sol b/src/test/mocks/MockBUIDLSubscriptionFailedInvalidUSDCAccepted.sol new file mode 100644 index 00000000..942aadcf --- /dev/null +++ b/src/test/mocks/MockBUIDLSubscriptionFailedInvalidUSDCAccepted.sol @@ -0,0 +1,38 @@ +pragma solidity ^0.8.10; + +import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import {SafeERC20} from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; + +/** + * @title MockBUIDLSubscriptionFailedInvalidUSDCAccepted + * @dev During issuance, the contract does not accept the proper amount of USDC but issues asset properly + */ +contract MockBUIDLSubscriptionFailedInvalidUSDCAccepted { + using SafeERC20 for IERC20; + + address public immutable asset; + address public immutable liquidity; + + /** + * @param _asset Address of asset token, ie BUIDL + * @param _liquidity Address of liquidity token, ie USDC + */ + constructor(address _asset, address _liquidity) { + asset = _asset; + liquidity = _liquidity; + } + + function test_coverage_ignore() public virtual { + // Intentionally left blank. + // Excludes contract from coverage. + } + + /** + * @notice Issue the asset token in exchange for selling the liquidity token + */ + function issuance(uint256 amount) external { + // TRIGGER ERROR: no enough payment token retrieved from msg.sender + IERC20(liquidity).safeTransferFrom(msg.sender, address(this), amount - 1); + IERC20(asset).safeTransfer(msg.sender, amount); + } +} diff --git a/src/test/mocks/MockERC4626.sol b/src/test/mocks/MockERC4626.sol index 74a0279b..e99b3b8e 100644 --- a/src/test/mocks/MockERC4626.sol +++ b/src/test/mocks/MockERC4626.sol @@ -11,4 +11,9 @@ contract MockERC4626 is ERC4626 { string memory symbol, address asset ) ERC4626(IERC20(asset)) ERC20(name, symbol) {} + + function test_coverage_ignore() public virtual { + // Intentionally left blank. + // Excludes contract from coverage. + } } diff --git a/src/test/mocks/MockGsmFailedBuyAssetRemainingGhoBalance.sol b/src/test/mocks/MockGsmFailedBuyAssetRemainingGhoBalance.sol new file mode 100644 index 00000000..c36c66a4 --- /dev/null +++ b/src/test/mocks/MockGsmFailedBuyAssetRemainingGhoBalance.sol @@ -0,0 +1,581 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.10; + +import {VersionedInitializable} from '@aave/core-v3/contracts/protocol/libraries/aave-upgradeability/VersionedInitializable.sol'; +import {IERC20} from '@aave/core-v3/contracts/dependencies/openzeppelin/contracts/IERC20.sol'; +import {GPv2SafeERC20} from '@aave/core-v3/contracts/dependencies/gnosis/contracts/GPv2SafeERC20.sol'; +import {EIP712} from '@openzeppelin/contracts/utils/cryptography/EIP712.sol'; +import {SignatureChecker} from '@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol'; +import {SafeCast} from '@openzeppelin/contracts/utils/math/SafeCast.sol'; +import {AccessControl} from '@openzeppelin/contracts/access/AccessControl.sol'; +import {IGhoFacilitator} from '../../contracts/gho/interfaces/IGhoFacilitator.sol'; +import {IGhoToken} from '../../contracts/gho/interfaces/IGhoToken.sol'; +import {IGsmPriceStrategy} from '../../contracts/facilitators/gsm/priceStrategy/interfaces/IGsmPriceStrategy.sol'; +import {IGsmFeeStrategy} from '../../contracts/facilitators/gsm/feeStrategy/interfaces/IGsmFeeStrategy.sol'; +import {IGsm} from '../../contracts/facilitators/gsm/interfaces/IGsm.sol'; + +/** + * @title MockGsmFailedBuyAssetRemainingGhoBalance + * @author Aave + * @notice GSM that transfers incorrect amount of GHO during buyAsset + */ +contract MockGsmFailedBuyAssetRemainingGhoBalance is + AccessControl, + VersionedInitializable, + EIP712, + IGsm +{ + using GPv2SafeERC20 for IERC20; + using SafeCast for uint256; + + /// @inheritdoc IGsm + bytes32 public constant CONFIGURATOR_ROLE = keccak256('CONFIGURATOR_ROLE'); + + /// @inheritdoc IGsm + bytes32 public constant TOKEN_RESCUER_ROLE = keccak256('TOKEN_RESCUER_ROLE'); + + /// @inheritdoc IGsm + bytes32 public constant SWAP_FREEZER_ROLE = keccak256('SWAP_FREEZER_ROLE'); + + /// @inheritdoc IGsm + bytes32 public constant LIQUIDATOR_ROLE = keccak256('LIQUIDATOR_ROLE'); + + /// @inheritdoc IGsm + bytes32 public constant BUY_ASSET_WITH_SIG_TYPEHASH = + keccak256( + 'BuyAssetWithSig(address originator,uint256 minAmount,address receiver,uint256 nonce,uint256 deadline)' + ); + + /// @inheritdoc IGsm + bytes32 public constant SELL_ASSET_WITH_SIG_TYPEHASH = + keccak256( + 'SellAssetWithSig(address originator,uint256 maxAmount,address receiver,uint256 nonce,uint256 deadline)' + ); + + /// @inheritdoc IGsm + address public immutable GHO_TOKEN; + + /// @inheritdoc IGsm + address public immutable UNDERLYING_ASSET; + + /// @inheritdoc IGsm + address public immutable PRICE_STRATEGY; + + /// @inheritdoc IGsm + mapping(address => uint256) public nonces; + + address internal _ghoTreasury; + address internal _feeStrategy; + bool internal _isFrozen; + bool internal _isSeized; + uint128 internal _exposureCap; + uint128 internal _currentExposure; + uint128 internal _accruedFees; + + /** + * @dev Require GSM to not be frozen for functions marked by this modifier + */ + modifier notFrozen() { + require(!_isFrozen, 'GSM_FROZEN'); + _; + } + + /** + * @dev Require GSM to not be seized for functions marked by this modifier + */ + modifier notSeized() { + require(!_isSeized, 'GSM_SEIZED'); + _; + } + + function test_coverage_ignore() public virtual { + // Intentionally left blank. + // Excludes contract from coverage. + } + + /** + * @dev Constructor + * @param ghoToken The address of the GHO token contract + * @param underlyingAsset The address of the collateral asset + * @param priceStrategy The address of the price strategy + */ + constructor(address ghoToken, address underlyingAsset, address priceStrategy) EIP712('GSM', '1') { + require(ghoToken != address(0), 'ZERO_ADDRESS_NOT_VALID'); + require(underlyingAsset != address(0), 'ZERO_ADDRESS_NOT_VALID'); + require( + IGsmPriceStrategy(priceStrategy).UNDERLYING_ASSET() == underlyingAsset, + 'INVALID_PRICE_STRATEGY' + ); + GHO_TOKEN = ghoToken; + UNDERLYING_ASSET = underlyingAsset; + PRICE_STRATEGY = priceStrategy; + } + + /** + * @notice GSM initializer + * @param admin The address of the default admin role + * @param ghoTreasury The address of the GHO treasury + * @param exposureCap Maximum amount of user-supplied underlying asset in GSM + */ + function initialize( + address admin, + address ghoTreasury, + uint128 exposureCap + ) external initializer { + require(admin != address(0), 'ZERO_ADDRESS_NOT_VALID'); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(CONFIGURATOR_ROLE, admin); + _updateGhoTreasury(ghoTreasury); + _updateExposureCap(exposureCap); + } + + /// @inheritdoc IGsm + function buyAsset( + uint256 minAmount, + address receiver + ) external notFrozen notSeized returns (uint256, uint256) { + return _buyAsset(msg.sender, minAmount, receiver); + } + + /// @inheritdoc IGsm + function buyAssetWithSig( + address originator, + uint256 minAmount, + address receiver, + uint256 deadline, + bytes calldata signature + ) external notFrozen notSeized returns (uint256, uint256) { + require(deadline >= block.timestamp, 'SIGNATURE_DEADLINE_EXPIRED'); + bytes32 digest = keccak256( + abi.encode( + '\x19\x01', + _domainSeparatorV4(), + BUY_ASSET_WITH_SIG_TYPEHASH, + abi.encode(originator, minAmount, receiver, nonces[originator]++, deadline) + ) + ); + require( + SignatureChecker.isValidSignatureNow(originator, digest, signature), + 'SIGNATURE_INVALID' + ); + + return _buyAsset(originator, minAmount, receiver); + } + + /// @inheritdoc IGsm + function sellAsset( + uint256 maxAmount, + address receiver + ) external notFrozen notSeized returns (uint256, uint256) { + return _sellAsset(msg.sender, maxAmount, receiver); + } + + /// @inheritdoc IGsm + function sellAssetWithSig( + address originator, + uint256 maxAmount, + address receiver, + uint256 deadline, + bytes calldata signature + ) external notFrozen notSeized returns (uint256, uint256) { + require(deadline >= block.timestamp, 'SIGNATURE_DEADLINE_EXPIRED'); + bytes32 digest = keccak256( + abi.encode( + '\x19\x01', + _domainSeparatorV4(), + SELL_ASSET_WITH_SIG_TYPEHASH, + abi.encode(originator, maxAmount, receiver, nonces[originator]++, deadline) + ) + ); + require( + SignatureChecker.isValidSignatureNow(originator, digest, signature), + 'SIGNATURE_INVALID' + ); + + return _sellAsset(originator, maxAmount, receiver); + } + + /// @inheritdoc IGsm + function rescueTokens( + address token, + address to, + uint256 amount + ) external onlyRole(TOKEN_RESCUER_ROLE) { + require(amount > 0, 'INVALID_AMOUNT'); + if (token == GHO_TOKEN) { + uint256 rescuableBalance = IERC20(token).balanceOf(address(this)) - _accruedFees; + require(rescuableBalance >= amount, 'INSUFFICIENT_GHO_TO_RESCUE'); + } + if (token == UNDERLYING_ASSET) { + uint256 rescuableBalance = IERC20(token).balanceOf(address(this)) - _currentExposure; + require(rescuableBalance >= amount, 'INSUFFICIENT_EXOGENOUS_ASSET_TO_RESCUE'); + } + IERC20(token).safeTransfer(to, amount); + emit TokensRescued(token, to, amount); + } + + /// @inheritdoc IGsm + function setSwapFreeze(bool enable) external onlyRole(SWAP_FREEZER_ROLE) { + if (enable) { + require(!_isFrozen, 'GSM_ALREADY_FROZEN'); + } else { + require(_isFrozen, 'GSM_ALREADY_UNFROZEN'); + } + _isFrozen = enable; + emit SwapFreeze(msg.sender, enable); + } + + /// @inheritdoc IGsm + function seize() external notSeized onlyRole(LIQUIDATOR_ROLE) returns (uint256) { + _isSeized = true; + _currentExposure = 0; + _updateExposureCap(0); + + (, uint256 ghoMinted) = IGhoToken(GHO_TOKEN).getFacilitatorBucket(address(this)); + uint256 underlyingBalance = IERC20(UNDERLYING_ASSET).balanceOf(address(this)); + if (underlyingBalance > 0) { + IERC20(UNDERLYING_ASSET).safeTransfer(_ghoTreasury, underlyingBalance); + } + + emit Seized(msg.sender, _ghoTreasury, underlyingBalance, ghoMinted); + return underlyingBalance; + } + + /// @inheritdoc IGsm + function burnAfterSeize(uint256 amount) external onlyRole(LIQUIDATOR_ROLE) returns (uint256) { + require(_isSeized, 'GSM_NOT_SEIZED'); + require(amount > 0, 'INVALID_AMOUNT'); + + (, uint256 ghoMinted) = IGhoToken(GHO_TOKEN).getFacilitatorBucket(address(this)); + if (amount > ghoMinted) { + amount = ghoMinted; + } + IGhoToken(GHO_TOKEN).transferFrom(msg.sender, address(this), amount); + IGhoToken(GHO_TOKEN).burn(amount); + + emit BurnAfterSeize(msg.sender, amount, (ghoMinted - amount)); + return amount; + } + + /// @inheritdoc IGsm + function updateFeeStrategy(address feeStrategy) external onlyRole(CONFIGURATOR_ROLE) { + _updateFeeStrategy(feeStrategy); + } + + /// @inheritdoc IGsm + function updateExposureCap(uint128 exposureCap) external onlyRole(CONFIGURATOR_ROLE) { + _updateExposureCap(exposureCap); + } + + /// @inheritdoc IGhoFacilitator + function distributeFeesToTreasury() public virtual override { + uint256 accruedFees = _accruedFees; + if (accruedFees > 0) { + _accruedFees = 0; + IERC20(GHO_TOKEN).transfer(_ghoTreasury, accruedFees); + emit FeesDistributedToTreasury(_ghoTreasury, GHO_TOKEN, accruedFees); + } + } + + /// @inheritdoc IGhoFacilitator + function updateGhoTreasury(address newGhoTreasury) external override onlyRole(CONFIGURATOR_ROLE) { + _updateGhoTreasury(newGhoTreasury); + } + + /// @inheritdoc IGsm + function DOMAIN_SEPARATOR() external view returns (bytes32) { + return _domainSeparatorV4(); + } + + /// @inheritdoc IGsm + function getGhoAmountForBuyAsset( + uint256 minAssetAmount + ) external view returns (uint256, uint256, uint256, uint256) { + ( + uint256 assetAmount, + uint256 ghoSold, + uint256 grossAmount, + uint256 fee + ) = _calculateGhoAmountForBuyAsset(minAssetAmount); + return (assetAmount, ghoSold, grossAmount, fee); + } + + /// @inheritdoc IGsm + function getGhoAmountForSellAsset( + uint256 maxAssetAmount + ) external view returns (uint256, uint256, uint256, uint256) { + return _calculateGhoAmountForSellAsset(maxAssetAmount); + } + + /// @inheritdoc IGsm + function getAssetAmountForBuyAsset( + uint256 maxGhoAmount + ) external view returns (uint256, uint256, uint256, uint256) { + bool withFee = _feeStrategy != address(0); + uint256 grossAmount = withFee + ? IGsmFeeStrategy(_feeStrategy).getGrossAmountFromTotalBought(maxGhoAmount) + : maxGhoAmount; + // round down so maxGhoAmount is guaranteed + uint256 assetAmount = IGsmPriceStrategy(PRICE_STRATEGY).getGhoPriceInAsset(grossAmount, false); + uint256 finalGrossAmount = IGsmPriceStrategy(PRICE_STRATEGY).getAssetPriceInGho( + assetAmount, + true + ); + uint256 finalFee = withFee ? IGsmFeeStrategy(_feeStrategy).getBuyFee(finalGrossAmount) : 0; + return (assetAmount, finalGrossAmount + finalFee, finalGrossAmount, finalFee); + } + + /// @inheritdoc IGsm + function getAssetAmountForSellAsset( + uint256 minGhoAmount + ) external view returns (uint256, uint256, uint256, uint256) { + bool withFee = _feeStrategy != address(0); + uint256 grossAmount = withFee + ? IGsmFeeStrategy(_feeStrategy).getGrossAmountFromTotalSold(minGhoAmount) + : minGhoAmount; + // round up so minGhoAmount is guaranteed + uint256 assetAmount = IGsmPriceStrategy(PRICE_STRATEGY).getGhoPriceInAsset(grossAmount, true); + uint256 finalGrossAmount = IGsmPriceStrategy(PRICE_STRATEGY).getAssetPriceInGho( + assetAmount, + false + ); + uint256 finalFee = withFee ? IGsmFeeStrategy(_feeStrategy).getSellFee(finalGrossAmount) : 0; + return (assetAmount, finalGrossAmount - finalFee, finalGrossAmount, finalFee); + } + + /// @inheritdoc IGsm + function getAvailableUnderlyingExposure() external view returns (uint256) { + return _exposureCap > _currentExposure ? _exposureCap - _currentExposure : 0; + } + + /// @inheritdoc IGsm + function getExposureCap() external view returns (uint128) { + return _exposureCap; + } + + /// @inheritdoc IGsm + function getAvailableLiquidity() external view returns (uint256) { + return _currentExposure; + } + + /// @inheritdoc IGsm + function getFeeStrategy() external view returns (address) { + return _feeStrategy; + } + + /// @inheritdoc IGsm + function getAccruedFees() external view returns (uint256) { + return _accruedFees; + } + + /// @inheritdoc IGsm + function getIsFrozen() external view returns (bool) { + return _isFrozen; + } + + /// @inheritdoc IGsm + function getIsSeized() external view returns (bool) { + return _isSeized; + } + + /// @inheritdoc IGsm + function canSwap() external view returns (bool) { + return !_isFrozen && !_isSeized; + } + + /// @inheritdoc IGhoFacilitator + function getGhoTreasury() external view override returns (address) { + return _ghoTreasury; + } + + /// @inheritdoc IGsm + function GSM_REVISION() public pure virtual override returns (uint256) { + return 2; + } + + /** + * @dev Buys an underlying asset with GHO + * @param originator The originator of the request + * @param minAmount The minimum amount of the underlying asset desired for purchase + * @param receiver The recipient address of the underlying asset being purchased + * @return The amount of underlying asset bought + * @return The amount of GHO sold by the user + */ + function _buyAsset( + address originator, + uint256 minAmount, + address receiver + ) internal returns (uint256, uint256) { + ( + uint256 assetAmount, + uint256 ghoSold, + uint256 grossAmount, + uint256 fee + ) = _calculateGhoAmountForBuyAsset(minAmount); + + _beforeBuyAsset(originator, assetAmount, receiver); + + require(assetAmount > 0, 'INVALID_AMOUNT'); + require(_currentExposure >= assetAmount, 'INSUFFICIENT_AVAILABLE_EXOGENOUS_ASSET_LIQUIDITY'); + + _currentExposure -= uint128(assetAmount); + _accruedFees += fee.toUint128(); + // TRIGGER ERROR: transfer incorrect GHO amount to GSM + IGhoToken(GHO_TOKEN).transferFrom(originator, address(this), ghoSold - 1); + IGhoToken(GHO_TOKEN).burn(grossAmount); + IERC20(UNDERLYING_ASSET).safeTransfer(receiver, assetAmount); + + emit BuyAsset(originator, receiver, assetAmount, ghoSold, fee); + return (assetAmount, ghoSold); + } + + /** + * @dev Hook that is called before `buyAsset`. + * @dev This can be used to add custom logic + * @param originator Originator of the request + * @param amount The amount of the underlying asset desired for purchase + * @param receiver Recipient address of the underlying asset being purchased + */ + function _beforeBuyAsset(address originator, uint256 amount, address receiver) internal virtual {} + + /** + * @dev Sells an underlying asset for GHO + * @param originator The originator of the request + * @param maxAmount The maximum amount of the underlying asset desired to sell + * @param receiver The recipient address of the GHO being purchased + * @return The amount of underlying asset sold + * @return The amount of GHO bought by the user + */ + function _sellAsset( + address originator, + uint256 maxAmount, + address receiver + ) internal returns (uint256, uint256) { + ( + uint256 assetAmount, + uint256 ghoBought, + uint256 grossAmount, + uint256 fee + ) = _calculateGhoAmountForSellAsset(maxAmount); + + _beforeSellAsset(originator, assetAmount, receiver); + + require(assetAmount > 0, 'INVALID_AMOUNT'); + require(_currentExposure + assetAmount <= _exposureCap, 'EXOGENOUS_ASSET_EXPOSURE_TOO_HIGH'); + + _currentExposure += uint128(assetAmount); + _accruedFees += fee.toUint128(); + IERC20(UNDERLYING_ASSET).safeTransferFrom(originator, address(this), assetAmount); + + IGhoToken(GHO_TOKEN).mint(address(this), grossAmount); + IGhoToken(GHO_TOKEN).transfer(receiver, ghoBought); + + emit SellAsset(originator, receiver, assetAmount, grossAmount, fee); + return (assetAmount, ghoBought); + } + + /** + * @dev Hook that is called before `sellAsset`. + * @dev This can be used to add custom logic + * @param originator Originator of the request + * @param amount The amount of the underlying asset desired to sell + * @param receiver Recipient address of the GHO being purchased + */ + function _beforeSellAsset( + address originator, + uint256 amount, + address receiver + ) internal virtual {} + + /** + * @dev Returns the amount of GHO sold in exchange of buying underlying asset + * @param assetAmount The amount of underlying asset to buy + * @return The exact amount of asset the user purchases + * @return The total amount of GHO the user sells (gross amount in GHO plus fee) + * @return The gross amount of GHO + * @return The fee amount in GHO, applied on top of gross amount of GHO + */ + function _calculateGhoAmountForBuyAsset( + uint256 assetAmount + ) internal view returns (uint256, uint256, uint256, uint256) { + bool withFee = _feeStrategy != address(0); + // pick the highest GHO amount possible for given asset amount + uint256 grossAmount = IGsmPriceStrategy(PRICE_STRATEGY).getAssetPriceInGho(assetAmount, true); + uint256 fee = withFee ? IGsmFeeStrategy(_feeStrategy).getBuyFee(grossAmount) : 0; + uint256 ghoSold = grossAmount + fee; + uint256 finalGrossAmount = withFee + ? IGsmFeeStrategy(_feeStrategy).getGrossAmountFromTotalBought(ghoSold) + : ghoSold; + // pick the lowest asset amount possible for given GHO amount + uint256 finalAssetAmount = IGsmPriceStrategy(PRICE_STRATEGY).getGhoPriceInAsset( + finalGrossAmount, + false + ); + uint256 finalFee = ghoSold - finalGrossAmount; + return (finalAssetAmount, finalGrossAmount + finalFee, finalGrossAmount, finalFee); + } + + /** + * @dev Returns the amount of GHO bought in exchange of a given amount of underlying asset + * @param assetAmount The amount of underlying asset to sell + * @return The exact amount of asset the user sells + * @return The total amount of GHO the user buys (gross amount in GHO minus fee) + * @return The gross amount of GHO + * @return The fee amount in GHO, applied to the gross amount of GHO + */ + function _calculateGhoAmountForSellAsset( + uint256 assetAmount + ) internal view returns (uint256, uint256, uint256, uint256) { + bool withFee = _feeStrategy != address(0); + // pick the lowest GHO amount possible for given asset amount + uint256 grossAmount = IGsmPriceStrategy(PRICE_STRATEGY).getAssetPriceInGho(assetAmount, false); + uint256 fee = withFee ? IGsmFeeStrategy(_feeStrategy).getSellFee(grossAmount) : 0; + uint256 ghoBought = grossAmount - fee; + uint256 finalGrossAmount = withFee + ? IGsmFeeStrategy(_feeStrategy).getGrossAmountFromTotalSold(ghoBought) + : ghoBought; + // pick the highest asset amount possible for given GHO amount + uint256 finalAssetAmount = IGsmPriceStrategy(PRICE_STRATEGY).getGhoPriceInAsset( + finalGrossAmount, + true + ); + uint256 finalFee = finalGrossAmount - ghoBought; + return (finalAssetAmount, finalGrossAmount - finalFee, finalGrossAmount, finalFee); + } + + /** + * @dev Updates Fee Strategy + * @param feeStrategy The address of the new Fee Strategy + */ + function _updateFeeStrategy(address feeStrategy) internal { + address oldFeeStrategy = _feeStrategy; + _feeStrategy = feeStrategy; + emit FeeStrategyUpdated(oldFeeStrategy, feeStrategy); + } + + /** + * @dev Updates Exposure Cap + * @param exposureCap The value of the new Exposure Cap + */ + function _updateExposureCap(uint128 exposureCap) internal { + uint128 oldExposureCap = _exposureCap; + _exposureCap = exposureCap; + emit ExposureCapUpdated(oldExposureCap, exposureCap); + } + + /** + * @dev Updates GHO Treasury Address + * @param newGhoTreasury The address of the new GHO Treasury + */ + function _updateGhoTreasury(address newGhoTreasury) internal { + require(newGhoTreasury != address(0), 'ZERO_ADDRESS_NOT_VALID'); + address oldGhoTreasury = _ghoTreasury; + _ghoTreasury = newGhoTreasury; + emit GhoTreasuryUpdated(oldGhoTreasury, newGhoTreasury); + } + + /// @inheritdoc VersionedInitializable + function getRevision() internal pure virtual override returns (uint256) { + return GSM_REVISION(); + } +} diff --git a/src/test/mocks/MockGsmFailedSellAssetRemainingGhoBalance.sol b/src/test/mocks/MockGsmFailedSellAssetRemainingGhoBalance.sol new file mode 100644 index 00000000..b87d5ba0 --- /dev/null +++ b/src/test/mocks/MockGsmFailedSellAssetRemainingGhoBalance.sol @@ -0,0 +1,431 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.10; + +import {VersionedInitializable} from '@aave/core-v3/contracts/protocol/libraries/aave-upgradeability/VersionedInitializable.sol'; +import {IERC20} from '@aave/core-v3/contracts/dependencies/openzeppelin/contracts/IERC20.sol'; +import {GPv2SafeERC20} from '@aave/core-v3/contracts/dependencies/gnosis/contracts/GPv2SafeERC20.sol'; +import {EIP712} from '@openzeppelin/contracts/utils/cryptography/EIP712.sol'; +import {SignatureChecker} from '@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol'; +import {SafeCast} from '@openzeppelin/contracts/utils/math/SafeCast.sol'; +import {AccessControl} from '@openzeppelin/contracts/access/AccessControl.sol'; +import {IGhoFacilitator} from '../../contracts/gho/interfaces/IGhoFacilitator.sol'; +import {IGhoToken} from '../../contracts/gho/interfaces/IGhoToken.sol'; +import {IGsmPriceStrategy} from '../../contracts/facilitators/gsm/priceStrategy/interfaces/IGsmPriceStrategy.sol'; +import {IGsmFeeStrategy} from '../../contracts/facilitators/gsm/feeStrategy/interfaces/IGsmFeeStrategy.sol'; +import {IGsm} from '../../contracts/facilitators/gsm/interfaces/IGsm.sol'; + +/** + * @title MockGsmFailedSellAssetRemainingGhoBalance + * @author Aave + * @notice GSM that transfers incorrect amount of GHO during sellAsset + */ +contract MockGsmFailedSellAssetRemainingGhoBalance is + AccessControl, + VersionedInitializable, + EIP712, + IGsm +{ + using GPv2SafeERC20 for IERC20; + using SafeCast for uint256; + + /// @inheritdoc IGsm + bytes32 public constant CONFIGURATOR_ROLE = keccak256('CONFIGURATOR_ROLE'); + + /// @inheritdoc IGsm + bytes32 public constant TOKEN_RESCUER_ROLE = keccak256('TOKEN_RESCUER_ROLE'); + + /// @inheritdoc IGsm + bytes32 public constant SWAP_FREEZER_ROLE = keccak256('SWAP_FREEZER_ROLE'); + + /// @inheritdoc IGsm + bytes32 public constant LIQUIDATOR_ROLE = keccak256('LIQUIDATOR_ROLE'); + + /// @inheritdoc IGsm + bytes32 public constant BUY_ASSET_WITH_SIG_TYPEHASH = + keccak256( + 'BuyAssetWithSig(address originator,uint256 minAmount,address receiver,uint256 nonce,uint256 deadline)' + ); + + /// @inheritdoc IGsm + bytes32 public constant SELL_ASSET_WITH_SIG_TYPEHASH = + keccak256( + 'SellAssetWithSig(address originator,uint256 maxAmount,address receiver,uint256 nonce,uint256 deadline)' + ); + + /// @inheritdoc IGsm + address public immutable GHO_TOKEN; + + /// @inheritdoc IGsm + address public immutable UNDERLYING_ASSET; + + /// @inheritdoc IGsm + address public immutable PRICE_STRATEGY; + + /// @inheritdoc IGsm + mapping(address => uint256) public nonces; + + address internal _ghoTreasury; + address internal _feeStrategy; + bool internal _isFrozen; + bool internal _isSeized; + uint128 internal _exposureCap; + uint128 internal _currentExposure; + uint128 internal _accruedFees; + + /** + * @dev Require GSM to not be frozen for functions marked by this modifier + */ + modifier notFrozen() { + require(!_isFrozen, 'GSM_FROZEN'); + _; + } + + /** + * @dev Require GSM to not be seized for functions marked by this modifier + */ + modifier notSeized() { + require(!_isSeized, 'GSM_SEIZED'); + _; + } + + function test_coverage_ignore() public virtual { + // Intentionally left blank. + // Excludes contract from coverage. + } + + /** + * @dev Constructor + * @param ghoToken The address of the GHO token contract + * @param underlyingAsset The address of the collateral asset + * @param priceStrategy The address of the price strategy + */ + constructor(address ghoToken, address underlyingAsset, address priceStrategy) EIP712('GSM', '1') { + require(ghoToken != address(0), 'ZERO_ADDRESS_NOT_VALID'); + require(underlyingAsset != address(0), 'ZERO_ADDRESS_NOT_VALID'); + require( + IGsmPriceStrategy(priceStrategy).UNDERLYING_ASSET() == underlyingAsset, + 'INVALID_PRICE_STRATEGY' + ); + GHO_TOKEN = ghoToken; + UNDERLYING_ASSET = underlyingAsset; + PRICE_STRATEGY = priceStrategy; + } + + /** + * @notice GSM initializer + * @param admin The address of the default admin role + * @param ghoTreasury The address of the GHO treasury + * @param exposureCap Maximum amount of user-supplied underlying asset in GSM + */ + function initialize( + address admin, + address ghoTreasury, + uint128 exposureCap + ) external initializer { + require(admin != address(0), 'ZERO_ADDRESS_NOT_VALID'); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(CONFIGURATOR_ROLE, admin); + _updateGhoTreasury(ghoTreasury); + _updateExposureCap(exposureCap); + } + + /// @inheritdoc IGsm + function buyAsset( + uint256 minAmount, + address receiver + ) external notFrozen notSeized returns (uint256, uint256) { + // return default values + return (0, 0); + } + + /// @inheritdoc IGsm + function buyAssetWithSig( + address originator, + uint256 minAmount, + address receiver, + uint256 deadline, + bytes calldata signature + ) external notFrozen notSeized returns (uint256, uint256) { + // return default values + return (0, 0); + } + + /// @inheritdoc IGsm + function sellAsset( + uint256 maxAmount, + address receiver + ) external notFrozen notSeized returns (uint256, uint256) { + return _sellAsset(msg.sender, maxAmount, receiver); + } + + /// @inheritdoc IGsm + function sellAssetWithSig( + address originator, + uint256 maxAmount, + address receiver, + uint256 deadline, + bytes calldata signature + ) external notFrozen notSeized returns (uint256, uint256) { + require(deadline >= block.timestamp, 'SIGNATURE_DEADLINE_EXPIRED'); + bytes32 digest = keccak256( + abi.encode( + '\x19\x01', + _domainSeparatorV4(), + SELL_ASSET_WITH_SIG_TYPEHASH, + abi.encode(originator, maxAmount, receiver, nonces[originator]++, deadline) + ) + ); + require( + SignatureChecker.isValidSignatureNow(originator, digest, signature), + 'SIGNATURE_INVALID' + ); + + return _sellAsset(originator, maxAmount, receiver); + } + + /// @inheritdoc IGsm + function rescueTokens( + address token, + address to, + uint256 amount + ) external onlyRole(TOKEN_RESCUER_ROLE) { + // intentionally left blank + } + + /// @inheritdoc IGsm + function setSwapFreeze(bool enable) external onlyRole(SWAP_FREEZER_ROLE) { + // intentionally left blank + } + + /// @inheritdoc IGsm + function seize() external notSeized onlyRole(LIQUIDATOR_ROLE) returns (uint256) { + return 0; + } + + /// @inheritdoc IGsm + function burnAfterSeize(uint256 amount) external onlyRole(LIQUIDATOR_ROLE) returns (uint256) { + // return default values + return 0; + } + + /// @inheritdoc IGsm + function updateFeeStrategy(address feeStrategy) external onlyRole(CONFIGURATOR_ROLE) { + // intentionally left blank + } + + /// @inheritdoc IGsm + function updateExposureCap(uint128 exposureCap) external onlyRole(CONFIGURATOR_ROLE) { + // intentionally left blank + } + + /// @inheritdoc IGhoFacilitator + function distributeFeesToTreasury() public virtual override { + // intentionally left blank + } + + /// @inheritdoc IGhoFacilitator + function updateGhoTreasury(address newGhoTreasury) external override onlyRole(CONFIGURATOR_ROLE) { + // intentionally left blank + } + + /// @inheritdoc IGsm + function DOMAIN_SEPARATOR() external view returns (bytes32) { + return _domainSeparatorV4(); + } + + /// @inheritdoc IGsm + function getGhoAmountForBuyAsset( + uint256 minAssetAmount + ) external view returns (uint256, uint256, uint256, uint256) { + // return default values + return (0, 0, 0, 0); + } + + /// @inheritdoc IGsm + function getGhoAmountForSellAsset( + uint256 maxAssetAmount + ) external view returns (uint256, uint256, uint256, uint256) { + return _calculateGhoAmountForSellAsset(maxAssetAmount); + } + + /// @inheritdoc IGsm + function getAssetAmountForBuyAsset( + uint256 maxGhoAmount + ) external view returns (uint256, uint256, uint256, uint256) { + // return default values + return (0, 0, 0, 0); + } + + /// @inheritdoc IGsm + function getAssetAmountForSellAsset( + uint256 minGhoAmount + ) external view returns (uint256, uint256, uint256, uint256) { + bool withFee = _feeStrategy != address(0); + uint256 grossAmount = withFee + ? IGsmFeeStrategy(_feeStrategy).getGrossAmountFromTotalSold(minGhoAmount) + : minGhoAmount; + // round up so minGhoAmount is guaranteed + uint256 assetAmount = IGsmPriceStrategy(PRICE_STRATEGY).getGhoPriceInAsset(grossAmount, true); + uint256 finalGrossAmount = IGsmPriceStrategy(PRICE_STRATEGY).getAssetPriceInGho( + assetAmount, + false + ); + uint256 finalFee = withFee ? IGsmFeeStrategy(_feeStrategy).getSellFee(finalGrossAmount) : 0; + return (assetAmount, finalGrossAmount - finalFee, finalGrossAmount, finalFee); + } + + /// @inheritdoc IGsm + function getAvailableUnderlyingExposure() external view returns (uint256) { + return _exposureCap > _currentExposure ? _exposureCap - _currentExposure : 0; + } + + /// @inheritdoc IGsm + function getExposureCap() external view returns (uint128) { + return _exposureCap; + } + + /// @inheritdoc IGsm + function getAvailableLiquidity() external view returns (uint256) { + return _currentExposure; + } + + /// @inheritdoc IGsm + function getFeeStrategy() external view returns (address) { + return _feeStrategy; + } + + /// @inheritdoc IGsm + function getAccruedFees() external view returns (uint256) { + return _accruedFees; + } + + /// @inheritdoc IGsm + function getIsFrozen() external view returns (bool) { + return _isFrozen; + } + + /// @inheritdoc IGsm + function getIsSeized() external view returns (bool) { + return _isSeized; + } + + /// @inheritdoc IGsm + function canSwap() external view returns (bool) { + return !_isFrozen && !_isSeized; + } + + /// @inheritdoc IGhoFacilitator + function getGhoTreasury() external view override returns (address) { + return _ghoTreasury; + } + + /// @inheritdoc IGsm + function GSM_REVISION() public pure virtual override returns (uint256) { + return 2; + } + + /** + * @dev Sells an underlying asset for GHO + * @param originator The originator of the request + * @param maxAmount The maximum amount of the underlying asset desired to sell + * @param receiver The recipient address of the GHO being purchased + * @return The amount of underlying asset sold + * @return The amount of GHO bought by the user + */ + function _sellAsset( + address originator, + uint256 maxAmount, + address receiver + ) internal returns (uint256, uint256) { + ( + uint256 assetAmount, + uint256 ghoBought, + uint256 grossAmount, + uint256 fee + ) = _calculateGhoAmountForSellAsset(maxAmount); + + _beforeSellAsset(originator, assetAmount, receiver); + + require(assetAmount > 0, 'INVALID_AMOUNT'); + require(_currentExposure + assetAmount <= _exposureCap, 'EXOGENOUS_ASSET_EXPOSURE_TOO_HIGH'); + + _currentExposure += uint128(assetAmount); + _accruedFees += fee.toUint128(); + IERC20(UNDERLYING_ASSET).safeTransferFrom(originator, address(this), assetAmount); + + IGhoToken(GHO_TOKEN).mint(address(this), grossAmount); + IGhoToken(GHO_TOKEN).transfer(receiver, ghoBought); + // TRIGGER ERROR: invalid transfer of GHO amount to GSM Converter (msg.sender) + IGhoToken(GHO_TOKEN).transfer(msg.sender, 1); + + emit SellAsset(originator, receiver, assetAmount, grossAmount, fee); + return (assetAmount, ghoBought); + } + + /** + * @dev Hook that is called before `sellAsset`. + * @dev This can be used to add custom logic + * @param originator Originator of the request + * @param amount The amount of the underlying asset desired to sell + * @param receiver Recipient address of the GHO being purchased + */ + function _beforeSellAsset( + address originator, + uint256 amount, + address receiver + ) internal virtual {} + + /** + * @dev Returns the amount of GHO bought in exchange of a given amount of underlying asset + * @param assetAmount The amount of underlying asset to sell + * @return The exact amount of asset the user sells + * @return The total amount of GHO the user buys (gross amount in GHO minus fee) + * @return The gross amount of GHO + * @return The fee amount in GHO, applied to the gross amount of GHO + */ + function _calculateGhoAmountForSellAsset( + uint256 assetAmount + ) internal view returns (uint256, uint256, uint256, uint256) { + bool withFee = _feeStrategy != address(0); + // pick the lowest GHO amount possible for given asset amount + uint256 grossAmount = IGsmPriceStrategy(PRICE_STRATEGY).getAssetPriceInGho(assetAmount, false); + uint256 fee = withFee ? IGsmFeeStrategy(_feeStrategy).getSellFee(grossAmount) : 0; + uint256 ghoBought = grossAmount - fee; + uint256 finalGrossAmount = withFee + ? IGsmFeeStrategy(_feeStrategy).getGrossAmountFromTotalSold(ghoBought) + : ghoBought; + // pick the highest asset amount possible for given GHO amount + uint256 finalAssetAmount = IGsmPriceStrategy(PRICE_STRATEGY).getGhoPriceInAsset( + finalGrossAmount, + true + ); + uint256 finalFee = finalGrossAmount - ghoBought; + return (finalAssetAmount, finalGrossAmount - finalFee, finalGrossAmount, finalFee); + } + + /** + * @dev Updates Exposure Cap + * @param exposureCap The value of the new Exposure Cap + */ + function _updateExposureCap(uint128 exposureCap) internal { + uint128 oldExposureCap = _exposureCap; + _exposureCap = exposureCap; + emit ExposureCapUpdated(oldExposureCap, exposureCap); + } + + /** + * @dev Updates GHO Treasury Address + * @param newGhoTreasury The address of the new GHO Treasury + */ + function _updateGhoTreasury(address newGhoTreasury) internal { + require(newGhoTreasury != address(0), 'ZERO_ADDRESS_NOT_VALID'); + address oldGhoTreasury = _ghoTreasury; + _ghoTreasury = newGhoTreasury; + emit GhoTreasuryUpdated(oldGhoTreasury, newGhoTreasury); + } + + /// @inheritdoc VersionedInitializable + function getRevision() internal pure virtual override returns (uint256) { + return GSM_REVISION(); + } +} diff --git a/src/test/mocks/MockRedemption.sol b/src/test/mocks/MockRedemption.sol new file mode 100644 index 00000000..7a9d2113 --- /dev/null +++ b/src/test/mocks/MockRedemption.sol @@ -0,0 +1,66 @@ +/** + * Copyright 2024 Circle Internet Financial, LTD. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +pragma solidity ^0.8.10; + +import {IRedemption} from '../../contracts/facilitators/gsm/dependencies/circle/IRedemption.sol'; +import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import {SafeERC20} from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; + +/** + * @title MockRedemption Mock + * @dev Mock USDC-BUIDL off-ramp redemption contract. Prod version here invokes secondary contracts for liquidity: https://etherscan.io/address/0x31d3f59ad4aac0eee2247c65ebe8bf6e9e470a53#code + * @dev Asset token is ERC20-compatible + * @dev Liquidity token is ERC20-compatible + */ +contract MockRedemption is IRedemption { + using SafeERC20 for IERC20; + + /** + * @inheritdoc IRedemption + */ + address public immutable asset; + + /** + * @inheritdoc IRedemption + */ + address public immutable liquidity; + + /** + * @param _asset Address of asset token + * @param _liquidity Address of liquidity token + */ + constructor(address _asset, address _liquidity) { + asset = _asset; + liquidity = _liquidity; + } + + function test_coverage_ignore() public virtual { + // Intentionally left blank. + // Excludes contract from coverage. + } + + /** + * @inheritdoc IRedemption + */ + function redeem(uint256 amount) external { + // mock outcome from Redemption + IERC20(asset).safeTransferFrom(msg.sender, address(this), amount); + IERC20(liquidity).safeTransfer(msg.sender, amount); + } +} diff --git a/src/test/mocks/MockRedemptionFailed.sol b/src/test/mocks/MockRedemptionFailed.sol new file mode 100644 index 00000000..b0f32bf8 --- /dev/null +++ b/src/test/mocks/MockRedemptionFailed.sol @@ -0,0 +1,65 @@ +/** + * Copyright 2024 Circle Internet Financial, LTD. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +pragma solidity ^0.8.10; + +import {IRedemption} from '../../contracts/facilitators/gsm/dependencies/circle/IRedemption.sol'; +import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import {SafeERC20} from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; + +/** + * @title MockRedemptionFailed + * @dev Asset token is ERC20-compatible + * @dev Liquidity token is ERC20-compatible + */ +contract MockRedemptionFailed is IRedemption { + using SafeERC20 for IERC20; + + /** + * @inheritdoc IRedemption + */ + address public immutable asset; + + /** + * @inheritdoc IRedemption + */ + address public immutable liquidity; + + /** + * @param _asset Address of asset token + * @param _liquidity Address of liquidity token + */ + constructor(address _asset, address _liquidity) { + asset = _asset; + liquidity = _liquidity; + } + + function test_coverage_ignore() public virtual { + // Intentionally left blank. + // Excludes contract from coverage. + } + + /** + * @inheritdoc IRedemption + */ + function redeem(uint256 amount) external { + // mock outcome from Redemption + IERC20(asset).safeTransferFrom(msg.sender, address(this), amount); + IERC20(liquidity).safeTransfer(msg.sender, amount - 1); + } +} diff --git a/src/test/mocks/MockRedemptionFailedIssuedAssetAmount.sol b/src/test/mocks/MockRedemptionFailedIssuedAssetAmount.sol new file mode 100644 index 00000000..5412810b --- /dev/null +++ b/src/test/mocks/MockRedemptionFailedIssuedAssetAmount.sol @@ -0,0 +1,65 @@ +/** + * Copyright 2024 Circle Internet Financial, LTD. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +pragma solidity ^0.8.10; + +import {IRedemption} from '../../contracts/facilitators/gsm/dependencies/circle/IRedemption.sol'; +import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import {SafeERC20} from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; + +/** + * @title MockRedemptionFailedIssuedAssetAmount + * @dev Asset token is ERC20-compatible + * @dev Liquidity token is ERC20-compatible + */ +contract MockRedemptionFailedIssuedAssetAmount is IRedemption { + using SafeERC20 for IERC20; + + /** + * @inheritdoc IRedemption + */ + address public immutable asset; + + /** + * @inheritdoc IRedemption + */ + address public immutable liquidity; + + /** + * @param _asset Address of asset token + * @param _liquidity Address of liquidity token + */ + constructor(address _asset, address _liquidity) { + asset = _asset; + liquidity = _liquidity; + } + + function test_coverage_ignore() public virtual { + // Intentionally left blank. + // Excludes contract from coverage. + } + + /** + * @inheritdoc IRedemption + */ + function redeem(uint256 amount) external { + // mock outcome from Redemption + IERC20(asset).safeTransferFrom(msg.sender, address(this), amount - 1); + IERC20(liquidity).safeTransfer(msg.sender, amount); + } +} diff --git a/src/test/mocks/MockUSTBSubscriptionRedemption.sol b/src/test/mocks/MockUSTBSubscriptionRedemption.sol new file mode 100644 index 00000000..8ef79f12 --- /dev/null +++ b/src/test/mocks/MockUSTBSubscriptionRedemption.sol @@ -0,0 +1,58 @@ +pragma solidity ^0.8.10; + +import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import {SafeERC20} from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; + +/** + * @title MockUSTBSubscription + */ +contract MockUSTBSubscription { + using SafeERC20 for IERC20; + + address public immutable asset; + address public immutable liquidity; + uint256 public USTBPrice; + + /** + * @param _asset Address of asset token, ie USTB + * @param _liquidity Address of liquidity token, ie USDC + */ + constructor(address _asset, address _liquidity, uint256 _price) { + asset = _asset; + liquidity = _liquidity; + setUSTBPrice(_price); + } + + function test_coverage_ignore() public virtual { + // Intentionally left blank. + // Excludes contract from coverage. + } + + /** + * @notice Subscribe to the asset token (USTB) in exchange for the liquidity token (USDC) + * @param amount The amount of the USDC token to exchange, in base units + */ + function subscribe(uint256 amount) external { + uint256 USTBAmount = amount / USTBPrice; + IERC20(liquidity).safeTransferFrom(msg.sender, address(this), amount); + IERC20(asset).safeTransfer(msg.sender, USTBAmount); + } + + /** + * @notice Redeem the asset token (USTB) in exchange for the liquidity token (USDC) + * @param amount The amount of the USTB token to exchange, in base units + */ + function redeem(uint256 amount) external { + uint256 USDCAmount = amount * USTBPrice; + IERC20(asset).safeTransfer(msg.sender, amount); + IERC20(liquidity).safeTransferFrom(msg.sender, address(this), USDCAmount); + } + + /** + * @notice Set the price of USTB, amount of USDC for 1 USTB. USTB/USDC both have 6 decimals. + * @param newPrice The new price of USTB + */ + function setUSTBPrice(uint256 newPrice) public { + USTBPrice = newPrice; + } +} diff --git a/src/test/mocks/MockUpgradeable.sol b/src/test/mocks/MockUpgradeable.sol index 285679b0..a490a575 100644 --- a/src/test/mocks/MockUpgradeable.sol +++ b/src/test/mocks/MockUpgradeable.sol @@ -14,6 +14,11 @@ contract MockUpgradeable is Initializable { // Intentionally left bank } + function test_coverage_ignore() public virtual { + // Intentionally left blank. + // Excludes contract from coverage. + } + /** * @dev Initializer */