diff --git a/src/wallet/EnsoWalletV2.sol b/src/wallet/EnsoWalletV2.sol new file mode 100644 index 0000000..21f2790 --- /dev/null +++ b/src/wallet/EnsoWalletV2.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.20; + +import { AbstractEnsoShortcuts } from "../AbstractEnsoShortcuts.sol"; +import { AbstractMultiSend } from "../AbstractMultiSend.sol"; +import { Withdrawable } from "../utils/Withdrawable.sol"; + +import { Initializable } from "openzeppelin-contracts/proxy/utils/Initializable.sol"; + +contract EnsoWalletV2 is AbstractMultiSend, AbstractEnsoShortcuts, Initializable, Withdrawable { + string public constant VERSION = "1.0.0"; + address public factory; + address private _owner; + + error InvalidSender(address sender); + + modifier onlyOwner() { + _checkOwner(); + _; + } + + /** + * @notice Initializes the wallet with an owner address + * @param owner_ The address that will own this wallet + */ + function initialize(address owner_) external initializer { + _owner = owner_; + // sender has to be the factory + factory = msg.sender; + } + + /** + * @notice Executes an arbitrary call to a target contract + * @param target The address of the contract to call + * @param value The amount of native token to send with the call + * @param data The calldata to send to the target contract + * @return success Whether the call succeeded + */ + function execute( + address target, + uint256 value, + bytes memory data + ) + external + payable + onlyOwner + returns (bool success) + { + assembly { + success := call(gas(), target, value, add(data, 0x20), mload(data), 0, 0) + } + } + + /** + * @notice Executes a shortcut + * @dev Can be called by owner or factory + * @param accountId The bytes32 value representing an API user + * @param requestId The bytes32 value representing an API request + * @param commands An array of bytes32 values that encode calls + * @param state An array of bytes that are used to generate call data for each command + * @return response Array of response data from each executed command + */ + function executeShortcut( + bytes32 accountId, + bytes32 requestId, + bytes32[] calldata commands, + bytes[] calldata state + ) + public + payable + override + returns (bytes[] memory response) + { + return super.executeShortcut(accountId, requestId, commands, state); + } + + /// @notice Abstract override function to return owner + function owner() public view override returns (address) { + return _owner; + } + + /// @notice Abstract override function to validate msg.sender + function _checkMsgSender() internal view override(AbstractEnsoShortcuts, AbstractMultiSend) { + if (msg.sender != factory && msg.sender != owner()) revert InvalidSender(msg.sender); + } + + /// @notice Abstract override function to validate if sender is the owner + function _checkOwner() internal view override { + if (msg.sender != owner()) revert InvalidSender(msg.sender); + } +} diff --git a/src/wallet/EnsoWalletV2Factory.sol b/src/wallet/EnsoWalletV2Factory.sol new file mode 100644 index 0000000..9c211f5 --- /dev/null +++ b/src/wallet/EnsoWalletV2Factory.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.20; + +import { Token, TokenType } from "../interfaces/IEnsoRouter.sol"; + +import { IEnsoWalletV2 } from "./interfaces/IEnsoWalletV2.sol"; + +import { IERC1155 } from "openzeppelin-contracts/token/ERC1155/IERC1155.sol"; +import { IERC20, SafeERC20 } from "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC721 } from "openzeppelin-contracts/token/ERC721/IERC721.sol"; +import { LibClone } from "solady/utils/LibClone.sol"; + +contract EnsoWalletV2Factory { + using LibClone for address; + using SafeERC20 for IERC20; + + address public immutable implementation; + + event EnsoWalletV2Deployed(address wallet, address indexed account); + + error WrongMsgValue(uint256 value, uint256 expectedAmount); + error UnsupportedTokenType(TokenType tokenType); + + constructor(address implementation_) { + implementation = implementation_; + } + + function deploy(address account) external returns (address wallet) { + return _deploy(account); + } + + function deployAndExecute( + Token calldata tokenIn, + bytes calldata data + ) + external + payable + returns (address wallet, bytes memory response) + { + return _deployAndExecute(tokenIn, data); + } + + function getAddress(address account) external view returns (address) { + bytes32 salt = _getSalt(account); + return implementation.predictDeterministicAddress(salt, address(this)); + } + + function _deployAndExecute( + Token calldata tokenIn, + bytes calldata data + ) + private + returns (address wallet, bytes memory response) + { + // strictly only msg.sender can deploy and execute + wallet = _deploy(msg.sender); + bool isNativeAsset = _transfer(tokenIn, wallet); + if (!isNativeAsset && msg.value != 0) revert WrongMsgValue(msg.value, 0); + + bool success; + (success, response) = wallet.call{ value: msg.value }(data); + if (!success) { + assembly { + revert(add(response, 32), mload(response)) + } + } + } + + function _deploy(address account) private returns (address wallet) { + bytes32 salt = _getSalt(account); + wallet = implementation.predictDeterministicAddress(salt, address(this)); + if (wallet.code.length == 0) { + implementation.cloneDeterministic(salt); + IEnsoWalletV2(wallet).initialize(account); + emit EnsoWalletV2Deployed(wallet, account); + } + } + + function _transfer(Token calldata token, address receiver) private returns (bool isNativeAsset) { + TokenType tokenType = token.tokenType; + + if (tokenType == TokenType.ERC20) { + (IERC20 erc20, uint256 amount) = abi.decode(token.data, (IERC20, uint256)); + erc20.safeTransferFrom(msg.sender, receiver, amount); + } else if (tokenType == TokenType.Native) { + // no need to get amount, it will come from msg.value + isNativeAsset = true; + } else if (tokenType == TokenType.ERC721) { + (IERC721 erc721, uint256 tokenId) = abi.decode(token.data, (IERC721, uint256)); + erc721.safeTransferFrom(msg.sender, receiver, tokenId); + } else if (tokenType == TokenType.ERC1155) { + (IERC1155 erc1155, uint256 tokenId, uint256 amount) = abi.decode(token.data, (IERC1155, uint256, uint256)); + erc1155.safeTransferFrom(msg.sender, receiver, tokenId, amount, "0x"); + } else { + revert UnsupportedTokenType(tokenType); + } + } + + function _getSalt(address account) internal pure returns (bytes32) { + return keccak256(abi.encode(account)); + } +} diff --git a/src/wallet/interfaces/IEnsoWalletV2.sol b/src/wallet/interfaces/IEnsoWalletV2.sol new file mode 100644 index 0000000..87118b6 --- /dev/null +++ b/src/wallet/interfaces/IEnsoWalletV2.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.20; + +interface IEnsoWalletV2 { + error InvalidSender(address sender); + + function initialize(address owner_) external; + + function executeShortcut( + bytes32 accountId, + bytes32 requestId, + bytes32[] calldata commands, + bytes[] calldata state + ) + external + payable + returns (bytes[] memory response); +} + diff --git a/test/unit/concrete/wallet/ensowalletv2/EnsoWalletV2.t.sol b/test/unit/concrete/wallet/ensowalletv2/EnsoWalletV2.t.sol new file mode 100644 index 0000000..97a4e86 --- /dev/null +++ b/test/unit/concrete/wallet/ensowalletv2/EnsoWalletV2.t.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.20; + +import { EnsoWalletV2 } from "../../../../../src/wallet/EnsoWalletV2.sol"; +import { EnsoWalletV2Factory } from "../../../../../src/wallet/EnsoWalletV2Factory.sol"; + +import { MockERC1155 } from "../../../../mocks/MockERC1155.sol"; +import { MockERC20 } from "../../../../mocks/MockERC20.sol"; +import { MockERC721 } from "../../../../mocks/MockERC721.sol"; + +import { Test } from "forge-std-1.9.7/Test.sol"; +import { IERC1155 } from "openzeppelin-contracts/token/ERC1155/IERC1155.sol"; + +abstract contract EnsoWalletV2_Unit_Concrete_Test is Test { + address payable internal constant EOA_1 = payable(0xE150e171dDf7ef6785e2c6fBBbE9eCd0f2f63682); + bytes32 internal constant EOA_1_PK = 0x74dc97524c0473f102953ebfe8bbec30f0e9cd304703ed7275c708921deaab3b; + + address payable internal s_deployer; + address payable internal s_owner; + address payable internal s_user; + address payable internal s_account1; + address payable internal s_account2; + + EnsoWalletV2 internal s_walletImplementation; + EnsoWalletV2Factory internal s_walletFactory; + EnsoWalletV2 internal s_wallet; + + MockERC20 internal s_erc20; + MockERC721 internal s_erc721; + MockERC1155 internal s_erc1155; + + function setUp() public virtual { + s_deployer = payable(vm.addr(1)); + vm.deal(s_deployer, 1000 ether); + vm.label(s_deployer, "Deployer"); + + s_owner = payable(vm.addr(2)); + vm.deal(s_owner, 1000 ether); + vm.label(s_owner, "Owner"); + + s_user = payable(vm.addr(3)); + vm.deal(s_user, 1000 ether); + vm.label(s_user, "User"); + + s_account1 = payable(vm.addr(5)); + vm.deal(s_account1, 1000 ether); + vm.label(s_account1, "Account 1"); + + s_account2 = payable(vm.addr(6)); + vm.deal(s_account2, 1000 ether); + vm.label(s_account2, "Account 2"); + + vm.startPrank(s_deployer); + + // Deploy implementation + s_walletImplementation = new EnsoWalletV2(); + vm.label(address(s_walletImplementation), "EnsoWalletV2Implementation"); + + // Deploy factory + s_walletFactory = new EnsoWalletV2Factory(address(s_walletImplementation)); + vm.label(address(s_walletFactory), "EnsoWalletV2Factory"); + + // Deploy mock tokens + s_erc20 = new MockERC20("Mock ERC20", "MERC20"); + vm.label(address(s_erc20), "MockERC20"); + + s_erc721 = new MockERC721("Mock ERC721", "MERC721"); + vm.label(address(s_erc721), "MockERC721"); + + s_erc1155 = new MockERC1155("Mock ERC1155"); + vm.label(address(s_erc1155), "MockERC1155"); + + vm.stopPrank(); + } + + function _deployWallet(address owner) internal returns (EnsoWalletV2 wallet) { + // vm.startPrank(s_factory); + wallet = EnsoWalletV2(payable(s_walletFactory.deploy(owner))); + // vm.stopPrank(); + } +} diff --git a/test/unit/concrete/wallet/ensowalletv2/execute.t.sol b/test/unit/concrete/wallet/ensowalletv2/execute.t.sol new file mode 100644 index 0000000..3c94347 --- /dev/null +++ b/test/unit/concrete/wallet/ensowalletv2/execute.t.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.20; + +import { EnsoWalletV2 } from "../../../../../src/wallet/EnsoWalletV2.sol"; +import { EnsoWalletV2_Unit_Concrete_Test } from "./EnsoWalletV2.t.sol"; + +contract Target { + function func() external pure returns (uint256) { + return 42; + } + + function functionWithValue(uint256 value) external payable returns (uint256) { + return value; + } + + function revert() external pure { + revert("Test revert"); + } +} + +contract EnsoWalletV2_Execute_Unit_Concrete_Test is EnsoWalletV2_Unit_Concrete_Test { + Target internal s_target; + + function setUp() public override { + super.setUp(); + + s_target = new Target(); + vm.label(address(s_target), "Target"); + + s_wallet = _deployWallet(s_owner); + } + + function test_WhenValidCall() external { + // it should execute call successfully + vm.startPrank(s_owner); + bool success = s_wallet.execute(address(s_target), 0, abi.encodeWithSelector(Target.func.selector)); + + assertTrue(success); + } + + function test_WhenCallWithValue() external { + // it should execute call with value + uint256 value = 1 ether; + + vm.startPrank(s_owner); + bool success = s_wallet.execute{ value: value }( + address(s_target), value, abi.encodeWithSelector(Target.functionWithValue.selector, value) + ); + + assertTrue(success); + } + + function test_RevertWhen_TargetReverts() external { + // it should revert when target call reverts + vm.startPrank(s_owner); + bool success = s_wallet.execute(address(s_target), 0, abi.encodeWithSelector(Target.revert.selector)); + + assertFalse(success); + } + + function test_RevertWhen_NotOwner() external { + // it should revert when not called by owner + vm.startPrank(s_user); + vm.expectRevert(abi.encodeWithSelector(EnsoWalletV2.InvalidSender.selector, s_user)); + s_wallet.execute(address(s_target), 0, ""); + } +} + diff --git a/test/unit/concrete/wallet/ensowalletv2/executeShortcut.t.sol b/test/unit/concrete/wallet/ensowalletv2/executeShortcut.t.sol new file mode 100644 index 0000000..dd408d7 --- /dev/null +++ b/test/unit/concrete/wallet/ensowalletv2/executeShortcut.t.sol @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.20; + +import { EnsoWalletV2 } from "../../../../../src/wallet/EnsoWalletV2.sol"; +import { WeirollPlanner } from "../../../../utils/WeirollPlanner.sol"; + +import { EnsoWalletV2_Unit_Concrete_Test } from "./EnsoWalletV2.t.sol"; +import { WETH } from "solady/tokens/WETH.sol"; + +contract Target { + function func() external payable returns (uint256) { + return 42; + } + + function revertFunc() external pure { + revert("Test revert"); + } +} + +contract EnsoWalletV2_ExecuteShortcut_Unit_Concrete_Test is EnsoWalletV2_Unit_Concrete_Test { + bytes32 internal constant ACCOUNT_ID = bytes32("test_account"); + bytes32 internal constant REQUEST_ID = bytes32("test_request"); + WETH internal weth; + + function setUp() public override { + super.setUp(); + s_wallet = _deployWallet(s_owner); + weth = new WETH(); + } + + function test_WhenCalledByOwner() external { + // it should allow owner to execute shortcuts + vm.startPrank(s_owner); + bytes32[] memory commands = new bytes32[](0); + bytes[] memory state = new bytes[](0); + + s_wallet.executeShortcut(ACCOUNT_ID, REQUEST_ID, commands, state); + } + + function test_WhenCalledByFactory() external { + // it should allow factory to execute shortcuts + vm.startPrank(address(s_walletFactory)); + bytes32[] memory commands = new bytes32[](0); + bytes[] memory state = new bytes[](0); + + s_wallet.executeShortcut(ACCOUNT_ID, REQUEST_ID, commands, state); + } + + function test_RevertWhen_NotOwnerOrFactory() external { + // it should revert when not called by owner or factory + vm.startPrank(s_user); + bytes32[] memory commands = new bytes32[](0); + bytes[] memory state = new bytes[](0); + + vm.expectRevert(abi.encodeWithSelector(EnsoWalletV2.InvalidSender.selector, s_user)); + s_wallet.executeShortcut(ACCOUNT_ID, REQUEST_ID, commands, state); + } + + function test_executeShortcutWithNative() external { + // action: + // wrap 1 ETH to WETH + // transfer 1 WETH to s_account1 + + uint256 value = 1 ether; + + bytes32[] memory commands = new bytes32[](2); + bytes[] memory state = new bytes[](2); + + commands[0] = WeirollPlanner.buildCommand( + weth.deposit.selector, + 0x03, // call with value + 0x00ffffffffff, // 1 input + 0xff, // no output + address(weth) + ); + + commands[1] = WeirollPlanner.buildCommand( + weth.transfer.selector, + 0x01, // call + 0x0100ffffffff, // 1 input + 0xff, // no output + address(weth) + ); + + state[0] = abi.encode(1 ether); + state[1] = abi.encode(s_account1); + + vm.startPrank(s_owner); + vm.deal(address(s_wallet), value); + + s_wallet.executeShortcut{ value: value }(ACCOUNT_ID, REQUEST_ID, commands, state); + + // it should transfer WETH to account1 + assertEq(weth.balanceOf(s_account1), value); + } + + function test_executeShortcutWithERC20() external { + // action: + // transfer 1 WETH to s_account1 + + uint256 value = 1 ether; + + bytes32[] memory commands = new bytes32[](1); + bytes[] memory state = new bytes[](2); + + commands[0] = WeirollPlanner.buildCommand( + weth.transfer.selector, + 0x01, // call + 0x0001ffffffff, // 1 input + 0xff, // no output + address(weth) + ); + + state[0] = abi.encode(s_account1); + state[1] = abi.encode(value); + + vm.startPrank(s_owner); + + // fund wallet with WETH + weth.deposit{ value: value }(); + weth.transfer(address(s_wallet), value); + + s_wallet.executeShortcut(ACCOUNT_ID, REQUEST_ID, commands, state); + + // it should transfer WETH to account1 + assertEq(weth.balanceOf(s_account1), value); + } + + function test_revert_executeShortcut() external { + // action: + // wrap 1 ETH to WETH + // but try to wrap more than available + + uint256 value = 1 ether; + + bytes32[] memory commands = new bytes32[](1); + bytes[] memory state = new bytes[](1); + + commands[0] = WeirollPlanner.buildCommand( + weth.deposit.selector, + 0x03, // call with value + 0x00ffffffffff, // 1 input + 0xff, // no output + address(weth) + ); + + // should revert because not enough ETH + state[0] = abi.encode(value + 1); + + vm.startPrank(s_owner); + + vm.expectRevert(); + s_wallet.executeShortcut{ value: value }(ACCOUNT_ID, REQUEST_ID, commands, state); + } + + function test_WhenWithValue() external { + // it should handle value transfers + uint256 value = 0.5 ether; + + vm.deal(address(s_wallet), value); + vm.startPrank(s_owner); + + bytes32[] memory commands = new bytes32[](0); + bytes[] memory state = new bytes[](0); + + uint256 balanceBefore = address(s_wallet).balance; + s_wallet.executeShortcut{ value: value }(ACCOUNT_ID, REQUEST_ID, commands, state); + uint256 balanceAfter = address(s_wallet).balance; + + // Value should be transferred to the wallet + assertEq(balanceAfter - balanceBefore, value); + } +} diff --git a/test/unit/concrete/wallet/ensowalletv2/initialize.t.sol b/test/unit/concrete/wallet/ensowalletv2/initialize.t.sol new file mode 100644 index 0000000..f6ed584 --- /dev/null +++ b/test/unit/concrete/wallet/ensowalletv2/initialize.t.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.20; + +import { EnsoWalletV2 } from "../../../../../src/wallet/EnsoWalletV2.sol"; +import { EnsoWalletV2_Unit_Concrete_Test } from "./EnsoWalletV2.t.sol"; +import { Initializable } from "openzeppelin-contracts/proxy/utils/Initializable.sol"; + +contract EnsoWalletV2_Initialize_Unit_Concrete_Test is EnsoWalletV2_Unit_Concrete_Test { + function setUp() public override { + super.setUp(); + + s_wallet = _deployWallet(s_owner); + } + + function test_RevertWhen_AlreadyInitialized() external { + // it should revert when trying to initialize again + vm.startPrank(address(s_walletFactory)); + vm.expectRevert(abi.encodeWithSelector(Initializable.InvalidInitialization.selector)); + s_wallet.initialize(s_owner); + } + + function test_WhenNotInitialized() external { + // it should initialize correctly + EnsoWalletV2 wallet = EnsoWalletV2(payable(address(s_walletImplementation))); + + vm.startPrank(address(s_walletFactory)); + wallet.initialize(s_owner); + + // it should set owner + assertEq(wallet.owner(), s_owner); + + // it should set factory + assertEq(wallet.factory(), address(s_walletFactory)); + } +} + diff --git a/test/unit/concrete/wallet/ensowalletv2/owner.t.sol b/test/unit/concrete/wallet/ensowalletv2/owner.t.sol new file mode 100644 index 0000000..4925e7d --- /dev/null +++ b/test/unit/concrete/wallet/ensowalletv2/owner.t.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.20; + +import { EnsoWalletV2 } from "../../../../../src/wallet/EnsoWalletV2.sol"; +import { EnsoWalletV2_Unit_Concrete_Test } from "./EnsoWalletV2.t.sol"; + +contract EnsoWalletV2_Owner_Unit_Concrete_Test is EnsoWalletV2_Unit_Concrete_Test { + function test_WhenWalletInitialized() external { + // it should return correct owner + s_wallet = _deployWallet(s_owner); + assertEq(s_wallet.owner(), s_owner); + } + + function test_WhenCalledByOwner() external { + // it should allow owner-only functions + s_wallet = _deployWallet(s_owner); + + vm.startPrank(s_owner); + // This should not revert + s_wallet.execute(s_user, 0, ""); + } + + function test_RevertWhen_NotOwner() external { + // it should revert when not called by owner + s_wallet = _deployWallet(s_owner); + + vm.startPrank(s_user); + vm.expectRevert(abi.encodeWithSelector(EnsoWalletV2.InvalidSender.selector, s_user)); + s_wallet.execute(s_user, 0, ""); + } +} \ No newline at end of file diff --git a/test/unit/concrete/wallet/ensowalletv2/version.t.sol b/test/unit/concrete/wallet/ensowalletv2/version.t.sol new file mode 100644 index 0000000..87b9773 --- /dev/null +++ b/test/unit/concrete/wallet/ensowalletv2/version.t.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.20; + +import { EnsoWalletV2 } from "../../../../../src/wallet/EnsoWalletV2.sol"; +import { EnsoWalletV2_Unit_Concrete_Test } from "./EnsoWalletV2.t.sol"; + +contract EnsoWalletV2_Version_Unit_Concrete_Test is EnsoWalletV2_Unit_Concrete_Test { + function test_WhenCalled() external { + s_wallet = _deployWallet(s_owner); + assertEq(keccak256(bytes(s_wallet.VERSION())) == keccak256(bytes("1.0.0")), true); + } +} diff --git a/test/unit/concrete/wallet/ensowalletv2factory/deploy.t.sol b/test/unit/concrete/wallet/ensowalletv2factory/deploy.t.sol new file mode 100644 index 0000000..58447ff --- /dev/null +++ b/test/unit/concrete/wallet/ensowalletv2factory/deploy.t.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.20; + +import { EnsoWalletV2 } from "../../../../../src/wallet/EnsoWalletV2.sol"; +import { EnsoWalletV2Factory } from "../../../../../src/wallet/EnsoWalletV2Factory.sol"; +import { EnsoWalletV2_Unit_Concrete_Test } from "../ensowalletv2/EnsoWalletV2.t.sol"; + +contract EnsoWalletV2Factory_Deploy_Unit_Concrete_Test is EnsoWalletV2_Unit_Concrete_Test { + function test_WhenCalled() external { + // it should deploy new wallet + vm.startPrank(s_user); + address walletAddress = s_walletFactory.deploy(s_account1); + + // it should be a contract + assertTrue(walletAddress.code.length > 0); + + // it should be correctly initialized + EnsoWalletV2 wallet = EnsoWalletV2(payable(walletAddress)); + assertEq(wallet.owner(), s_account1); + assertEq(wallet.factory(), address(s_walletFactory)); + } + + function test_WhenCalledMultipleTimes() external { + // it should return same address for same account + vm.startPrank(s_user); + address walletAddress1 = s_walletFactory.deploy(s_account1); + address walletAddress2 = s_walletFactory.deploy(s_account1); + + assertEq(walletAddress1, walletAddress2); + } + + function test_WhenDifferentAccounts() external { + // it should return different addresses for different accounts + vm.startPrank(s_user); + address walletAddress1 = s_walletFactory.deploy(s_account1); + address walletAddress2 = s_walletFactory.deploy(s_account2); + + assertTrue(walletAddress1 != walletAddress2); + } +} diff --git a/test/unit/concrete/wallet/ensowalletv2factory/deployAndExecute.t.sol b/test/unit/concrete/wallet/ensowalletv2factory/deployAndExecute.t.sol new file mode 100644 index 0000000..dc3cbb7 --- /dev/null +++ b/test/unit/concrete/wallet/ensowalletv2factory/deployAndExecute.t.sol @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.20; + +import { Token, TokenType } from "../../../../../src/interfaces/IEnsoRouter.sol"; +import { EnsoWalletV2 } from "../../../../../src/wallet/EnsoWalletV2.sol"; +import { EnsoWalletV2Factory } from "../../../../../src/wallet/EnsoWalletV2Factory.sol"; +import { WeirollPlanner } from "../../../../utils/WeirollPlanner.sol"; +import { WETH } from "solady/tokens/WETH.sol"; + +import { EnsoWalletV2_Unit_Concrete_Test } from "../ensowalletv2/EnsoWalletV2.t.sol"; + +contract Target { + function func() external payable returns (uint256) { + return 42; + } + + function revertFunc() external pure { + revert("Test revert"); + } +} + +contract EnsoWalletV2Factory_DeployAndExecute_Unit_Concrete_Test is EnsoWalletV2_Unit_Concrete_Test { + WETH weth; + + function setUp() public override { + super.setUp(); + + weth = new WETH(); + } + + function test_executeShortcutWithNative() external { + // action: + // wrap 1 ETH to WETH + // transfer 1 WETH to s_account1 + + // it should deploy wallet and execute with native token + Token memory tokenIn = Token({ tokenType: TokenType.Native, data: "" }); + uint256 value = 1 ether; + + bytes32[] memory commands = new bytes32[](2); + bytes[] memory state = new bytes[](2); + + commands[0] = WeirollPlanner.buildCommand( + weth.deposit.selector, + 0x03, // call with value + 0x00ffffffffff, // 1 input + 0xff, // no output + address(weth) + ); + + commands[1] = WeirollPlanner.buildCommand( + weth.transfer.selector, + 0x01, // call + 0x0100ffffffff, // 1 input + 0xff, // no output + address(weth) + ); + + state[0] = abi.encode(1 ether); + state[1] = abi.encode(s_account1); + + bytes memory executeData = _buildExecuteShortcutsCalldata(commands, state); + + vm.startPrank(s_user); + (address walletAddress,) = s_walletFactory.deployAndExecute{ value: value }(tokenIn, executeData); + + // it should deploy wallet + assertTrue(walletAddress.code.length > 0); + + // it should initialize wallet correctly + EnsoWalletV2 wallet = EnsoWalletV2(payable(walletAddress)); + assertEq(wallet.owner(), s_user); + assertEq(wallet.factory(), address(s_walletFactory)); + + // it should transfer value to wallet + assertEq(weth.balanceOf(s_account1), value); + } + + function test_executeShortcutWithERC20() external { + // action: + // transfer 1 WETH to s_account1 + + uint256 value = 1 ether; + Token memory tokenIn = Token({ tokenType: TokenType.ERC20, data: abi.encode(weth, value) }); + + bytes32[] memory commands = new bytes32[](1); + bytes[] memory state = new bytes[](2); + + commands[0] = WeirollPlanner.buildCommand( + weth.transfer.selector, + 0x01, // call + 0x0100ffffffff, // 1 input + 0xff, // no output + address(weth) + ); + + state[0] = abi.encode(1 ether); + state[1] = abi.encode(s_account1); + + bytes memory executeData = _buildExecuteShortcutsCalldata(commands, state); + + vm.startPrank(s_user); + + // make sure user has enough WETH + weth.deposit{ value: value }(); + weth.approve(address(s_walletFactory), value); + + vm.startPrank(s_user); + (address walletAddress,) = s_walletFactory.deployAndExecute(tokenIn, executeData); + + // it should deploy wallet + assertTrue(walletAddress.code.length > 0); + + // it should initialize wallet correctly + EnsoWalletV2 wallet = EnsoWalletV2(payable(walletAddress)); + assertEq(wallet.owner(), s_user); + assertEq(wallet.factory(), address(s_walletFactory)); + + // it should transfer value to wallet + assertEq(weth.balanceOf(s_account1), value); + } + + function test_revert_ERC20WithNativeValue() external { + uint256 value = 1 ether; + Token memory tokenIn = Token({ tokenType: TokenType.ERC20, data: abi.encode(weth, value) }); + + bytes32[] memory commands = new bytes32[](0); + bytes[] memory state = new bytes[](0); + + bytes memory executeData = _buildExecuteShortcutsCalldata(commands, state); + + vm.startPrank(s_user); + weth.deposit{ value: value }(); + weth.approve(address(s_walletFactory), value); + + vm.expectRevert(abi.encodeWithSelector(EnsoWalletV2Factory.WrongMsgValue.selector, value, 0)); + s_walletFactory.deployAndExecute{ value: value }(tokenIn, executeData); + } + + function test_revert_executeShortcut() external { + Token memory tokenIn = Token({ tokenType: TokenType.Native, data: "" }); + uint256 value = 1 ether; + + bytes32[] memory commands = new bytes32[](1); + bytes[] memory state = new bytes[](1); + + commands[0] = WeirollPlanner.buildCommand( + weth.deposit.selector, + 0x03, // call with value + 0x00ffffffffff, // 1 input + 0xff, // no output + address(weth) + ); + + // should revert because not enough ETH + state[0] = abi.encode(1 ether + 1); + + bytes memory executeData = _buildExecuteShortcutsCalldata(commands, state); + + vm.startPrank(s_user); + vm.expectRevert(); + s_walletFactory.deployAndExecute{ value: value }(tokenIn, executeData); + } + + function _buildExecuteShortcutsCalldata( + bytes32[] memory commands, + bytes[] memory state + ) + private + view + returns (bytes memory) + { + return abi.encodeWithSelector(s_wallet.executeShortcut.selector, bytes32(0), bytes32(0), commands, state); + } + + // function test_WhenCalledWithERC20() external { + // // it should deploy wallet and execute with ERC20 token + // uint256 tokenAmount = 100 * 1e18; + // s_erc20.mint(s_user, tokenAmount); + // + // Token memory tokenIn = Token({ tokenType: TokenType.ERC20, data: abi.encode(s_erc20, tokenAmount) }); + // + // bytes memory executeData = abi.encodeWithSelector(Target.func.selector); + // + // vm.startPrank(s_user); + // s_erc20.approve(address(s_walletFactory), tokenAmount); + // (address walletAddress, bool success) = s_walletFactory.deployAndExecute(tokenIn, executeData); + // + // // it should return success + // assertTrue(success); + // + // // it should transfer tokens to wallet + // assertEq(s_erc20.balanceOf(walletAddress), tokenAmount); + // assertEq(s_erc20.balanceOf(s_user), 0); + // } + // + // function test_WhenCalledWithERC721() external { + // // it should deploy wallet and execute with ERC721 token + // uint256 tokenId = 123; + // s_erc721.mint(s_user, tokenId); + // + // Token memory tokenIn = Token({ tokenType: TokenType.ERC721, data: abi.encode(s_erc721, tokenId) }); + // + // bytes memory executeData = abi.encodeWithSelector(Target.func.selector); + // + // vm.startPrank(s_user); + // s_erc721.approve(address(s_walletFactory), tokenId); + // (address walletAddress, bool success) = s_walletFactory.deployAndExecute(tokenIn, executeData); + // + // // it should return success + // assertTrue(success); + // + // // it should transfer NFT to wallet + // assertEq(s_erc721.ownerOf(tokenId), walletAddress); + // } + // + // function test_WhenCalledWithERC1155() external { + // // it should deploy wallet and execute with ERC1155 token + // uint256 tokenId = 456; + // uint256 tokenAmount = 50; + // s_erc1155.mint(s_user, tokenId, tokenAmount); + // + // Token memory tokenIn = + // Token({ tokenType: TokenType.ERC1155, data: abi.encode(s_erc1155, tokenId, tokenAmount) }); + // + // bytes memory executeData = abi.encodeWithSelector(Target.func.selector); + // + // vm.startPrank(s_user); + // s_erc1155.setApprovalForAll(address(s_walletFactory), true); + // (address walletAddress, bool success) = s_walletFactory.deployAndExecute(tokenIn, executeData); + // + // // it should return success + // assertTrue(success); + // + // // it should transfer tokens to wallet + // assertEq(s_erc1155.balanceOf(walletAddress, tokenId), tokenAmount); + // assertEq(s_erc1155.balanceOf(s_user, tokenId), 0); + // } + // + // function test_ExecuteReverts() external { + // // it should revert when execute data reverts + // Token memory tokenIn = Token({ tokenType: TokenType.Native, data: "" }); + // + // bytes memory executeData = abi.encodeWithSelector(Target.revert.selector); + // + // vm.startPrank(s_user); + // (, bool success) = s_walletFactory.deployAndExecute(tokenIn, executeData); + // assertFalse(success); + // } + // + // function test_RevertWhen_WrongMsgValue() external { + // // it should revert when msg.value is provided for non-native tokens + // uint256 tokenAmount = 100 * 1e18; + // s_erc20.mint(s_user, tokenAmount); + // + // Token memory tokenIn = Token({ tokenType: TokenType.ERC20, data: abi.encode(s_erc20, tokenAmount) }); + // + // bytes memory executeData = abi.encodeWithSelector(Target.func.selector); + // uint256 wrongValue = 0.1 ether; + // + // vm.startPrank(s_user); + // s_erc20.approve(address(s_walletFactory), tokenAmount); + // vm.expectRevert(abi.encodeWithSelector(EnsoWalletV2Factory.WrongMsgValue.selector, wrongValue, 0)); + // s_walletFactory.deployAndExecute{ value: wrongValue }(tokenIn, executeData); + // } +} diff --git a/test/unit/concrete/wallet/ensowalletv2factory/getAddress.t.sol b/test/unit/concrete/wallet/ensowalletv2factory/getAddress.t.sol new file mode 100644 index 0000000..7173b60 --- /dev/null +++ b/test/unit/concrete/wallet/ensowalletv2factory/getAddress.t.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.20; + +import { EnsoWalletV2Factory } from "../../../../../src/wallet/EnsoWalletV2Factory.sol"; +import { EnsoWalletV2_Unit_Concrete_Test } from "../ensowalletv2/EnsoWalletV2.t.sol"; +import { Vm } from "forge-std/Test.sol"; + +contract EnsoWalletV2Factory_GetAddress_Unit_Concrete_Test is EnsoWalletV2_Unit_Concrete_Test { + function test_WhenCalled() external { + // it should return predicted address + address predictedAddress = s_walletFactory.getAddress(s_account1); + + // it should be correct (not deployed yet, but predictable) + assertTrue(predictedAddress != address(0)); + assertTrue(predictedAddress.code.length == 0); + + // Deploy to verify + vm.startPrank(s_user); + address actualAddress = s_walletFactory.deploy(s_account1); + + assertEq(predictedAddress, actualAddress); + } + + function test_WhenCalledMultipleTimes() external { + // it should return same address for same account + address predictedAddress1 = s_walletFactory.getAddress(s_account1); + address predictedAddress2 = s_walletFactory.getAddress(s_account1); + + assertEq(predictedAddress1, predictedAddress2); + } + + function test_WhenDifferentAccounts() external { + // it should return different addresses for different accounts + address predictedAddress1 = s_walletFactory.getAddress(s_account1); + address predictedAddress2 = s_walletFactory.getAddress(s_account2); + + assertTrue(predictedAddress1 != predictedAddress2); + } + + function test_WhenDeployedTwice() external { + // it should emit event only on first deployment + address predictedAddress = s_walletFactory.getAddress(s_account1); + + vm.startPrank(s_user); + + // First deployment - should emit event + vm.expectEmit(true, true, true, true, address(s_walletFactory)); + emit EnsoWalletV2Factory.EnsoWalletV2Deployed(predictedAddress, s_account1); + address walletAddress1 = s_walletFactory.deploy(s_account1); + + // Second deployment - should not emit event (same address) + vm.recordLogs(); + address walletAddress2 = s_walletFactory.deploy(s_account1); + + // Verify same address returned + assertEq(walletAddress1, walletAddress2); + assertEq(walletAddress1, predictedAddress); + + // Verify no events were emitted on second deployment + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(logs.length, 0); + } +} +