From 7131a139dad91c3f07e21d4ccbc3ff3aae14dd3a Mon Sep 17 00:00:00 2001 From: Sebastian Liu Date: Fri, 23 Aug 2024 23:46:20 -0700 Subject: [PATCH] feat(spg): add multicall integration (#38) * feat(spg): add multicall * doc(spg): add multicall doc --- MULTICALL.md | 216 +++++++++++++++++++++++++++++ contracts/StoryProtocolGateway.sol | 33 +++-- test/StoryProtocolGateway.t.sol | 121 +++++++++++++++- 3 files changed, 353 insertions(+), 17 deletions(-) create mode 100644 MULTICALL.md diff --git a/MULTICALL.md b/MULTICALL.md new file mode 100644 index 0000000..1f5f581 --- /dev/null +++ b/MULTICALL.md @@ -0,0 +1,216 @@ +# Batch SPG Function Calls Guide +## Background +Prior to this point, registering multiple IPs or performing other operations such as minting, attaching licensing terms, and registering derivatives requires separate transactions for each operation. This can be inefficient and costly. To streamline the process, you can batch multiple transactions into a single one. Two solutions are now available for this: + +1. **Batch SPG function calls:** Use [SPG's built-in `multicall` function](#batch-spg-function-calls-via-built-in-multicall-function). +2. **Batch function calls beyond SPG:** Use the [Multicall3 Contract](#batch-function-calls-via-multicall3-contract). +--- + +## 1. Batch SPG Function Calls via Built-in `multicall` Function + +Story Protocol Gateway (SPG) includes a `multicall` function that allows you to combine multiple read or write operations into a single transaction. + +### Function Definition + +The `multicall` function accepts an array of encoded call data and returns an array of encoded results corresponding to each function call: + +```solidity +/// @dev Executes a batch of function calls on this contract. +function multicall(bytes[] calldata data) external virtual returns (bytes[] memory results); +``` + +### Example Usage + +Suppose you want to mint multiple NFTs, register them as IPs, and link them as derivatives to some parent IPs. + +To accomplish this, you can use SPG’s `multicall` function to batch the calls to the `mintAndRegisterIpAndMakeDerivative` function. + +Here’s how you might do it: + +```solidity +// SPG contract +contract SPG { + ... + function mintAndRegisterIpAndMakeDerivative( + address nftContract, + MakeDerivative calldata derivData, + IPMetadata calldata ipMetadata, + address recipient + ) external returns (address ipId, uint256 tokenId) { + ... + } + ... +} +``` + +To batch call `mintAndRegisterIpAndMakeDerivative` using the `multicall` function: + +```typescript +// batch mint, register, and make derivatives for multiple IPs +await SPG.multicall([ + SPG.contract.methods.mintAndRegisterIpAndMakeDerivative( + nftContract1, + derivData1, + recipient1, + ipMetadata1, + ).encodeABI(), + + SPG.contract.methods.mintAndRegisterIpAndMakeDerivative( + nftContract2, + derivData2, + recipient2, + ipMetadata2, + ).encodeABI(), + + SPG.contract.methods.mintAndRegisterIpAndMakeDerivative( + nftContract3, + derivData3, + recipient3, + ipMetadata3, + ).encodeABI(), + ... + // Add more calls as needed +]); +``` + +--- + +## 2. Batch Function Calls via Multicall3 Contract + +> ⚠️ **Note:** The Multicall3 contract is not fully compatible with SPG functions that involve SPGNFT minting due to access control and context changes during Multicall execution. For such operations, use [SPG's built-in `multicall` function](#batch-spg-function-calls-via-built-in-multicall-function). + +The Multicall3 contract allows you to execute multiple calls within a single transaction and aggregate the results. +The `viem` library provides native support for Multicall3. + +### Story Iliad Testnet Multicall3 Deployment Info +(Same address across all EVM chains) +```json +{ + "contractName": "Multicall3", + "chainId": 1513, + "contractAddress": "0xcA11bde05977b3631167028862bE2a173976CA11", + "url": "https://explorer.testnet.storyprotocol.net/address/0xcA11bde05977b3631167028862bE2a173976CA11?tab=contract" +} +``` + +### Main Functions + +To batch multiple function calls, you can use the following functions: + +1. **`aggregate3`**: Batches calls using the `Call3` struct. +2. **`aggregate3Value`**: Similar to `aggregate3`, but also allows attaching a value to each call. + +```solidity +/// @notice Aggregate calls, ensuring each returns success if required. +/// @param calls An array of Call3 structs. +/// @return returnData An array of Result structs. +function aggregate3(Call3[] calldata calls) external payable returns (Result[] memory returnData); + +/// @notice Aggregate calls with an attached msg value. +/// @param calls An array of Call3Value structs. +/// @return returnData An array of Result structs. +function aggregate3Value(Call3Value[] calldata calls) external payable returns (Result[] memory returnData); +``` + +#### Struct Definitions + +- **Call3**: Used in `aggregate3`. +- **Call3Value**: Used in `aggregate3Value`. + +```solidity +struct Call3 { + address target; // Target contract to call. + bool allowFailure; // If false, the multicall will revert if this call fails. + bytes callData; // Data to call on the target contract. +} + +struct Call3Value { + address target; + bool allowFailure; + uint256 value; // Value (in wei) to send with the call. + bytes callData; // Data to call on the target contract. +} +``` + +#### Return Type + +- **Result**: Struct returned by both `aggregate3` and `aggregate3Value`. + +```solidity +struct Result { + bool success; // Whether the function call succeeded. + bytes returnData; // Data returned from the function call. +} +``` + +For detailed examples in Solidity, TypeScript, and Python, see the [Multicall3 repository](https://github.com/mds1/multicall/tree/main/examples). + +### Limitations + +For a list of limitations when using Multicall3, refer to the [Multicall3 README](https://github.com/mds1/multicall/blob/main/README.md#batch-contract-writes). + +### Additional Resources + +- [Multicall3 Documentation](https://github.com/mds1/multicall/blob/main/README.md) +- [Multicall Documentation from Viem](https://viem.sh/docs/contract/multicall#multicall) + +### Full Multicall3 Interface + +```solidity +interface IMulticall3 { + struct Call { + address target; + bytes callData; + } + + struct Call3 { + address target; + bool allowFailure; + bytes callData; + } + + struct Call3Value { + address target; + bool allowFailure; + uint256 value; + bytes callData; + } + + struct Result { + bool success; + bytes returnData; + } + + function aggregate(Call[] calldata calls) external payable returns (uint256 blockNumber, bytes[] memory returnData); + + function aggregate3(Call3[] calldata calls) external payable returns (Result[] memory returnData); + + function aggregate3Value(Call3Value[] calldata calls) external payable returns (Result[] memory returnData); + + function blockAndAggregate(Call[] calldata calls) external payable returns (uint256 blockNumber, bytes32 blockHash, Result[] memory returnData); + + function getBasefee() external view returns (uint256 basefee); + + function getBlockHash(uint256 blockNumber) external view returns (bytes32 blockHash); + + function getBlockNumber() external view returns (uint256 blockNumber); + + function getChainId() external view returns (uint256 chainid); + + function getCurrentBlockCoinbase() external view returns (address coinbase); + + function getCurrentBlockDifficulty() external view returns (uint256 difficulty); + + function getCurrentBlockGasLimit() external view returns (uint256 gaslimit); + + function getCurrentBlockTimestamp() external view returns (uint256 timestamp); + + function getEthBalance(address addr) external view returns (uint256 balance); + + function getLastBlockHash() external view returns (bytes32 blockHash); + + function tryAggregate(bool requireSuccess, Call[] calldata calls) external payable returns (Result[] memory returnData); + + function tryBlockAndAggregate(bool requireSuccess, Call[] calldata calls) external payable returns (uint256 blockNumber, bytes32 blockHash, Result[] memory returnData); +} +``` diff --git a/contracts/StoryProtocolGateway.sol b/contracts/StoryProtocolGateway.sol index 60a6cf0..b52bb8c 100644 --- a/contracts/StoryProtocolGateway.sol +++ b/contracts/StoryProtocolGateway.sol @@ -1,37 +1,44 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.23; -import { ERC165Checker } from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { BeaconProxy } from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { ERC721Holder } from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; +import { ERC165Checker } from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol"; import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { MulticallUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/MulticallUpgradeable.sol"; // solhint-disable-next-line max-line-length import { AccessManagedUpgradeable } from "@openzeppelin/contracts-upgradeable/access/manager/AccessManagedUpgradeable.sol"; + +import { Licensing } from "@storyprotocol/core/lib/Licensing.sol"; import { IIPAccount } from "@storyprotocol/core/interfaces/IIPAccount.sol"; +import { AccessPermission } from "@storyprotocol/core/lib/AccessPermission.sol"; import { ILicenseToken } from "@storyprotocol/core/interfaces/ILicenseToken.sol"; import { IAccessController } from "@storyprotocol/core/interfaces/access/IAccessController.sol"; -// solhint-disable-next-line max-line-length -import { IPILicenseTemplate, PILTerms } from "@storyprotocol/core/interfaces/modules/licensing/IPILicenseTemplate.sol"; -import { ILicensingModule } from "@storyprotocol/core/interfaces/modules/licensing/ILicensingModule.sol"; -import { ILicenseTemplate } from "@storyprotocol/core/interfaces/modules/licensing/ILicenseTemplate.sol"; import { ILicenseRegistry } from "@storyprotocol/core/interfaces/registries/ILicenseRegistry.sol"; -import { ILicensingHook } from "@storyprotocol/core/interfaces/modules/licensing/ILicensingHook.sol"; -import { Licensing } from "@storyprotocol/core/lib/Licensing.sol"; +import { IIPAssetRegistry } from "@storyprotocol/core/interfaces/registries/IIPAssetRegistry.sol"; import { IRoyaltyModule } from "@storyprotocol/core/interfaces/modules/royalty/IRoyaltyModule.sol"; - +import { ILicensingHook } from "@storyprotocol/core/interfaces/modules/licensing/ILicensingHook.sol"; +import { ILicensingModule } from "@storyprotocol/core/interfaces/modules/licensing/ILicensingModule.sol"; +import { ILicenseTemplate } from "@storyprotocol/core/interfaces/modules/licensing/ILicenseTemplate.sol"; import { ICoreMetadataModule } from "@storyprotocol/core/interfaces/modules/metadata/ICoreMetadataModule.sol"; -import { IIPAssetRegistry } from "@storyprotocol/core/interfaces/registries/IIPAssetRegistry.sol"; -import { AccessPermission } from "@storyprotocol/core/lib/AccessPermission.sol"; -import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +// solhint-disable-next-line max-line-length +import { IPILicenseTemplate, PILTerms } from "@storyprotocol/core/interfaces/modules/licensing/IPILicenseTemplate.sol"; import { IStoryProtocolGateway } from "./interfaces/IStoryProtocolGateway.sol"; import { ISPGNFT } from "./interfaces/ISPGNFT.sol"; import { Errors } from "./lib/Errors.sol"; import { SPGNFTLib } from "./lib/SPGNFTLib.sol"; -contract StoryProtocolGateway is IStoryProtocolGateway, ERC721Holder, AccessManagedUpgradeable, UUPSUpgradeable { +contract StoryProtocolGateway is + IStoryProtocolGateway, + MulticallUpgradeable, + ERC721Holder, + AccessManagedUpgradeable, + UUPSUpgradeable +{ using ERC165Checker for address; using SafeERC20 for IERC20; diff --git a/test/StoryProtocolGateway.t.sol b/test/StoryProtocolGateway.t.sol index 5bddf71..508c28b 100644 --- a/test/StoryProtocolGateway.t.sol +++ b/test/StoryProtocolGateway.t.sol @@ -1,6 +1,5 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.23; - import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; import { IIPAccount } from "@storyprotocol/core/interfaces/IIPAccount.sol"; import { AccessPermission } from "@storyprotocol/core/lib/AccessPermission.sol"; @@ -25,6 +24,7 @@ contract StoryProtocolGatewayTest is BaseTest { } ISPGNFT internal nftContract; + ISPGNFT[] internal nftContracts; address internal minter; address internal caller; mapping(uint256 index => IPAsset) internal ipAsset; @@ -199,9 +199,9 @@ contract StoryProtocolGatewayTest is BaseTest { modifier withEnoughTokens() { require(caller != address(0), "withEnoughTokens: caller not set"); - mockToken.mint(address(caller), 2000 * 10 ** mockToken.decimals()); - mockToken.approve(address(nftContract), 1000 * 10 ** mockToken.decimals()); - mockToken.approve(address(spg), 1000 * 10 ** mockToken.decimals()); + mockToken.mint(address(caller), 10000 * 10 ** mockToken.decimals()); + mockToken.approve(address(nftContract), 10000 * 10 ** mockToken.decimals()); + mockToken.approve(address(spg), 10000 * 10 ** mockToken.decimals()); _; } @@ -468,6 +468,117 @@ contract StoryProtocolGatewayTest is BaseTest { }); } + function test_SPG_multicall_createCollection() public { + nftContracts = new ISPGNFT[](10); + bytes[] memory data = new bytes[](10); + for (uint256 i = 0; i < 10; i++) { + data[i] = abi.encodeWithSignature( + "createCollection(string,string,uint32,uint256,address,address)", + "Test Collection", + "TEST", + 100, + 100 * 10 ** mockToken.decimals(), + address(mockToken), + minter + ); + } + + bytes[] memory results = spg.multicall(data); + for (uint256 i = 0; i < 10; i++) { + nftContracts[i] = ISPGNFT(abi.decode(results[i], (address))); + } + + for (uint256 i = 0; i < 10; i++) { + assertEq(nftContracts[i].name(), "Test Collection"); + assertEq(nftContracts[i].symbol(), "TEST"); + assertEq(nftContracts[i].totalSupply(), 0); + assertTrue(nftContracts[i].hasRole(SPGNFTLib.MINTER_ROLE, alice)); + assertEq(nftContracts[i].mintFee(), 100 * 10 ** mockToken.decimals()); + } + } + + function test_SPG_multicall_mintAndRegisterIp() public withCollection whenCallerHasMinterRole { + mockToken.mint(address(caller), 1000 * 10 * 10 ** mockToken.decimals()); + mockToken.approve(address(nftContract), 1000 * 10 * 10 ** mockToken.decimals()); + + bytes[] memory data = new bytes[](10); + for (uint256 i = 0; i < 10; i++) { + data[i] = abi.encodeWithSelector( + spg.mintAndRegisterIp.selector, + address(nftContract), + bob, + ipMetadataDefault + ); + } + bytes[] memory results = spg.multicall(data); + address[] memory ipIds = new address[](10); + uint256[] memory tokenIds = new uint256[](10); + + for (uint256 i = 0; i < 10; i++) { + (ipIds[i], tokenIds[i]) = abi.decode(results[i], (address, uint256)); + assertTrue(ipAssetRegistry.isRegistered(ipIds[i])); + assertSPGNFTMetadata(tokenIds[i], ipMetadataDefault.nftMetadataURI); + assertMetadata(ipIds[i], ipMetadataDefault); + } + } + + function test_SPG_multicall_mintAndRegisterIpAndMakeDerivative() + public + withCollection + whenCallerHasMinterRole + withEnoughTokens + withNonCommercialParentIp + { + (address licenseTemplateParent, uint256 licenseTermsIdParent) = licenseRegistry.getAttachedLicenseTerms( + ipIdParent, + 0 + ); + address[] memory parentIpIds = new address[](1); + parentIpIds[0] = ipIdParent; + + uint256[] memory licenseTermsIds = new uint256[](1); + licenseTermsIds[0] = licenseTermsIdParent; + + bytes[] memory data = new bytes[](10); + for (uint256 i = 0; i < 10; i++) { + data[i] = abi.encodeWithSelector( + spg.mintAndRegisterIpAndMakeDerivative.selector, + address(nftContract), + ISPG.MakeDerivative({ + parentIpIds: parentIpIds, + licenseTemplate: address(pilTemplate), + licenseTermsIds: licenseTermsIds, + royaltyContext: "" + }), + ipMetadataDefault, + caller + ); + } + + bytes[] memory results = spg.multicall(data); + + for (uint256 i = 0; i < 10; i++) { + (address ipIdChild, uint256 tokenIdChild) = abi.decode(results[i], (address, uint256)); + assertTrue(ipAssetRegistry.isRegistered(ipIdChild)); + assertEq(tokenIdChild, i + 2); + assertSPGNFTMetadata(tokenIdChild, ipMetadataDefault.nftMetadataURI); + assertMetadata(ipIdChild, ipMetadataDefault); + (address licenseTemplateChild, uint256 licenseTermsIdChild) = licenseRegistry.getAttachedLicenseTerms( + ipIdChild, + 0 + ); + assertEq(licenseTemplateChild, licenseTemplateParent); + assertEq(licenseTermsIdChild, licenseTermsIdParent); + assertEq(IIPAccount(payable(ipIdChild)).owner(), caller); + assertParentChild({ + ipIdParent: ipIdParent, + ipIdChild: ipIdChild, + expectedParentCount: 1, + expectedParentIndex: 0 + }); + } + } + /// @dev Assert metadata for the SPGNFT. function assertSPGNFTMetadata(uint256 tokenId, string memory expectedMetadata) internal { assertEq(nftContract.tokenURI(tokenId), expectedMetadata); @@ -580,6 +691,7 @@ contract StoryProtocolGatewayTest is BaseTest { }); assertTrue(ipAssetRegistry.isRegistered(ipIdChild)); assertEq(tokenIdChild, 2); + assertSPGNFTMetadata(tokenIdChild, ipMetadataDefault.nftMetadataURI); assertMetadata(ipIdChild, ipMetadataDefault); (address licenseTemplateChild, uint256 licenseTermsIdChild) = licenseRegistry.getAttachedLicenseTerms( ipIdChild, @@ -646,6 +758,7 @@ contract StoryProtocolGatewayTest is BaseTest { }); assertEq(ipIdChildActual, ipIdChild); assertTrue(ipAssetRegistry.isRegistered(ipIdChild)); + assertSPGNFTMetadata(tokenIdChild, ipMetadataEmpty.nftMetadataURI); assertMetadata(ipIdChild, ipMetadataDefault); (address licenseTemplateChild, uint256 licenseTermsIdChild) = licenseRegistry.getAttachedLicenseTerms( ipIdChild,