diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1181480e2..7166555f9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,26 +4,9 @@ on: [pull_request] env: FOUNDRY_PROFILE: ci + MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v3 - with: - submodules: recursive - - uses: actions/setup-node@v3 - - uses: actions/cache@v3.0.3 - id: cache - with: - path: '**/node_modules' - key: npm-${{ hashFiles('**/yarn.lock') }} - restore-keys: npm- - - run: yarn - if: steps.cache.outputs.cache-hit != 'true' - - run: npm run lint foundry-test: strategy: @@ -31,61 +14,56 @@ jobs: name: Foundry Unit Test runs-on: ubuntu-latest - needs: lint steps: - uses: actions/checkout@v3 with: submodules: recursive + fetch-depth: 0 - name: List files in the repository run: | - ls ${{ github.workspace }} + ls -R ${{ github.workspace }} - - uses: chill-viking/npm-ci@latest - name: Install NPM Dependencies + - name: Test Env Variables + env: + MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} + run: | + echo "MAINNET_RPC_URL is ${{ secrets.MAINNET_RPC_URL }}" + echo "env.MAINNET_RPC_URL is $MAINNET_RPC_URL" + echo "env.FOUNDRY_PROFILE is $FOUNDRY_PROFILE" + echo "DONE." + + - name: Run install + uses: borales/actions-yarn@v4 with: - working_directory: ${{ github.workspace }} + cmd: install # will run `yarn install` command - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 with: version: nightly + - name: List files in the repository + run: | + ls -R ${{ github.workspace }} + - name: Run Forge build run: | forge --version - forge build --sizes + forge build --force --sizes id: build - name: Run Forge tests run: | - forge test -vvv + make test id: forge-test - - name: Gas Difference - run: - forge snapshot --gas-report --diff --desc - id: forge-gas-snapshot-diff + # - name: Gas Difference + # run: + # forge snapshot --gas-report --diff --desc + # id: forge-gas-snapshot-diff - - name: Code Coverage - run: - forge coverage --report lcov --report summary - id: forge-code-coverage - - hardhat-test: - strategy: - fail-fast: true - - name: Hardhat Unit Test - runs-on: ubuntu-latest - needs: lint - steps: - - uses: actions/checkout@v3 - with: - submodules: recursive - #- name: Environment - # uses: actions/setup-node@v3 - - name: Test - uses: ambersun1234/hardhat-test-action@v0.0.1 - with: - network: hardhat \ No newline at end of file + # - name: Code Coverage + # run: + # forge coverage --report lcov --report summary + # id: forge-code-coverage diff --git a/.prettierrc b/.prettierrc index 44d4a4ec0..5b006662f 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,19 +1,9 @@ { + "useTabs": false, + "printWidth": 120, "trailingComma": "es5", "tabWidth": 4, "semi": false, "singleQuote": false, - "useTabs": false, - "overrides": [ - { - "files": "*.sol", - "options": { - "printWidth": 150, - "tabWidth": 4, - "useTabs": false, - "singleQuote": false, - "bracketSpacing": true - } - } - ] -} \ No newline at end of file + "bracketSpacing": true +} diff --git a/.solhint.json b/.solhint.json index d90612029..44134d5aa 100644 --- a/.solhint.json +++ b/.solhint.json @@ -3,19 +3,20 @@ "plugins": ["prettier"], "rules": { "code-complexity": ["error", 8], - "compiler-version": ["error", ">=0.8.0"], + "compiler-version": ["error", ">=0.8.19"], "const-name-snakecase": "off", + "no-empty-blocks": "off", "constructor-syntax": "error", "func-visibility": ["error", { "ignoreConstructors": true }], - "max-line-length": ["error", 150], + "modifier-name-mixedcase": "error", + "max-line-length": ["error", 120], "not-rely-on-time": "off", - "prettier/prettier": [ - "error", - { - "endOfLine": "auto" - } - ], - "reason-string": ["error", { "maxLength": 64 }], - "private-vars-leading-underscore": ["error", { "strict": false }] + "reason-string": ["warn", { "maxLength": 64 }], + "no-unused-import": "error", + "no-unused-vars": "error", + "no-inline-assembly": "off", + "avoid-low-level-calls": "off", + "no-global-import": "error", + "prettier/prettier": "error" } } diff --git a/contracts/Counter.sol b/contracts/Counter.sol deleted file mode 100644 index aded7997b..000000000 --- a/contracts/Counter.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = newNumber; - } - - function increment() public { - number++; - } -} diff --git a/contracts/IPAccountImpl.sol b/contracts/IPAccountImpl.sol new file mode 100644 index 000000000..c2b87894f --- /dev/null +++ b/contracts/IPAccountImpl.sol @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.21; + +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import { IERC721Receiver } from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import { IERC1155Receiver } from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; +import { IAccessController } from "contracts/interfaces/IAccessController.sol"; +import { IERC6551Account } from "contracts/interfaces/erc6551/IERC6551Account.sol"; +import { IIPAccount } from "contracts/interfaces/IIPAccount.sol"; + +/// @title IPAccountImpl +/// @notice The Story Protocol's implementation of the IPAccount. +contract IPAccountImpl is IERC165, IIPAccount { + address public accessController; + + uint256 public state; + + receive() external payable override(IERC6551Account) {} + + /// @notice Checks if the contract supports a specific interface + /// @param interfaceId_ The interface identifier, as specified in ERC-165 + /// @return True if the contract supports the interface, false otherwise + function supportsInterface(bytes4 interfaceId_) external pure returns (bool) { + return (interfaceId_ == type(IIPAccount).interfaceId || + interfaceId_ == type(IERC6551Account).interfaceId || + interfaceId_ == type(IERC1155Receiver).interfaceId || + interfaceId_ == type(IERC721Receiver).interfaceId || + interfaceId_ == type(IERC165).interfaceId); + } + + /// @notice Initializes the IPAccount with the given access controller + /// @param accessController_ The address of the access controller + // TODO: can only be called by IPAccountRegistry + function initialize(address accessController_) external { + require(accessController_ != address(0), "Invalid access controller"); + require(accessController == address(0), "Already initialized"); + accessController = accessController_; + } + + /// @notice Returns the identifier of the non-fungible token which owns the account + /// @return chainId The EIP-155 ID of the chain the token exists on + /// @return tokenContract The contract address of the token + /// @return tokenId The ID of the token + function token() public view override returns (uint256, address, uint256) { + bytes memory footer = new bytes(0x60); + // 0x4d = 77 bytes (ERC-1167 Header, address, ERC-1167 Footer, salt) + // 0x60 = 96 bytes (chainId, tokenContract, tokenId) + // ERC-1167 Header (10 bytes) + // (20 bytes) + // ERC-1167 Footer (15 bytes) + // (32 bytes) + // (32 bytes) + // (32 bytes) + // (32 bytes) + assembly { + extcodecopy(address(), add(footer, 0x20), 0x4d, 0x60) + } + + return abi.decode(footer, (uint256, address, uint256)); + } + + /// @notice Checks if the signer is valid for the given data + /// @param signer_ The signer to check + /// @param data_ The data to check against + /// @return The function selector if the signer is valid, 0 otherwise + function isValidSigner(address signer_, bytes calldata data_) external view returns (bytes4) { + if (_isValidSigner(signer_, address(0), data_)) { + return IERC6551Account.isValidSigner.selector; + } + + return bytes4(0); + } + + /// @notice Returns the owner of the IP Account. + /// @return The address of the owner. + function owner() public view returns (address) { + (uint256 chainId, address contractAddress, uint256 tokenId) = token(); + if (chainId != block.chainid) return address(0); + return IERC721(contractAddress).ownerOf(tokenId); + } + + /// @notice Checks if the signer is valid for the given data and recipient + /// @dev It leverages the access controller to check the permission + /// @param signer_ The signer to check + /// @param to_ The recipient of the transaction + /// @param data_ The calldata to check against + /// @return True if the signer is valid, false otherwise + function _isValidSigner(address signer_, address to_, bytes calldata data_) internal view returns (bool) { + require(data_.length == 0 || data_.length >= 4, "Invalid calldata"); + bytes4 selector = bytes4(0); + if (data_.length >= 4) { + selector = bytes4(data_[:4]); + } + return IAccessController(accessController).checkPolicy(address(this), signer_, to_, selector); + } + + /// @notice Executes a transaction from the IP Account. + /// @param to_ The recipient of the transaction. + /// @param value_ The amount of Ether to send. + /// @param data_ The data to send along with the transaction. + /// @return result The return data from the transaction. + function execute(address to_, uint256 value_, bytes calldata data_) external payable returns (bytes memory result) { + require(_isValidSigner(msg.sender, to_, data_), "Invalid signer"); + + ++state; + + bool success; + (success, result) = to_.call{ value: value_ }(data_); + + if (!success) { + assembly { + revert(add(result, 32), mload(result)) + } + } + } + + function onERC721Received(address, address, uint256, bytes memory) public pure returns (bytes4) { + return this.onERC721Received.selector; + } + + function onERC1155Received(address, address, uint256, uint256, bytes memory) public pure returns (bytes4) { + return this.onERC1155Received.selector; + } + + function onERC1155BatchReceived( + address, + address, + uint256[] memory, + uint256[] memory, + bytes memory + ) public pure returns (bytes4) { + return this.onERC1155BatchReceived.selector; + } +} diff --git a/contracts/interfaces/IAccessController.sol b/contracts/interfaces/IAccessController.sol new file mode 100644 index 000000000..ebd7432d0 --- /dev/null +++ b/contracts/interfaces/IAccessController.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: UNLICENSED +// See https://github.com/storyprotocol/protocol-contracts/blob/main/StoryProtocol-AlphaTestingAgreement-17942166.3.pdf +pragma solidity ^0.8.21; + +interface IAccessController { + /// @notice Sets the policy for a specific function call + /// @param ipAccount_ The account that owns the IP + /// @param signer_ The account that signs the transaction + /// @param to_ The recipient(modules) of the transaction + /// @param func_ The function selector + /// @param permission_ The permission level + function setPolicy(address ipAccount_, address signer_, address to_, bytes4 func_, uint8 permission_) external; + + /// @notice Gets the policy for a specific function call + /// @param ipAccount_ The account that owns the IP + /// @param signer_ The account that signs the transaction + /// @param to_ The recipient (modules) of the transaction + /// @param func_ The function selector + /// @return The current permission level for the function call + function getPolicy(address ipAccount_, address signer_, address to_, bytes4 func_) external view returns (uint8); + + /// @notice Checks the policy for a specific function call + /// @param ipAccount_ The account that owns the IP + /// @param signer_ The account that signs the transaction + /// @param to_ The recipient of the transaction + /// @param func_ The function selector + /// @return A boolean indicating whether the function call is allowed + function checkPolicy(address ipAccount_, address signer_, address to_, bytes4 func_) external view returns (bool); +} diff --git a/contracts/interfaces/IIPAccount.sol b/contracts/interfaces/IIPAccount.sol new file mode 100644 index 000000000..4d6dfc125 --- /dev/null +++ b/contracts/interfaces/IIPAccount.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: UNLICENSED +// See https://github.com/storyprotocol/protocol-contracts/blob/main/StoryProtocol-AlphaTestingAgreement-17942166.3.pdf +pragma solidity ^0.8.21; + +import { IERC721Receiver } from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; +import { IERC1155Receiver } from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; +import { IERC6551Account } from "contracts/interfaces/erc6551/IERC6551Account.sol"; + +/// @title IIPAccount +/// @dev IPAccount is a token-bound account that adopts the EIP-6551 standard. +/// These accounts are deployed at deterministic addresses through the official 6551 account registry. +/// As a deployed smart contract, IPAccount can store IP-related information, +/// like ownership of other NFTs such as license NFT or Royalty NFT. +/// IPAccount can interact with modules by making calls as a normal transaction sender. +/// This allows for seamless operations on the state and data of IP. +/// IPAccount is core identity for all actions. +interface IIPAccount is IERC6551Account, IERC721Receiver, IERC1155Receiver { + /// @notice Executes a transaction from the IP Account. + /// @param to_ The recipient of the transaction. + /// @param value_ The amount of Ether to send. + /// @param data_ The data to send along with the transaction. + /// @return The return data from the transaction. + function execute(address to_, uint256 value_, bytes calldata data_) external payable returns (bytes memory); + + /// @notice Returns the owner of the IP Account. + /// @return The address of the owner. + function owner() external view returns (address); +} diff --git a/contracts/interfaces/erc6551/IERC6551Account.sol b/contracts/interfaces/erc6551/IERC6551Account.sol new file mode 100644 index 000000000..0eb3abb05 --- /dev/null +++ b/contracts/interfaces/erc6551/IERC6551Account.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @dev the ERC-165 identifier for this interface is `0x6faff5f1` +interface IERC6551Account { + /** + * @dev Allows the account to receive Ether + * + * Accounts MUST implement a `receive` function + * + * Accounts MAY perform arbitrary logic to restrict conditions + * under which Ether can be received + */ + receive() external payable; + + /** + * @dev Returns the identifier of the non-fungible token which owns the account + * + * The return value of this function MUST be constant - it MUST NOT change over time + * + * @return chainId The EIP-155 ID of the chain the token exists on + * @return tokenContract The contract address of the token + * @return tokenId The ID of the token + */ + function token() + external + view + returns ( + uint256 chainId, + address tokenContract, + uint256 tokenId + ); + + /** + * @dev Returns a value that SHOULD be modified each time the account changes state + * + * @return The current account state + */ + function state() external view returns (uint256); + + /** + * @dev Returns a magic value indicating whether a given signer is authorized to act on behalf + * of the account + * + * MUST return the bytes4 magic value 0x523e3260 if the given signer is valid + * + * By default, the holder of the non-fungible token the account is bound to MUST be considered + * a valid signer + * + * Accounts MAY implement additional authorization logic which invalidates the holder as a + * signer or grants signing permissions to other non-holder accounts + * + * @param signer The address to check signing authorization for + * @param context Additional data used to determine whether the signer is valid + * @return magicValue Magic value indicating whether the signer is valid + */ + function isValidSigner(address signer, bytes calldata context) + external + view + returns (bytes4 magicValue); +} diff --git a/contracts/interfaces/erc6551/IERC6551Registry.sol b/contracts/interfaces/erc6551/IERC6551Registry.sol new file mode 100644 index 000000000..53959f73c --- /dev/null +++ b/contracts/interfaces/erc6551/IERC6551Registry.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IERC6551Registry { + /** + * @dev The registry SHALL emit the AccountCreated event upon successful account creation + */ + event AccountCreated( + address account, + address indexed implementation, + uint256 chainId, + address indexed tokenContract, + uint256 indexed tokenId, + uint256 salt + ); + + /** + * @dev Creates a token bound account for a non-fungible token + * + * If account has already been created, returns the account address without calling create2 + * + * If initData is not empty and account has not yet been created, calls account with + * provided initData after creation + * + * Emits AccountCreated event + * + * @return the address of the account + */ + function createAccount( + address implementation, + uint256 chainId, + address tokenContract, + uint256 tokenId, + uint256 seed, + bytes calldata initData + ) external returns (address); + + /** + * @dev Returns the computed token bound account address for a non-fungible token + * + * @return The computed address of the token bound account + */ + function account( + address implementation, + uint256 chainId, + address tokenContract, + uint256 tokenId, + uint256 salt + ) external view returns (address); +} diff --git a/contracts/interfaces/modules/base/IModule.sol b/contracts/interfaces/modules/base/IModule.sol new file mode 100644 index 000000000..e918fe940 --- /dev/null +++ b/contracts/interfaces/modules/base/IModule.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: UNLICENSED +// See https://github.com/storyprotocol/protocol-contracts/blob/main/StoryProtocol-AlphaTestingAgreement-17942166.3.pdf +pragma solidity ^0.8.21; + +interface IModule { + function name() external returns (string memory); +} diff --git a/contracts/interfaces/registries/IIPAccountRegistry.sol b/contracts/interfaces/registries/IIPAccountRegistry.sol new file mode 100644 index 000000000..f0e93fd49 --- /dev/null +++ b/contracts/interfaces/registries/IIPAccountRegistry.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: UNLICENSED +// See https://github.com/storyprotocol/protocol-contracts/blob/main/StoryProtocol-AlphaTestingAgreement-17942166.3.pdf +pragma solidity ^0.8.21; + +/// @title Interface for IP Account Registry +/// @notice This interface manages the registration and tracking of IP Accounts +interface IIPAccountRegistry { + /// @notice Event emitted when a new IP Account is created + /// @param account The address of the new IP Account + /// @param implementation The address of the IP Account implementation + /// @param chainId The chain ID where the IP Account was created + /// @param tokenContract The address of the token contract associated with the IP Account + /// @param tokenId The ID of the token associated with the IP Account + event IPAccountRegistered( + address indexed account, + address indexed implementation, + uint256 indexed chainId, + address tokenContract, + uint256 tokenId + ); + + /// @notice Deploys an IPAccount contract with the IPAccount implementation and returns the address of the new IP + /// @dev The IPAccount deployment deltegates to public ERC6551 Registry + /// @param chainId_ The chain ID where the IP Account will be created + /// @param tokenContract_ The address of the token contract to be associated with the IP Account + /// @param tokenId_ The ID of the token to be associated with the IP Account + /// @return The address of the newly created IP Account + function registerIpAccount( + uint256 chainId_, + address tokenContract_, + uint256 tokenId_ + ) external returns (address); + + /// @notice Returns the IPAccount address for the given NFT token + /// @param chainId_ The chain ID where the IP Account is located + /// @param tokenContract_ The address of the token contract associated with the IP Account + /// @param tokenId_ The ID of the token associated with the IP Account + /// @return The address of the IP Account associated with the given NFT token + function ipAccount( + uint256 chainId_, + address tokenContract_, + uint256 tokenId_ + ) external view returns (address); + + /// @notice Checks if the IPAccount is registered + /// @param chainId_ The chain ID where the IP Account is located + /// @param tokenContract_ The address of the token contract associated with the IP Account + /// @param tokenId_ The ID of the token associated with the IP Account + /// @return True if the IP Account is registered, false otherwise + function isRegistered( + uint256 chainId_, + address tokenContract_, + uint256 tokenId_ + ) external view returns (bool); + + /// @notice Returns the IPAccount implementation address + /// @return The address of the IPAccount implementation + function getIPAccountImpl() external view returns (address); +} diff --git a/contracts/interfaces/registries/IModuleRegistry.sol b/contracts/interfaces/registries/IModuleRegistry.sol new file mode 100644 index 000000000..efbc8beea --- /dev/null +++ b/contracts/interfaces/registries/IModuleRegistry.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: UNLICENSED +// See https://github.com/storyprotocol/protocol-contracts/blob/main/StoryProtocol-AlphaTestingAgreement-17942166.3.pdf +pragma solidity ^0.8.21; + +interface IModuleRegistry { + function registerModule(string memory name, address moduleAddress) external; + + function getModule( + string memory name, + address account + ) external view returns (address); +} diff --git a/contracts/registries/IPAccountRegistry.sol b/contracts/registries/IPAccountRegistry.sol new file mode 100644 index 000000000..c52bd6577 --- /dev/null +++ b/contracts/registries/IPAccountRegistry.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: UNLICENSED +// See https://github.com/storyprotocol/protocol-contracts/blob/main/StoryProtocol-AlphaTestingAgreement-17942166.3.pdf +pragma solidity ^0.8.21; + +import { IIPAccountRegistry } from "contracts/interfaces/registries/IIPAccountRegistry.sol"; +import { IERC6551Registry } from "contracts/interfaces/erc6551/IERC6551Registry.sol"; + +/// @title IPAccountRegistry +/// @notice This contract is responsible for managing the registration and tracking of IP Accounts. +/// It leverages a public ERC6551 registry to deploy IPAccount contracts. +contract IPAccountRegistry is IIPAccountRegistry { + address internal immutable IP_ACCOUNT_IMPL; + uint256 internal immutable IP_ACCOUNT_SALT; + address internal immutable ERC6551_PUBLIC_REGISTRY; + address internal immutable ACCESS_CONTROLLER; + + error NonExistIpAccountImpl(); + + /// @notice Constructor for the IPAccountRegistry contract. + /// @param erc6551Registry_ The address of the ERC6551 registry. + /// @param accessController_ The address of the access controller. + /// @param ipAccountImpl_ The address of the IP account implementation. + constructor(address erc6551Registry_, address accessController_, address ipAccountImpl_) { + if (ipAccountImpl_ == address(0)) revert NonExistIpAccountImpl(); + IP_ACCOUNT_IMPL = ipAccountImpl_; + IP_ACCOUNT_SALT = 0; + ERC6551_PUBLIC_REGISTRY = erc6551Registry_; + ACCESS_CONTROLLER = accessController_; + } + + /// @notice Deploys an IPAccount contract with the IPAccount implementation and returns the address of the new IP. + /// @param chainId_ The chain ID where the IP Account will be created. + /// @param tokenContract_ The address of the token contract to be associated with the IP Account. + /// @param tokenId_ The ID of the token to be associated with the IP Account. + /// @return ipAccountAddress The address of the newly created IP Account. + function registerIpAccount( + uint256 chainId_, + address tokenContract_, + uint256 tokenId_ + ) external returns (address ipAccountAddress) { + bytes memory initData = abi.encodeWithSignature("initialize(address)", ACCESS_CONTROLLER); + ipAccountAddress = IERC6551Registry(ERC6551_PUBLIC_REGISTRY).createAccount( + IP_ACCOUNT_IMPL, + chainId_, + tokenContract_, + tokenId_, + IP_ACCOUNT_SALT, + initData + ); + emit IPAccountRegistered(ipAccountAddress, IP_ACCOUNT_IMPL, chainId_, tokenContract_, tokenId_); + } + + /// @notice Returns the IPAccount address for the given NFT token. + /// @param chainId_ The chain ID where the IP Account is located. + /// @param tokenContract_ The address of the token contract associated with the IP Account. + /// @param tokenId_ The ID of the token associated with the IP Account. + /// @return The address of the IP Account associated with the given NFT token. + function ipAccount(uint256 chainId_, address tokenContract_, uint256 tokenId_) external view returns (address) { + return _get6551AccountAddress(chainId_, tokenContract_, tokenId_); + } + + /// @notice Returns true if the IPAccount is registered. + /// @param chainId_ The chain ID where the IP Account is located. + /// @param tokenContract_ The address of the token contract associated with the IP Account. + /// @param tokenId_ The ID of the token associated with the IP Account. + /// @return True if the IP Account is registered, false otherwise. + function isRegistered(uint256 chainId_, address tokenContract_, uint256 tokenId_) external view returns (bool) { + return _get6551AccountAddress(chainId_, tokenContract_, tokenId_).code.length != 0; + } + + /// @notice Returns the IPAccount implementation address. + /// @return The address of the IPAccount implementation. + function getIPAccountImpl() external view override returns (address) { + return IP_ACCOUNT_IMPL; + } + + function _get6551AccountAddress( + uint256 chainId_, + address tokenContract_, + uint256 tokenId_ + ) internal view returns (address) { + return + IERC6551Registry(ERC6551_PUBLIC_REGISTRY).account( + IP_ACCOUNT_IMPL, + chainId_, + tokenContract_, + tokenId_, + IP_ACCOUNT_SALT + ); + } +} diff --git a/test/foundry/Counter.t.sol b/test/foundry/Counter.t.sol deleted file mode 100644 index 91a51256a..000000000 --- a/test/foundry/Counter.t.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import { Test } from "forge-std/Test.sol"; -import { Counter } from "../../contracts/Counter.sol"; - -contract CounterTest is Test { - Counter public counter; - - function setUp() public { - counter = new Counter(); - counter.setNumber(0); - } - - function testIncrement() public { - counter.increment(); - assertEq(counter.number(), 1); - } - - function testSetNumber(uint256 x) public { - counter.setNumber(x); - assertEq(counter.number(), x); - } -} diff --git a/test/foundry/IPAccount.t.sol b/test/foundry/IPAccount.t.sol new file mode 100644 index 000000000..0d53a11c5 --- /dev/null +++ b/test/foundry/IPAccount.t.sol @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +import "forge-std/Test.sol"; + +import "contracts/registries/IPAccountRegistry.sol"; +import "contracts/IPAccountImpl.sol"; +import "contracts/interfaces/IIPAccount.sol"; +import "contracts/interfaces/erc6551/IERC6551Account.sol"; +import "test/foundry/mocks/MockERC721.sol"; +import "test/foundry/mocks/MockERC6551Registry.sol"; +import "test/foundry/mocks/MockAccessController.sol"; +import "test/foundry/mocks/MockModule.sol"; + +contract IPAccountTest is Test { + IPAccountRegistry public registry; + IPAccountImpl public implementation; + MockERC721 nft = new MockERC721(); + MockERC6551Registry public erc6551Registry = new MockERC6551Registry(); + MockAccessController public accessController = new MockAccessController(); + MockModule public module = new MockModule(); + + + function setUp() public { + implementation = new IPAccountImpl(); + registry = new IPAccountRegistry(address(erc6551Registry), address(accessController), address(implementation)); + } + + function test_IPAccount_Idempotency() public { + address owner = vm.addr(1); + uint256 tokenId = 100; + + address predictedAccount = registry.ipAccount( + block.chainid, + address(nft), + tokenId + ); + + nft.mint(owner, tokenId); + + vm.prank(owner, owner); + + address deployedAccount = registry.registerIpAccount( + block.chainid, + address(nft), + tokenId + ); + + assertTrue(deployedAccount != address(0)); + + assertEq(predictedAccount, deployedAccount); + + // Create account twice + deployedAccount = registry.registerIpAccount( + block.chainid, + address(nft), + tokenId + ); + assertEq(predictedAccount, deployedAccount); + } + + function test_IPAccount_TokenAndOwnership() public { + address owner = vm.addr(1); + uint256 tokenId = 100; + + nft.mint(owner, tokenId); + + vm.prank(owner, owner); + address account = registry.registerIpAccount( + block.chainid, + address(nft), + tokenId + ); + + IIPAccount ipAccount = IIPAccount(payable(account)); + + // Check token and owner functions + (uint256 chainId_, address tokenAddress_, uint256 tokenId_) = ipAccount.token(); + assertEq(chainId_, block.chainid); + assertEq(tokenAddress_, address(nft)); + assertEq(tokenId_, tokenId); + assertEq(ipAccount.isValidSigner(owner, ""), IERC6551Account.isValidSigner.selector); + + // Transfer token to new owner and make sure account owner changes + address newOwner = vm.addr(2); + vm.prank(owner); + nft.safeTransferFrom(owner, newOwner, tokenId); + assertEq( + ipAccount.isValidSigner(newOwner, ""), + IERC6551Account.isValidSigner.selector + ); + } + + function test_IPAccount_OwnerExecutionPass() public { + address owner = vm.addr(1); + uint256 tokenId = 100; + + nft.mint(owner, tokenId); + + vm.prank(owner, owner); + address account = registry.registerIpAccount( + block.chainid, + address(nft), + tokenId + ); + + uint256 subTokenId = 111; + nft.mint(account, subTokenId); + + IIPAccount ipAccount = IIPAccount(payable(account)); + + vm.prank(owner); + bytes memory result = ipAccount.execute(address(module), 0, abi.encodeWithSignature("executeSuccessfully(string)", "test")); + assertEq("test", abi.decode(result, (string))); + + assertEq(ipAccount.state(), 1); + } + + function test_IPAccount_revert_NonOwnerNoPermissionToExecute() public { + address owner = vm.addr(1); + uint256 tokenId = 100; + + nft.mint(owner, tokenId); + + address account = registry.registerIpAccount( + block.chainid, + address(nft), + tokenId + ); + + uint256 subTokenId = 111; + nft.mint(account, subTokenId); + + IIPAccount ipAccount = IIPAccount(payable(account)); + + vm.prank(vm.addr(3)); + vm.expectRevert("Invalid signer"); + ipAccount.execute(address(module), 0, abi.encodeWithSignature("executeSuccessfully(string)", "test")); + assertEq(ipAccount.state(), 0); + } + + function test_IPAccount_revert_OwnerExecuteFailed() public { + address owner = vm.addr(1); + uint256 tokenId = 100; + + nft.mint(owner, tokenId); + + address account = registry.registerIpAccount( + block.chainid, + address(nft), + tokenId + ); + + uint256 subTokenId = 111; + nft.mint(account, subTokenId); + + IIPAccount ipAccount = IIPAccount(payable(account)); + + vm.prank(owner); + vm.expectRevert("MockModule: executeRevert"); + ipAccount.execute(address(module), 0, abi.encodeWithSignature("executeRevert()")); + assertEq(ipAccount.state(), 0); + } + + function test_IPAccount_ERC721Receive() public { + address owner = vm.addr(1); + uint256 tokenId = 100; + + nft.mint(owner, tokenId); + + vm.prank(owner, owner); + address account = registry.registerIpAccount( + block.chainid, + address(nft), + tokenId + ); + + address otherOwner = vm.addr(2); + uint256 otherTokenId = 200; + nft.mint(otherOwner, otherTokenId); + vm.prank(otherOwner); + nft.safeTransferFrom(otherOwner, account, otherTokenId); + assertEq(nft.balanceOf(account), 1); + assertEq(nft.ownerOf(otherTokenId), account); + } +} diff --git a/test/foundry/IPAccountRegistry.t.sol b/test/foundry/IPAccountRegistry.t.sol new file mode 100644 index 000000000..5a2586577 --- /dev/null +++ b/test/foundry/IPAccountRegistry.t.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +import "forge-std/Test.sol"; + +import "contracts/registries/IPAccountRegistry.sol"; +import "contracts/IPAccountImpl.sol"; +import "test/foundry/mocks/MockERC6551Registry.sol"; +import "test/foundry/mocks/MockAccessController.sol"; + +contract RegistryTest is Test { + IPAccountRegistry public registry; + IPAccountImpl public implementation; + MockERC6551Registry public erc6551Registry; + MockAccessController public accessController; + uint256 chainId; + address tokenAddress; + uint256 tokenId; + + function setUp() public { + implementation = new IPAccountImpl(); + erc6551Registry = new MockERC6551Registry(); + accessController = new MockAccessController(); + chainId = 100; + tokenAddress = address(200); + tokenId = 300; + } + + function test_IPAccountRegistry_registerIpAccount() public { + registry = new IPAccountRegistry(address(erc6551Registry), address(accessController), address(implementation)); + address ipAccountAddr; + ipAccountAddr = registry.registerIpAccount( + chainId, + tokenAddress, + tokenId + ); + + address registryComputedAddress = registry.ipAccount( + chainId, + tokenAddress, + tokenId + ); + assertEq(ipAccountAddr, registryComputedAddress); + + IPAccountImpl ipAccount = IPAccountImpl(payable(ipAccountAddr)); + + (uint256 chainId_, address tokenAddress_, uint256 tokenId_) = ipAccount.token(); + assertEq(chainId_, chainId); + assertEq(tokenAddress_, tokenAddress); + assertEq(tokenId_, tokenId); + } + + function test_IPAccountRegistry_revert_createAccount_ifInitFailed() public { + // expect init revert for invalid accessController address + registry = new IPAccountRegistry(address(erc6551Registry), address(0), address(implementation)); + vm.expectRevert("Invalid access controller"); + registry.registerIpAccount( + chainId, + tokenAddress, + tokenId + ); + } +} diff --git a/test/foundry/mocks/MockAccessController.sol b/test/foundry/mocks/MockAccessController.sol new file mode 100644 index 000000000..6e90ad506 --- /dev/null +++ b/test/foundry/mocks/MockAccessController.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; +import "contracts/interfaces/IAccessController.sol"; +import "contracts/interfaces/IIPAccount.sol"; + +contract MockAccessController is IAccessController { + + bool public isAllowed = true; + + function setAllowed(bool _isAllowed) external { + isAllowed = _isAllowed; + } + + function setPolicy(address, address, address, bytes4, uint8) external pure { + + } + + function getPolicy(address, address, address, bytes4) external pure returns (uint8) { + return 1; + } + + function checkPolicy(address ipAccount, address signer, address, bytes4) external view returns(bool) { + return IIPAccount(payable(ipAccount)).owner() == signer && isAllowed; + } +} diff --git a/test/foundry/mocks/MockERC6551Registry.sol b/test/foundry/mocks/MockERC6551Registry.sol new file mode 100644 index 000000000..43355f729 --- /dev/null +++ b/test/foundry/mocks/MockERC6551Registry.sol @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; +import { Create2 } from "@openzeppelin/contracts/utils/Create2.sol"; +import { IERC6551Account } from 'contracts/interfaces/erc6551/IERC6551Account.sol'; + +contract MockERC6551Registry { + /** + * @dev The registry SHALL emit the AccountCreated event upon successful account creation + */ + event AccountCreated( + address account, + address indexed implementation, + uint256 chainId, + address indexed tokenContract, + uint256 indexed tokenId, + uint256 salt + ); + + /** + * @dev Creates a token bound account for a non-fungible token + * + * If account has already been created, returns the account address without calling create2 + * + * If initData is not empty and account has not yet been created, calls account with + * provided initData after creation + * + * Emits AccountCreated event + * + * @return the address of the account + */ + function createAccount( + address implementation, + uint256 chainId, + address tokenContract, + uint256 tokenId, + uint256 salt, + bytes calldata initData + ) external returns (address) { + bytes memory code = _getCreationCode( + implementation, + chainId, + tokenContract, + tokenId, + salt + ); + + address _account = Create2.computeAddress(bytes32(salt), keccak256(code)); + + if (_account.code.length != 0) return _account; + + emit AccountCreated(_account, implementation, chainId, tokenContract, tokenId, salt); + + _account = Create2.deploy(0, bytes32(salt), code); + + if (initData.length != 0) { + (bool success, bytes memory result) = _account.call(initData); + if (!success) { + assembly { + revert(add(result, 32), mload(result)) + } + } + } + + return _account; + } + + /** + * @dev Returns the computed token bound account address for a non-fungible token + * + * @return The computed address of the token bound account + */ + function account( + address implementation, + uint256 chainId, + address tokenContract, + uint256 tokenId, + uint256 salt + ) external view returns (address) { + bytes32 bytecodeHash = keccak256( + _getCreationCode( + implementation, + chainId, + tokenContract, + tokenId, + salt + ) + ); + + return Create2.computeAddress(bytes32(salt), bytecodeHash); + } + + function _getCreationCode( + address implementation_, + uint256 chainId_, + address tokenContract_, + uint256 tokenId_, + uint256 salt_ + ) internal pure returns (bytes memory) { + return + // Proxy that delegate call to IPAccountProxy + // | 0x00000000 36 calldatasize cds + // | 0x00000001 3d returndatasize 0 cds + // | 0x00000002 3d returndatasize 0 0 cds + // | 0x00000003 37 calldatacopy + // | 0x00000004 3d returndatasize 0 + // | 0x00000005 3d returndatasize 0 0 + // | 0x00000006 3d returndatasize 0 0 0 + // | 0x00000007 36 calldatasize cds 0 0 0 + // | 0x00000008 3d returndatasize 0 cds 0 0 0 + // | 0x00000009 73bebebebebe. push20 0xbebebebe 0xbebe 0 cds 0 0 0 + // | 0x0000001e 5a gas gas 0xbebe 0 cds 0 0 0 + // | 0x0000001f f4 delegatecall suc 0 + // | 0x00000020 3d returndatasize rds suc 0 + // | 0x00000021 82 dup3 0 rds suc 0 + // | 0x00000022 80 dup1 0 0 rds suc 0 + // | 0x00000023 3e returndatacopy suc 0 + // | 0x00000024 90 swap1 0 suc + // | 0x00000025 3d returndatasize rds 0 suc + // | 0x00000026 91 swap2 suc 0 rds + // | 0x00000027 602b push1 0x2b 0x2b suc 0 rds + // | ,=< 0x00000029 57 jumpi 0 rds + // | | 0x0000002a fd revert + // | `-> 0x0000002b 5b jumpdest 0 rds + // \ 0x0000002c f3 return + abi.encodePacked( + hex"3d60ad80600a3d3981f3363d3d373d3d3d363d73", + implementation_, + hex"5af43d82803e903d91602b57fd5bf3", + abi.encode(salt_, chainId_, tokenContract_, tokenId_) + ); + } +} diff --git a/test/foundry/mocks/MockERC721.sol b/test/foundry/mocks/MockERC721.sol new file mode 100644 index 000000000..1db8755b1 --- /dev/null +++ b/test/foundry/mocks/MockERC721.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: BUSDL-1.1 +pragma solidity ^0.8.13; + +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; + +contract MockERC721 is ERC721 { + constructor() ERC721("MockERC721", "M721") {} + + function mint(address to, uint256 tokenId) external { + _safeMint(to, tokenId); + } +} diff --git a/test/foundry/mocks/MockModule.sol b/test/foundry/mocks/MockModule.sol new file mode 100644 index 000000000..76895cb22 --- /dev/null +++ b/test/foundry/mocks/MockModule.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +import "contracts/interfaces/modules/base/IModule.sol"; + +contract MockModule is IModule { + function name() external pure returns(string memory) { + return "MockModule"; + } + + function executeSuccessfully(string memory param) external pure returns(string memory) { + return param; + } + + function executeRevert() external pure { + revert("MockModule: executeRevert"); + } +}