diff --git a/deployment-config/mydeploy.json b/deployment-config/mydeploy.json index 24e301b..a6aefa0 100644 --- a/deployment-config/mydeploy.json +++ b/deployment-config/mydeploy.json @@ -41,10 +41,21 @@ "address": "0x00000000004F96C07B83e86600D86F0000000000" }, + "queue": { + "queueSalt": "0x69b369df5a15ed01cbbf40000000000000000000000000000000000000000000", + "address": "0x000000000012475236b87af9d3ffc0de73cb6a50" + }, + + "solver": { + "solverSalt": "0xd800db1e20957502752abd000000000000000000000000000000000000000000", + "address": "0x000000000017b37cc62fed0df40bc89030643f62" + }, + "rolesAuthority": { "rolesAuthoritySalt": "0x66bbc3b3b3000b01466a3a00000000000000000000000000000000000000000a", "strategist": "0xC2d99d76bb9D46BF8Ec9449E4DfAE48C30CF0839", "exchangeRateBot": "0x00000000004F96C07B83e86600D86F0000000000", + "solverBot": "0x00000000004F96C07B83e86600D86F0000000000", "address": "0x00000000004F96C07B83e86600D86F0000000000" }, diff --git a/lib/foundry-arbitrum b/lib/foundry-arbitrum deleted file mode 160000 index 1ff06d8..0000000 --- a/lib/foundry-arbitrum +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 1ff06d8dd25299851ec388e167c156396559892a diff --git a/lib/openzeppelin-contracts-upgradeable b/lib/openzeppelin-contracts-upgradeable index 2d081f2..723f8ca 160000 --- a/lib/openzeppelin-contracts-upgradeable +++ b/lib/openzeppelin-contracts-upgradeable @@ -1 +1 @@ -Subproject commit 2d081f24cac1a867f6f73d512f2022e1fa987854 +Subproject commit 723f8cab09cdae1aca9ec9cc1cfa040c2d4b06c1 diff --git a/script/ConfigReader.s.sol b/script/ConfigReader.s.sol index 40d2fdd..fd28cc7 100644 --- a/script/ConfigReader.s.sol +++ b/script/ConfigReader.s.sol @@ -49,6 +49,11 @@ library ConfigReader { address[] rateProviders; address[] priceFeeds; address base; + bytes32 queueSalt; + address queue; + bytes32 solverSalt; + address solver; + address solverBot; } function toConfig(string memory _config, string memory _chainConfig) internal pure returns (Config memory config) { @@ -83,11 +88,20 @@ library ConfigReader { config.assets = _config.readAddressArray(".teller.assets"); config.peerEid = uint32(_config.readUint(".teller.peerEid")); + // Reading from the 'queue' section + config.queueSalt = _config.readBytes32(".queue.queueSalt"); + config.queue = _config.readAddress(".queue.address"); + + // Reading from the 'solver' section + config.solverSalt = _config.readBytes32(".solver.solverSalt"); + config.solver = _config.readAddress(".solver.address"); + // Reading from the 'rolesAuthority' section config.rolesAuthority = _config.readAddress(".rolesAuthority.address"); config.rolesAuthoritySalt = _config.readBytes32(".rolesAuthority.rolesAuthoritySalt"); config.strategist = _config.readAddress(".rolesAuthority.strategist"); config.exchangeRateBot = _config.readAddress(".rolesAuthority.exchangeRateBot"); + config.solverBot = _config.readAddress(".rolesAuthority.solverBot"); // Reading from the 'decoder' section config.decoderSalt = _config.readBytes32(".decoder.decoderSalt"); diff --git a/script/deploy/deployAll.s.sol b/script/deploy/deployAll.s.sol index d02c159..9912217 100644 --- a/script/deploy/deployAll.s.sol +++ b/script/deploy/deployAll.s.sol @@ -15,9 +15,11 @@ import { DeployCrossChainOPTellerWithMultiAssetSupport } from "./single/05a_DeployCrossChainOPTellerWithMultiAssetSupport.s.sol"; import { DeployMultiChainLayerZeroTellerWithMultiAssetSupport } from "./single/05b_DeployMultiChainLayerZeroTellerWithMultiAssetSupport.s.sol"; -import { DeployRolesAuthority } from "./single/06_DeployRolesAuthority.s.sol"; -import { TellerSetup } from "./single/07_TellerSetup.s.sol"; -import { SetAuthorityAndTransferOwnerships } from "./single/08_SetAuthorityAndTransferOwnerships.s.sol"; +import { DeployAtomicQueueV2 } from "./single/06_DeployAtomicQueueV2.s.sol"; +import { DeployAtomicSolverV4 } from "./single/07_DeployAtomicSolverV4.s.sol"; +import { DeployRolesAuthority } from "./single/08_DeployRolesAuthority.s.sol"; +import { TellerSetup } from "./single/09_TellerSetup.s.sol"; +import { SetAuthorityAndTransferOwnerships } from "./single/10_SetAuthorityAndTransferOwnerships.s.sol"; import { ConfigReader, IAuthority } from "../ConfigReader.s.sol"; import { console } from "forge-std/console.sol"; @@ -77,6 +79,12 @@ contract DeployAll is BaseScript { new TellerSetup().deploy(config); console.log("Teller setup complete"); + address queue = new DeployAtomicQueueV2().deploy(config); + config.queue = queue; + + address solver = new DeployAtomicSolverV4().deploy(config); + config.solver = solver; + address rolesAuthority = new DeployRolesAuthority().deploy(config); config.rolesAuthority = rolesAuthority; console.log("Roles Authority: ", rolesAuthority); diff --git a/script/deploy/single/06_DeployAtomicQueueV2.s.sol b/script/deploy/single/06_DeployAtomicQueueV2.s.sol new file mode 100644 index 0000000..7646015 --- /dev/null +++ b/script/deploy/single/06_DeployAtomicQueueV2.s.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.21; + +import { AtomicQueueV2 } from "./../../../src/atomic-queue/AtomicQueueV2.sol"; +import { BaseScript } from "../../Base.s.sol"; +import { stdJson as StdJson } from "forge-std/StdJson.sol"; +import { ConfigReader } from "../../ConfigReader.s.sol"; + +contract DeployAtomicQueueV2 is BaseScript { + using StdJson for string; + + function run() public returns (address manager) { + manager = deploy(getConfig()); + } + + function deploy(ConfigReader.Config memory config) public override broadcast returns (address) { + // Require config Values + require(config.queueSalt != bytes32(0), "queue salt must not be zero"); + + // Create Contract + bytes memory creationCode = type(AtomicQueueV2).creationCode; + + AtomicQueueV2 queue = AtomicQueueV2(CREATEX.deployCreate3(config.queueSalt, abi.encodePacked(creationCode))); + + return address(queue); + } +} diff --git a/script/deploy/single/07_DeployAtomicSolverV4.s.sol b/script/deploy/single/07_DeployAtomicSolverV4.s.sol new file mode 100644 index 0000000..116a8e0 --- /dev/null +++ b/script/deploy/single/07_DeployAtomicSolverV4.s.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.21; + +import { AtomicSolverV4 } from "./../../../src/atomic-queue/AtomicSolverV4.sol"; +import { BaseScript } from "../../Base.s.sol"; +import { stdJson as StdJson } from "forge-std/StdJson.sol"; +import { ConfigReader } from "../../ConfigReader.s.sol"; + +contract DeployAtomicSolverV4 is BaseScript { + using StdJson for string; + + function run() public returns (address manager) { + manager = deploy(getConfig()); + } + + function deploy(ConfigReader.Config memory config) public override broadcast returns (address) { + // Require config Values + require(config.solverSalt != bytes32(0), "solver salt must not be zero"); + + // Create Contract + bytes memory creationCode = type(AtomicSolverV4).creationCode; + + AtomicSolverV4 solver = AtomicSolverV4( + CREATEX.deployCreate3(config.solverSalt, abi.encodePacked(creationCode, abi.encode(broadcaster))) + ); + + return address(solver); + } +} diff --git a/script/deploy/single/06_DeployRolesAuthority.s.sol b/script/deploy/single/08_DeployRolesAuthority.s.sol similarity index 72% rename from script/deploy/single/06_DeployRolesAuthority.s.sol rename to script/deploy/single/08_DeployRolesAuthority.s.sol index 09604d9..e7a6683 100644 --- a/script/deploy/single/06_DeployRolesAuthority.s.sol +++ b/script/deploy/single/08_DeployRolesAuthority.s.sol @@ -6,6 +6,7 @@ import { ManagerWithMerkleVerification } from "./../../../src/base/Roles/Manager import { BoringVault } from "./../../../src/base/BoringVault.sol"; import { TellerWithMultiAssetSupport } from "./../../../src/base/Roles/TellerWithMultiAssetSupport.sol"; import { AccountantWithRateProviders } from "./../../../src/base/Roles/AccountantWithRateProviders.sol"; +import { AtomicSolverV4 } from "./../../../src/atomic-queue/AtomicSolverV4.sol"; import { BaseScript } from "../../Base.s.sol"; import { ConfigReader } from "../../ConfigReader.s.sol"; import { CrossChainTellerBase } from "../../../src/base/Roles/CrossChain/CrossChainTellerBase.sol"; @@ -21,6 +22,9 @@ contract DeployRolesAuthority is BaseScript { uint8 public constant MANAGER_ROLE = 2; uint8 public constant TELLER_ROLE = 3; uint8 public constant UPDATE_EXCHANGE_RATE_ROLE = 4; + uint8 public constant SOLVER_ROLE = 5; + uint8 public constant QUEUE_ROLE = 6; // queue role is for calling finishSolve in solver + uint8 public constant SOLVER_CALLER_ROLE = 7; function run() public virtual returns (address rolesAuthority) { return deploy(getConfig()); @@ -32,6 +36,8 @@ contract DeployRolesAuthority is BaseScript { require(config.manager.code.length != 0, "manager must have code"); require(config.teller.code.length != 0, "teller must have code"); require(config.accountant.code.length != 0, "accountant must have code"); + require(config.queue.code.length != 0, "queue must have code"); + require(config.solver.code.length != 0, "solver must have code"); require(config.boringVault != address(0), "boringVault"); require(config.manager != address(0), "manager"); require(config.teller != address(0), "teller"); @@ -58,6 +64,10 @@ contract DeployRolesAuthority is BaseScript { // 1. VAULT_STRATEGIST (BOT EOA) // 2. MANAGER (CONTRACT) // 3. TELLER (CONTRACT) + // 4. EXCHANGE_RATE_BOT (BOT EOA) + // 5. SOLVER (CONTRACT) + // 6. QUEUE (CONTRACT) + // 7. SOLVER_BOT (BOT EOA) // --- Roles --- // 1. STRATEGIST_ROLE // - manager.manageVaultWithMerkleVerification @@ -69,6 +79,16 @@ contract DeployRolesAuthority is BaseScript { // - boringVault.enter() // - boringVault.exit() // - assigned to TELLER + // 5. SOLVER_ROLE + // - teller.bulkWithdraw + // - assigned to SOLVER + // 6. QUEUE_ROLE + // - solver.finshSolve + // - assigned to QUEUE + // 7. SOLVER_CALLER_ROLE + // - solver.p2pSolve + // - solver.redeemSolve + // - assigned to SOLVER_BOT // --- Public --- // 1. teller.deposit rolesAuthority.setRoleCapability( @@ -101,6 +121,16 @@ contract DeployRolesAuthority is BaseScript { UPDATE_EXCHANGE_RATE_ROLE, config.accountant, AccountantWithRateProviders.updateExchangeRate.selector, true ); + rolesAuthority.setRoleCapability( + SOLVER_ROLE, config.teller, TellerWithMultiAssetSupport.bulkWithdraw.selector, true + ); + + rolesAuthority.setRoleCapability(QUEUE_ROLE, config.solver, AtomicSolverV4.finishSolve.selector, true); + + rolesAuthority.setRoleCapability(SOLVER_CALLER_ROLE, config.solver, AtomicSolverV4.p2pSolve.selector, true); + + rolesAuthority.setRoleCapability(SOLVER_CALLER_ROLE, config.solver, AtomicSolverV4.redeemSolve.selector, true); + // --- Assign roles to users --- rolesAuthority.setUserRole(config.strategist, STRATEGIST_ROLE, true); @@ -111,6 +141,12 @@ contract DeployRolesAuthority is BaseScript { rolesAuthority.setUserRole(config.exchangeRateBot, UPDATE_EXCHANGE_RATE_ROLE, true); + rolesAuthority.setUserRole(config.solver, SOLVER_ROLE, true); + + rolesAuthority.setUserRole(config.queue, QUEUE_ROLE, true); + + rolesAuthority.setUserRole(config.solverBot, SOLVER_CALLER_ROLE, true); + // Post Deploy Checks require( rolesAuthority.doesUserHaveRole(config.strategist, STRATEGIST_ROLE), @@ -122,6 +158,12 @@ contract DeployRolesAuthority is BaseScript { rolesAuthority.doesUserHaveRole(config.exchangeRateBot, UPDATE_EXCHANGE_RATE_ROLE), "exchangeRateBot should have UPDATE_EXCHANGE_RATE_ROLE" ); + require(rolesAuthority.doesUserHaveRole(config.solver, SOLVER_ROLE), "solver should have SOLVER_ROLE"); + require(rolesAuthority.doesUserHaveRole(config.queue, QUEUE_ROLE), "queue should have QUEUE_ROLE"); + require( + rolesAuthority.doesUserHaveRole(config.solverBot, SOLVER_CALLER_ROLE), + "solverBot should have SOLVER_CALLER_ROLE" + ); require( rolesAuthority.canCall( config.strategist, @@ -158,6 +200,22 @@ contract DeployRolesAuthority is BaseScript { ), "exchangeRateBot should be able to call accountant.updateExchangeRate" ); + require( + rolesAuthority.canCall(config.solver, config.teller, TellerWithMultiAssetSupport.bulkWithdraw.selector), + "solver should be able to call teller.bulkWithdraw" + ); + require( + rolesAuthority.canCall(config.queue, config.solver, AtomicSolverV4.finishSolve.selector), + "queue should be able to call solver.finishSolve" + ); + require( + rolesAuthority.canCall(config.solverBot, config.solver, AtomicSolverV4.p2pSolve.selector), + "solverBot should be able to call solver.p2pSolve" + ); + require( + rolesAuthority.canCall(config.solverBot, config.solver, AtomicSolverV4.redeemSolve.selector), + "solverBot should be able to call solver.redeemSolve" + ); require( rolesAuthority.canCall(address(1), config.teller, TellerWithMultiAssetSupport.deposit.selector), "anyone should be able to call teller.deposit" diff --git a/script/deploy/single/07_TellerSetup.s.sol b/script/deploy/single/09_TellerSetup.s.sol similarity index 100% rename from script/deploy/single/07_TellerSetup.s.sol rename to script/deploy/single/09_TellerSetup.s.sol diff --git a/script/deploy/single/08_SetAuthorityAndTransferOwnerships.s.sol b/script/deploy/single/10_SetAuthorityAndTransferOwnerships.s.sol similarity index 88% rename from script/deploy/single/08_SetAuthorityAndTransferOwnerships.s.sol rename to script/deploy/single/10_SetAuthorityAndTransferOwnerships.s.sol index d706e5a..59dcbe1 100644 --- a/script/deploy/single/08_SetAuthorityAndTransferOwnerships.s.sol +++ b/script/deploy/single/10_SetAuthorityAndTransferOwnerships.s.sol @@ -22,6 +22,7 @@ contract SetAuthorityAndTransferOwnerships is BaseScript { require(address(config.manager).code.length != 0, "manager must have code"); require(address(config.teller).code.length != 0, "teller must have code"); require(address(config.accountant).code.length != 0, "accountant must have code"); + require(address(config.solver).code.length != 0, "solver must have code"); require(address(config.boringVault) != address(0), "boringVault"); require(address(config.manager) != address(0), "manager"); require(address(config.accountant) != address(0), "accountant"); @@ -34,10 +35,12 @@ contract SetAuthorityAndTransferOwnerships is BaseScript { IAuthority(config.accountant).setAuthority(config.rolesAuthority); IAuthority(config.manager).setAuthority(config.rolesAuthority); IAuthority(config.teller).setAuthority(config.rolesAuthority); + IAuthority(config.solver).setAuthority(config.rolesAuthority); IAuthority(config.boringVault).transferOwnership(config.protocolAdmin); IAuthority(config.manager).transferOwnership(config.protocolAdmin); IAuthority(config.accountant).transferOwnership(config.protocolAdmin); IAuthority(config.teller).transferOwnership(config.protocolAdmin); + IAuthority(config.solver).transferOwnership(config.protocolAdmin); IAuthority(config.rolesAuthority).transferOwnership(config.protocolAdmin); // Post Configuration Check @@ -45,5 +48,6 @@ contract SetAuthorityAndTransferOwnerships is BaseScript { require(IAuthority(config.manager).owner() == config.protocolAdmin, "manager"); require(IAuthority(config.accountant).owner() == config.protocolAdmin, "accountant"); require(IAuthority(config.teller).owner() == config.protocolAdmin, "teller"); + require(IAuthority(config.solver).owner() == config.protocolAdmin, "solver"); } } diff --git a/script/deploy/single/11_DeployDecoderAndSanitizer.s.sol b/script/deploy/single/11_DeployDecoderAndSanitizer.s.sol new file mode 100644 index 0000000..dbf07f7 --- /dev/null +++ b/script/deploy/single/11_DeployDecoderAndSanitizer.s.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.21; + +import { IonPoolDecoderAndSanitizer } from "../../src/base/DecodersAndSanitizers/IonPoolDecoderAndSanitizer.sol"; +import { BaseScript } from "../Base.s.sol"; +import { stdJson as StdJson } from "@forge-std/StdJson.sol"; +import { ConfigReader } from "../ConfigReader.s.sol"; + +contract DeployDecoderAndSanitizer is BaseScript { + using StdJson for string; + + function run() public returns (address decoder) { + return deploy(getConfig()); + } + + function deploy(ConfigReader.Config memory config) public override broadcast returns (address) { + // Require config Values + require(config.boringVault.code.length != 0, "boringVault must have code"); + require(config.decoderSalt != bytes32(0), "decoder salt must not be zero"); + require(config.boringVault != address(0), "boring vault must be set"); + + // Create Contract + bytes memory creationCode = type(IonPoolDecoderAndSanitizer).creationCode; + IonPoolDecoderAndSanitizer decoder = IonPoolDecoderAndSanitizer( + CREATEX.deployCreate3(config.decoderSalt, abi.encodePacked(creationCode, abi.encode(config.boringVault))) + ); + + return address(decoder); + } +} diff --git a/src/atomic-queue/AtomicQueueV2.sol b/src/atomic-queue/AtomicQueueV2.sol new file mode 100644 index 0000000..4b66086 --- /dev/null +++ b/src/atomic-queue/AtomicQueueV2.sol @@ -0,0 +1,362 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.21; + +import { FixedPointMathLib } from "@solmate/utils/FixedPointMathLib.sol"; +import { SafeTransferLib } from "@solmate/utils/SafeTransferLib.sol"; +import { ERC20 } from "@solmate/tokens/ERC20.sol"; +import { ReentrancyGuard } from "@solmate/utils/ReentrancyGuard.sol"; +import { IAtomicSolver } from "./IAtomicSolver.sol"; +import { AtomicSolverV4 } from "./AtomicSolverV4.sol"; + +/** + * @title AtomicQueueV2 + * @notice Allows users to create `AtomicRequests` that specify an ERC20 asset to `offer` + * and an ERC20 asset to `want` in return. + * @notice Making atomic requests where the exchange rate between offer and want is not + * relatively stable is effectively the same as placing a limit order between + * those assets, so requests can be filled at a rate worse than the current market rate. + * @notice It is possible for a user to make multiple requests that use the same offer asset. + * If this is done it is important that the user has approved the queue to spend the + * total amount of assets aggregated from all their requests, and to also have enough + * `offer` asset to cover the aggregate total request of `offerAmount`. + * @author jpick713 + */ +contract AtomicQueueV2 is ReentrancyGuard { + using SafeTransferLib for ERC20; + using FixedPointMathLib for uint256; + + // ========================================= STRUCTS ========================================= + + /** + * @notice Stores request information needed to fulfill a users atomic request. + * @param deadline unix timestamp for when request is no longer valid + * @param atomicPrice the price in terms of `want` asset the user wants their `offer` assets "sold" at + * @dev atomicPrice MUST be in terms of `want` asset decimals. + * @param offerAmount the amount of `offer` asset the user wants converted to `want` asset + * @param inSolve bool used during solves to prevent duplicate users, and to prevent re-doing multiple checks + */ + struct AtomicRequest { + uint64 deadline; // deadline to fulfill request + uint88 atomicPrice; // In terms of want asset decimals + uint96 offerAmount; // The amount of offer asset the user wants to sell. + bool inSolve; // Indicates whether this user is currently having their request fulfilled. + } + + /** + * @notice Used in `viewSolveMetaData` helper function to return data in a clean struct. + * @param user the address of the user + * @param flags 8 bits indicating the state of the user only the first 4 bits are used XXXX0000 + * Either all flags are false(user is solvable) or only 1 is true(an error occurred). + * From right to left + * - 0: indicates user deadline has passed. + * - 1: indicates user request has zero offer amount. + * - 2: indicates user does not have enough offer asset in wallet. + * - 3: indicates user has not given AtomicQueue approval. + * @param assetsToOffer the amount of offer asset to solve + * @param assetsForWant the amount of assets users want for their offer assets + */ + struct SolveMetaData { + address user; + uint8 flags; + uint256 assetsToOffer; + uint256 assetsForWant; + } + + // ========================================= GLOBAL STATE ========================================= + + /** + * @notice Maps user address to offer asset to want asset to a AtomicRequest struct. + */ + mapping(ERC20 => mapping(ERC20 => mapping(address => AtomicRequest))) public userAtomicRequest; + + //============================== ERRORS =============================== + + error AtomicQueueV2__UserRepeated(address user); + error AtomicQueueV2__RequestDeadlineExceeded(address user); + error AtomicQueueV2__UserNotInSolve(address user); + error AtomicQueueV2__ZeroOfferAmount(address user); + error AtomicQueueV2__PriceTooHigh(address user, uint256 atomicPrice, uint256 priceToCheck); + //============================== EVENTS =============================== + + /** + * @notice Emitted when `updateAtomicRequest` is called. + */ + event AtomicRequestUpdated( + address user, + address offerToken, + address wantToken, + uint256 amount, + uint256 deadline, + uint256 minPrice, + uint256 timestamp + ); + + /** + * @notice Emitted when `solve` exchanges a users offer asset for their want asset. + */ + event AtomicRequestFulfilled( + address user, + address offerToken, + address wantToken, + uint256 offerAmountSpent, + uint256 wantAmountReceived, + uint256 timestamp + ); + + //============================== USER FUNCTIONS =============================== + + /** + * @notice Get a users Atomic Request. + * @param user the address of the user to get the request for + * @param offer the ERC0 token they want to exchange for the want + * @param want the ERC20 token they want in exchange for the offer + */ + function getUserAtomicRequest(address user, ERC20 offer, ERC20 want) external view returns (AtomicRequest memory) { + return userAtomicRequest[offer][want][user]; + } + + /** + * @notice Helper function that returns either + * true: Withdraw request is valid. + * false: Withdraw request is not valid. + * @dev It is possible for a withdraw request to return false from this function, but using the + * request in `updateAtomicRequest` will succeed, but solvers will not be able to include + * the user in `solve` unless some other state is changed. + * @param offer the ERC0 token they want to exchange for the want + * @param user the address of the user making the request + * @param userRequest the request struct to validate + */ + function isAtomicRequestValid( + ERC20 offer, + address user, + AtomicRequest calldata userRequest + ) + external + view + returns (bool) + { + // Validate amount. + if (userRequest.offerAmount > offer.balanceOf(user)) return false; + // Validate deadline. + if (block.timestamp > userRequest.deadline) return false; + // Validate approval. + if (offer.allowance(user, address(this)) < userRequest.offerAmount) return false; + // Validate offerAmount is nonzero. + if (userRequest.offerAmount == 0) return false; + // Validate atomicPrice is nonzero. + if (userRequest.atomicPrice == 0) return false; + + return true; + } + + /** + * @notice Allows user to add/update their withdraw request. + * @notice It is possible for a withdraw request with a zero atomicPrice to be made, and solved. + * If this happens, users will be selling their shares for no assets in return. + * To determine a safe atomicPrice, share.previewRedeem should be used to get + * a good share price, then the user can lower it from there to make their request fill faster. + * @param offer the ERC20 token the user is offering in exchange for the want + * @param want the ERC20 token the user wants in exchange for offer + * @param userRequest the users request + */ + function updateAtomicRequest(ERC20 offer, ERC20 want, AtomicRequest calldata userRequest) external nonReentrant { + AtomicRequest storage request = userAtomicRequest[offer][want][msg.sender]; + + request.deadline = userRequest.deadline; + request.atomicPrice = userRequest.atomicPrice; + request.offerAmount = userRequest.offerAmount; + + // Emit full amount user has. + emit AtomicRequestUpdated( + msg.sender, + address(offer), + address(want), + userRequest.offerAmount, + userRequest.deadline, + userRequest.atomicPrice, + block.timestamp + ); + } + + //============================== SOLVER FUNCTIONS =============================== + + /** + * @notice Called by solvers in order to exchange offer asset for want asset. + * @notice Solvers are optimistically transferred the offer asset, then are required to + * approve this contract to spend enough of want assets to cover all requests. + * @dev It is very likely `solve` TXs will be front run if broadcasted to public mem pools, + * so solvers should use private mem pools. + * @param offer the ERC20 offer token to solve for + * @param want the ERC20 want token to solve for + * @param users an array of user addresses to solve for + * @param runData extra data that is passed back to solver when `finishSolve` is called + * @param solver the address to make `finishSolve` callback to + */ + function solve( + ERC20 offer, + ERC20 want, + address[] calldata users, + bytes calldata runData, + address solver + ) + external + nonReentrant + { + // Save offer asset decimals. + uint8 offerDecimals = offer.decimals(); + + // Get price limit from runData. + // store price limit in last variable of wantAndOfferInfo + // index 0 is total want assets, index 1 is total offer assets, index 2 is priceLimit + uint256[3] memory wantAndOfferInfo = _wantAndOfferInfoHelper(runData); + + mapping(address => AtomicRequest) storage intermediateUserKey = userAtomicRequest[offer][want]; + for (uint256 i; i < users.length;) { + AtomicRequest storage request = intermediateUserKey[users[i]]; + + if (request.inSolve) revert AtomicQueueV2__UserRepeated(users[i]); + if (block.timestamp > request.deadline) revert AtomicQueueV2__RequestDeadlineExceeded(users[i]); + if (request.offerAmount == 0) revert AtomicQueueV2__ZeroOfferAmount(users[i]); + if (request.atomicPrice > wantAndOfferInfo[2]) { + revert AtomicQueueV2__PriceTooHigh(users[i], request.atomicPrice, wantAndOfferInfo[2]); + } + // still have DoS/griefing vector even if we skip instead of revert by price, + // since users can still front run requests with high offer amounts or draw down their approval/balance + + // User gets whatever their atomic price * offerAmount is. + wantAndOfferInfo[1] += _calculateAssetAmount(request.offerAmount, request.atomicPrice, offerDecimals); + + // If all checks above passed, the users request is valid and should be fulfilled. + wantAndOfferInfo[0] += request.offerAmount; + request.inSolve = true; + { + // Transfer shares from user to solver. + offer.safeTransferFrom(users[i], solver, request.offerAmount); + } + unchecked { + ++i; + } + } + + IAtomicSolver(solver).finishSolve(runData, msg.sender, offer, want, wantAndOfferInfo[0], wantAndOfferInfo[1]); + + for (uint256 i; i < users.length;) { + AtomicRequest storage request = intermediateUserKey[users[i]]; + + if (request.inSolve) { + // We know that the minimum price and deadline arguments are satisfied since this can only be true if + // they were. + + // Send user their share of assets. + uint256 assetsToUser = _calculateAssetAmount(request.offerAmount, request.atomicPrice, offerDecimals); + + want.safeTransferFrom(solver, users[i], assetsToUser); + + emit AtomicRequestFulfilled( + users[i], address(offer), address(want), request.offerAmount, assetsToUser, block.timestamp + ); + + // Set shares to withdraw to 0. + request.offerAmount = 0; + request.inSolve = false; + } else { + revert AtomicQueueV2__UserNotInSolve(users[i]); + } + unchecked { + ++i; + } + } + } + + /** + * @notice Helper function solvers can use to determine if users are solvable, and the required amounts to do so. + * @notice Repeated users are not accounted for in this setup, so if solvers have repeat users in their `users` + * array the results can be wrong. + * @dev Since a user can have multiple requests with the same offer asset but different want asset, it is + * possible for `viewSolveMetaData` to report no errors, but for a solve to fail, if any solves were done + * between the time `viewSolveMetaData` and before `solve` is called. + * @param offer the ERC20 offer token to check for solvability + * @param want the ERC20 want token to check for solvability + * @param users an array of user addresses to check for solvability + */ + function viewSolveMetaData( + ERC20 offer, + ERC20 want, + address[] calldata users + ) + external + view + returns (SolveMetaData[] memory metaData, uint256 totalAssetsForWant, uint256 totalAssetsToOffer) + { + // Save offer asset decimals. + uint8 offerDecimals = offer.decimals(); + + // Setup meta data. + metaData = new SolveMetaData[](users.length); + + mapping(address => AtomicRequest) storage intermediateUserKey = userAtomicRequest[offer][want]; + + for (uint256 i; i < users.length; ++i) { + AtomicRequest memory request = intermediateUserKey[users[i]]; + + metaData[i].user = users[i]; + + if (block.timestamp > request.deadline) { + metaData[i].flags |= uint8(1); + } + if (request.offerAmount == 0) { + metaData[i].flags |= uint8(1) << 1; + } + if (offer.balanceOf(users[i]) < request.offerAmount) { + metaData[i].flags |= uint8(1) << 2; + } + if (offer.allowance(users[i], address(this)) < request.offerAmount) { + metaData[i].flags |= uint8(1) << 3; + } + + metaData[i].assetsToOffer = request.offerAmount; + + // User gets whatever their execution share price is. + uint256 userAssets = _calculateAssetAmount(request.offerAmount, request.atomicPrice, offerDecimals); + metaData[i].assetsForWant = userAssets; + + // If flags is zero, no errors occurred. + if (metaData[i].flags == 0) { + totalAssetsForWant += userAssets; + totalAssetsToOffer += request.offerAmount; + } + } + } + + //============================== INTERNAL FUNCTIONS =============================== + + /** + * @notice Helper function to calculate the amount of want assets a users wants in exchange for + * `offerAmount` of offer asset. + */ + function _calculateAssetAmount( + uint256 offerAmount, + uint256 atomicPrice, + uint8 offerDecimals + ) + internal + pure + returns (uint256) + { + return atomicPrice.mulDivDown(offerAmount, 10 ** offerDecimals); + } + + function _wantAndOfferInfoHelper( + bytes calldata runData + ) + internal + pure + returns (uint256[3] memory wantAndOfferInfo) + { + // decode runData + (,,,,, uint256 priceLimit) = + abi.decode(runData, (AtomicSolverV4.SolveType, address, uint256, uint256, address, uint256)); + + wantAndOfferInfo[2] = priceLimit; + } +} diff --git a/src/atomic-queue/AtomicSolverV4.sol b/src/atomic-queue/AtomicSolverV4.sol new file mode 100644 index 0000000..bbb37cf --- /dev/null +++ b/src/atomic-queue/AtomicSolverV4.sol @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.21; + +import { AtomicQueueV2 } from "./AtomicQueueV2.sol"; +import { IAtomicSolver } from "./IAtomicSolver.sol"; +import { Auth, Authority } from "@solmate/auth/Auth.sol"; +import { FixedPointMathLib } from "@solmate/utils/FixedPointMathLib.sol"; +import { SafeTransferLib } from "@solmate/utils/SafeTransferLib.sol"; +import { ERC20 } from "@solmate/tokens/ERC20.sol"; +import { TellerWithMultiAssetSupport } from "src/base/Roles/TellerWithMultiAssetSupport.sol"; +import { AccountantWithRateProviders } from "src/base/Roles/AccountantWithRateProviders.sol"; + +/** + * @title AtomicSolverV4 + * @author jpick713 + */ +contract AtomicSolverV4 is IAtomicSolver, Auth { + using SafeTransferLib for ERC20; + using FixedPointMathLib for uint256; + // ========================================= CONSTANTS ========================================= + + ERC20 internal constant eETH = ERC20(0x35fA164735182de50811E8e2E824cFb9B6118ac2); + ERC20 internal constant weETH = ERC20(0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee); + + // ========================================= ENUMS ========================================= + + /** + * @notice The Solve Type, used in `finishSolve` to determine the logic used. + * @notice P2P Solver wants to swap share.asset() for user(s) shares + * @notice REDEEM Solver needs to redeem shares, then can cover user(s) required assets. + */ + enum SolveType { + P2P, + REDEEM + } + + //============================== ERRORS =============================== + + error AtomicSolverV4___WrongInitiator(); + error AtomicSolverV4___AlreadyInSolveContext(); + error AtomicSolverV4___FailedToSolve(); + error AtomicSolverV4___SolveMaxAssetsExceeded(uint256 actualAssets, uint256 maxAssets); + error AtomicSolverV4___P2PSolveMinSharesNotMet(uint256 actualShares, uint256 minShares); + error AtomicSolverV4___BoringVaultTellerMismatch(address vault, address teller); + + //============================== IMMUTABLES =============================== + + constructor(address _owner) Auth(_owner, Authority(address(0))) { } + + //============================== SOLVE FUNCTIONS =============================== + /** + * @notice Solver wants to exchange p2p share.asset() for withdraw queue shares. + * @dev Solver should approve this contract to spend share.asset(). + */ + function p2pSolve( + AtomicQueueV2 queue, + ERC20 offer, + ERC20 want, + address[] calldata users, + uint256 minOfferReceived, + uint256 maxAssets + ) + external + requiresAuth + { + bytes memory runData = abi.encode(SolveType.P2P, msg.sender, minOfferReceived, maxAssets, type(uint256).max); + + // Solve for `users`. + queue.solve(offer, want, users, runData, address(this)); + } + + /** + * @notice Solver wants to redeem withdraw offer shares, to help cover withdraw. + * @dev `offer` MUST be an ERC4626 vault. + */ + function redeemSolve( + AtomicQueueV2 queue, + ERC20 offer, + ERC20 want, + address[] calldata users, + uint256 minimumAssetsOut, + uint256 maxAssets, + TellerWithMultiAssetSupport teller + ) + external + requiresAuth + { + AccountantWithRateProviders accountant = teller.accountant(); + uint256 priceToCheckAtomicPrice = accountant.getRateInQuoteSafe(want); + if (priceToCheckAtomicPrice == 0) { + revert AtomicSolverV4___FailedToSolve(); + } + + bytes memory runData = + abi.encode(SolveType.REDEEM, msg.sender, minimumAssetsOut, maxAssets, teller, priceToCheckAtomicPrice); + + // Solve for `users`. + queue.solve(offer, want, users, runData, address(this)); + } + + //============================== ISOLVER FUNCTIONS =============================== + + /** + * @notice Implement the finishSolve function WithdrawQueue expects to call. + * @dev nonReentrant is not needed on this function because it is impossible to reenter, + * because the above solve functions have the nonReentrant modifier. + * The only way to have the first 2 checks pass is if the msg.sender is the queue, + * and this contract is msg.sender of `Queue.solve()`, which is only called in the above + * functions. + */ + function finishSolve( + bytes calldata runData, + address initiator, + ERC20 offer, + ERC20 want, + uint256 offerReceived, + uint256 wantApprovalAmount + ) + external + requiresAuth + { + if (initiator != address(this)) revert AtomicSolverV4___WrongInitiator(); + + address queue = msg.sender; + + SolveType _type = abi.decode(runData, (SolveType)); + + if (_type == SolveType.P2P) { + _p2pSolve(queue, runData, offer, want, offerReceived, wantApprovalAmount); + } else if (_type == SolveType.REDEEM) { + _redeemSolve(queue, runData, offer, want, offerReceived, wantApprovalAmount); + } + } + + //============================== HELPER FUNCTIONS =============================== + + /** + * @notice Helper function containing the logic to handle p2p solves. + */ + function _p2pSolve( + address queue, + bytes memory runData, + ERC20 offer, + ERC20 want, + uint256 offerReceived, + uint256 wantApprovalAmount + ) + internal + { + (, address solver, uint256 minOfferReceived, uint256 maxAssets) = + abi.decode(runData, (SolveType, address, uint256, uint256)); + + // Make sure solver is receiving the minimum amount of offer. + if (offerReceived < minOfferReceived) { + revert AtomicSolverV4___P2PSolveMinSharesNotMet(offerReceived, minOfferReceived); + } + + // Make sure solvers `maxAssets` was not exceeded. + if (wantApprovalAmount > maxAssets) { + revert AtomicSolverV4___SolveMaxAssetsExceeded(wantApprovalAmount, maxAssets); + } + + // Transfer required want from solver. + want.safeTransferFrom(solver, address(this), wantApprovalAmount); + + // Transfer offer to solver. + offer.safeTransfer(solver, offerReceived); + + // Approve queue to spend wantApprovalAmount. + want.safeApprove(queue, wantApprovalAmount); + } + + /** + * @notice Helper function containing the logic to handle redeem solves. + */ + function _redeemSolve( + address queue, + bytes memory runData, + ERC20 offer, + ERC20 want, + uint256 offerReceived, + uint256 wantApprovalAmount + ) + internal + { + (, address solver, uint256 minimumAssetsOut, uint256 maxAssets, TellerWithMultiAssetSupport teller) = + abi.decode(runData, (SolveType, address, uint256, uint256, TellerWithMultiAssetSupport)); + + if (address(offer) != address(teller.vault())) { + revert AtomicSolverV4___BoringVaultTellerMismatch(address(offer), address(teller)); + } + // Make sure solvers `maxAssets` was not exceeded. + if (wantApprovalAmount > maxAssets) { + revert AtomicSolverV4___SolveMaxAssetsExceeded(wantApprovalAmount, maxAssets); + } + + // Redeem the shares, sending assets to solver. + teller.bulkWithdraw(want, offerReceived, minimumAssetsOut, solver); + + // Transfer required assets from solver. + want.safeTransferFrom(solver, address(this), wantApprovalAmount); + + // Approve queue to spend wantApprovalAmount. + want.safeApprove(queue, wantApprovalAmount); + } +} diff --git a/src/base/DecodersAndSanitizers/EtherFiLiquidDecoderAndSanitizer.sol b/src/base/DecodersAndSanitizers/EtherFiLiquidDecoderAndSanitizer.sol index b496764..a34cdb8 100644 --- a/src/base/DecodersAndSanitizers/EtherFiLiquidDecoderAndSanitizer.sol +++ b/src/base/DecodersAndSanitizers/EtherFiLiquidDecoderAndSanitizer.sol @@ -77,7 +77,9 @@ contract EtherFiLiquidDecoderAndSanitizer is * @notice BalancerV2, NativeWrapper, Curve, and Gearbox all specify a `withdraw(uint256)`, * all cases are handled the same way. */ - function withdraw(uint256) + function withdraw( + uint256 + ) external pure override( diff --git a/src/base/DecodersAndSanitizers/EtherFiLiquidEthDecoderAndSanitizer.sol b/src/base/DecodersAndSanitizers/EtherFiLiquidEthDecoderAndSanitizer.sol index 5fe07a7..18e52ca 100644 --- a/src/base/DecodersAndSanitizers/EtherFiLiquidEthDecoderAndSanitizer.sol +++ b/src/base/DecodersAndSanitizers/EtherFiLiquidEthDecoderAndSanitizer.sol @@ -86,7 +86,9 @@ contract EtherFiLiquidEthDecoderAndSanitizer is * @notice BalancerV2, NativeWrapper, Curve, and Gearbox all specify a `withdraw(uint256)`, * all cases are handled the same way. */ - function withdraw(uint256) + function withdraw( + uint256 + ) external pure override( diff --git a/src/base/DecodersAndSanitizers/EtherFiLiquidUsdDecoderAndSanitizer.sol b/src/base/DecodersAndSanitizers/EtherFiLiquidUsdDecoderAndSanitizer.sol index 675aa2e..cbbdcef 100644 --- a/src/base/DecodersAndSanitizers/EtherFiLiquidUsdDecoderAndSanitizer.sol +++ b/src/base/DecodersAndSanitizers/EtherFiLiquidUsdDecoderAndSanitizer.sol @@ -79,7 +79,9 @@ contract EtherFiLiquidUsdDecoderAndSanitizer is * @notice BalancerV2, NativeWrapper, Curve, and Gearbox all specify a `withdraw(uint256)`, * all cases are handled the same way. */ - function withdraw(uint256) + function withdraw( + uint256 + ) external pure override( @@ -114,7 +116,9 @@ contract EtherFiLiquidUsdDecoderAndSanitizer is * @notice EtherFi, and Lido all specify a `wrap(uint256)`, * all cases are handled the same way. */ - function wrap(uint256) + function wrap( + uint256 + ) external pure override(EtherFiDecoderAndSanitizer, LidoDecoderAndSanitizer) @@ -128,7 +132,9 @@ contract EtherFiLiquidUsdDecoderAndSanitizer is * @notice EtherFi, and Lido all specify a `unwrap(uint256)`, * all cases are handled the same way. */ - function unwrap(uint256) + function unwrap( + uint256 + ) external pure override(EtherFiDecoderAndSanitizer, LidoDecoderAndSanitizer) diff --git a/src/base/DecodersAndSanitizers/LidoLiquidDecoderAndSanitizer.sol b/src/base/DecodersAndSanitizers/LidoLiquidDecoderAndSanitizer.sol index 3efcb16..0be64ca 100644 --- a/src/base/DecodersAndSanitizers/LidoLiquidDecoderAndSanitizer.sol +++ b/src/base/DecodersAndSanitizers/LidoLiquidDecoderAndSanitizer.sol @@ -64,7 +64,9 @@ contract LidoLiquidDecoderAndSanitizer is * @notice BalancerV2, NativeWrapper, Curve, and Gearbox all specify a `withdraw(uint256)`, * all cases are handled the same way. */ - function withdraw(uint256) + function withdraw( + uint256 + ) external pure override( diff --git a/src/base/DecodersAndSanitizers/Protocols/EigenLayerLSTStakingDecoderAndSanitizer.sol b/src/base/DecodersAndSanitizers/Protocols/EigenLayerLSTStakingDecoderAndSanitizer.sol index c345bac..31ea765 100644 --- a/src/base/DecodersAndSanitizers/Protocols/EigenLayerLSTStakingDecoderAndSanitizer.sol +++ b/src/base/DecodersAndSanitizers/Protocols/EigenLayerLSTStakingDecoderAndSanitizer.sol @@ -23,7 +23,9 @@ abstract contract EigenLayerLSTStakingDecoderAndSanitizer is BaseDecoderAndSanit addressesFound = abi.encodePacked(strategy, token); } - function queueWithdrawals(DecoderCustomTypes.QueuedWithdrawalParams[] calldata queuedWithdrawalParams) + function queueWithdrawals( + DecoderCustomTypes.QueuedWithdrawalParams[] calldata queuedWithdrawalParams + ) external pure virtual diff --git a/src/base/DecodersAndSanitizers/Protocols/UniswapV3DecoderAndSanitizer.sol b/src/base/DecodersAndSanitizers/Protocols/UniswapV3DecoderAndSanitizer.sol index 41bf656..3fce397 100644 --- a/src/base/DecodersAndSanitizers/Protocols/UniswapV3DecoderAndSanitizer.sol +++ b/src/base/DecodersAndSanitizers/Protocols/UniswapV3DecoderAndSanitizer.sol @@ -23,7 +23,9 @@ abstract contract UniswapV3DecoderAndSanitizer is BaseDecoderAndSanitizer { //============================== UNISWAP V3 =============================== - function exactInput(DecoderCustomTypes.ExactInputParams calldata params) + function exactInput( + DecoderCustomTypes.ExactInputParams calldata params + ) external pure virtual @@ -44,7 +46,9 @@ abstract contract UniswapV3DecoderAndSanitizer is BaseDecoderAndSanitizer { addressesFound = abi.encodePacked(addressesFound, params.recipient); } - function mint(DecoderCustomTypes.MintParams calldata params) + function mint( + DecoderCustomTypes.MintParams calldata params + ) external pure virtual @@ -55,7 +59,9 @@ abstract contract UniswapV3DecoderAndSanitizer is BaseDecoderAndSanitizer { addressesFound = abi.encodePacked(params.token0, params.token1, params.recipient); } - function increaseLiquidity(DecoderCustomTypes.IncreaseLiquidityParams calldata params) + function increaseLiquidity( + DecoderCustomTypes.IncreaseLiquidityParams calldata params + ) external view virtual @@ -71,7 +77,9 @@ abstract contract UniswapV3DecoderAndSanitizer is BaseDecoderAndSanitizer { addressesFound = abi.encodePacked(operator, token0, token1); } - function decreaseLiquidity(DecoderCustomTypes.DecreaseLiquidityParams calldata params) + function decreaseLiquidity( + DecoderCustomTypes.DecreaseLiquidityParams calldata params + ) external view virtual @@ -88,7 +96,9 @@ abstract contract UniswapV3DecoderAndSanitizer is BaseDecoderAndSanitizer { return addressesFound; } - function collect(DecoderCustomTypes.CollectParams calldata params) + function collect( + DecoderCustomTypes.CollectParams calldata params + ) external view virtual diff --git a/src/interfaces/EtherFiLiquid1.sol b/src/interfaces/EtherFiLiquid1.sol index 14d5ffc..080242e 100644 --- a/src/interfaces/EtherFiLiquid1.sol +++ b/src/interfaces/EtherFiLiquid1.sol @@ -72,7 +72,9 @@ interface EtherFiLiquid1 { function addPosition(uint32 index, uint32 positionId, bytes memory configurationData, bool inDebtArray) external; function addPositionToCatalogue(uint32 positionId) external; function allowance(address, address) external view returns (uint256); - function alternativeAssetData(address) + function alternativeAssetData( + address + ) external view returns (bool isSupported, uint32 holdingPosition, uint32 depositFee); diff --git a/src/interfaces/IStaking.sol b/src/interfaces/IStaking.sol index 5abf6ee..f1d953a 100644 --- a/src/interfaces/IStaking.sol +++ b/src/interfaces/IStaking.sol @@ -84,7 +84,9 @@ interface IUNSTETH { bool isClaimed; } - function getWithdrawalStatus(uint256[] calldata _requestIds) + function getWithdrawalStatus( + uint256[] calldata _requestIds + ) external view returns (WithdrawalRequestStatus[] memory statuses); diff --git a/src/interfaces/RawDataDecoderAndSanitizerInterfaces.sol b/src/interfaces/RawDataDecoderAndSanitizerInterfaces.sol index 0e5705b..6cb7bfb 100644 --- a/src/interfaces/RawDataDecoderAndSanitizerInterfaces.sol +++ b/src/interfaces/RawDataDecoderAndSanitizerInterfaces.sol @@ -24,7 +24,9 @@ interface INonFungiblePositionManager { } function ownerOf(uint256 tokenId) external view returns (address); - function positions(uint256 tokenId) + function positions( + uint256 tokenId + ) external view returns ( diff --git a/test/CrossChain/@layerzerolabs-custom/test-evm-foundry-custom/OptionsHelper.sol b/test/CrossChain/@layerzerolabs-custom/test-evm-foundry-custom/OptionsHelper.sol index 0bd63c0..f0f2cb4 100644 --- a/test/CrossChain/@layerzerolabs-custom/test-evm-foundry-custom/OptionsHelper.sol +++ b/test/CrossChain/@layerzerolabs-custom/test-evm-foundry-custom/OptionsHelper.sol @@ -8,7 +8,9 @@ import { UlnOptions } from "@layerzerolabs/lz-evm-messagelib-v2/contracts/uln/li contract UlnOptionsMock { using UlnOptions for bytes; - function decode(bytes calldata _options) + function decode( + bytes calldata _options + ) public pure returns (bytes memory executorOptions, bytes memory dvnOptions) @@ -31,7 +33,9 @@ contract OptionsHelper { (gas, value) = this.decodeLzReceiveOption(option); } - function _parseExecutorNativeDropOption(bytes memory _options) + function _parseExecutorNativeDropOption( + bytes memory _options + ) internal view returns (uint256 amount, bytes32 receiver) @@ -42,7 +46,9 @@ contract OptionsHelper { (amount, receiver) = this.decodeNativeDropOption(option); } - function _parseExecutorLzComposeOption(bytes memory _options) + function _parseExecutorLzComposeOption( + bytes memory _options + ) internal view returns (uint16 index, uint256 gas, uint256 value) @@ -103,7 +109,9 @@ contract OptionsHelper { return ExecutorOptions.decodeNativeDropOption(_option); } - function decodeLzComposeOption(bytes calldata _option) + function decodeLzComposeOption( + bytes calldata _option + ) external pure returns (uint16 index, uint128 gas, uint128 value) diff --git a/test/CrossChain/@layerzerolabs-custom/test-evm-foundry-custom/SendUln302Mock.sol b/test/CrossChain/@layerzerolabs-custom/test-evm-foundry-custom/SendUln302Mock.sol index 78ab9b0..7ac7df9 100644 --- a/test/CrossChain/@layerzerolabs-custom/test-evm-foundry-custom/SendUln302Mock.sol +++ b/test/CrossChain/@layerzerolabs-custom/test-evm-foundry-custom/SendUln302Mock.sol @@ -101,7 +101,9 @@ contract SendUln302Mock is SendUlnBase, SendLibBaseE2 { (otherWorkerFees, encodedPacket) = _payDVNs(fees, _packet, _options); } - function _splitOptions(bytes calldata _options) + function _splitOptions( + bytes calldata _options + ) internal pure override diff --git a/test/ion/IonPoolSolver.t.sol b/test/ion/IonPoolSolver.t.sol new file mode 100644 index 0000000..adc1706 --- /dev/null +++ b/test/ion/IonPoolSolver.t.sol @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.21; + +import { BoringVault } from "./../../src/base/BoringVault.sol"; +import { EthPerWstEthRateProvider } from "./../../src/oracles/EthPerWstEthRateProvider.sol"; +import { ETH_PER_STETH_CHAINLINK, WSTETH_ADDRESS } from "@ion-protocol/Constants.sol"; +import { IonPoolSharedSetup } from "./IonPoolSharedSetup.sol"; +import { ERC20 } from "@solmate/tokens/ERC20.sol"; +import { FixedPointMathLib } from "@solmate/utils/FixedPointMathLib.sol"; +import { TellerWithMultiAssetSupport } from "./../../src/base/Roles/TellerWithMultiAssetSupport.sol"; +import { AtomicSolverV4 } from "./../../src/atomic-queue/AtomicSolverV4.sol"; +import { AtomicQueueV2 } from "./../../src/atomic-queue/AtomicQueueV2.sol"; +import { RolesAuthority, Authority } from "@solmate/auth/authorities/RolesAuthority.sol"; +import { StdUtils, IMulticall3 } from "forge-std/StdUtils.sol"; + +import { console2 } from "forge-std/console2.sol"; + +contract IonPoolSolverTest is IonPoolSharedSetup { + using FixedPointMathLib for uint256; + + AtomicSolverV4 public atomicSolver; + AtomicQueueV2 public atomicQueue; + address immutable SOLVER_OWNER = makeAddr("AtomicSolverV4"); + uint8 public constant SOLVER_ROLE = 12; + uint8 public constant QUEUE_ROLE = 13; + uint8 public constant SOLVER_CALLER_ROLE = 14; + + EthPerWstEthRateProvider ethPerWstEthRateProvider; + + function setUp() public override { + super.setUp(); + + WETH.approve(address(boringVault), type(uint256).max); + WSTETH.approve(address(boringVault), type(uint256).max); + + vm.startPrank(TELLER_OWNER); + teller.addAsset(WETH); + teller.addAsset(WSTETH); + vm.stopPrank(); + + // Setup accountant + + ethPerWstEthRateProvider = + new EthPerWstEthRateProvider(address(ETH_PER_STETH_CHAINLINK), address(WSTETH_ADDRESS), 1 days); + bool isPeggedToBase = false; + + atomicSolver = new AtomicSolverV4(SOLVER_OWNER); + + atomicQueue = new AtomicQueueV2(); + + vm.prank(SOLVER_OWNER); + atomicSolver.setAuthority(rolesAuthority); + vm.stopPrank(); + + rolesAuthority.setRoleCapability( + SOLVER_ROLE, address(teller), TellerWithMultiAssetSupport.bulkWithdraw.selector, true + ); + + rolesAuthority.setRoleCapability(QUEUE_ROLE, address(atomicSolver), AtomicSolverV4.finishSolve.selector, true); + + rolesAuthority.setRoleCapability( + SOLVER_CALLER_ROLE, address(atomicSolver), AtomicSolverV4.p2pSolve.selector, true + ); + + rolesAuthority.setRoleCapability( + SOLVER_CALLER_ROLE, address(atomicSolver), AtomicSolverV4.redeemSolve.selector, true + ); + + rolesAuthority.setUserRole(address(atomicSolver), SOLVER_ROLE, true); + rolesAuthority.setUserRole(address(atomicQueue), QUEUE_ROLE, true); + rolesAuthority.setUserRole(SOLVER_OWNER, SOLVER_CALLER_ROLE, true); + + vm.prank(ACCOUNTANT_OWNER); + accountant.setRateProviderData( + ERC20(address(WSTETH_ADDRESS)), isPeggedToBase, address(ethPerWstEthRateProvider) + ); + } + + function test_Deposit_MultipleUsers() public { + uint256 depositAmt = 10 ether; + uint256 minimumMint = 10 ether; + + // base / deposit asset + uint256 basePerQuote = ethPerWstEthRateProvider.getRate(); // base / quote + uint256 quotePerShare = accountant.getRateInQuoteSafe(WSTETH); // quote / share + + uint256 basePerShare = accountant.getRate(); + uint256 expectedQuotePerShare = basePerShare * 1e18 / basePerQuote; // (base / share) / (base / quote) = quote / + // share + + uint256 shares = depositAmt.mulDivDown(1e18, quotePerShare); + // mint amount = deposit amount * exchangeRate + + deal(address(WSTETH), address(this), depositAmt); + teller.deposit(WSTETH, depositAmt, minimumMint); + address[] memory users = new address[](3); + users[0] = makeAddr("user1"); + users[1] = makeAddr("user2"); + users[2] = makeAddr("user3"); + deal(address(WSTETH), users[0], depositAmt); + deal(address(WSTETH), users[1], depositAmt); + deal(address(WETH), users[2], depositAmt); + vm.startPrank(users[0]); + ERC20(WSTETH).approve(address(boringVault), type(uint256).max); + teller.deposit(WSTETH, depositAmt, minimumMint); + vm.stopPrank(); + vm.startPrank(users[1]); + ERC20(WSTETH).approve(address(boringVault), type(uint256).max); + teller.deposit(WSTETH, depositAmt, minimumMint); + vm.stopPrank(); + vm.startPrank(users[2]); + ERC20(WETH).approve(address(boringVault), type(uint256).max); + teller.deposit(WETH, depositAmt, minimumMint); + vm.stopPrank(); + + assertEq(quotePerShare, expectedQuotePerShare, "exchange rate must read from price oracle"); + assertEq(boringVault.balanceOf(address(this)), shares, "shares minted"); + assertEq(WSTETH.balanceOf(address(this)), 0, "WSTETH transferred from user"); + assertEq(WSTETH.balanceOf(address(boringVault)), depositAmt * 3, "WSTETH transferred to vault"); + assertEq(WETH.balanceOf(address(boringVault)), depositAmt, "WETH transferred from user"); + + // set atomic queue requests + + AtomicQueueV2.AtomicRequest memory request1 = AtomicQueueV2.AtomicRequest({ + deadline: 2 ** 32, + atomicPrice: 10 ** 17, //0.1 + offerAmount: 10 ** 18, //1 share + inSolve: false + }); + + AtomicQueueV2.AtomicRequest memory request2 = AtomicQueueV2.AtomicRequest({ + deadline: 2 ** 32, + atomicPrice: 10 ** 18, //1 + offerAmount: 10 ** 18, //1 share + inSolve: false + }); + + AtomicQueueV2.AtomicRequest memory request3 = AtomicQueueV2.AtomicRequest({ + deadline: 2 ** 32, + atomicPrice: 2 * 10 ** 18, //2 + offerAmount: 10 ** 18, //1 share + inSolve: false + }); + + vm.startPrank(users[0]); + atomicQueue.updateAtomicRequest(ERC20(boringVault), ERC20(WSTETH), request1); + ERC20(boringVault).approve(address(atomicQueue), type(uint256).max); + vm.stopPrank(); + vm.startPrank(users[1]); + atomicQueue.updateAtomicRequest(ERC20(boringVault), ERC20(WSTETH), request2); + ERC20(boringVault).approve(address(atomicQueue), type(uint256).max); + vm.stopPrank(); + vm.startPrank(users[2]); + atomicQueue.updateAtomicRequest(ERC20(boringVault), ERC20(WSTETH), request3); + ERC20(boringVault).approve(address(atomicQueue), type(uint256).max); + vm.stopPrank(); + + AtomicQueueV2.AtomicRequest[] memory requests = new AtomicQueueV2.AtomicRequest[](3); + requests[0] = atomicQueue.getUserAtomicRequest(users[0], ERC20(boringVault), ERC20(WSTETH)); + requests[1] = atomicQueue.getUserAtomicRequest(users[1], ERC20(boringVault), ERC20(WSTETH)); + requests[2] = atomicQueue.getUserAtomicRequest(users[2], ERC20(boringVault), ERC20(WSTETH)); + + assertEq(requests[0].atomicPrice, 10 ** 17, "request 1 atomic price"); + assertEq(requests[1].atomicPrice, 10 ** 18, "request 2 atomic price"); + assertEq(requests[2].atomicPrice, 2 * 10 ** 18, "request 3 atomic price"); + + bool isValidRequest = atomicQueue.isAtomicRequestValid(ERC20(boringVault), users[0], requests[0]); + + assertEq(isValidRequest, true, "request 1 is valid"); + + isValidRequest = atomicQueue.isAtomicRequestValid(ERC20(boringVault), users[1], requests[1]); + + assertEq(isValidRequest, true, "request 2 is valid"); + + isValidRequest = atomicQueue.isAtomicRequestValid(ERC20(boringVault), users[2], requests[2]); + + assertEq(isValidRequest, true, "request 3 is valid"); + + IMulticall3.Call[] memory calls = new IMulticall3.Call[](3); + calls[0] = IMulticall3.Call({ + target: address(atomicQueue), + callData: abi.encodeWithSelector( + AtomicQueueV2.isAtomicRequestValid.selector, ERC20(boringVault), users[0], requests[0] + ) + }); + calls[1] = IMulticall3.Call({ + target: address(atomicQueue), + callData: abi.encodeWithSelector( + AtomicQueueV2.isAtomicRequestValid.selector, ERC20(boringVault), users[1], requests[1] + ) + }); + calls[2] = IMulticall3.Call({ + target: address(atomicQueue), + callData: abi.encodeWithSelector( + AtomicQueueV2.isAtomicRequestValid.selector, ERC20(boringVault), users[2], requests[2] + ) + }); + + IMulticall3 multicall = IMulticall3(0xcA11bde05977b3631167028862bE2a173976CA11); + IMulticall3.Result[] memory results = multicall.tryAggregate(false, calls); + + assertEq(abi.decode(results[0].returnData, (bool)), true, "request 1 is valid"); + assertEq(abi.decode(results[1].returnData, (bool)), true, "request 2 is valid"); + assertEq(abi.decode(results[2].returnData, (bool)), true, "request 3 is valid"); + + uint256 maxPriceAllowed = accountant.getRateInQuoteSafe(WSTETH); + + vm.startPrank(SOLVER_OWNER); + vm.expectRevert( + abi.encodeWithSelector( + AtomicQueueV2.AtomicQueueV2__PriceTooHigh.selector, users[1], requests[1].atomicPrice, maxPriceAllowed + ) + ); + // queue, vault, want, users, min want asset (slippage param), maxAssets (cumsum of atomicPrice and + // offerAmounts), teller + atomicSolver.redeemSolve(atomicQueue, ERC20(boringVault), ERC20(WSTETH), users, 10 ** 18, 3 * 10 ** 18, teller); + vm.stopPrank(); + + AtomicQueueV2.AtomicRequest memory newRequest2 = AtomicQueueV2.AtomicRequest({ + deadline: 2 ** 32, + atomicPrice: 8 * 10 ** 17, //0.8 + offerAmount: 10 ** 18, //1 share + inSolve: false + }); + + vm.prank(users[1]); + atomicQueue.updateAtomicRequest(ERC20(boringVault), ERC20(WSTETH), newRequest2); + + requests[1] = atomicQueue.getUserAtomicRequest(users[1], ERC20(boringVault), ERC20(WSTETH)); + + assertEq(requests[1].atomicPrice, 8 * 10 ** 17, "request 2 atomic price"); + + address[] memory validUsers = new address[](2); + + validUsers[0] = users[0]; + validUsers[1] = users[1]; + + uint256 solverBalancePre = WSTETH.balanceOf(address(SOLVER_OWNER)); + + vm.startPrank(SOLVER_OWNER); + ERC20(WSTETH).approve(address(atomicSolver), type(uint256).max); + // queue, vault, want, users, min want asset (slippage param), maxAssets (cumsum of atomicPrice and + // offerAmounts), teller + atomicSolver.redeemSolve( + atomicQueue, ERC20(boringVault), ERC20(WSTETH), validUsers, 10 ** 18, 3 * 10 ** 18, teller + ); + vm.stopPrank(); + + // only one share requested, so should have only exactly atomic price as balance + assertEq(WSTETH.balanceOf(validUsers[0]), request1.atomicPrice, "WSTETH transferred to user"); + assertEq(WSTETH.balanceOf(validUsers[1]), newRequest2.atomicPrice, "WSTETH transferred to user"); + + requests[0] = atomicQueue.getUserAtomicRequest(users[0], ERC20(boringVault), ERC20(WSTETH)); + requests[1] = atomicQueue.getUserAtomicRequest(users[1], ERC20(boringVault), ERC20(WSTETH)); + + assertEq(requests[0].offerAmount, 0, "reset offer amount"); + assertEq(requests[1].offerAmount, 0, "reset offer amount"); + + uint256 solverBalancePost = WSTETH.balanceOf(address(SOLVER_OWNER)); + + assertGt(solverBalancePost, solverBalancePre, "solver balance increased"); + } +}