diff --git a/.gitmodules b/.gitmodules index 7872f5e50..0f105f378 100644 --- a/.gitmodules +++ b/.gitmodules @@ -22,3 +22,6 @@ [submodule "lib/dynamic-contracts"] path = lib/dynamic-contracts url = https://github.com/thirdweb-dev/dynamic-contracts +[submodule "lib/prb-proxy"] + path = lib/prb-proxy + url = https://github.com/PaulRBerg/prb-proxy diff --git a/contracts/prebuilts/unaudited/checkout/Checkout.sol b/contracts/prebuilts/unaudited/checkout/Checkout.sol new file mode 100644 index 000000000..02151c198 --- /dev/null +++ b/contracts/prebuilts/unaudited/checkout/Checkout.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "@openzeppelin/contracts/proxy/Clones.sol"; + +import "./interface/ICheckout.sol"; +import "./Vault.sol"; +import "./Executor.sol"; + +import "../../../external-deps/openzeppelin/utils/Create2.sol"; + +import "../../../extension/PermissionsEnumerable.sol"; + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +contract Checkout is PermissionsEnumerable, ICheckout { + /// @dev Registry of vaults created through this Checkout + mapping(address => bool) public isVaultRegistered; + + /// @dev Registry of executors created through this Checkout + mapping(address => bool) public isExecutorRegistered; + + address public immutable vaultImplementation; + address public immutable executorImplementation; + + constructor(address _defaultAdmin, address _vaultImplementation, address _executorImplementation) { + vaultImplementation = _vaultImplementation; + executorImplementation = _executorImplementation; + + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + } + + function createVault(address _vaultAdmin, bytes32 _salt) external payable returns (address) { + bytes32 salthash = keccak256(abi.encodePacked(msg.sender, _salt)); + address vault = Clones.cloneDeterministic(vaultImplementation, salthash); + + (bool success, ) = vault.call(abi.encodeWithSelector(Vault.initialize.selector, _vaultAdmin)); + + require(success, "Deployment failed"); + + isVaultRegistered[vault] = true; + + emit VaultCreated(vault, _vaultAdmin); + + return vault; + } + + function createExecutor(address _executorAdmin, bytes32 _salt) external payable returns (address) { + bytes32 salthash = keccak256(abi.encodePacked(msg.sender, _salt)); + address executor = Clones.cloneDeterministic(executorImplementation, salthash); + + (bool success, ) = executor.call(abi.encodeWithSelector(Executor.initialize.selector, _executorAdmin)); + + require(success, "Deployment failed"); + + isExecutorRegistered[executor] = true; + + emit ExecutorCreated(executor, _executorAdmin); + + return executor; + } + + function authorizeVaultToExecutor(address _vault, address _executor) external { + require(IVault(_vault).canAuthorizeVaultToExecutor(msg.sender), "Not authorized"); + require(isExecutorRegistered[_executor], "Executor not found"); + + IVault(_vault).setExecutor(_executor); + + emit VaultAuthorizedToExecutor(_vault, _executor); + } +} diff --git a/contracts/prebuilts/unaudited/checkout/Executor.sol b/contracts/prebuilts/unaudited/checkout/Executor.sol new file mode 100644 index 000000000..f255a3351 --- /dev/null +++ b/contracts/prebuilts/unaudited/checkout/Executor.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "./interface/IExecutor.sol"; +import "./interface/IVault.sol"; + +import "../../../lib/CurrencyTransferLib.sol"; +import "../../../eip/interface/IERC20.sol"; + +import "../../../extension/PermissionsEnumerable.sol"; +import "../../../extension/Initializable.sol"; + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +contract Executor is Initializable, PermissionsEnumerable, IExecutor { + /// @dev Address of the Checkout entrypoint. + address public checkout; + + constructor() { + _disableInitializers(); + } + + function initialize(address _defaultAdmin) external initializer { + checkout = msg.sender; + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + } + + receive() external payable {} + + function execute(UserOp calldata op) external { + require(_canExecute(), "Not authorized"); + + if (op.valueToSend != 0) { + IVault(op.vault).transferTokensToExecutor(op.currency, op.valueToSend); + } + + bool success; + if (op.currency == CurrencyTransferLib.NATIVE_TOKEN) { + (success, ) = op.target.call{ value: op.valueToSend }(op.data); + } else { + if (op.approvalRequired) { + IERC20(op.currency).approve(op.target, op.valueToSend); + } + + (success, ) = op.target.call(op.data); + } + + require(success, "Execution failed"); + } + + // TODO: rethink design and interface here + function swapAndExecute(UserOp calldata op, SwapOp calldata swap) external { + require(_canExecute(), "Not authorized"); + IVault(op.vault).swapAndTransferTokensToExecutor(op.currency, op.valueToSend, swap); + } + + function _canExecute() internal view returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, msg.sender); + } +} diff --git a/contracts/prebuilts/unaudited/checkout/Vault.sol b/contracts/prebuilts/unaudited/checkout/Vault.sol new file mode 100644 index 000000000..1070d5334 --- /dev/null +++ b/contracts/prebuilts/unaudited/checkout/Vault.sol @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "./interface/IVault.sol"; + +import "../../../lib/CurrencyTransferLib.sol"; +import "../../../eip/interface/IERC20.sol"; + +import "../../../extension/PermissionsEnumerable.sol"; +import "../../../extension/Initializable.sol"; + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +contract Vault is Initializable, PermissionsEnumerable, IVault { + /// @dev Address of the executor for this vault. + address public executor; + + /// @dev Address of the Checkout entrypoint. + address public checkout; + + mapping(address => bool) public isApprovedRouter; + + constructor() { + _disableInitializers(); + } + + function initialize(address _defaultAdmin) external initializer { + checkout = msg.sender; + _setupRole(DEFAULT_ADMIN_ROLE, _defaultAdmin); + } + + // ================================================= + // =============== Withdraw ======================== + // ================================================= + + function withdraw(address _token, uint256 _amount) external { + require(hasRole(DEFAULT_ADMIN_ROLE, msg.sender), "Not authorized"); + + CurrencyTransferLib.transferCurrency(_token, address(this), msg.sender, _amount); + + emit TokensWithdrawn(_token, _amount); + } + + // ================================================= + // =============== Executor functions ============== + // ================================================= + + function transferTokensToExecutor(address _token, uint256 _amount) external { + require(_canTransferTokens(), "Not authorized"); + + uint256 balance = _token == CurrencyTransferLib.NATIVE_TOKEN + ? address(this).balance + : IERC20(_token).balanceOf(address(this)); + + require(balance >= _amount, "Not enough balance"); + + CurrencyTransferLib.transferCurrency(_token, address(this), msg.sender, _amount); + + emit TokensTransferredToExecutor(msg.sender, _token, _amount); + } + + function swapAndTransferTokensToExecutor(address _token, uint256 _amount, SwapOp memory _swapOp) external { + require(_canTransferTokens(), "Not authorized"); + require(isApprovedRouter[_swapOp.router], "Invalid router address"); + + _swap(_swapOp); + + uint256 balance = _token == CurrencyTransferLib.NATIVE_TOKEN + ? address(this).balance + : IERC20(_token).balanceOf(address(this)); + + require(balance >= _amount, "Not enough balance"); + + CurrencyTransferLib.transferCurrency(_token, address(this), msg.sender, _amount); + + emit TokensTransferredToExecutor(msg.sender, _token, _amount); + } + + // ================================================= + // =============== Swap functionality ============== + // ================================================= + + function swap(SwapOp memory _swapOp) external { + require(_canSwap(), "Not authorized"); + + _swap(_swapOp); + } + + function _swap(SwapOp memory _swapOp) internal { + address _tokenIn = _swapOp.tokenIn; + address _router = _swapOp.router; + + // get quote for amountIn + (, bytes memory quoteData) = _router.staticcall(_swapOp.quoteCalldata); + uint256 amountIn; + uint256 offset = _swapOp.amountInOffset; + + assembly { + amountIn := mload(add(add(quoteData, 32), offset)) + } + + // perform swap + bool success; + if (_tokenIn == CurrencyTransferLib.NATIVE_TOKEN) { + (success, ) = _router.call{ value: amountIn }(_swapOp.swapCalldata); + } else { + IERC20(_tokenIn).approve(_swapOp.router, amountIn); + (success, ) = _router.call(_swapOp.swapCalldata); + } + + require(success, "Swap failed"); + } + + // ================================================= + // =============== Setter functions ================ + // ================================================= + + function setExecutor(address _executor) external { + require(_canSetExecutor(), "Not authorized"); + if (_executor == executor) { + revert("Executor already set"); + } + + executor = _executor; + } + + function approveSwapRouter(address _swapRouter, bool _toApprove) external { + require(_canSetSwap(), "Not authorized"); + require(_swapRouter != address(0), "Zero address"); + + isApprovedRouter[_swapRouter] = _toApprove; + } + + // ================================================= + // =============== Role checks ===================== + // ================================================= + + function canAuthorizeVaultToExecutor(address _expectedAdmin) external view returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, _expectedAdmin); + } + + function _canSetExecutor() internal view returns (bool) { + return msg.sender == checkout; + } + + function _canTransferTokens() internal view returns (bool) { + return msg.sender == executor; + } + + function _canSwap() internal view returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, msg.sender); + } + + function _canSetSwap() internal view returns (bool) { + return hasRole(DEFAULT_ADMIN_ROLE, msg.sender); + } +} diff --git a/contracts/prebuilts/unaudited/checkout/interface/ICheckout.sol b/contracts/prebuilts/unaudited/checkout/interface/ICheckout.sol new file mode 100644 index 000000000..da9db32bc --- /dev/null +++ b/contracts/prebuilts/unaudited/checkout/interface/ICheckout.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +interface ICheckout { + /// @dev Emitted when an executor is authorized to use funds from the given vault. + event VaultAuthorizedToExecutor(address _vault, address _executor); + + /// @dev Emitted when a new Executor contract is deployed. + event ExecutorCreated(address _executor, address _defaultAdmin); + + /// @dev Emitted when a new Vault contrac is deployed. + event VaultCreated(address _vault, address _defaultAdmin); + + function createVault(address _vaultAdmin, bytes32 _salt) external payable returns (address); + + function createExecutor(address _executorAdmin, bytes32 _salt) external payable returns (address); + + function authorizeVaultToExecutor(address _vault, address _executor) external; +} diff --git a/contracts/prebuilts/unaudited/checkout/interface/IExecutor.sol b/contracts/prebuilts/unaudited/checkout/interface/IExecutor.sol new file mode 100644 index 000000000..d6500bf0c --- /dev/null +++ b/contracts/prebuilts/unaudited/checkout/interface/IExecutor.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "./ISwap.sol"; + +interface IExecutor is ISwap { + /** + * @notice Details of the transaction to execute on target contract. + * + * @param target Address to send the transaction to + * + * @param currency Represents both native token and erc20 token + * + * @param vault Vault providing liquidity for this transaction + * + * @param approvalRequired If need to approve erc20 to the target contract + * + * @param swap If need to swap first + * + * @param valueToSend Transaction value to send - both native and erc20 + * + * @param data Transaction calldata + */ + struct UserOp { + address target; + address currency; + address vault; + bool approvalRequired; + uint256 valueToSend; + bytes data; + } + + function execute(UserOp calldata op) external; + + function swapAndExecute(UserOp calldata op, SwapOp memory swapOp) external; +} diff --git a/contracts/prebuilts/unaudited/checkout/interface/ISwap.sol b/contracts/prebuilts/unaudited/checkout/interface/ISwap.sol new file mode 100644 index 000000000..523b0a049 --- /dev/null +++ b/contracts/prebuilts/unaudited/checkout/interface/ISwap.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +interface ISwap { + struct SwapOp { + address router; + address tokenOut; + address tokenIn; + uint256 amountIn; + uint256 amountInOffset; + bytes swapCalldata; + bytes quoteCalldata; + } +} diff --git a/contracts/prebuilts/unaudited/checkout/interface/IVault.sol b/contracts/prebuilts/unaudited/checkout/interface/IVault.sol new file mode 100644 index 000000000..23c26ef38 --- /dev/null +++ b/contracts/prebuilts/unaudited/checkout/interface/IVault.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "./ISwap.sol"; + +interface IVault is ISwap { + /// @dev Emitted when contract admin withdraws tokens. + event TokensWithdrawn(address _token, uint256 _amount); + + /// @dev Emitted when executor contract withdraws tokens. + event TokensTransferredToExecutor(address indexed _executor, address _token, uint256 _amount); + + function withdraw(address _token, uint256 _amount) external; + + function transferTokensToExecutor(address _token, uint256 _amount) external; + + function swapAndTransferTokensToExecutor(address _token, uint256 _amount, SwapOp memory _swapOp) external; + + function setExecutor(address _executor) external; + + function approveSwapRouter(address _swapRouter, bool _toApprove) external; + + function canAuthorizeVaultToExecutor(address _expectedAdmin) external view returns (bool); +} diff --git a/contracts/prebuilts/unaudited/checkout/prb/IPluginCheckout.sol b/contracts/prebuilts/unaudited/checkout/prb/IPluginCheckout.sol new file mode 100644 index 000000000..9a174031e --- /dev/null +++ b/contracts/prebuilts/unaudited/checkout/prb/IPluginCheckout.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +interface IPluginCheckout { + /** + * @notice Details of the transaction to execute on target contract. + * + * @param target Address to send the transaction to + * + * @param currency Represents both native token and erc20 token + * + * @param approvalRequired If need to approve erc20 to the target contract + * + * @param valueToSend Transaction value to send - both native and erc20 + * + * @param data Transaction calldata + */ + struct UserOp { + address target; + address currency; + bool approvalRequired; + uint256 valueToSend; + bytes data; + } + + struct SwapOp { + address router; + address tokenOut; + address tokenIn; + uint256 amountIn; + uint256 amountInOffset; + bytes swapCalldata; + bytes quoteCalldata; + } + + function execute(UserOp calldata op) external; + + function swapAndExecute(UserOp calldata op, SwapOp memory swapOp) external; +} diff --git a/contracts/prebuilts/unaudited/checkout/prb/PluginCheckout.sol b/contracts/prebuilts/unaudited/checkout/prb/PluginCheckout.sol new file mode 100644 index 000000000..d5d29796e --- /dev/null +++ b/contracts/prebuilts/unaudited/checkout/prb/PluginCheckout.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +import { IPRBProxyPlugin } from "@prb/proxy/src/interfaces/IPRBProxyPlugin.sol"; + +import "./TargetCheckout.sol"; + +contract PluginCheckout is IPRBProxyPlugin, TargetCheckout { + function getMethods() external pure override returns (bytes4[] memory) { + bytes4[] memory methods = new bytes4[](3); + methods[0] = this.withdraw.selector; + methods[1] = this.execute.selector; + methods[2] = this.swapAndExecute.selector; + return methods; + } +} diff --git a/contracts/prebuilts/unaudited/checkout/prb/TargetCheckout.sol b/contracts/prebuilts/unaudited/checkout/prb/TargetCheckout.sol new file mode 100644 index 000000000..982f27705 --- /dev/null +++ b/contracts/prebuilts/unaudited/checkout/prb/TargetCheckout.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.11; + +import "../../../../lib/CurrencyTransferLib.sol"; +import "../../../../eip/interface/IERC20.sol"; + +import { IPRBProxy } from "@prb/proxy/src/interfaces/IPRBProxy.sol"; +import "./IPluginCheckout.sol"; + +// $$\ $$\ $$\ $$\ $$\ +// $$ | $$ | \__| $$ | $$ | +// $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$ |$$\ $$\ $$\ $$$$$$\ $$$$$$$\ +// \_$$ _| $$ __$$\ $$ |$$ __$$\ $$ __$$ |$$ | $$ | $$ |$$ __$$\ $$ __$$\ +// $$ | $$ | $$ |$$ |$$ | \__|$$ / $$ |$$ | $$ | $$ |$$$$$$$$ |$$ | $$ | +// $$ |$$\ $$ | $$ |$$ |$$ | $$ | $$ |$$ | $$ | $$ |$$ ____|$$ | $$ | +// \$$$$ |$$ | $$ |$$ |$$ | \$$$$$$$ |\$$$$$\$$$$ |\$$$$$$$\ $$$$$$$ | +// \____/ \__| \__|\__|\__| \_______| \_____\____/ \_______|\_______/ + +contract TargetCheckout is IPluginCheckout { + mapping(address => bool) public isApprovedRouter; + + function withdraw(address _token, uint256 _amount) external { + require(msg.sender == IPRBProxy(address(this)).owner(), "Not authorized"); + + CurrencyTransferLib.transferCurrency(_token, address(this), msg.sender, _amount); + } + + function approveSwapRouter(address _swapRouter, bool _toApprove) external { + require(msg.sender == IPRBProxy(address(this)).owner(), "Not authorized"); + require(_swapRouter != address(0), "Zero address"); + + isApprovedRouter[_swapRouter] = _toApprove; + } + + function execute(UserOp calldata op) external { + require(_canExecute(op, msg.sender), "Not authorized"); + + _execute(op); + } + + function swapAndExecute(UserOp calldata op, SwapOp calldata swapOp) external { + require(isApprovedRouter[swapOp.router], "Invalid router address"); + require(_canExecute(op, msg.sender), "Not authorized"); + + _swap(swapOp); + _execute(op); + } + + // ================================================= + // =============== Internal functions ============== + // ================================================= + + function _execute(UserOp calldata op) internal { + bool success; + if (op.currency == CurrencyTransferLib.NATIVE_TOKEN) { + (success, ) = op.target.call{ value: op.valueToSend }(op.data); + } else { + if (op.valueToSend != 0 && op.approvalRequired) { + IERC20(op.currency).approve(op.target, op.valueToSend); + } + + (success, ) = op.target.call(op.data); + } + + require(success, "Execution failed"); + } + + function _swap(SwapOp memory _swapOp) internal { + address _tokenIn = _swapOp.tokenIn; + address _router = _swapOp.router; + + // get quote for amountIn + (, bytes memory quoteData) = _router.staticcall(_swapOp.quoteCalldata); + uint256 amountIn; + uint256 offset = _swapOp.amountInOffset; + + assembly { + amountIn := mload(add(add(quoteData, 32), offset)) + } + + // perform swap + bool success; + if (_tokenIn == CurrencyTransferLib.NATIVE_TOKEN) { + (success, ) = _router.call{ value: amountIn }(_swapOp.swapCalldata); + } else { + IERC20(_tokenIn).approve(_swapOp.router, amountIn); + (success, ) = _router.call(_swapOp.swapCalldata); + } + + require(success, "Swap failed"); + } + + function _canExecute(UserOp calldata op, address caller) internal view returns (bool) { + address owner = IPRBProxy(address(this)).owner(); + if (owner != caller) { + bool permission = IPRBProxy(address(this)).registry().getPermissionByOwner({ + owner: owner, + envoy: caller, + target: op.target + }); + + return permission; + } + + return true; + } +} diff --git a/foundry.toml b/foundry.toml index a86fc9b13..2b603cf26 100644 --- a/foundry.toml +++ b/foundry.toml @@ -39,6 +39,7 @@ remappings = [ 'erc721a/=lib/ERC721A/', '@thirdweb-dev/dynamic-contracts/=lib/dynamic-contracts/', 'lib/sstore2=lib/dynamic-contracts/lib/sstore2/', + '@prb/proxy/=lib/prb-proxy/', ] fs_permissions = [{ access = "read-write", path = "./src/test/smart-wallet/utils"}] src = 'contracts' diff --git a/lib/prb-proxy b/lib/prb-proxy new file mode 160000 index 000000000..1c43be323 --- /dev/null +++ b/lib/prb-proxy @@ -0,0 +1 @@ +Subproject commit 1c43be323f8ff9f5105f9eb0b493ef5b7fc65c4b diff --git a/src/test/checkout/Map.t.sol b/src/test/checkout/Map.t.sol new file mode 100644 index 000000000..8897087fd --- /dev/null +++ b/src/test/checkout/Map.t.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../utils/BaseTest.sol"; + +interface IRandom { + function random() external view returns (uint256); +} + +contract MapCheck { + struct S { + address addr; + } + uint256 public a; + mapping(bytes4 => S) public myMap; + + constructor() { + a = 10; + myMap[IRandom.random.selector] = S({ addr: address(123) }); + } + + function checkA() public view returns (uint256 res) { + assembly { + res := sload(0) + } + } + + function checkMap() public view returns (uint256 res) { + bytes4 sel = IRandom.random.selector; + assembly { + mstore(0, sel) + + mstore(32, myMap.slot) + + let hash := keccak256(0, 64) + res := myMap.slot + } + } +} + +contract MapCheckTest { + MapCheck internal c; + + function setUp() public { + c = new MapCheck(); + } + + function test_mappingSlot() public { + console.log(c.myMap(IRandom.random.selector)); + console.logBytes4(IRandom.random.selector); + + console.log(c.checkA()); + console.log(c.checkMap()); + } +} diff --git a/src/test/checkout/PluginCheckout.t.sol b/src/test/checkout/PluginCheckout.t.sol new file mode 100644 index 000000000..c116c4899 --- /dev/null +++ b/src/test/checkout/PluginCheckout.t.sol @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../utils/BaseTest.sol"; + +import { IDrop } from "contracts/extension/interface/IDrop.sol"; + +import { PluginCheckout, IPluginCheckout } from "contracts/prebuilts/unaudited/checkout/prb/PluginCheckout.sol"; +import { IPRBProxyPlugin } from "@prb/proxy/src/interfaces/IPRBProxyPlugin.sol"; +import { IPRBProxy } from "@prb/proxy/src/interfaces/IPRBProxy.sol"; +import { IPRBProxyRegistry } from "@prb/proxy/src/interfaces/IPRBProxyRegistry.sol"; +import { PRBProxy } from "@prb/proxy/src/PRBProxy.sol"; +import { PRBProxyRegistry } from "@prb/proxy/src/PRBProxyRegistry.sol"; + +contract PluginCheckoutTest is BaseTest { + PluginCheckout internal checkoutPlugin; + PRBProxy internal proxy; + PRBProxyRegistry internal proxyRegistry; + + address internal owner; + address internal alice; + address internal bob; + address internal random; + + address internal receiver; + + DropERC721 internal targetDrop; + + MockERC20 internal mainCurrency; + MockERC20 internal altCurrencyOne; + MockERC20 internal altCurrencyTwo; + + function setClaimConditionCurrency(DropERC721 drop, address _currency) public { + DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = type(uint256).max; + conditions[0].quantityLimitPerWallet = 100; + conditions[0].pricePerToken = 10; + conditions[0].currency = _currency; + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + } + + function setUp() public override { + super.setUp(); + + // setup actors + owner = getActor(1); + alice = getActor(2); + bob = getActor(3); + random = getActor(4); + receiver = getActor(5); + + // setup currencies + mainCurrency = new MockERC20(); + altCurrencyOne = new MockERC20(); + altCurrencyTwo = new MockERC20(); + + // mint and approve currencies + mainCurrency.mint(address(owner), 100 ether); + altCurrencyOne.mint(address(owner), 100 ether); + altCurrencyTwo.mint(address(owner), 100 ether); + + // setup target NFT Drop contract + targetDrop = DropERC721(getContract("DropERC721")); + vm.prank(deployer); + targetDrop.lazyMint(100, "ipfs://", ""); + setClaimConditionCurrency(targetDrop, address(mainCurrency)); + + // deploy contracts + checkoutPlugin = new PluginCheckout(); + proxyRegistry = new PRBProxyRegistry(); + + vm.prank(owner); + proxy = PRBProxy( + payable(address(proxyRegistry.deployAndInstallPlugin(IPRBProxyPlugin(address(checkoutPlugin))))) + ); + } + + function test_executor_executeOp() public { + // deposit currencies in vault + vm.startPrank(owner); + mainCurrency.transfer(address(proxy), 10 ether); + vm.stopPrank(); + + // create user op -- claim tokens on targetDrop + uint256 _quantityToClaim = 5; + uint256 _totalPrice = 5 * 10; // claim condition price is set as 10 above in setup + DropERC721.AllowlistProof memory alp; + bytes memory callData = abi.encodeWithSelector( + IDrop.claim.selector, + receiver, + _quantityToClaim, + address(mainCurrency), + 10, + alp, + "" + ); + IPluginCheckout.UserOp memory op = IPluginCheckout.UserOp({ + target: address(targetDrop), + currency: address(mainCurrency), + approvalRequired: true, + valueToSend: _totalPrice, + data: callData + }); + + // check state before + assertEq(targetDrop.balanceOf(receiver), 0); + assertEq(targetDrop.nextTokenIdToClaim(), 0); + assertEq(mainCurrency.balanceOf(address(proxy)), 10 ether); + assertEq(mainCurrency.balanceOf(address(saleRecipient)), 0); + + // execute + vm.prank(owner); + PluginCheckout(address(proxy)).execute(op); + + // check state after + assertEq(targetDrop.balanceOf(receiver), _quantityToClaim); + assertEq(targetDrop.nextTokenIdToClaim(), _quantityToClaim); + assertEq(mainCurrency.balanceOf(address(proxy)), 10 ether - _totalPrice); + assertEq(mainCurrency.balanceOf(address(saleRecipient)), _totalPrice - (_totalPrice * platformFeeBps) / 10_000); + } +} diff --git a/src/test/checkout/Prototype.t.sol b/src/test/checkout/Prototype.t.sol new file mode 100644 index 000000000..844d96801 --- /dev/null +++ b/src/test/checkout/Prototype.t.sol @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import "../utils/BaseTest.sol"; + +import { Checkout, ICheckout } from "contracts/prebuilts/unaudited/checkout/Checkout.sol"; +import { Vault, IVault } from "contracts/prebuilts/unaudited/checkout/Vault.sol"; +import { Executor, IExecutor } from "contracts/prebuilts/unaudited/checkout/Checkout.sol"; +import { IDrop } from "contracts/extension/interface/IDrop.sol"; + +contract CheckoutPrototypeTest is BaseTest { + address internal vaultImplementation; + address internal executorImplementation; + + Checkout internal checkout; + + Vault internal vaultOne; + Vault internal vaultTwo; + Executor internal executorOne; + Executor internal executorTwo; + + address internal vaultAdminOne; + address internal vaultAdminTwo; + address internal executorAdminOne; + address internal executorAdminTwo; + + address internal receiver; + + DropERC721 internal targetDrop; + + MockERC20 internal mainCurrency; + MockERC20 internal altCurrencyOne; + MockERC20 internal altCurrencyTwo; + + function setClaimConditionCurrency(DropERC721 drop, address _currency) public { + DropERC721.ClaimCondition[] memory conditions = new DropERC721.ClaimCondition[](1); + conditions[0].maxClaimableSupply = type(uint256).max; + conditions[0].quantityLimitPerWallet = 100; + conditions[0].pricePerToken = 10; + conditions[0].currency = _currency; + + vm.prank(deployer); + drop.setClaimConditions(conditions, false); + } + + function setUp() public override { + super.setUp(); + + // setup actors + vaultAdminOne = getActor(1); + vaultAdminTwo = getActor(2); + executorAdminOne = getActor(3); + executorAdminTwo = getActor(4); + receiver = getActor(5); + + // setup currencies + mainCurrency = new MockERC20(); + altCurrencyOne = new MockERC20(); + altCurrencyTwo = new MockERC20(); + + // mint and approve currencies + mainCurrency.mint(address(vaultAdminOne), 100 ether); + altCurrencyOne.mint(address(vaultAdminOne), 100 ether); + altCurrencyTwo.mint(address(vaultAdminOne), 100 ether); + mainCurrency.mint(address(vaultAdminTwo), 100 ether); + altCurrencyOne.mint(address(vaultAdminTwo), 100 ether); + altCurrencyTwo.mint(address(vaultAdminTwo), 100 ether); + + // setup target NFT Drop contract + targetDrop = DropERC721(getContract("DropERC721")); + vm.prank(deployer); + targetDrop.lazyMint(100, "ipfs://", ""); + setClaimConditionCurrency(targetDrop, address(mainCurrency)); + + // deploy vault and executor implementations + vaultImplementation = address(new Vault()); + executorImplementation = address(new Executor()); + + // deploy checkout + checkout = new Checkout(deployer, vaultImplementation, executorImplementation); + } + + function test_checkout_createVault() public { + vaultOne = Vault(checkout.createVault(vaultAdminOne, "vaultAdminOne")); + + assertEq(vaultOne.checkout(), address(checkout)); + assertTrue(vaultOne.hasRole(bytes32(0x00), vaultAdminOne)); + assertTrue(checkout.isVaultRegistered(address(vaultOne))); + + // should revert when deploying with same salt again + vm.expectRevert("ERC1167: create2 failed"); + checkout.createVault(vaultAdminOne, "vaultAdminOne"); + } + + function test_checkout_createExecutor() public { + executorOne = Executor(payable(checkout.createExecutor(executorAdminOne, "executorAdminOne"))); + + assertEq(executorOne.checkout(), address(checkout)); + assertTrue(executorOne.hasRole(bytes32(0x00), executorAdminOne)); + assertTrue(checkout.isExecutorRegistered(address(executorOne))); + + // should revert when deploying with same salt again + vm.expectRevert("ERC1167: create2 failed"); + checkout.createExecutor(executorAdminOne, "executorAdminOne"); + } + + function test_checkout_authorizeVaultToExecutor() public { + vaultOne = Vault(checkout.createVault(vaultAdminOne, "vaultAdminOne")); + executorOne = Executor(payable(checkout.createExecutor(executorAdminOne, "executorAdminOne"))); + + vm.prank(vaultAdminOne); + checkout.authorizeVaultToExecutor(address(vaultOne), address(executorOne)); + assertEq(vaultOne.executor(), address(executorOne)); + + // revert for unauthorized caller + vm.prank(address(0x123)); + vm.expectRevert("Not authorized"); + checkout.authorizeVaultToExecutor(address(vaultOne), address(executorOne)); + + // revert for unknown executor + vm.prank(vaultAdminOne); + vm.expectRevert("Executor not found"); + checkout.authorizeVaultToExecutor(address(vaultOne), address(0x123)); + } + + function test_executor_executeOp() public { + // setup contracts + vaultOne = Vault(checkout.createVault(vaultAdminOne, "vaultAdminOne")); + executorOne = Executor(payable(checkout.createExecutor(executorAdminOne, "executorAdminOne"))); + + vm.prank(vaultAdminOne); + checkout.authorizeVaultToExecutor(address(vaultOne), address(executorOne)); + + // deposit currencies in vault + vm.startPrank(address(vaultAdminOne)); + mainCurrency.transfer(address(vaultOne), 10 ether); + vm.stopPrank(); + + // create user op -- claim tokens on targetDrop + uint256 _quantityToClaim = 5; + uint256 _totalPrice = 5 * 10; // claim condition price is set as 10 above in setup + DropERC721.AllowlistProof memory alp; + bytes memory callData = abi.encodeWithSelector( + IDrop.claim.selector, + receiver, + _quantityToClaim, + address(mainCurrency), + 10, + alp, + "" + ); + IExecutor.UserOp memory op = IExecutor.UserOp({ + target: address(targetDrop), + currency: address(mainCurrency), + vault: address(vaultOne), + approvalRequired: true, + valueToSend: _totalPrice, + data: callData + }); + + // check state before + assertEq(targetDrop.balanceOf(receiver), 0); + assertEq(targetDrop.nextTokenIdToClaim(), 0); + assertEq(mainCurrency.balanceOf(address(vaultOne)), 10 ether); + assertEq(mainCurrency.balanceOf(address(saleRecipient)), 0); + assertEq(mainCurrency.allowance(address(vaultOne), address(executorOne)), 0); + + // execute + vm.prank(executorAdminOne); + executorOne.execute(op); + + // check state after + assertEq(targetDrop.balanceOf(receiver), _quantityToClaim); + assertEq(targetDrop.nextTokenIdToClaim(), _quantityToClaim); + assertEq(mainCurrency.balanceOf(address(vaultOne)), 10 ether - _totalPrice); + assertEq(mainCurrency.balanceOf(address(saleRecipient)), _totalPrice - (_totalPrice * platformFeeBps) / 10_000); + assertEq(mainCurrency.allowance(address(vaultOne), address(executorOne)), 0); + } +}