diff --git a/contracts/interfaces/modules/external/ITokenWithdrawalModule.sol b/contracts/interfaces/modules/external/ITokenWithdrawalModule.sol new file mode 100644 index 000000000..052f9f655 --- /dev/null +++ b/contracts/interfaces/modules/external/ITokenWithdrawalModule.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import { IModule } from "../base/IModule.sol"; + +/// @title Token Management Module +/// @notice Module for transferring ERC20, ERC721, and ERC1155 tokens for IP Accounts. +/// @dev SECURITY RISK: An IPAccount can delegate to a frontend contract (not a registered module) to transfer tokens +/// on behalf of the IPAccount via the Token Management Module. This frontend contract can transfer any tokens that are +/// approved by the IPAccount for the Token Management Module. In other words, there's no mechanism for this module to +/// granularly control which token a caller (approved contract in this case) can transfer. +interface ITokenWithdrawalModule is IModule { + /// @notice Withdraws ERC20 token from the IP account to the IP account owner. + /// @dev When calling this function, the caller must have the permission to call `transfer` via the IP account. + /// @dev Does not support transfer of multiple tokens at once. + /// @param ipAccount The IP account to transfer the ERC20 token from + /// @param tokenContract The address of the ERC20 token contract + /// @param amount The amount of token to transfer + function withdrawERC20(address payable ipAccount, address tokenContract, uint256 amount) external; + + /// @notice Withdraws ERC721 token from the IP account to the IP account owner. + /// @dev When calling this function, the caller must have the permission to call `transferFrom` via the IP account. + /// @dev Does not support batch transfers. + /// @param ipAccount The IP account to transfer the ERC721 token from + /// @param tokenContract The address of the ERC721 token contract + /// @param tokenId The ID of the token to transfer + function withdrawERC721(address payable ipAccount, address tokenContract, uint256 tokenId) external; + + /// @notice Withdraws ERC1155 token from the IP account to the IP account owner. + /// @dev When calling this function, the caller must have the permission to call `safeTransferFrom` via the IP + /// account. + /// @dev Does not support batch transfers. + /// @param ipAccount The IP account to transfer the ERC1155 token from + /// @param tokenContract The address of the ERC1155 token contract + /// @param tokenId The ID of the token to transfer + /// @param amount The amount of token to transfer + function withdrawERC1155( + address payable ipAccount, + address tokenContract, + uint256 tokenId, + uint256 amount + ) external; +} diff --git a/contracts/lib/modules/Module.sol b/contracts/lib/modules/Module.sol index 2ec26936c..9f6030c9a 100644 --- a/contracts/lib/modules/Module.sol +++ b/contracts/lib/modules/Module.sol @@ -20,3 +20,5 @@ string constant LICENSING_MODULE_KEY = "LICENSING_MODULE"; string constant DISPUTE_MODULE_KEY = "DISPUTE_MODULE"; string constant ROYALTY_MODULE_KEY = "ROYALTY_MODULE"; + +string constant TOKEN_WITHDRAWAL_MODULE_KEY = "TOKEN_MANAGEMENT_MODULE"; diff --git a/contracts/modules/external/TokenWithdrawalModule.sol b/contracts/modules/external/TokenWithdrawalModule.sol new file mode 100644 index 000000000..b399a7651 --- /dev/null +++ b/contracts/modules/external/TokenWithdrawalModule.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import { ERC165Checker } from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol"; + +import { IIPAccount } from "../../interfaces/IIPAccount.sol"; +import { IIPAccountRegistry } from "../../interfaces/registries/IIPAccountRegistry.sol"; +import { ITokenWithdrawalModule } from "../../interfaces/modules/external/ITokenWithdrawalModule.sol"; +import { IPAccountChecker } from "../../lib/registries/IPAccountChecker.sol"; +import { TOKEN_WITHDRAWAL_MODULE_KEY } from "../../lib/modules/Module.sol"; +import { BaseModule } from "../BaseModule.sol"; +import { AccessControlled } from "../../access/AccessControlled.sol"; + +/// @title Token Management Module +/// @notice Module for transferring ERC20, ERC721, and ERC1155 tokens for IP Accounts. +/// @dev SECURITY RISK: An IPAccount can delegate to a frontend contract (not a registered module) to transfer tokens +/// on behalf of the IPAccount via the Token Management Module. This frontend contract can transfer any tokens that are +/// approved by the IPAccount for the Token Management Module. In other words, there's no mechanism for this module to +/// granularly control which token a caller (approved contract in this case) can transfer. +contract TokenWithdrawalModule is AccessControlled, BaseModule, ITokenWithdrawalModule { + using ERC165Checker for address; + using IPAccountChecker for IIPAccountRegistry; + + string public constant override name = TOKEN_WITHDRAWAL_MODULE_KEY; + + constructor( + address accessController, + address ipAccountRegistry + ) AccessControlled(accessController, ipAccountRegistry) {} + + /// @notice Withdraws ERC20 token from the IP account to the IP account owner. + /// @dev When calling this function, the caller must have the permission to call `transfer` via the IP account. + /// @dev Does not support transfer of multiple tokens at once. + /// @param ipAccount The IP account to transfer the ERC20 token from + /// @param tokenContract The address of the ERC20 token contract + /// @param amount The amount of token to transfer + function withdrawERC20( + address payable ipAccount, + address tokenContract, + uint256 amount + ) external verifyPermission(ipAccount) { + IIPAccount(ipAccount).execute( + tokenContract, + 0, + abi.encodeWithSignature("transfer(address,uint256)", IIPAccount(ipAccount).owner(), amount) + ); + } + + /// @notice Withdraws ERC721 token from the IP account to the IP account owner. + /// @dev When calling this function, the caller must have the permission to call `transferFrom` via the IP account. + /// @dev Does not support batch transfers. + /// @param ipAccount The IP account to transfer the ERC721 token from + /// @param tokenContract The address of the ERC721 token contract + /// @param tokenId The ID of the token to transfer + function withdrawERC721( + address payable ipAccount, + address tokenContract, + uint256 tokenId + ) external verifyPermission(ipAccount) { + IIPAccount(ipAccount).execute( + tokenContract, + 0, + abi.encodeWithSignature( + "transferFrom(address,address,uint256)", + ipAccount, + IIPAccount(ipAccount).owner(), + tokenId + ) + ); + } + + /// @notice Withdraws ERC1155 token from the IP account to the IP account owner. + /// @dev When calling this function, the caller must have the permission to call `safeTransferFrom` via the IP + /// account. + /// @dev Does not support batch transfers. + /// @param ipAccount The IP account to transfer the ERC1155 token from + /// @param tokenContract The address of the ERC1155 token contract + /// @param tokenId The ID of the token to transfer + /// @param amount The amount of token to transfer + function withdrawERC1155( + address payable ipAccount, + address tokenContract, + uint256 tokenId, + uint256 amount + ) external verifyPermission(ipAccount) { + IIPAccount(ipAccount).execute( + tokenContract, + 0, + abi.encodeWithSignature( + "safeTransferFrom(address,address,uint256,uint256,bytes)", + ipAccount, + IIPAccount(ipAccount).owner(), + tokenId, + amount, + "" + ) + ); + } +} diff --git a/test/foundry/AccessController.t.sol b/test/foundry/AccessController.t.sol index 644df72df..7ba3104cc 100644 --- a/test/foundry/AccessController.t.sol +++ b/test/foundry/AccessController.t.sol @@ -4,10 +4,11 @@ pragma solidity ^0.8.23; import { IIPAccount } from "../../contracts/interfaces/IIPAccount.sol"; import { AccessPermission } from "../../contracts/lib/AccessPermission.sol"; import { Errors } from "../../contracts/lib/Errors.sol"; +import { TOKEN_WITHDRAWAL_MODULE_KEY } from "../../contracts/lib/modules/Module.sol"; +import { TokenWithdrawalModule } from "../../contracts/modules/external/TokenWithdrawalModule.sol"; import { MockModule } from "./mocks/module/MockModule.sol"; import { MockOrchestratorModule } from "./mocks/module/MockOrchestratorModule.sol"; -import { MockTokenManagementModule } from "./mocks/module/MockTokenManagementModule.sol"; import { MockERC1155 } from "./mocks/token/MockERC1155.sol"; import { MockERC20 } from "./mocks/token/MockERC20.sol"; import { BaseTest } from "./utils/BaseTest.t.sol"; @@ -1526,12 +1527,11 @@ contract AccessControllerTest is BaseTest { address anotherAccount = vm.addr(3); - MockTokenManagementModule tokenManagementModule = new MockTokenManagementModule( + TokenWithdrawalModule tokenWithdrawalModule = new TokenWithdrawalModule( address(accessController), - address(ipAccountRegistry), - address(moduleRegistry) + address(ipAccountRegistry) ); - moduleRegistry.registerModule("MockTokenManagementModule", address(tokenManagementModule)); + moduleRegistry.registerModule(TOKEN_WITHDRAWAL_MODULE_KEY, address(tokenWithdrawalModule)); vm.prank(owner); ipAccount.execute( address(accessController), @@ -1539,7 +1539,7 @@ contract AccessControllerTest is BaseTest { abi.encodeWithSignature( "setPermission(address,address,address,bytes4,uint8)", address(ipAccount), - address(tokenManagementModule), + address(tokenWithdrawalModule), address(mockNFT), mockNFT.transferFrom.selector, AccessPermission.ALLOW @@ -1548,20 +1548,15 @@ contract AccessControllerTest is BaseTest { assertEq( accessController.getPermission( address(ipAccount), - address(tokenManagementModule), + address(tokenWithdrawalModule), address(mockNFT), mockNFT.transferFrom.selector ), AccessPermission.ALLOW ); vm.prank(owner); - tokenManagementModule.transferERC721Token( - payable(address(ipAccount)), - anotherAccount, - address(mockNFT), - tokenId - ); - assertEq(mockNFT.ownerOf(tokenId), anotherAccount); + tokenWithdrawalModule.withdrawERC721(payable(address(ipAccount)), address(mockNFT), tokenId); + assertEq(mockNFT.ownerOf(tokenId), owner); } // ipAccount transfer ERC1155 to another account @@ -1572,12 +1567,11 @@ contract AccessControllerTest is BaseTest { address anotherAccount = vm.addr(3); - MockTokenManagementModule tokenManagementModule = new MockTokenManagementModule( + TokenWithdrawalModule tokenWithdrawalModule = new TokenWithdrawalModule( address(accessController), - address(ipAccountRegistry), - address(moduleRegistry) + address(ipAccountRegistry) ); - moduleRegistry.registerModule("MockTokenManagementModule", address(tokenManagementModule)); + moduleRegistry.registerModule(TOKEN_WITHDRAWAL_MODULE_KEY, address(tokenWithdrawalModule)); vm.prank(owner); ipAccount.execute( address(accessController), @@ -1585,7 +1579,7 @@ contract AccessControllerTest is BaseTest { abi.encodeWithSignature( "setPermission(address,address,address,bytes4,uint8)", address(ipAccount), - address(tokenManagementModule), + address(tokenWithdrawalModule), address(mock1155), mock1155.safeTransferFrom.selector, AccessPermission.ALLOW @@ -1594,21 +1588,15 @@ contract AccessControllerTest is BaseTest { assertEq( accessController.getPermission( address(ipAccount), - address(tokenManagementModule), + address(tokenWithdrawalModule), address(mock1155), mock1155.safeTransferFrom.selector ), AccessPermission.ALLOW ); vm.prank(owner); - tokenManagementModule.transferERC1155Token( - payable(address(ipAccount)), - anotherAccount, - address(mock1155), - tokenId, - 1e18 - ); - assertEq(mock1155.balanceOf(anotherAccount, tokenId), 1e18); + tokenWithdrawalModule.withdrawERC1155(payable(address(ipAccount)), address(mock1155), tokenId, 1e18); + assertEq(mock1155.balanceOf(owner, tokenId), 1e18); } // ipAccount transfer ERC20 to another account function test_AccessController_ERC20Transfer() public { @@ -1617,12 +1605,11 @@ contract AccessControllerTest is BaseTest { address anotherAccount = vm.addr(3); - MockTokenManagementModule tokenManagementModule = new MockTokenManagementModule( + TokenWithdrawalModule tokenWithdrawalModule = new TokenWithdrawalModule( address(accessController), - address(ipAccountRegistry), - address(moduleRegistry) + address(ipAccountRegistry) ); - moduleRegistry.registerModule("MockTokenManagementModule", address(tokenManagementModule)); + moduleRegistry.registerModule(TOKEN_WITHDRAWAL_MODULE_KEY, address(tokenWithdrawalModule)); vm.prank(owner); ipAccount.execute( address(accessController), @@ -1630,7 +1617,7 @@ contract AccessControllerTest is BaseTest { abi.encodeWithSignature( "setPermission(address,address,address,bytes4,uint8)", address(ipAccount), - address(tokenManagementModule), + address(tokenWithdrawalModule), address(mock20), mock20.transfer.selector, AccessPermission.ALLOW @@ -1639,14 +1626,14 @@ contract AccessControllerTest is BaseTest { assertEq( accessController.getPermission( address(ipAccount), - address(tokenManagementModule), + address(tokenWithdrawalModule), address(mock20), mock20.transfer.selector ), AccessPermission.ALLOW ); vm.prank(owner); - tokenManagementModule.transferERC20Token(payable(address(ipAccount)), anotherAccount, address(mock20), 1e18); - assertEq(mock20.balanceOf(anotherAccount), 1e18); + tokenWithdrawalModule.withdrawERC20(payable(address(ipAccount)), address(mock20), 1e18); + assertEq(mock20.balanceOf(owner), 1e18); } } diff --git a/test/foundry/mocks/module/MockTokenManagementModule.sol b/test/foundry/mocks/module/MockTokenManagementModule.sol deleted file mode 100644 index f0c0a91ba..000000000 --- a/test/foundry/mocks/module/MockTokenManagementModule.sol +++ /dev/null @@ -1,84 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.23; - -import { ERC165Checker } from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol"; - -import { IIPAccount } from "../../../../contracts/interfaces/IIPAccount.sol"; -import { IModule } from "../../../../contracts/interfaces/modules/base/IModule.sol"; -import { IModuleRegistry } from "../../../../contracts/interfaces/registries/IModuleRegistry.sol"; -import { IIPAccountRegistry } from "../../../../contracts/interfaces/registries/IIPAccountRegistry.sol"; -import { IPAccountChecker } from "../../../../contracts/lib/registries/IPAccountChecker.sol"; -import { BaseModule } from "../../../../contracts/modules/BaseModule.sol"; -import { AccessControlled } from "../../../../contracts/access/AccessControlled.sol"; - -contract MockTokenManagementModule is BaseModule, AccessControlled { - using ERC165Checker for address; - using IPAccountChecker for IIPAccountRegistry; - - IModuleRegistry public moduleRegistry; - - constructor( - address _accessController, - address _ipAccountRegistry, - address _moduleRegistry - ) AccessControlled(_accessController, _ipAccountRegistry) { - moduleRegistry = IModuleRegistry(_moduleRegistry); - } - - function name() external pure returns (string memory) { - return "MockTokenManagementModule"; - } - - function transferERC721Token( - address payable ipAccount, - address to, - address tokenContract, - uint256 tokenId - ) external verifyPermission(ipAccount) { - IIPAccount(ipAccount).execute( - tokenContract, - 0, - abi.encodeWithSignature("transferFrom(address,address,uint256)", ipAccount, to, tokenId) - ); - } - - // transfer ERC1155 token - function transferERC1155Token( - address payable ipAccount, - address to, - address tokenContract, - uint256 tokenId, - uint256 amount - ) external verifyPermission(ipAccount) { - IIPAccount(ipAccount).execute( - tokenContract, - 0, - abi.encodeWithSignature( - "safeTransferFrom(address,address,uint256,uint256,bytes)", - ipAccount, - to, - tokenId, - amount, - "" - ) - ); - } - - // transfer ERC20 token - function transferERC20Token( - address payable ipAccount, - address to, - address tokenContract, - uint256 amount - ) external verifyPermission(ipAccount) { - IIPAccount(ipAccount).execute( - tokenContract, - 0, - abi.encodeWithSignature("transfer(address,uint256)", to, amount) - ); - } - - function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { - return interfaceId == type(IModule).interfaceId || super.supportsInterface(interfaceId); - } -} diff --git a/test/foundry/modules/external/TokenWithdrawalModule.t.sol b/test/foundry/modules/external/TokenWithdrawalModule.t.sol new file mode 100644 index 000000000..9f3830882 --- /dev/null +++ b/test/foundry/modules/external/TokenWithdrawalModule.t.sol @@ -0,0 +1,357 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.23; + +// external +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; + +// contracts +import { IIPAccount } from "../../../../contracts/interfaces/IIPAccount.sol"; +import { AccessPermission } from "../../../../contracts/lib/AccessPermission.sol"; +import { TokenWithdrawalModule } from "../../../../contracts/modules/external/TokenWithdrawalModule.sol"; +import { Errors } from "../../../../contracts/lib/Errors.sol"; +import { TOKEN_WITHDRAWAL_MODULE_KEY } from "../../../../contracts/lib/modules/Module.sol"; + +// test +import { MockERC20 } from "../../mocks/token/MockERC20.sol"; +import { MockERC721 } from "../../mocks/token/MockERC721.sol"; +import { MockERC1155 } from "../../mocks/token/MockERC1155.sol"; +import { BaseTest } from "../../utils/BaseTest.t.sol"; + +contract TokenWithdrawalModuleTest is BaseTest { + using Strings for *; + + MockERC20 private tErc20 = new MockERC20(); + MockERC721 private tErc721 = new MockERC721("MockERC721"); + MockERC1155 private tErc1155 = new MockERC1155("uri"); + + TokenWithdrawalModule private tokenWithdrawalModule; + + IIPAccount private ipAcct1; + IIPAccount private ipAcct2; + + uint256 mintAmount20 = 100 * 10 ** tErc20.decimals(); + + address randomFrontend = address(0x123); + + function setUp() public override { + super.setUp(); + buildDeployAccessCondition(DeployAccessCondition({ accessController: true, governance: true })); + buildDeployRegistryCondition(DeployRegistryCondition({ licenseRegistry: false, moduleRegistry: true })); + deployConditionally(); + postDeploymentSetup(); + + // Create IPAccounts (Alice is the owner) + mockNFT.mintId(alice, 1); + mockNFT.mintId(alice, 2); + + ipAcct1 = IIPAccount(payable(ipAccountRegistry.registerIpAccount(block.chainid, address(mockNFT), 1))); + ipAcct2 = IIPAccount(payable(ipAccountRegistry.registerIpAccount(block.chainid, address(mockNFT), 2))); + + vm.label(address(ipAcct1), "IPAccount1"); + vm.label(address(ipAcct2), "IPAccount2"); + + tokenWithdrawalModule = new TokenWithdrawalModule(address(accessController), address(ipAccountRegistry)); + + vm.prank(u.admin); + moduleRegistry.registerModule(TOKEN_WITHDRAWAL_MODULE_KEY, address(tokenWithdrawalModule)); + } + + modifier testERC20_mintToIpAcct1() { + _expectBalanceERC20(address(ipAcct1), 0); + tErc20.mint(address(ipAcct1), mintAmount20); + _expectBalanceERC20(address(ipAcct1), mintAmount20); + _expectBalanceERC20(address(ipAcct2), 0); + _; + } + + function test_TokenWithdrawalModule_withdrawERC20() public testERC20_mintToIpAcct1 { + _approveERC20(alice, ipAcct1, address(tokenWithdrawalModule)); + + vm.prank(alice); + tokenWithdrawalModule.withdrawERC20(payable(ipAcct1), address(tErc20), mintAmount20 / 2); + + _expectBalanceERC20(address(ipAcct1), mintAmount20 / 2); + _expectBalanceERC20(alice, mintAmount20 / 2); + + vm.prank(alice); + ipAcct1.execute( + address(tokenWithdrawalModule), + 0, + abi.encodeWithSelector( + tokenWithdrawalModule.withdrawERC20.selector, + payable(ipAcct1), + address(tErc20), + mintAmount20 / 2 + ) + ); + + _expectBalanceERC20(address(ipAcct1), 0); + _expectBalanceERC20(alice, mintAmount20); + } + + function test_TokenWithdrawalModule_withdrawERC20_delegatedCall() public testERC20_mintToIpAcct1 { + // signer: tokenWithdrawalModule + // to: tErc20 + // func: transfer + _approveERC20(alice, ipAcct1, address(tokenWithdrawalModule)); + + // signer: randomFrontend + // to: tokenWithdrawalModule + // func: withdrawERC20 + vm.prank(alice); + ipAcct1.execute( + address(accessController), + 0, + abi.encodeWithSignature( + "setPermission(address,address,address,bytes4,uint8)", + address(ipAcct1), + randomFrontend, + address(tokenWithdrawalModule), + tokenWithdrawalModule.withdrawERC20.selector, + AccessPermission.ALLOW + ) + ); + + vm.prank(randomFrontend); + tokenWithdrawalModule.withdrawERC20(payable(ipAcct1), address(tErc20), mintAmount20 / 2); + + _expectBalanceERC20(address(ipAcct1), mintAmount20 / 2); + _expectBalanceERC20(alice, mintAmount20 / 2); + + vm.prank(randomFrontend); + ipAcct1.execute( + address(tokenWithdrawalModule), + 0, + abi.encodeWithSelector( + tokenWithdrawalModule.withdrawERC20.selector, + payable(ipAcct1), + address(tErc20), + mintAmount20 / 2 + ) + ); + + _expectBalanceERC20(address(ipAcct1), 0); + _expectBalanceERC20(alice, mintAmount20); + } + + function test_TokenWithdrawalModule_withdrawERC20_revert_malicious_anotherERC20Transfer() + public + testERC20_mintToIpAcct1 + { + address maliciousFrontend = address(0x456); + + MockERC20 anotherErc20 = new MockERC20(); + + anotherErc20.mint(address(ipAcct1), 1000); + assertEq(anotherErc20.balanceOf(address(ipAcct1)), 1000, "ERC20 balance does not match"); + + // Approve TokenWithdrawalModule to transfer tErc20 from IPAccount1 + // signer: tokenWithdrawalModule + // to: tErc20 + // func: transfer + _approveERC20(alice, ipAcct1, address(tokenWithdrawalModule)); + + // Approve TokenWithdrawalModule to transfer anotherErc20 from IPAccount1 + // signer: tokenWithdrawalModule + // to: anotherErc20 + // func: transfer + vm.prank(address(ipAcct1)); + accessController.setPermission( + address(ipAcct1), + address(tokenWithdrawalModule), + address(anotherErc20), + anotherErc20.transfer.selector, + AccessPermission.ALLOW + ); + + // Approve a frontend contract to transfer token on behalf of a user (IPAccount1) + // signer: maliciousFrontend + // to: tokenWithdrawalModule + // func: withdrawERC20 + vm.prank(alice); + ipAcct1.execute( + address(accessController), + 0, + abi.encodeWithSignature( + "setPermission(address,address,address,bytes4,uint8)", + address(ipAcct1), + maliciousFrontend, + address(tokenWithdrawalModule), + tokenWithdrawalModule.withdrawERC20.selector, + AccessPermission.ALLOW + ) + ); + + // + // ======= Malicious behavior ======= + // Alice (owner of IPAccount1) only wants the frontend to transfer tErc20. However, because there's no + // restriction on delegation, the malicious frontend can transfer anotherErc20 as well. + // ================================== + // + + vm.startPrank(maliciousFrontend); + tokenWithdrawalModule.withdrawERC20(payable(ipAcct1), address(tErc20), 1000); + tokenWithdrawalModule.withdrawERC20(payable(ipAcct1), address(anotherErc20), 1000); + vm.stopPrank(); + + // another token is drained + assertEq(tErc20.balanceOf(alice), 1000, "ERC20 balance does not match"); + assertEq(anotherErc20.balanceOf(alice), 1000, "ERC20 balance does not match"); + } + + function test_TokenWithdrawalModule_withdrawERC20_revert_moduleCaller_invalidAccess() + public + testERC20_mintToIpAcct1 + { + _approveERC20(alice, ipAcct1, address(tokenWithdrawalModule)); + + _expectInvalidAccess( + address(ipAcct1), + address(this), + address(tokenWithdrawalModule), + tokenWithdrawalModule.withdrawERC20.selector + ); + tokenWithdrawalModule.withdrawERC20(payable(ipAcct1), address(tErc20), mintAmount20); + + _expectBalanceERC20(address(ipAcct1), mintAmount20); + } + + function test_TokenWithdrawalModule_withdrawERC721() public { + uint256 tokenId = tErc721.mint(address(ipAcct1)); + _expectOwnerERC721(address(ipAcct1), tokenId); + + _approveERC721(alice, ipAcct1, address(tokenWithdrawalModule)); + + vm.prank(alice); + tokenWithdrawalModule.withdrawERC721(payable(ipAcct1), address(tErc721), tokenId); + + _expectOwnerERC721(alice, tokenId); + } + + function test_TokenWithdrawalModule_withdrawERC721_revert_moduleCaller_invalidAccess() public { + uint256 tokenId = tErc721.mint(address(ipAcct1)); + _expectOwnerERC721(address(ipAcct1), tokenId); + + _approveERC721(alice, ipAcct1, address(tokenWithdrawalModule)); + + _expectInvalidAccess( + address(ipAcct1), + address(this), + address(tokenWithdrawalModule), + tokenWithdrawalModule.withdrawERC721.selector + ); + tokenWithdrawalModule.withdrawERC721(payable(ipAcct1), address(tErc721), tokenId); + + _expectOwnerERC721(address(ipAcct1), tokenId); + } + + function test_TokenWithdrawalModule_withdrawERC1155() public { + uint256 tokenId = 1; + uint256 mintAmount1155 = 100; + tErc1155.mintId(address(ipAcct1), tokenId, mintAmount1155); + _expectBalanceERC1155(address(ipAcct1), tokenId, mintAmount1155); + _expectBalanceERC1155(alice, tokenId, 0); + + _approveERC1155(alice, ipAcct1, address(tokenWithdrawalModule)); + + vm.prank(alice); + tokenWithdrawalModule.withdrawERC1155(payable(ipAcct1), address(tErc1155), tokenId, mintAmount1155); + + _expectBalanceERC1155(address(ipAcct1), tokenId, 0); + _expectBalanceERC1155(alice, tokenId, mintAmount1155); + } + + function test_TokenWithdrawalModule_withdrawERC1155_revert_moduleCaller_invalidAccess() public { + uint256 tokenId = 1; + uint256 mintAmount1155 = 100; + tErc1155.mintId(address(ipAcct1), tokenId, mintAmount1155); + _expectBalanceERC1155(address(ipAcct1), tokenId, mintAmount1155); + _expectBalanceERC1155(alice, tokenId, 0); + + _approveERC1155(alice, ipAcct1, address(tokenWithdrawalModule)); + + _expectInvalidAccess( + address(ipAcct1), + address(this), + address(tokenWithdrawalModule), + tokenWithdrawalModule.withdrawERC1155.selector + ); + tokenWithdrawalModule.withdrawERC1155(payable(ipAcct1), address(tErc1155), tokenId, mintAmount1155); + + _expectBalanceERC1155(address(ipAcct1), tokenId, mintAmount1155); + _expectBalanceERC1155(alice, tokenId, 0); + } + + // + // Helpers + // + + function _approveERC20(address owner, IIPAccount ipAccount, address signer) internal { + vm.prank(owner); + ipAccount.execute( + address(accessController), + 0, + abi.encodeWithSignature( + // ipAccount, signer, to, func, permission + "setPermission(address,address,address,bytes4,uint8)", + address(ipAccount), + signer, + address(tErc20), + tErc20.transfer.selector, + AccessPermission.ALLOW + ) + ); + } + + function _approveERC721(address owner, IIPAccount ipAccount, address signer) internal { + vm.prank(owner); + ipAccount.execute( + address(accessController), + 0, + abi.encodeWithSignature( + // ipAccount, signer, to, func, permission + "setPermission(address,address,address,bytes4,uint8)", + address(ipAccount), + signer, + address(tErc721), + tErc721.transferFrom.selector, + AccessPermission.ALLOW + ) + ); + } + + function _approveERC1155(address owner, IIPAccount ipAccount, address signer) internal { + vm.prank(owner); + ipAccount.execute( + address(accessController), + 0, + abi.encodeWithSignature( + // ipAccount, signer, to, func, permission + "setPermission(address,address,address,bytes4,uint8)", + address(ipAccount), + signer, + address(tErc1155), + tErc1155.safeTransferFrom.selector, + AccessPermission.ALLOW + ) + ); + } + + function _expectBalanceERC20(address account, uint256 expected) internal { + assertEq(tErc20.balanceOf(account), expected, "ERC20 balance does not match"); + } + + function _expectOwnerERC721(address account, uint256 tokenId) internal { + assertEq(tErc721.ownerOf(tokenId), account, "Owner does not match"); + } + + function _expectBalanceERC1155(address account, uint256 tokenId, uint256 expected) internal { + assertEq(tErc1155.balanceOf(account, tokenId), expected, "ERC1155 balance does not match"); + } + + function _expectInvalidAccess(address ipAccount, address signer, address to, bytes4 func) internal { + vm.expectRevert( + abi.encodeWithSelector(Errors.AccessController__PermissionDenied.selector, ipAccount, signer, to, func) + ); + } +}