generated from storyprotocol/solidity-template
-
Notifications
You must be signed in to change notification settings - Fork 10
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Royalty Term Processor and E2E test (#133)
- Loading branch information
1 parent
1d52be9
commit 2aabf9f
Showing
5 changed files
with
389 additions
and
1 deletion.
There are no files selected for viewing
61 changes: 61 additions & 0 deletions
61
contracts/modules/licensing/terms/RoyaltyTermsProcessor.sol
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
// SPDX-License-Identifier: BUSL-1.1 | ||
pragma solidity ^0.8.19; | ||
|
||
import { BaseTermsProcessor } from "./BaseTermsProcessor.sol"; | ||
import { IRoyaltyDistributor } from "contracts/modules/royalties/IRoyaltyDistributor.sol"; | ||
import { IIPAccountRegistry } from "contracts/ip-accounts/IIPAccountRegistry.sol"; | ||
import { IRoyaltyProportionPolicy } from "contracts/modules/royalties/policies/IRoyaltyProportionPolicy.sol"; | ||
import { EmptyArray, LengthMismatch } from "contracts/errors/General.sol"; | ||
|
||
struct RoyaltyTermsConfig { | ||
address payerNftContract; | ||
uint256 payerTokenId; | ||
address[] accounts; | ||
uint32[] allocationPercentages; | ||
bool isExecuted; | ||
} | ||
|
||
|
||
/// @title RoyaltyTermsProcessor | ||
/// @notice Processor to set royalty split plan for Licensee. | ||
/// the percentage of income from licensee will pay back to the licensor | ||
contract RoyaltyTermsProcessor is BaseTermsProcessor { | ||
IRoyaltyDistributor public immutable ROYALTY_DISTRIBUTOR; | ||
|
||
constructor(address authorizedExecutor, address royaltyDistributor) BaseTermsProcessor(authorizedExecutor) { | ||
ROYALTY_DISTRIBUTOR = IRoyaltyDistributor(royaltyDistributor); | ||
} | ||
|
||
/// @dev Parse license terms and translate license terms into Royalty split | ||
function _executeTerms(bytes calldata data) internal virtual override returns (bytes memory newData) { | ||
RoyaltyTermsConfig memory config = abi.decode(data, (RoyaltyTermsConfig)); | ||
if (config.accounts.length == 0) { | ||
revert EmptyArray(); | ||
} | ||
|
||
if (config.accounts.length != config.allocationPercentages.length) { | ||
revert LengthMismatch(); | ||
} | ||
IRoyaltyProportionPolicy.ProportionData memory policyData = IRoyaltyProportionPolicy.ProportionData({ | ||
accounts: config.accounts, | ||
percentAllocations: config.allocationPercentages | ||
}); | ||
ROYALTY_DISTRIBUTOR.updateDistribution(config.payerNftContract, config.payerTokenId, abi.encode(policyData)); | ||
|
||
RoyaltyTermsConfig memory newConfig = RoyaltyTermsConfig({ | ||
payerNftContract: config.payerNftContract, | ||
payerTokenId: config.payerTokenId, | ||
accounts: config.accounts, | ||
allocationPercentages: config.allocationPercentages, | ||
isExecuted: true | ||
}); | ||
|
||
newData = abi.encode(newConfig); | ||
} | ||
|
||
/// @dev Return true if the terms exec executed with any errors. | ||
function termsExecutedSuccessfully(bytes calldata data) external pure override returns (bool) { | ||
RoyaltyTermsConfig memory config = abi.decode(data, (RoyaltyTermsConfig)); | ||
return config.isExecuted; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,177 @@ | ||
// SPDX-License-Identifier: BUSDL-1.1 | ||
pragma solidity ^0.8.13; | ||
|
||
import "forge-std/Test.sol"; | ||
import "contracts/ip-accounts/IIPAccount.sol"; | ||
import "contracts/ip-accounts/IPAccountImpl.sol"; | ||
import "contracts/ip-accounts/IPAccountRegistry.sol"; | ||
import "contracts/modules/royalties/RoyaltyDistributor.sol"; | ||
import "contracts/modules/royalties/RoyaltyNFT.sol"; | ||
import "contracts/modules/royalties/ISplitMain.sol"; | ||
import "contracts/modules/royalties/policies/MutableRoyaltyProportionPolicy.sol"; | ||
import "contracts/modules/licensing/terms/RoyaltyTermsProcessor.sol"; | ||
import '../utils/BaseTest.sol'; | ||
import '../mocks/MockLicensingModule.sol'; | ||
import '../mocks/MockTermsProcessor.sol'; | ||
import '../mocks/RightsManagerHarness.sol'; | ||
import "../mocks/MockERC721.sol"; | ||
import "../mocks/MockERC20.sol"; | ||
import "contracts/errors/General.sol"; | ||
|
||
contract E2ETest is BaseTest { | ||
RoyaltyDistributor public royaltyDistributor; | ||
IPAccountRegistry public registry; | ||
IPAccountImpl public implementation; | ||
RoyaltyNFT public royaltyNft; | ||
ISplitMain public splitMain; | ||
MutableRoyaltyProportionPolicy public mutablePolicy; | ||
MockERC20 public mERC20; | ||
RoyaltyTermsProcessor royaltyTermsProcessor; | ||
|
||
function setUp() virtual override public { | ||
string memory mainnetRpc; | ||
try vm.envString("MAINNET_RPC_URL") returns (string memory rpcUrl) { | ||
mainnetRpc = rpcUrl; | ||
} catch { | ||
mainnetRpc = "https://eth-mainnet.g.alchemy.com/v2/demo"; | ||
} | ||
console.log(mainnetRpc); | ||
uint256 mainnetFork = vm.createFork(mainnetRpc); | ||
vm.selectFork(mainnetFork); | ||
assertEq(vm.activeFork(), mainnetFork); | ||
console.log(block.number); | ||
// using the existing SplitMain | ||
splitMain = ISplitMain(0x2ed6c4B5dA6378c7897AC67Ba9e43102Feb694EE); | ||
royaltyNft = new RoyaltyNFT(address(splitMain)); | ||
implementation = new IPAccountImpl(); | ||
registry = new IPAccountRegistry(address(implementation)); | ||
royaltyDistributor = new RoyaltyDistributor(address(registry), address(royaltyNft)); | ||
mutablePolicy = new MutableRoyaltyProportionPolicy(address(royaltyNft)); | ||
mERC20 = new MockERC20("Royalty Token", "RTK", 18); | ||
deployProcessors = false; | ||
super.setUp(); | ||
royaltyTermsProcessor = new RoyaltyTermsProcessor(address(ipAssetRegistry), address(royaltyDistributor)); | ||
} | ||
|
||
function test_e2e() public { | ||
uint256 rootIpAssetId = ipAssetRegistry.createIPAsset( | ||
IPAsset(1), | ||
"rootIPAssetName", | ||
"The root IP Asset description", | ||
"http://root.ipasset.media.url", | ||
alice, | ||
0 | ||
); | ||
|
||
uint256 ipAssetId = ipAssetRegistry.createIPAsset( | ||
IPAsset(1), | ||
"DerivativeIPAssetName", | ||
"The derivative IP Asset description", | ||
"http://derivative.ipasset.media.url", | ||
bob, | ||
rootIpAssetId | ||
); | ||
|
||
uint256 parentLicenseId = ipAssetRegistry.getLicenseIdByTokenId(rootIpAssetId, true); | ||
address rootIpAccount = registry.createAccount(block.chainid, address(ipAssetRegistry), rootIpAssetId, ""); | ||
address derivativeIpAccount = registry.createAccount(block.chainid, address(ipAssetRegistry), ipAssetId, ""); | ||
// build Royalty Terms Config | ||
address[] memory royaltyAccounts = new address[](2); | ||
uint32[] memory royaltyPercentages = new uint32[](2); | ||
royaltyAccounts[0] = rootIpAccount; | ||
royaltyAccounts[1] = derivativeIpAccount; | ||
royaltyPercentages[0] = 100000; | ||
royaltyPercentages[1] = 900000; | ||
RoyaltyTermsConfig memory termsConfig = RoyaltyTermsConfig({ | ||
payerNftContract: address(ipAssetRegistry), | ||
payerTokenId: ipAssetId, | ||
accounts: royaltyAccounts, | ||
allocationPercentages : royaltyPercentages, | ||
isExecuted: false | ||
}); | ||
|
||
// Create license | ||
vm.prank(alice); | ||
uint256 licenseId = ipAssetRegistry.createLicense( | ||
ipAssetId, | ||
parentLicenseId, | ||
alice, | ||
"https://derivative.commercial.license.uri", | ||
revoker, | ||
true, | ||
true, | ||
IERC5218.TermsProcessorConfig({ | ||
processor: royaltyTermsProcessor, | ||
data: abi.encode(termsConfig) | ||
}) | ||
); | ||
bool commercial = true; | ||
(RightsManager.License memory license, address owner) = ipAssetRegistry.getLicense(licenseId); | ||
assertEq(licenseId, 4); | ||
assertEq(owner, alice); | ||
assertEq(license.active, true); | ||
assertEq(license.canSublicense, true); | ||
assertEq(license.commercial, commercial); | ||
assertEq(license.parentLicenseId, parentLicenseId); | ||
assertEq(license.tokenId, ipAssetId); | ||
assertEq(license.revoker, revoker); | ||
assertEq(license.uri, "https://derivative.commercial.license.uri"); | ||
assertEq(address(license.termsProcessor), address(royaltyTermsProcessor)); | ||
assertEq(license.termsData, abi.encode(termsConfig)); | ||
assertEq(licenseRegistry.ownerOf(licenseId), alice); | ||
|
||
royaltyDistributor.setRoyaltyPolicy(address(ipAssetRegistry), ipAssetId, address(mutablePolicy), ""); | ||
|
||
// execute license terms | ||
vm.startPrank(alice); | ||
ipAssetRegistry.executeTerms(licenseId); | ||
licenseRegistry.transferFrom(alice, bob, licenseId); | ||
vm.stopPrank(); | ||
|
||
assertEq(ipAssetRegistry.isLicenseActive(licenseId), true); | ||
assertEq(royaltyNft.balanceOf(rootIpAccount, royaltyNft.toTokenId(derivativeIpAccount)), 100000); | ||
assertEq(royaltyNft.balanceOf(derivativeIpAccount, royaltyNft.toTokenId(derivativeIpAccount)), 900000); | ||
|
||
mERC20.mint(10000); | ||
mERC20.transfer(derivativeIpAccount, 10000); | ||
assertEq(mERC20.balanceOf(derivativeIpAccount), 10000); | ||
vm.startPrank(derivativeIpAccount); | ||
mERC20.approve(address(royaltyNft), 10000); | ||
vm.stopPrank(); | ||
royaltyDistributor.distribute(address(ipAssetRegistry), ipAssetId, address(mERC20)); | ||
// splitMain always reserve 1 as minimal balance | ||
assertEq(splitMain.getERC20Balance(rootIpAccount, mERC20), 999); | ||
assertEq(splitMain.getERC20Balance(derivativeIpAccount, mERC20), 8999); | ||
assertEq(mERC20.balanceOf(rootIpAccount), 0); | ||
assertEq(mERC20.balanceOf(derivativeIpAccount), 0); | ||
|
||
royaltyDistributor.claim(rootIpAccount, address(mERC20)); | ||
assertEq(mERC20.balanceOf(rootIpAccount), 998); | ||
royaltyDistributor.claim(derivativeIpAccount, address(mERC20)); | ||
assertEq(mERC20.balanceOf(derivativeIpAccount),8998); | ||
} | ||
|
||
function _getLicensingConfig() view internal virtual override returns (ILicensingModule.FranchiseConfig memory) { | ||
return ILicensingModule.FranchiseConfig({ | ||
nonCommercialConfig: ILicensingModule.IpAssetConfig({ | ||
canSublicense: true, | ||
franchiseRootLicenseId: 0 | ||
}), | ||
nonCommercialTerms: IERC5218.TermsProcessorConfig({ | ||
processor: nonCommercialTermsProcessor, | ||
data: abi.encode("nonCommercial") | ||
}), | ||
commercialConfig: ILicensingModule.IpAssetConfig({ | ||
canSublicense: true, | ||
franchiseRootLicenseId: 0 | ||
}), | ||
commercialTerms: IERC5218.TermsProcessorConfig({ | ||
processor: commercialTermsProcessor, | ||
data: abi.encode("commercial") | ||
}), | ||
rootIpAssetHasCommercialRights: true, | ||
revoker: revoker, | ||
commercialLicenseUri: "uriuri" | ||
}); | ||
} | ||
} |
101 changes: 101 additions & 0 deletions
101
test/foundry/licensing/terms/RoyaltyTermsProcessor.t.sol
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
// SPDX-License-Identifier: BUSDL-1.1 | ||
pragma solidity ^0.8.13; | ||
|
||
import "forge-std/Test.sol"; | ||
import 'test/foundry/utils/BaseTest.sol'; | ||
import "contracts/errors/General.sol"; | ||
import "contracts/modules/licensing/terms/RoyaltyTermsProcessor.sol"; | ||
import "test/foundry/mocks/MockRoyaltyDistributor.sol"; | ||
|
||
contract RoyaltyTermsProcessorTest is BaseTest { | ||
address licenseHolder = address(0x888888); | ||
RoyaltyTermsProcessor processor; | ||
uint256 licenseId; | ||
uint256 ipAssetId; | ||
uint256 parentLicenseId; | ||
MockRoyaltyDistributor royaltyDistributor; | ||
|
||
address[] accounts; | ||
uint32[] allocationPercentages; | ||
|
||
|
||
function setUp() virtual override public { | ||
deployProcessors = false; | ||
super.setUp(); | ||
royaltyDistributor = new MockRoyaltyDistributor(); | ||
ipAssetId = ipAssetRegistry.createIPAsset(IPAsset(1), "name", "description", "mediaUrl", licenseHolder, 0); | ||
parentLicenseId = ipAssetRegistry.getLicenseIdByTokenId(ipAssetId, false); | ||
processor = new RoyaltyTermsProcessor(address(ipAssetRegistry), address(royaltyDistributor)); | ||
} | ||
|
||
function test_revert_execute_terms_unauthorized() public { | ||
bytes memory data = abi.encode(1); | ||
vm.expectRevert(Unauthorized.selector); | ||
processor.executeTerms(data); | ||
} | ||
|
||
function test_revert_execute_terms_empty_accounts() public { | ||
RoyaltyTermsConfig memory termsConfig = RoyaltyTermsConfig({ | ||
payerNftContract: address(ipAssetRegistry), | ||
payerTokenId: ipAssetId, | ||
accounts: accounts, | ||
allocationPercentages : allocationPercentages, | ||
isExecuted: false | ||
}); | ||
vm.prank(address(ipAssetRegistry)); | ||
vm.expectRevert(EmptyArray.selector); | ||
processor.executeTerms(abi.encode(termsConfig)); | ||
} | ||
|
||
function test_revert_execute_terms_mismatch_accounts_allocations() public { | ||
accounts = [address(0x888888)]; | ||
allocationPercentages = [100000, 900000]; | ||
RoyaltyTermsConfig memory termsConfig = RoyaltyTermsConfig({ | ||
payerNftContract: address(ipAssetRegistry), | ||
payerTokenId: ipAssetId, | ||
accounts: accounts, | ||
allocationPercentages : allocationPercentages, | ||
isExecuted: false | ||
}); | ||
vm.prank(address(ipAssetRegistry)); | ||
vm.expectRevert(LengthMismatch.selector); | ||
processor.executeTerms(abi.encode(termsConfig)); | ||
} | ||
|
||
function test_execute_terms_start_on_license_creation() public { | ||
accounts = [address(0x777777), address(0x888888)]; | ||
allocationPercentages = [100000, 900000]; | ||
RoyaltyTermsConfig memory config = RoyaltyTermsConfig({ | ||
payerNftContract: address(ipAssetRegistry), | ||
payerTokenId: ipAssetId, | ||
accounts: accounts, | ||
allocationPercentages : allocationPercentages, | ||
isExecuted: false | ||
}); | ||
|
||
IERC5218.TermsProcessorConfig memory termsConfig = IERC5218.TermsProcessorConfig({ | ||
processor: processor, | ||
data: abi.encode(config) | ||
}); | ||
|
||
assertFalse(processor.termsExecutedSuccessfully(abi.encode(config)), "terms should be inactive before start time"); | ||
|
||
vm.prank(licenseHolder); | ||
licenseId = ipAssetRegistry.createLicense( | ||
ipAssetId, | ||
parentLicenseId, | ||
licenseHolder, | ||
"licenseUri", | ||
revoker, | ||
false, | ||
false, | ||
termsConfig | ||
); | ||
vm.prank(licenseHolder); | ||
ipAssetRegistry.executeTerms(licenseId); | ||
assertTrue(ipAssetRegistry.isLicenseActive(licenseId), "execution terms should make license active"); | ||
(RightsManager.License memory license, address owner) = ipAssetRegistry.getLicense(licenseId); | ||
assertEq(owner, licenseHolder); | ||
assertTrue(processor.termsExecutedSuccessfully(license.termsData), "execution terms should executed successfully."); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
// SPDX-License-Identifier: BUSL-1.1 | ||
pragma solidity ^0.8.18; | ||
|
||
import "contracts/modules/royalties/IRoyaltyDistributor.sol"; | ||
import { CollectModuleBase } from "contracts/modules/collect/CollectModuleBase.sol"; | ||
|
||
/// @title Mock Royalty Distributor | ||
/// @notice This mock contract is used for testing the Royalty Terms Processor. | ||
contract MockRoyaltyDistributor is IRoyaltyDistributor { | ||
address public defaultRoyaltyPolicy; | ||
/// @notice Set royalty policy to specified IP Asset. | ||
/// @param royaltyPolicy The royalty distribution policy. | ||
function setRoyaltyPolicy( | ||
address, | ||
uint256, | ||
address royaltyPolicy, | ||
bytes calldata | ||
) external { | ||
defaultRoyaltyPolicy = royaltyPolicy; | ||
} | ||
|
||
/// @notice Get royalty policy for specified IP Asset. | ||
/// @return The address of royalty distribution policy. | ||
function getRoyaltyPolicy(address, uint256) external view returns (address) { | ||
return defaultRoyaltyPolicy; | ||
} | ||
|
||
/// @notice update royalty distribution plan for given IP Asset. | ||
/// @param nftContract address of NFT collection contract. | ||
/// @param tokenId The NFT token Id of NFT collection contract. | ||
/// @param data The royalty distribution plan data. | ||
function updateDistribution(address nftContract, uint256 tokenId, bytes calldata data) external {} | ||
|
||
/// @notice distribute royalty to each recipient according to royalty distribution plan for given IP Asset. | ||
/// @param nftContract address of NFT collection contract. | ||
/// @param tokenId The NFT token Id of NFT collection contract. | ||
/// @param token The ERC20 token for royalty. | ||
function distribute(address nftContract, uint256 tokenId, address token) external {} | ||
|
||
/// @notice claim royalty to account. | ||
/// @param account address of the account to which withdraw royalty which distributed before. | ||
function claim(address account, address token) external {} | ||
|
||
/// @notice pause the royalty distribution. | ||
function pause() external {} | ||
|
||
/// @notice unpause the royalty distribution. | ||
function unpause() external {} | ||
} |
Oops, something went wrong.