Decode Story Protocol

Decode Story Protocol

Tags
Web3
License
Published
April 29, 2024
Author
Senn

Overview

Story Protocol is a solution aimed at streamlining the legal system for creative Intellectual Property (IP) by leveraging blockchain technology

Introduction

Story Protocol seeks to make the management of creative IP more efficient. It does this by creating a programmable system on the blockchain, allowing for an API-like interaction where IP can be licensed, remixed, and monetized according to terms set by the creators themselves.

Current Problem

Currently, protecting IP is a complex legal process that involves lawyers and is both expensive and time-consuming. Additionally, licensing IP to others for growth is challenging due to the need for one-to-one licensing deals, which are costly and not scalable, often preventing most licensing from occurring.

How to Sovle

IP Assets

Creators can register their IP on Story Protocol as "IP Assets" (IPA). Each IPA consists of:
  • An on-chain NFT that represents the IP, which could be an existing NFT like BAYC or a new NFT representing off-chain IP.
  • An associated IP Account, which is a modified ERC-6551 implementation (Token Bound Account).

Modules

Interaction with IP Assets is facilitated through various "modules," including licensing, royalty, and dispute modules. These allow the IP Asset owner to set terms for the use of their IP, such as commercial use permissions and costs. Derivative creators can then mint "License Tokens" (which are also NFTs), create revenue streams, or raise disputes.

Programmable IP License (PIL)

The on-chain terms and License Tokens are enforced by an off-chain legal contract known as the PIL, which provides real legal terms for how creators can use, monetize, and create derivatives of their IP.

Automation and Enforcement

The combination of IP Assets and modules automates and enforces the terms set out in the PIL, creating a bridge between legal agreements and blockchain technology.

Simplified Process

Before Story Protocol, legal complexities made it difficult for creators to collaborate and license IP. With Story Protocol, holders of IPs like Azuki & Pudgy can register their assets, set licensing terms using the PIL, and allow others to automatically license and create derivative works with a clear, on-chain agreement.
 

What you can do using Story Protocol

Register IP license policy

User can register specific IP policy, defining details of policy such as derivative IP, commercial usage, allowed territory, license minting fee, etc. Also it supports to program policy’s remix, for example, when one IP inherits two parent IPs with differnet policies, the remix action can be programmed.

Allow public or private license mint

IP can set its policy, defining details about derivative IP, commercial usage, allowed territory, license minting fee, etc. Other IP can interact with contract to mint license and link to parent IP without manual permission which reduce cost greatly. Also story protocol supports private policy, this is useful when an IP wants to both give permissionless and limited license, which increase the flexibility of the protocol.

Policy Remix

Policy’s remix can be programmed, so that when IP inhertis multiple parent IPs, they don’t need to negotiate with all the parents, they can just read the remix rules defined on the blockchain and make the remix decision.

Derivative IP

Story protocol supports derivative IP, the detailed rule of creating derivative IP is defined in individual policy, like whether allow indefinie derivative IP, whether creating derivative IP needs approval.

Royalty

Royalty can be programmed in the story protocol. And avenue can be transparently distribtued to ancestors of IP according to their shares which are calculated based on each ancestor’s royalty policy. Story protocol takes notice of the need of different type of royalty policies, so it supports to pragram various royalty policy in the framework.
 
In conclusion, Story Protocol aims to revolutionize how creators manage and derive value from their IP by making the process more accessible, transparent, and automated, thus lowering barriers to entry for creators and fostering a more collaborative and monetizable environment for creative works.

Contract Architecture

notion image

Contract

name
address
RegistrationModule
PILPolicyFrameworkManager
LicensingModule
IpResolver
IPAssetRegistry
ERC6551Registry
IPAccountImpl
MetadataProviderV1
ModuleRegistry
Governance
RoyaltyPolicyLAP
AncestorsVaultLAP
LiquidSplitFactory
LS1155CloneImpl
SplitMain

Tx

action
txHash
Mint NFT
Register IP Asset
Register PIL Policy
Add policy to IP Asset
Mint Licence NFT
Register Derivative IP Asset
IP NFT contract: 0x7ee32b8B515dEE0Ba2F25f612A04a731eEc24F49
IP NFT ID: 2321
Licence NFT ID: 635
IP account: 0x66875ccE0d3f8d07e8Ad41579F73F7c0500853ea

Code analysis

Register IP

User calls RegistrationModule.registerRootIp to register a ERC721 NFT as root IP. In this process, it creates ERC6551 account of this NFT, check parent’s license, link IP asset to its parent and set royalty.
notion image

RegistrationModule.registerRootIp

Inside the RegistrationModule.registerRootIp:
  • Check the caller is the owner of the NFT, or he/she has been approved by the owner.
  • Call IPAssetRegistry.register to register IP.
  • If callers has specified policy, then contract will add policy to this IP.
/// story-protocol/protocol-core/contracts/modules/RegistrationModule.sol /// @notice Registers a root-level IP into the protocol. Root-level IPs can be thought of as organizational hubs /// for encapsulating policies that actual IPs can use to register through. As such, a root-level IP is not an /// actual IP, but a container for IP policy management for their child IP assets. /// @param policyId The policy that identifies the licensing terms of the IP. /// @param tokenContract The address of the NFT bound to the root-level IP. /// @param tokenId The token id of the NFT bound to the root-level IP. /// @param ipName The name assigned to the new IP. /// @param contentHash The content hash of the IP being registered. /// @param externalURL An external URI to link to the IP. function registerRootIp( uint256 policyId, address tokenContract, uint256 tokenId, string memory ipName, bytes32 contentHash, string calldata externalURL ) external returns (address) { // Perform registrant authorization. // Check that the caller is authorized to perform the registration. // TODO: Perform additional registration authorization logic, allowing // registrants or root-IP creators to specify their own auth logic. address owner = IERC721(tokenContract).ownerOf(tokenId); if (msg.sender != owner && !IERC721(tokenContract).isApprovedForAll(owner, msg.sender)) { revert Errors.RegistrationModule__InvalidOwner(); } bytes memory metadata = abi.encode( IP.MetadataV1({ name: ipName, hash: contentHash, registrationDate: uint64(block.timestamp), registrant: msg.sender, uri: externalURL }) ); // Perform core IP registration and IP account creation. address ipId = _IP_ASSET_REGISTRY.register( block.chainid, tokenContract, tokenId, address(ipResolver), true, metadata ); // Perform core IP policy creation. if (policyId != 0) { // If we know the policy ID, we can register it directly on creation. // TODO: return policy index _LICENSING_MODULE.addPolicyToIp(ipId, policyId); } emit RootIPRegistered(msg.sender, ipId, policyId); return ipId; }
 

IPAssetRegistry.register

Inside the IPAssetRegistry.register:
  • calls _register and emit IPRegistered event
// story-protocol/protocol-core/contracts/registries/IPAssetRegistry.sol /// @notice Registers an NFT as IP, creating a corresponding IP record. /// @param chainId The chain identifier of where the NFT resides. /// @param tokenContract The address of the NFT. /// @param tokenId The token identifier of the NFT. /// @param resolverAddr The address of the resolver to associate with the IP. /// @param createAccount Whether to create an IP account when registering. /// @param metadata_ Metadata in bytes to associate with the IP. /// @return ipId_ The address of the newly registered IP. function register( uint256 chainId, address tokenContract, uint256 tokenId, address resolverAddr, bool createAccount, bytes calldata metadata_ ) external returns (address ipId_) { ipId_ = _register( new uint256[](0), "", chainId, tokenContract, tokenId, resolverAddr, createAccount, metadata_ ); emit IPRegistered(ipId_, chainId, tokenContract, tokenId, resolverAddr, address(_metadataProvider), metadata_); }
 
Inside IPAssetRegistry._register:
  • get the id of the NFT which is the ERC6551 account address of the NFT. It calls the ERC6551 registry contract to calculate the corresponding address.
  • check whether NFT has been registered as IP.
  • get the owner of NFT, check whether caller has authority to register this NFT as IP.
  • if the NFT has not been registered as IP, and createAccount is true, then register it.
  • bind resolver to the NFT.
  • bind metadata to the NFT.
  • if license IP are specified which means this IP has parent, then link this NFT to its parent via license.
// story-protocol/protocol-core/contracts/registries/IPAssetRegistry.sol /// @dev Registers an NFT as an IP. /// @param licenseIds IP asset licenses used to derive the new IP asset, if any. /// @param royaltyContext The context for the royalty module to process. /// @param chainId The chain identifier of where the NFT resides. /// @param tokenContract The address of the NFT. /// @param tokenId The token identifier of the NFT. /// @param resolverAddr The address of the resolver to associate with the IP. /// @param createAccount Whether to create an IP account when registering. /// @param data Canonical metadata to associate with the IP. function _register( uint256[] memory licenseIds, bytes memory royaltyContext, uint256 chainId, address tokenContract, uint256 tokenId, address resolverAddr, bool createAccount, bytes calldata data ) internal returns (address id) { // get the address if the ERC6551 account of the NFT. id = ipId(chainId, tokenContract, tokenId); // check the NFT hasn't been registered. if (_records[id].resolver != address(0)) { revert Errors.IPAssetRegistry__AlreadyRegistered(); } // get the owner of the NFT address _owner = IERC721(tokenContract).ownerOf(tokenId); // check the authority of the msg.sender if ( msg.sender != _owner && msg.sender != address(REGISTRATION_MODULE) && !isApprovedForAll[_owner][msg.sender] ) { revert Errors.IPAssetRegistry__RegistrantUnauthorized(); } if (id.code.length == 0 && createAccount && id != registerIpAccount(chainId, tokenContract, tokenId)) { revert Errors.IPAssetRegistry__InvalidAccount(); } _setResolver(id, resolverAddr); _setMetadata(id, _metadataProvider, data); totalSupply++; // if license is specified which means the NFT IP has parents, then link to parents. if (licenseIds.length != 0) { ILicensingModule licensingModule = ILicensingModule(MODULE_REGISTRY.getModule(LICENSING_MODULE_KEY)); licensingModule.linkIpToParents(licenseIds, id, royaltyContext); } } // get the corresponding ERC6551 account of this NFT. function ipId(uint256 chainId, address tokenContract, uint256 tokenId) public view returns (address) { return super.ipAccount(chainId, tokenContract, tokenId); }
 
IP_ACCOUNT_IMPL is the story protocol’s ERC6551 account implementation address.
IP_ACCOUNT_SALT is salt used in NFT account address calculation in the ERC6551 protocol.
// story-protocol/protocol-core/contracts/registries/IPAccountRegistry.sol function ipAccount(uint256 chainId, address tokenContract, uint256 tokenId) public view returns (address) { return _get6551AccountAddress(chainId, tokenContract, tokenId); } /// @dev Helper function to get the IPAccount address from the ERC6551 registry. function _get6551AccountAddress( uint256 chainId, address tokenContract, uint256 tokenId ) internal view returns (address) { return IERC6551Registry(ERC6551_PUBLIC_REGISTRY).account( IP_ACCOUNT_IMPL, IP_ACCOUNT_SALT, chainId, tokenContract, tokenId ); }
 

IPAccountRegistry.registerIpAccount

Inside the IPAccountRegistry.registerIpAccount, it calls ERC6551 registry to register account of the NFT.
// story-protocol/protocol-core/contracts/registries/IPAccountRegistry.sol /// @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 ipAccountAddress The address of the newly created IP Account function registerIpAccount( uint256 chainId, address tokenContract, uint256 tokenId ) public returns (address ipAccountAddress) { ipAccountAddress = IERC6551Registry(ERC6551_PUBLIC_REGISTRY).createAccount( IP_ACCOUNT_IMPL, IP_ACCOUNT_SALT, chainId, tokenContract, tokenId ); emit IPAccountRegistered(ipAccountAddress, IP_ACCOUNT_IMPL, chainId, tokenContract, tokenId); }
 
IPAssetRegistry._setResolver checks the resolver’s interface and set IP asset’s resolver.
// story-protocol/protocol-core/contracts/registries/IPAssetRegistry.sol /// @dev Sets the resolver for the specified IP. /// @param id The canonical ID of the IP. /// @param resolverAddr The address of the resolver being set. function _setResolver(address id, address resolverAddr) internal { ERC165Checker.supportsInterface(resolverAddr, type(IResolver).interfaceId); _records[id].resolver = resolverAddr; emit IPResolverSet(id, resolverAddr); }
 
IPAssetRegistry._setMetadata sets the metadata provider of the IP asset.
// story-protocol/protocol-core/contracts/registries/IPAssetRegistry.sol /// @dev Sets the for the specified IP asset. /// @param id The canonical identifier for the specified IP asset. /// @param provider The metadata provider hosting the data. /// @param data The metadata to set for the IP asset. function _setMetadata(address id, IMetadataProviderMigratable provider, bytes calldata data) internal { _records[id].metadataProvider = provider; provider.setMetadata(id, data); emit MetadataSet(id, address(provider), data); }
 
Inside the MetadataProviderV1.setMetadata , it records metadata of this IP asset.
// story-protocol/protocol-core/contracts/registries/metadata/MetadataProviderV1.sol /// @notice Sets the metadata associated with an IP asset. /// @dev Enforced to be only callable by the IP asset registry. /// @param ipId The address identifier of the IP asset. /// @param metadata The metadata in bytes to associate with the IP asset. function setMetadata(address ipId, bytes memory metadata) external virtual onlyIPAssetRegistry { _verifyMetadata(metadata); _ipMetadata[ipId] = metadata; emit MetadataSet(ipId, metadata); } /// @dev Checks that the data conforms to the canonical metadata standards. /// @param metadata The canonical metadata in bytes to verify. function _verifyMetadata(bytes memory metadata) internal virtual;
 

licensingModule.linkIpToParents

Inside the IPAssetRegistry._register, it calls MODULE_REGISTRY to get address of license module.
// story-protocol/protocol-core/contracts/registries/IPAssetRegistry.sol function _register( uint256[] memory licenseIds, bytes memory royaltyContext, uint256 chainId, address tokenContract, uint256 tokenId, address resolverAddr, bool createAccount, bytes calldata data ) internal returns (address id) { // ... if (licenseIds.length != 0) { ILicensingModule licensingModule = ILicensingModule(MODULE_REGISTRY.getModule(LICENSING_MODULE_KEY)); licensingModule.linkIpToParents(licenseIds, id, royaltyContext); } }
 
MODULE_REGISTRY is a contract registers module’s address, only protocol admin can add and remove.
// story-protocol/protocol-core/contracts/registries/ModuleRegistry.sol /// @dev Returns the address of a registered module by its name. mapping(string moduleName => address moduleAddress) internal modules; /// @notice Returns the address of a module. /// @param name The name of the module. /// @return The address of the module. function getModule(string memory name) external view returns (address) { return modules[name]; }
 
IPAssetRegistry calls LicensingModule.linkIpToParents to link IP asset to its parent. This process will check license, link IP asset to its parent and set royalty. We’ll analyze this process later.
// story-protocol/protocol-core/contracts/modules/licensing/LicensingModule.sol /// @notice Links an IP to the licensors listed in the license NFTs, if their policies allow it. Burns the license /// NFTs in the proccess. The caller must be the owner of the IP asset and license NFTs. /// @param licenseIds The id of the licenses to burn /// @param childIpId The id of the child IP to be linked /// @param royaltyContext The context for the royalty module to process function linkIpToParents( uint256[] calldata licenseIds, address childIpId, bytes calldata royaltyContext ) external nonReentrant verifyPermission(childIpId) { _verifyIpNotDisputed(childIpId); address holder = IIPAccount(payable(childIpId)).owner(); address[] memory licensors = new address[](licenseIds.length); bytes[] memory royaltyData = new bytes[](licenseIds.length); // If royalty policy address is address(0), this means no royalty policy to set. address royaltyAddressAcc = address(0); for (uint256 i = 0; i < licenseIds.length; i++) { if (LICENSE_REGISTRY.isLicenseRevoked(licenseIds[i])) { revert Errors.LicensingModule__LinkingRevokedLicense(); } // This function: // - Verifies the license holder is the caller // - Verifies the license is valid (through IPolicyFrameworkManager) // - Verifies all licenses must have either no royalty policy or the same one. // (That's why we send the royaltyAddressAcc and get it as a return value). // Finally, it will add the policy to the child IP, and set the parent. (licensors[i], royaltyAddressAcc, royaltyData[i]) = _verifyRoyaltyAndLink( i, licenseIds[i], childIpId, holder, royaltyAddressAcc ); } emit IpIdLinkedToParents(msg.sender, childIpId, licensors); // Licenses unanimously require royalty, so we can call the royalty module if (royaltyAddressAcc != address(0)) { ROYALTY_MODULE.onLinkToParents(childIpId, royaltyAddressAcc, licensors, royaltyData, royaltyContext); } // Burn licenses LICENSE_REGISTRY.burnLicenses(holder, licenseIds); }
 

Register PIL policy

User can call PILPolicyFrameworkManager.registerPolicy to register a specific policy which can be bound to IP.
notion image
Inside the PILPolicyFrameworkManager.registerPolicy:
  • verify the consistency of params in the policy including commercialization side and derivative side.
  • call LICENSING_MODULE.registerPolicy to register policy.
Note that story protocol use PILPolicyFrameworkManager as the entry to register policy, and store the bytes representation of policy in the LicensingModule. This is because there may be new policies in the future to fit different kinds of needs, so the content of policy(the parameters) may change. Story protocol uses a modular design to encapsulate some critical policy related functionalies in each individual PILPolicyFrameworkManager, like policy’s verification and aggretion methods, only stores bytes representation of policy in the LicensingModule. In this way, it can be easily to develop new policy without upgrade the LicenseModule.
// story-protocol/protocol-core/contracts/modules/licensing/PILPolicyFrameworkManager.sol /// @notice Registers a new policy to the registry /// @dev Internally, this function must generate a Licensing.Policy struct and call registerPolicy. /// @param params parameters needed to register a PILPolicy /// @return policyId The ID of the newly registered policy function registerPolicy(RegisterPILPolicyParams calldata params) external nonReentrant returns (uint256 policyId) { /// Minting fee amount & address checked in LicensingModule, no need to check here. /// We don't limit charging for minting to commercial use, you could sell a NC license in theory. _verifyComercialUse(params.policy, params.royaltyPolicy, params.mintingFee, params.mintingFeeToken); _verifyDerivatives(params.policy); /// TODO: DO NOT deploy on production networks without hashing string[] values instead of storing them Licensing.Policy memory pol = Licensing.Policy({ isLicenseTransferable: params.transferable, policyFramework: address(this), frameworkData: abi.encode(params.policy), royaltyPolicy: params.royaltyPolicy, royaltyData: abi.encode(params.policy.commercialRevShare), mintingFee: params.mintingFee, mintingFeeToken: params.mintingFeeToken }); // No need to emit here, as the LicensingModule will emit the event return LICENSING_MODULE.registerPolicy(pol); }
// story-protocol/protocol-core/contracts/modules/licensing/PILPolicyFrameworkManager.sol /// @notice Licensing parameters for the Programmable IP License v1 (PIL) standard /// @param transferable Whether or not the license is transferable /// @param attribution Whether or not attribution is required when reproducing the work /// @param commercialUse Whether or not the work can be used commercially /// @param commercialAttribution Whether or not attribution is required when reproducing the work commercially /// @param commercializerChecker commercializers that are allowed to commercially exploit the work. If zero address, /// then no restrictions is enforced. /// @param commercialRevShare Percentage of revenue that must be shared with the licensor /// @param derivativesAllowed Whether or not the licensee can create derivatives of his work /// @param derivativesAttribution Whether or not attribution is required for derivatives of the work /// @param derivativesApproval Whether or not the licensor must approve derivatives of the work before they can be /// linked to the licensor IP ID /// @param derivativesReciprocal Whether or not the licensee must license derivatives of the work under the same terms. /// @param territories List of territories where the license is valid. If empty, global. /// @param distributionChannels List of distribution channels where the license is valid. Empty if no restrictions. /// @param contentRestrictions List of content restrictions. Empty if no restrictions. /// TODO: DO NOT deploy on production networks without hashing string[] instead of storing them struct PILPolicy { bool attribution; bool commercialUse; bool commercialAttribution; address commercializerChecker; bytes commercializerCheckerData; uint32 commercialRevShare; bool derivativesAllowed; bool derivativesAttribution; bool derivativesApproval; bool derivativesReciprocal; string[] territories; string[] distributionChannels; string[] contentRestrictions; } /// @param transferable Whether or not the license is transferable /// @param royaltyPolicy Address of a royalty policy contract (e.g. RoyaltyPolicyLS) that will handle royalty payments /// @param mintingFee Fee to be paid when minting a license /// @param mintingFeeToken Token to be used to pay the minting fee /// @param policy PILPolicy compliant licensing term values struct RegisterPILPolicyParams { bool transferable; address royaltyPolicy; uint256 mintingFee; address mintingFeeToken; PILPolicy policy; }
 

Check PIL parameters validity

_verifyComercialUse and _verifyDerivatives checks the consistency of params in the PIL.
// story-protocol/protocol-core/contracts/modules/licensing/PILPolicyFrameworkManager.sol /// @dev Checks the configuration of commercial use and throws if the policy is not compliant /// @param policy The policy to verify /// @param royaltyPolicy The address of the royalty policy // solhint-disable-next-line code-complexity function _verifyComercialUse( PILPolicy calldata policy, address royaltyPolicy, uint256 mintingFee, address mintingFeeToken ) internal view { if (!policy.commercialUse) { if (policy.commercialAttribution) { revert PILFrameworkErrors.PILPolicyFrameworkManager__CommercialDisabled_CantAddAttribution(); } if (policy.commercializerChecker != address(0)) { revert PILFrameworkErrors.PILPolicyFrameworkManager__CommercialDisabled_CantAddCommercializers(); } if (policy.commercialRevShare > 0) { revert PILFrameworkErrors.PILPolicyFrameworkManager__CommercialDisabled_CantAddRevShare(); } if (royaltyPolicy != address(0)) { revert PILFrameworkErrors.PILPolicyFrameworkManager__CommercialDisabled_CantAddRoyaltyPolicy(); } if (mintingFee > 0) { revert PILFrameworkErrors.PILPolicyFrameworkManager__CommercialDisabled_CantAddMintingFee(); } if (mintingFeeToken != address(0)) { revert PILFrameworkErrors.PILPolicyFrameworkManager__CommercialDisabled_CantAddMintingFeeToken(); } } else { if (royaltyPolicy == address(0)) { revert PILFrameworkErrors.PILPolicyFrameworkManager__CommercialEnabled_RoyaltyPolicyRequired(); } if (policy.commercializerChecker != address(0)) { if (!policy.commercializerChecker.supportsInterface(type(IHookModule).interfaceId)) { revert Errors.PolicyFrameworkManager__CommercializerCheckerDoesNotSupportHook( policy.commercializerChecker ); } IHookModule(policy.commercializerChecker).validateConfig(policy.commercializerCheckerData); } } } /// @notice Checks the configuration of derivative parameters and throws if the policy is not compliant /// @param policy The policy to verify function _verifyDerivatives(PILPolicy calldata policy) internal pure { if (!policy.derivativesAllowed) { if (policy.derivativesAttribution) { revert PILFrameworkErrors.PILPolicyFrameworkManager__DerivativesDisabled_CantAddAttribution(); } if (policy.derivativesApproval) { revert PILFrameworkErrors.PILPolicyFrameworkManager__DerivativesDisabled_CantAddApproval(); } if (policy.derivativesReciprocal) { revert PILFrameworkErrors.PILPolicyFrameworkManager__DerivativesDisabled_CantAddReciprocal(); } } }
 

Call LicensingModule to register PIL

Inside LicensingModule.registerPolicy:
  • verify caller is valid.
  • check parameters in policy is valid.
  • register policy if this is a new policy and get corresponding policy Id (update total policy count, register policy hash to Id, register policy Id to policy data.
// story-protocol/protocol-core/contracts/modules/licensing/LicensingModule.sol /// @notice A particular configuration (flavor) of a Policy Framework, setting values for the licensing /// terms (parameters) of the framework. /// @param isLicenseTransferable Whether or not the license is transferable /// @param policyFramework address of the IPolicyFrameworkManager this policy is based on /// @param frameworkData Data to be used by the policy framework to verify minting and linking /// @param royaltyPolicy address of the royalty policy to be used by the policy framework, if any /// @param royaltyData Data to be used by the royalty policy (for example, encoding of the royalty percentage) /// @param mintingFee Fee to be paid when minting a license /// @param mintingFeeToken Token to be used to pay the minting fee struct Policy { bool isLicenseTransferable; address policyFramework; bytes frameworkData; address royaltyPolicy; bytes royaltyData; uint256 mintingFee; address mintingFeeToken; } /// @dev Returns the policy id for the given policy data (hashed) mapping(bytes32 policyHash => uint256 policyId) private _hashedPolicies; /// @dev Returns the policy data for the given policy id mapping(uint256 policyId => Licensing.Policy policyData) private _policies; /// @notice Registers a policy into the contract. MUST be called by a registered framework or it will revert. /// The policy data and its integrity must be verified by the policy framework manager. /// @param pol The Licensing policy data. MUST have same policy framework as the caller address /// @return policyId The id of the newly registered policy function registerPolicy(Licensing.Policy memory pol) external returns (uint256 policyId) { _verifyRegisteredFramework(address(msg.sender)); if (pol.policyFramework != address(msg.sender)) { revert Errors.LicensingModule__RegisterPolicyFrameworkMismatch(); } if (pol.royaltyPolicy != address(0) && !ROYALTY_MODULE.isWhitelistedRoyaltyPolicy(pol.royaltyPolicy)) { revert Errors.LicensingModule__RoyaltyPolicyNotWhitelisted(); } if (pol.mintingFee > 0 && !ROYALTY_MODULE.isWhitelistedRoyaltyToken(pol.mintingFeeToken)) { revert Errors.LicensingModule__MintingFeeTokenNotWhitelisted(); } (uint256 polId, bool newPol) = DataUniqueness.addIdOrGetExisting( abi.encode(pol), _hashedPolicies, _totalPolicies ); if (newPol) { _totalPolicies = polId; _policies[polId] = pol; emit PolicyRegistered( polId, pol.policyFramework, pol.frameworkData, pol.royaltyPolicy, pol.royaltyData, pol.mintingFee, pol.mintingFeeToken ); } return polId; } /// @dev Verifies that the framework is registered in the LicensingModule function _verifyRegisteredFramework(address policyFramework) private view { if (!_registeredFrameworkManagers[policyFramework]) { revert Errors.LicensingModule__FrameworkNotFound(); } }
// story-protocol/protocol-core/contracts/lib/DataUniqueness.sol function addIdOrGetExisting( bytes memory data, mapping(bytes32 => uint256) storage _hashToIds, uint256 existingIds ) internal returns (uint256 id, bool isNew) { // We could just use the hash as id to save some gas, but the UX/DX of having huge random // numbers for ID is bad enough to justify the cost, plus we have a counter if we need to. bytes32 hash = keccak256(data); id = _hashToIds[hash]; if (id != 0) { return (id, false); } id = existingIds + 1; _hashToIds[hash] = id; return (id, true); }

Add policy to IP Asset

User can call LicensingModule.addPolicyToIp to add policy to IP.
notion image
Inside:
  • verify msg.sender has authority to add policy to this IP.
  • check policy has been defined.
  • check the IP’s dispute status. If the IP has dispute went through, then this IP can’t be added policy.
  • add policy to IP.
// story-protocol/protocol-core/contracts/modules/licensing/LicensingModule.sol /// @notice Adds a policy to the set of policies of an IP. Reverts if policy is undefined in LicenseRegistry. /// @param ipId The id of the IP /// @param polId The id of the policy /// @return indexOnIpId The index of the policy in the IP's policy list function addPolicyToIp( address ipId, uint256 polId ) external nonReentrant verifyPermission(ipId) returns (uint256 indexOnIpId) { if (!isPolicyDefined(polId)) { revert Errors.LicensingModule__PolicyNotFound(); } _verifyIpNotDisputed(ipId); return _addPolicyIdToIp({ ipId: ipId, policyId: polId, isInherited: false, skipIfDuplicate: false }); } /// @notice Returns if policyId exists in the LicensingModule /// @param policyId The id of the policy /// @return isDefined True if the policy is defined function isPolicyDefined(uint256 policyId) public view returns (bool) { return _policies[policyId].policyFramework != address(0); }
 

Check policy is defined

During the registration of PIL, the information of PIL is recorded in the mapping _policies.
// story-protocol/protocol-core/contracts/modules/licensing/LicensingModule.sol /// @dev Returns the set of policy ids attached to the given ipId mapping(bytes32 hashIpIdAnInherited => EnumerableSet.UintSet policyIds) private _policiesPerIpId; /// @notice Returns if policyId exists in the LicensingModule /// @param policyId The id of the policy /// @return isDefined True if the policy is defined function isPolicyDefined(uint256 policyId) public view returns (bool) { return _policies[policyId].policyFramework != address(0); }

Check IP has no dispute tag

If the IP has dispute tag, it can be attached new PIL.
// story-protocol/protocol-core/contracts/modules/licensing/LicensingModule.sol /// @dev Verifies if the IP is disputed function _verifyIpNotDisputed(address ipId) private view { // TODO: in beta, any tag means revocation, for mainnet we need more context if (DISPUTE_MODULE.isIpTagged(ipId)) { revert Errors.LicensingModule__DisputedIpId(); } }
// story-protocol/protocol-core/contracts/modules/dispute/DisputeModule.sol /// @notice Returns true if the ipId is tagged with any tag (meaning at least one dispute went through) /// @param ipId The ipId /// @return isTagged True if the ipId is tagged function isIpTagged(address ipId) external view returns (bool) { return successfulDisputesPerIp[ipId] > 0; }
 

Verify whether can add policy to the IP

Inside LicensingModule._addPolicyIdToIp, it calls _verifyCanAddPolicy to check whether can add policy to the IP.
// story-protocol/protocol-core/contracts/modules/licensing/LicensingModule.sol /// @notice Status of a policy on IP asset /// @param index The local index of the policy in the IP asset /// @param isSet True if the policy is set in the IP asset /// @param active True if the policy is active /// @param isInherited True if the policy is inherited from a parent IP asset struct PolicySetup { uint256 index; bool isSet; bool active; bool isInherited; } /// @dev Adds a policy id to the ipId policy set. Reverts if policy set already has policyId function _addPolicyIdToIp( address ipId, uint256 policyId, bool isInherited, bool skipIfDuplicate ) private returns (uint256 index) { _verifyCanAddPolicy(policyId, ipId, isInherited); // Try and add the policy into the set. EnumerableSet.UintSet storage _pols = _policySetPerIpId(isInherited, ipId); if (!_pols.add(policyId)) { if (skipIfDuplicate) { return _policySetups[ipId][policyId].index; } revert Errors.LicensingModule__PolicyAlreadySetForIpId(); } index = _pols.length() - 1; PolicySetup storage setup = _policySetups[ipId][policyId]; // This should not happen, but just in case if (setup.isSet) { revert Errors.LicensingModule__PolicyAlreadySetForIpId(); } setup.index = index; setup.isSet = true; setup.active = true; setup.isInherited = isInherited; emit PolicyAddedToIpId(msg.sender, ipId, policyId, index, isInherited); return index; }
 
Inside the _verifyCanAddPolicy, it first checks whther it is a derivative via its inherited policy count(If a IP is a derivative, it must have policy inherited from its parent). Then it checks whether its valid to add policy to this IP according to different cases.
There are four cases:
Is derivative
Is inherited
Situation
Validity
False
False
It is a original work. And it wants to add new policy.
Valid
True
False
It a derivative work, and it try to add a policy by itself.
Invalid
False
True
It is marked an original work before, but currently it wants to be marked as a derivative of another IP.
Valid
True
True
It has been a derivative work, and it wants to inherit another new IP.
Valid
In conclusion, there is only one case which is not valid where a derivative wants to add a new policy by itself.
 
In story protocol, one IP can inherited from multiple IPs which is called IP remix. So after the initial check, _verifyCanAddPolicy will try to aggregate multiple policies to check those policies are compatible. It calls PILPolicyFrameworkManager.processInheritedPolicies to check policy compatibiliy.
Note that it is the new policy’s PILPolicyFrameworkManager which is used to check policy compatibility. This is reasonable, because each PILPolicyFrameworkManager itself is not upgradable. If there is new policy, the new PILPolicyFrameworkManager should implement new policy’s compatibility algorithm with former policies. Check detailed reason here.
 
PILPolicyFrameworkManager.processInheritedPolicies basically checks the compatibility of the new policy. And if it is compatible with former policies, then it will calculate the aggregated policy information, and LicensingModule will store the new aggregated information in the mapping _ipRights.
 
But there is one thing strange, the aggregate data is stored in _ipRights[pol.policyFramework][ipId], what if the two policies use differnet PolicyFrameworkManager, in this case, the former aggregate data will be empty bytes.
// story-protocol/protocol-core/contracts/modules/licensing/LicensingModule.sol /// @dev Verifies if the policyId can be added to the IP function _verifyCanAddPolicy(uint256 policyId, address ipId, bool isInherited) private { bool ipIdIsDerivative = _policySetPerIpId(true, ipId).length() > 0; if ( // Original work, owner is setting policies // (ipIdIsDerivative false, adding isInherited false) (!ipIdIsDerivative && !isInherited) ) { // Can add policy return; } else if (ipIdIsDerivative && !isInherited) { // Owner of derivative is trying to set policies revert Errors.LicensingModule__DerivativesCannotAddPolicy(); } // If we are here, this is a multiparent derivative // Checking for policy compatibility IPolicyFrameworkManager polManager = IPolicyFrameworkManager(policy(policyId).policyFramework); Licensing.Policy memory pol = _policies[policyId]; (bool aggregatorChanged, bytes memory newAggregator) = polManager.processInheritedPolicies( _ipRights[pol.policyFramework][ipId], policyId, pol.frameworkData ); if (aggregatorChanged) { _ipRights[pol.policyFramework][ipId] = newAggregator; } } /// @dev Returns the set of policy ids attached to the given ipId mapping(bytes32 hashIpIdAnInherited => EnumerableSet.UintSet policyIds) private _policiesPerIpId; /// @dev Returns the policy set for the given ipId function _policySetPerIpId(bool isInherited, address ipId) private view returns (EnumerableSet.UintSet storage) { return _policiesPerIpId[keccak256(abi.encode(isInherited, ipId))]; }
 
In PILPolicyFrameworkManager.processInheritedPolicies, it checks whether the previous aggregated policy is empty, if it is empty, then just initialize it with the new policy. Otherwise check the compatibility with the new policy.
First, it checks derivativesReciprocal which means whether or not the licensee must license derivatives of the work under the same terms. IP cant have two policies with different derivativesReciprocal setting. If both policy require derivativesReciprocal, then they should use same policy.
Second, it checks that the commercial setting is same of both policies.
Third, it checks the setting of territoriesAcc, distributionChannelsAcc and contentRestrictionsAcc . These three parameters are checked based on one rule that is one of the two sets contains the other, and a subset is taken. Because these three parameters are represented by hash, so it only implements a weakened version of this algorithm(It may due to gas consideration).
// story-protocol/protocol-core/contracts/modules/licensing/PILPolicyFrameworkManager.sol /// @notice Struct that accumulates values of inherited policies so we can verify compatibility when inheriting /// new policies. /// @dev The assumption is that new policies may be added later, not only when linking an IP to its parent. /// @param commercial Whether or not there is a policy that allows commercial use /// @param derivativesReciprocal Whether or not there is a policy that requires derivatives to be licensed under the /// same terms /// @param lastPolicyId The last policy ID that was added to the IP /// @param territoriesAcc The last hash of the territories array /// @param distributionChannelsAcc The last hash of the distributionChannels array /// @param contentRestrictionsAcc The last hash of the contentRestrictions array struct PILAggregator { bool commercial; bool derivativesReciprocal; uint256 lastPolicyId; bytes32 territoriesAcc; bytes32 distributionChannelsAcc; bytes32 contentRestrictionsAcc; } /// @notice Verify compatibility of one or more policies when inheriting them from one or more parent IPs. /// @dev Enforced to be only callable by LicenseRegistry /// @dev The assumption in this method is that we can add parents later on, hence the need /// for an aggregator, if not we will do this when linking to parents directly with an /// array of policies. /// @param aggregator common state of the policies for the IP /// @param policyId the ID of the policy being inherited /// @param policy the policy to inherit /// @return changedAgg true if the aggregator was changed /// @return newAggregator the new aggregator // solhint-disable-next-line code-complexity function processInheritedPolicies( bytes memory aggregator, uint256 policyId, bytes memory policy ) external view onlyLicensingModule returns (bool changedAgg, bytes memory newAggregator) { PILAggregator memory agg; PILPolicy memory newPolicy = abi.decode(policy, (PILPolicy)); if (aggregator.length == 0) { // Initialize the aggregator agg = PILAggregator({ commercial: newPolicy.commercialUse, derivativesReciprocal: newPolicy.derivativesReciprocal, lastPolicyId: policyId, territoriesAcc: keccak256(abi.encode(newPolicy.territories)), distributionChannelsAcc: keccak256(abi.encode(newPolicy.distributionChannels)), contentRestrictionsAcc: keccak256(abi.encode(newPolicy.contentRestrictions)) }); return (true, abi.encode(agg)); } else { agg = abi.decode(aggregator, (PILAggregator)); // Either all are reciprocal or none are if (agg.derivativesReciprocal != newPolicy.derivativesReciprocal) { revert PILFrameworkErrors.PILPolicyFrameworkManager__ReciprocalValueMismatch(); } else if (agg.derivativesReciprocal && newPolicy.derivativesReciprocal) { // Ids are uniqued because we hash them to compare on creation in LicenseRegistry, // so we can compare the ids safely. if (agg.lastPolicyId != policyId) { revert PILFrameworkErrors.PILPolicyFrameworkManager__ReciprocalButDifferentPolicyIds(); } } else { // Both non reciprocal if (agg.commercial != newPolicy.commercialUse) { revert PILFrameworkErrors.PILPolicyFrameworkManager__CommercialValueMismatch(); } bytes32 newHash = _verifHashedParams( agg.territoriesAcc, keccak256(abi.encode(newPolicy.territories)), _EMPTY_STRING_ARRAY_HASH ); if (newHash != agg.territoriesAcc) { agg.territoriesAcc = newHash; changedAgg = true; } newHash = _verifHashedParams( agg.distributionChannelsAcc, keccak256(abi.encode(newPolicy.distributionChannels)), _EMPTY_STRING_ARRAY_HASH ); if (newHash != agg.distributionChannelsAcc) { agg.distributionChannelsAcc = newHash; changedAgg = true; } newHash = _verifHashedParams( agg.contentRestrictionsAcc, keccak256(abi.encode(newPolicy.contentRestrictions)), _EMPTY_STRING_ARRAY_HASH ); if (newHash != agg.contentRestrictionsAcc) { agg.contentRestrictionsAcc = newHash; changedAgg = true; } } } return (changedAgg, abi.encode(agg)); }
 
After the policy compatibility check, it will insert the new policy’s ID into the mapping _policiesPerIpId. And store the policy’s detailed information in _policySetups. Also it uses skipIfDuplicate flag to control the action during adding duplicate policy.
// story-protocol/protocol-core/contracts/modules/licensing/LicensingModule.sol /// @notice Status of a policy on IP asset /// @param index The local index of the policy in the IP asset /// @param isSet True if the policy is set in the IP asset /// @param active True if the policy is active /// @param isInherited True if the policy is inherited from a parent IP asset struct PolicySetup { uint256 index; bool isSet; bool active; bool isInherited; } /// @dev Internal mapping to track if a policy was set by linking or minting, and the index of the policy in the /// ipId policy set. Policies can't be removed, but they can be deactivated by setting active to false. mapping(address ipId => mapping(uint256 policyId => PolicySetup setup)) private _policySetups; /// @dev Adds a policy id to the ipId policy set. Reverts if policy set already has policyId function _addPolicyIdToIp( address ipId, uint256 policyId, bool isInherited, bool skipIfDuplicate ) private returns (uint256 index) { _verifyCanAddPolicy(policyId, ipId, isInherited); // Try and add the policy into the set. EnumerableSet.UintSet storage _pols = _policySetPerIpId(isInherited, ipId); if (!_pols.add(policyId)) { if (skipIfDuplicate) { return _policySetups[ipId][policyId].index; } revert Errors.LicensingModule__PolicyAlreadySetForIpId(); } index = _pols.length() - 1; PolicySetup storage setup = _policySetups[ipId][policyId]; // This should not happen, but just in case if (setup.isSet) { revert Errors.LicensingModule__PolicyAlreadySetForIpId(); } setup.index = index; setup.isSet = true; setup.active = true; setup.isInherited = isInherited; emit PolicyAddedToIpId(msg.sender, ipId, policyId, index, isInherited); return index; } /// @dev Returns the set of policy ids attached to the given ipId mapping(bytes32 hashIpIdAnInherited => EnumerableSet.UintSet policyIds) private _policiesPerIpId; /// @dev Returns the policy set for the given ipId function _policySetPerIpId(bool isInherited, address ipId) private view returns (EnumerableSet.UintSet storage) { return _policiesPerIpId[keccak256(abi.encode(isInherited, ipId))]; }

Mint License

Users call LicensingModule.mintLicense to mint license of IP which is NFT of type ERC1155 . License can be used(burnt) by other IP to link with the licensor of the license.
notion image
Inside the LicensingModule.mintLicense:
  • check the policy defined
  • check licensor IP registered
  • sanity check of license mint amount and receiver address
  • check the IP has no dispute tag.
  • verify permission to mint the license. If the policy is in the licensor IP’s policy pool, then anyone can mint a license according to the rule set by the IP. Otherwise, this is a private policy, only caller with permission can mint.
  • process royalty related operation.
  • verify mint. Check child derivative and commercialization validity.
  • calll LICENSE_REGISTRY to mint license.
// story-protocol/protocol-core/contracts/modules/licensing/LicensingModule.sol /// @notice Mints a license to create derivative IP. License NFTs represent a policy granted by IPs (licensors). /// Reverts if caller is not authorized by any of the licensors. /// @dev This NFT needs to be burned in order to link a derivative IP with its parents. If this is the first /// combination of policy and licensors, a new licenseId will be created (by incrementing prev totalLicenses). /// If not, the license is fungible and an id will be reused. The licensing terms that regulate creating new /// licenses will be verified to allow minting. /// @param policyId The id of the policy with the licensing parameters /// @param licensorIpId The id of the licensor IP /// @param amount The amount of licenses to mint /// @param receiver The address that will receive the license /// @param royaltyContext The context for the royalty module to process /// @return licenseId The ID of the license NFT(s) // solhint-disable-next-line code-complexity function mintLicense( uint256 policyId, address licensorIpId, uint256 amount, // mint amount address receiver, bytes calldata royaltyContext ) external nonReentrant returns (uint256 licenseId) { // check the policy defined _verifyPolicy(_policies[policyId]); // check licensor IP registered if (!IP_ACCOUNT_REGISTRY.isIpAccount(licensorIpId)) { revert Errors.LicensingModule__LicensorNotRegistered(); } if (amount == 0) { revert Errors.LicensingModule__MintAmountZero(); } if (receiver == address(0)) { revert Errors.LicensingModule__ReceiverZeroAddress(); } _verifyIpNotDisputed(licensorIpId); bool isInherited = _policySetups[licensorIpId][policyId].isInherited; Licensing.Policy memory pol = policy(policyId); IPolicyFrameworkManager pfm = IPolicyFrameworkManager(pol.policyFramework); // If the IP ID doesn't have a policy (meaning, no derivatives), this means the caller is attempting to mint a // license on a private policy. IP account can mint license NFTs on a globally registerd policy (via PFM) // without attaching the policy to the IP account, thus making it a private policy licenses. if (!_policySetPerIpId(isInherited, licensorIpId).contains(policyId)) { // We have to check if the caller is licensor or authorized to mint. if (!_hasPermission(licensorIpId)) { revert Errors.LicensingModule__CallerNotLicensorAndPolicyNotSet(); } } // If the policy has a royalty policy, we need to call the royalty module to process the minting. // Otherwise, it's non commercial and we can skip the call. // NOTE: We must call `payLicenseMintingFee` after calling `onLicenseMinting` because minting licenses on // root IPs (licensors) might mean the licensors don't have royalty policy initialized, so we initialize it // (deploy the split clone contract) via `onLicenseMinting`. Then, pay the minting fee to the licensor's split // clone contract address. if (pol.royaltyPolicy != address(0)) { ROYALTY_MODULE.onLicenseMinting(licensorIpId, pol.royaltyPolicy, pol.royaltyData, royaltyContext); // If there's a minting fee, sender must pay it if (pol.mintingFee > 0) { ROYALTY_MODULE.payLicenseMintingFee( licensorIpId, msg.sender, pol.royaltyPolicy, pol.mintingFeeToken, pol.mintingFee * amount ); } } // If a policy is set, then is only up to the policy params. // When verifying mint via PFM, pass in `receiver` as the `licensee` since the receiver is the one who will own // the license NFT after minting. if (!pfm.verifyMint(receiver, isInherited, licensorIpId, receiver, amount, pol.frameworkData)) { revert Errors.LicensingModule__MintLicenseParamFailed(); } licenseId = LICENSE_REGISTRY.mintLicense(policyId, licensorIpId, pol.isLicenseTransferable, amount, receiver); } /// @dev Verifies if the policy is set function _verifyPolicy(Licensing.Policy memory pol) private pure { if (pol.policyFramework == address(0)) { revert Errors.LicensingModule__PolicyNotFound(); } }
// library impl: story-protocol/protocol-core/contracts/lib/registries/IPAccountChecker.sol // library used: story-protocol/protocol-core/contracts/modules/licensing/LicensingModule.sol /// @notice Checks if the given address is a valid IP Account. /// @param ipAccountRegistry_ The IP Account registry contract. /// @param ipAccountAddress_ The address to check. /// @return True if the address is a valid IP Account, false otherwise. function isIpAccount( IIPAccountRegistry ipAccountRegistry_, address ipAccountAddress_ ) external view returns (bool) { if (ipAccountAddress_ == address(0)) return false; if (ipAccountAddress_.code.length == 0) return false; if (!ERC165Checker.supportsERC165(ipAccountAddress_)) return false; if (!ERC165Checker.supportsInterface(ipAccountAddress_, type(IERC6551Account).interfaceId)) return false; if (!ERC165Checker.supportsInterface(ipAccountAddress_, type(IIPAccount).interfaceId)) return false; (uint chainId, address tokenContract, uint tokenId) = IIPAccount(payable(ipAccountAddress_)).token(); return ipAccountAddress_ == ipAccountRegistry_.ipAccount(chainId, tokenContract, tokenId); }
 

Process royalty

In the LicensingModule.mintLicense, if pol.royaltyPolicy is set, then it calls ROYALTY_MODULE's onLicenseMinting and payLicenseMintingFee to set up royalty.
 
The basic process here is to set ancestorsVault and splitClone contracts correctly for the functionality of royalty payment.
In story protocol’ design, if IP inhertis another IP, it needs pay royalty to that IP and all ancestors of that IP. Story protocol uses RNFT (Royalty NFT) which is of type ERC1155 to represent the rights to claim royalty.
ancestorsVaultis a contract used by IP to reserve its parents’ rights to claim royalty, which is deployed during the license mint process of this IP. Initially, all ancestors’ RNFTs are reserved in ancestorsVault. Ancestor IPs can call ancestorsVault.claim to claim their corresponding RNFT.
Royalty are stored in splitClone which is a wrap of 0xSplit’s SplitMain contract used to distribtute asset(Ether and ERC20) to different receivers based on their share.
IP’s revenue will be deposited to the splitClone of this IP, and ancestor IPs can call ancestorsVault.claim to claim their RNFT, and use splitClone to distribute and get corresponding royalty.
 
notion image
// story-protocol/protocol-core/contracts/modules/licensing/LicensingModule.sol function mintLicense( uint256 policyId, address licensorIpId, uint256 amount, // mint amount address receiver, bytes calldata royaltyContext ) external nonReentrant returns (uint256 licenseId) { // ... // If the policy has a royalty policy, we need to call the royalty module to process the minting. // Otherwise, it's non commercial and we can skip the call. // NOTE: We must call `payLicenseMintingFee` after calling `onLicenseMinting` because minting licenses on // root IPs (licensors) might mean the licensors don't have royalty policy initialized, so we initialize it // (deploy the split clone contract) via `onLicenseMinting`. Then, pay the minting fee to the licensor's split // clone contract address. if (pol.royaltyPolicy != address(0)) { ROYALTY_MODULE.onLicenseMinting(licensorIpId, pol.royaltyPolicy, pol.royaltyData, royaltyContext); // If there's a minting fee, sender must pay it if (pol.mintingFee > 0) { ROYALTY_MODULE.payLicenseMintingFee( licensorIpId, msg.sender, pol.royaltyPolicy, pol.mintingFeeToken, pol.mintingFee * amount ); } } // ... }
 
In the RoyaltyModule.onLicenseMinting:
  • check the royaltyPolicy is registerd
  • if the licensor is a derivative IP, then it will have royaltyPolicy recorded in RoyaltyModule, derivative is only allowed to issue same royalty policy as its parent IP.
  • calls royaltyPolicy.onLicenseMinting to handle royalty logic.
// story-protocol/protocol-core/contracts/modules/royalty/RoyaltyModule.sol /// @notice Executes royalty related logic on license minting /// @dev Enforced to be only callable by LicensingModule /// @param ipId The ipId whose license is being minted (licensor) /// @param royaltyPolicy The royalty policy address of the license being minted /// @param licenseData The license data custom to each the royalty policy /// @param externalData The external data custom to each the royalty policy function onLicenseMinting( address ipId, address royaltyPolicy, bytes calldata licenseData, bytes calldata externalData ) external nonReentrant onlyLicensingModule { if (!isWhitelistedRoyaltyPolicy[royaltyPolicy]) revert Errors.RoyaltyModule__NotWhitelistedRoyaltyPolicy(); address royaltyPolicyIpId = royaltyPolicies[ipId]; // if the node is a root node, then royaltyPolicyIpId will be address(0) and any type of royalty type can be // selected to mint a license if the node is a derivative node, then the any minted licenses by the derivative // node should have the same royalty policy as the parent node a derivative node set its royalty policy // immutably in onLinkToParents() function below if (royaltyPolicyIpId != royaltyPolicy && royaltyPolicyIpId != address(0)) revert Errors.RoyaltyModule__CanOnlyMintSelectedPolicy(); IRoyaltyPolicy(royaltyPolicy).onLicenseMinting(ipId, licenseData, externalData); }
 
Inside RoyaltyPolicyLAP.onLicenseMinting:
  • decode the royalty percent
  • check whether royalty has exceeded the limitation(100%). Because when an IP inherits another IP, it needs to pay the inherited IP’s all ancestors same royalty. For example, neeeds to pay 90% percent royalty. And wants to issue a license with 15% royalty, the summed royalty payment of the license will be 115% which is invalid.
  • if splitClone hasn’t been deployed, then calls _initPolicy to initialize.
  • if splitClone has been deployed, then only need to check whether there is room for derivative. Story protocol currently allows max 14 ancestors.
// story-protocol/protocol-core/contracts/modules/royalty/policies/RoyaltyPolicyLAP.sol /// @notice Returns the percentage scale - 1000 rnfts represents 100% uint32 public constant TOTAL_RNFT_SUPPLY = 1000; /// @notice The state data of the LAP royalty policy /// @param isUnlinkableToParents Indicates if the ipId is unlinkable to new parents /// @param splitClone The address of the liquid split clone contract for a given ipId /// @param ancestorsVault The address of the ancestors vault contract for a given ipId /// @param royaltyStack The royalty stack of a given ipId is the sum of the royalties to be paid to each ancestors /// @param ancestorsHash The hash of the unique ancestors addresses and royalties arrays struct LAPRoyaltyData { bool isUnlinkableToParents; address splitClone; address ancestorsVault; uint32 royaltyStack; bytes32 ancestorsHash; } /// @notice Initializes a royalty policy LAP for a given IP asset /// @param targetAncestors The expected ancestors addresses of an ipId /// @param targetRoyaltyAmount The expected royalties of each of the ancestors for a given ipId /// @param parentAncestors1 The addresses of the ancestors of the first parent /// @param parentAncestors2 The addresses of the ancestors of the second parent /// @param parentAncestorsRoyalties1 The royalties of each of the ancestors of the first parent /// @param parentAncestorsRoyalties2 The royalties of each of the ancestors of the second parent struct InitParams { address[] targetAncestors; uint32[] targetRoyaltyAmount; address[] parentAncestors1; address[] parentAncestors2; uint32[] parentAncestorsRoyalties1; uint32[] parentAncestorsRoyalties2; } /// @notice Returns the royalty data for a given IP asset mapping(address ipId => LAPRoyaltyData) public royaltyData; /// @notice Executes royalty related logic on minting a license /// @dev Enforced to be only callable by RoyaltyModule /// @param ipId The ipId whose license is being minted (licensor) /// @param licenseData The license data custom to each the royalty policy /// @param externalData The external data custom to each the royalty policy function onLicenseMinting( address ipId, bytes calldata licenseData, bytes calldata externalData ) external onlyRoyaltyModule { uint32 newLicenseRoyalty = abi.decode(licenseData, (uint32)); LAPRoyaltyData memory data = royaltyData[ipId]; if (data.royaltyStack + newLicenseRoyalty > TOTAL_RNFT_SUPPLY) revert Errors.RoyaltyPolicyLAP__AboveRoyaltyStackLimit(); if (data.splitClone == address(0)) { // If the policy is already initialized, it means that the ipId setup is already done. If not, it means // that the license for this royalty policy is being minted for the first time parentIpIds are zero given // that only roots can call _initPolicy() for the first time in the function onLicenseMinting() while // derivatives already // called _initPolicy() when linking to their parents with onLinkToParents() call. address[] memory rootParents = new address[](0); bytes[] memory rootParentRoyalties = new bytes[](0); _initPolicy(ipId, rootParents, rootParentRoyalties, externalData); } else { InitParams memory params = abi.decode(externalData, (InitParams)); // If the policy is already initialized and an ipId has the maximum number of ancestors // it can not have any derivative and therefore is not allowed to mint any license if (params.targetAncestors.length >= MAX_ANCESTORS) revert Errors.RoyaltyPolicyLAP__LastPositionNotAbleToMintLicense(); // the check below ensures that the ancestors hash is the same as the one stored in the royalty data // and that the targetAncestors passed in by the user matches the record stored in state on policy // initialization if ( keccak256(abi.encodePacked(params.targetAncestors, params.targetRoyaltyAmount)) != royaltyData[ipId].ancestorsHash ) revert Errors.RoyaltyPolicyLAP__InvalidAncestorsHash(); } }
 
_initPolicy is used to set up AncestorsVault and SplitClone which will also be used when an IP wants to link to parent IP.
Inside:
  • decode the expected ancestors setting from externalData
  • decode each parent’s royalty percent
  • sanity check on ancestors count and parent count. current the max ancestor counts is 14 and max parent count is 2.
  • calculate royaltyStack which is the sum of royaltys needed to pay for all ancestors. Equivalent amount of RNFT will be reserved in ancestorsVault for ancestors to claim.
  • make the parent unlinkable to other parent. Because if parent links to other IP, which means the royalty distributation of the parent will change, and the derivative of the parent IP will also need to change. Currently story protocol disallow IP with issued license to link to other IPs to avoid this situation. But there is inconvenience here, what if the parent IP forget to link to some IPs and want to correct the royalty setting?
  • deploy ancestorsVault. If the IP is a root IP (without parent IP), then no need to deploy. Here _initPolicy uses address(RoyaltyPolicyLAP) as ancestorsVault just to make the code simple. Because the root IP will have 0 royaltyStack, no RNFT will be minted to ancestorsVault, so the address of ancestorsVault doesn’t matter.
  • deploy splitClone, distribute RNFTs to the IP account and ancestorsVault.
  • record IP’s royalty’s data in mapping royaltyData.
// story-protocol/protocol-core/contracts/modules/royalty/policies/RoyaltyPolicyLAP.sol /// @notice Initializes a royalty policy LAP for a given IP asset /// @param targetAncestors The expected ancestors addresses of an ipId /// @param targetRoyaltyAmount The expected royalties of each of the ancestors for a given ipId /// @param parentAncestors1 The addresses of the ancestors of the first parent /// @param parentAncestors2 The addresses of the ancestors of the second parent /// @param parentAncestorsRoyalties1 The royalties of each of the ancestors of the first parent /// @param parentAncestorsRoyalties2 The royalties of each of the ancestors of the second parent struct InitParams { address[] targetAncestors; uint32[] targetRoyaltyAmount; address[] parentAncestors1; address[] parentAncestors2; uint32[] parentAncestorsRoyalties1; uint32[] parentAncestorsRoyalties2; } /// @notice The state data of the LAP royalty policy /// @param isUnlinkableToParents Indicates if the ipId is unlinkable to new parents /// @param splitClone The address of the liquid split clone contract for a given ipId /// @param ancestorsVault The address of the ancestors vault contract for a given ipId /// @param royaltyStack The royalty stack of a given ipId is the sum of the royalties to be paid to each ancestors /// @param ancestorsHash The hash of the unique ancestors addresses and royalties arrays struct LAPRoyaltyData { bool isUnlinkableToParents; address splitClone; address ancestorsVault; uint32 royaltyStack; bytes32 ancestorsHash; } /// @notice Returns the royalty data for a given IP asset mapping(address ipId => LAPRoyaltyData) public royaltyData; /// @notice Returns the maximum number of parents uint256 public constant MAX_PARENTS = 2; /// @notice Returns the maximum number of total ancestors. /// @dev The IP derivative tree is limited to 14 ancestors, which represents 3 levels of a binary tree 14 = 2+4+8 uint256 public constant MAX_ANCESTORS = 14; /// @dev Initializes the royalty policy for a given IP asset. /// @dev Enforced to be only callable by RoyaltyModule /// @param ipId The to initialize the policy for /// @param parentIpIds The parent ipIds that the children ipId is being linked to (if any) /// @param licenseData The license data custom to each the royalty policy /// @param externalData The external data custom to each the royalty policy function _initPolicy( address ipId, address[] memory parentIpIds, bytes[] memory licenseData, bytes calldata externalData ) internal onlyRoyaltyModule { // decode license and external data InitParams memory params = abi.decode(externalData, (InitParams)); uint32[] memory parentRoyalties = new uint32[](parentIpIds.length); for (uint256 i = 0; i < parentIpIds.length; i++) { parentRoyalties[i] = abi.decode(licenseData[i], (uint32)); } if (params.targetAncestors.length > MAX_ANCESTORS) revert Errors.RoyaltyPolicyLAP__AboveAncestorsLimit(); if (parentIpIds.length > MAX_PARENTS) revert Errors.RoyaltyPolicyLAP__AboveParentLimit(); // calculate new royalty stack uint32 royaltyStack = _checkAncestorsDataIsValid(parentIpIds, parentRoyalties, params); // set the parents as unlinkable / loop limited to 2 parents for (uint256 i = 0; i < parentIpIds.length; i++) { royaltyData[parentIpIds[i]].isUnlinkableToParents = true; } // deploy ancestors vault if not root ip // 0xSplit requires two addresses to allow a split so for root ip address(this) is used as the second address address ancestorsVault = parentIpIds.length > 0 ? Clones.clone(ANCESTORS_VAULT_IMPL) : address(this); // deploy split clone address splitClone = _deploySplitClone(ipId, ancestorsVault, royaltyStack); royaltyData[ipId] = LAPRoyaltyData({ // whether calling via minting license or linking to parents the ipId becomes unlinkable isUnlinkableToParents: true, splitClone: splitClone, ancestorsVault: ancestorsVault, royaltyStack: royaltyStack, ancestorsHash: keccak256(abi.encodePacked(params.targetAncestors, params.targetRoyaltyAmount)) }); emit PolicyInitialized( ipId, splitClone, ancestorsVault, royaltyStack, params.targetAncestors, params.targetRoyaltyAmount ); }
 
_checkAncestorsDataIsValid will do sanity check and calculate the royaltyStack of the IP.
Inside:
  • check the counts of ancestors and royalties match
  • check the counts of parents and royalties match
  • calculate new ancestors and royalty
  • check the provided params match the calculated ancestors and royalty
// story-protocol/protocol-core/contracts/modules/royalty/policies/RoyaltyPolicyLAP.sol /// @dev Checks if the ancestors data is valid /// @param parentIpIds The parent ipIds /// @param parentRoyalties The parent royalties /// @param params The init params /// @return newRoyaltyStack The new royalty stack function _checkAncestorsDataIsValid( address[] memory parentIpIds, uint32[] memory parentRoyalties, InitParams memory params ) internal view returns (uint32) { if (params.targetRoyaltyAmount.length != params.targetAncestors.length) revert Errors.RoyaltyPolicyLAP__InvalidRoyaltyAmountLength(); if (parentRoyalties.length != parentIpIds.length) revert Errors.RoyaltyPolicyLAP__InvalidParentRoyaltiesLength(); ( address[] memory newAncestors, uint32[] memory newAncestorsRoyalty, uint32 newAncestorsCount, uint32 newRoyaltyStack ) = _getExpectedOutputs(parentIpIds, parentRoyalties, params); if (params.targetAncestors.length != newAncestorsCount) revert Errors.RoyaltyPolicyLAP__InvalidAncestorsLength(); if (newRoyaltyStack > TOTAL_RNFT_SUPPLY) revert Errors.RoyaltyPolicyLAP__AboveRoyaltyStackLimit(); for (uint256 k = 0; k < newAncestorsCount; k++) { if (params.targetAncestors[k] != newAncestors[k]) revert Errors.RoyaltyPolicyLAP__InvalidAncestors(); if (params.targetRoyaltyAmount[k] != newAncestorsRoyalty[k]) revert Errors.RoyaltyPolicyLAP__InvalidAncestorsRoyalty(); } return newRoyaltyStack; }
 
_getExpectedOutputs seems complicated, but actually the logic is simple. There is provided parent ancestors and corresponding royalty information in the params. _getExpectedOutputs checks the correctness of these information by comparing with the recorded information in contract. And accumulate the parent ancestors royalty to get current IP’s all ancestors and royalty.
// story-protocol/protocol-core/contracts/modules/royalty/policies/RoyaltyPolicyLAP.sol /// @notice Initializes a royalty policy LAP for a given IP asset /// @param targetAncestors The expected ancestors addresses of an ipId /// @param targetRoyaltyAmount The expected royalties of each of the ancestors for a given ipId /// @param parentAncestors1 The addresses of the ancestors of the first parent /// @param parentAncestors2 The addresses of the ancestors of the second parent /// @param parentAncestorsRoyalties1 The royalties of each of the ancestors of the first parent /// @param parentAncestorsRoyalties2 The royalties of each of the ancestors of the second parent struct InitParams { address[] targetAncestors; uint32[] targetRoyaltyAmount; address[] parentAncestors1; address[] parentAncestors2; uint32[] parentAncestorsRoyalties1; uint32[] parentAncestorsRoyalties2; } /// @dev Gets the expected outputs for the ancestors and ancestors royalties /// @param parentIpIds The parent ipIds /// @param parentRoyalties The parent royalties /// @param params The init params /// @return newAncestors The new ancestors /// @return newAncestorsRoyalty The new ancestors royalty /// @return ancestorsCount The number of ancestors /// @return royaltyStack The royalty stack // solhint-disable-next-line code-complexity function _getExpectedOutputs( address[] memory parentIpIds, uint32[] memory parentRoyalties, InitParams memory params ) internal view returns ( address[] memory newAncestors, uint32[] memory newAncestorsRoyalty, uint32 ancestorsCount, uint32 royaltyStack ) { newAncestorsRoyalty = new uint32[](params.targetRoyaltyAmount.length); newAncestors = new address[](params.targetAncestors.length); for (uint256 i = 0; i < parentIpIds.length; i++) { if (i == 0) { newAncestors[ancestorsCount] = parentIpIds[i]; newAncestorsRoyalty[ancestorsCount] += parentRoyalties[i]; royaltyStack += parentRoyalties[i]; ancestorsCount++; } else if (i == 1) { (uint256 index, bool isIn) = ArrayUtils.indexOf(newAncestors, parentIpIds[i]); if (!isIn) { newAncestors[ancestorsCount] = parentIpIds[i]; newAncestorsRoyalty[ancestorsCount] += parentRoyalties[i]; royaltyStack += parentRoyalties[i]; ancestorsCount++; } else { newAncestorsRoyalty[index] += parentRoyalties[i]; royaltyStack += parentRoyalties[i]; } } address[] memory parentAncestors = i == 0 ? params.parentAncestors1 : params.parentAncestors2; uint32[] memory parentAncestorsRoyalties = i == 0 ? params.parentAncestorsRoyalties1 : params.parentAncestorsRoyalties2; if ( keccak256(abi.encodePacked(parentAncestors, parentAncestorsRoyalties)) != royaltyData[parentIpIds[i]].ancestorsHash ) revert Errors.RoyaltyPolicyLAP__InvalidAncestorsHash(); for (uint256 j = 0; j < parentAncestors.length; j++) { if (i == 0) { newAncestors[ancestorsCount] = parentAncestors[j]; newAncestorsRoyalty[ancestorsCount] += parentAncestorsRoyalties[j]; royaltyStack += parentAncestorsRoyalties[j]; ancestorsCount++; } else if (i == 1) { (uint256 index, bool isIn) = ArrayUtils.indexOf(newAncestors, parentAncestors[j]); if (!isIn) { newAncestors[ancestorsCount] = parentAncestors[j]; newAncestorsRoyalty[ancestorsCount] += parentAncestorsRoyalties[j]; royaltyStack += parentAncestorsRoyalties[j]; ancestorsCount++; } else { newAncestorsRoyalty[index] += parentAncestorsRoyalties[j]; royaltyStack += parentAncestorsRoyalties[j]; } } } } }
 
Inside _deploySplitClone, it calls LIQUID_SPLIT_FACTORY to create SplitClone.
// story-protocol/protocol-core/contracts/modules/royalty/policies/RoyaltyPolicyLAP.sol /// @dev Deploys a liquid split clone contract /// @param ipId The ipId /// @param ancestorsVault The ancestors vault address /// @param royaltyStack The number of rnfts that the ipId has to give to its parents and/or grandparents /// @return The address of the deployed liquid split clone contract function _deploySplitClone(address ipId, address ancestorsVault, uint32 royaltyStack) internal returns (address) { address[] memory accounts = new address[](2); accounts[0] = ipId; accounts[1] = ancestorsVault; uint32[] memory initAllocations = new uint32[](2); initAllocations[0] = TOTAL_RNFT_SUPPLY - royaltyStack; initAllocations[1] = royaltyStack; address splitClone = ILiquidSplitFactory(LIQUID_SPLIT_FACTORY).createLiquidSplitClone( accounts, initAllocations, 0, // distributorFee address(0) // splitOwner ); return splitClone; }
 
Inside createLiquidSplitClone:
  • deploy SplitClone(using proxy)
  • initialize SplitClone
// story-protocol/protocol-core/contracts/LiquidSplitFactory/src/LiquidSplitFactory.sol uint256 public constant MAX_DISTRIBUTOR_FEE = 1e5; // = 10% * PERCENTAGE_SCALE function createLiquidSplitClone( address[] calldata accounts, uint32[] calldata initAllocations, uint32 _distributorFee, address owner ) external returns (LS1155CloneImpl ls) { /// checks // accounts & initAllocations are validated inside initializer if (_distributorFee > MAX_DISTRIBUTOR_FEE) { revert InvalidLiquidSplit__InvalidDistributorFee(_distributorFee); } /// effects /// interactions ls = LS1155CloneImpl(ls1155CloneImpl.clone(abi.encodePacked(_distributorFee, block.timestamp))); emit CreateLS1155Clone(ls); ls.initializer({accounts: accounts, initAllocations: initAllocations, _owner: owner}); }
 
During initialization of SplitClone:
  • sanity check of caller, accounts and initAllocations
  • call LiquidSplitCloneImpl.initializer to create 0xSplit’s split contract
  • mint RNFT to ancestorVault and IP account.
// story-protocol/protocol-core/contracts/LiquidSplitFactory/src/CloneImpl/LS1155CloneImpl.sol /// initializes each clone function initializer(address[] calldata accounts, uint32[] calldata initAllocations, address _owner) external { /// checks // only liquidSplitFactory may call `initializer` if (msg.sender != liquidSplitFactory) { revert Unauthorized(); } if (accounts.length != initAllocations.length) { revert InvalidLiquidSplit__AccountsAndAllocationsMismatch(accounts.length, initAllocations.length); } { uint32 sum = _getSum(initAllocations); if (sum != TOTAL_SUPPLY) { revert InvalidLiquidSplit__InvalidAllocationsSum(sum); } } /// effects owner = _owner; emit OwnershipTransferred(address(0), _owner); LiquidSplitCloneImpl.initializer(); /// interactions // mint NFTs to initial holders uint256 numAccs = accounts.length; unchecked { for (uint256 i; i < numAccs; ++i) { _mint({to: accounts[i], id: TOKEN_ID, amount: initAllocations[i], data: ""}); } } }
 
Inside the initializer:
  • calls 0xSplit’s splitMain’s createSplit to create split contract. Note here it uses two random recipients just for the purpose to create split. Later in the royalty distribution, the story protocol system uses split contract’s updateAndDistributeETH and updateAndDistributeERC20 method to update receivers inforamtion and distribute royalty correctly.
// story-protocol/protocol-core/contracts/LiquidSplitFactory/src/CloneImpl /// @dev cannot be called externally by default; inheriting contracts must /// be sure to properly secure any calls function initializer() internal { /// checks /// effects /// interactions // create dummy mutable split with this contract as controller; // recipients & distributorFee will be updated on first payout address[] memory recipients = new address[](2); recipients[0] = address(0); recipients[1] = address(1); uint32[] memory initPercentAllocations = new uint32[](2); initPercentAllocations[0] = uint32(500000); initPercentAllocations[1] = uint32(500000); payoutSplit = payable( splitMain.createSplit({ accounts: recipients, percentAllocations: initPercentAllocations, distributorFee: 0, controller: address(this) }) ); }
 
In the SplitMain.createSplit, it basically deploy a split contract proxy, and record hash representation of receivers information.
// SplitMain/contracts/SplitMain.sol /** @notice Creates a new split with recipients `accounts` with ownerships `percentAllocations`, a keeper fee for splitting of `distributorFee` and the controlling address `controller` * @param accounts Ordered, unique list of addresses with ownership in the split * @param percentAllocations Percent allocations associated with each address * @param distributorFee Keeper fee paid by split to cover gas costs of distribution * @param controller Controlling address (0x0 if immutable) * @return split Address of newly created split */ function createSplit( address[] calldata accounts, uint32[] calldata percentAllocations, uint32 distributorFee, address controller ) external override validSplit(accounts, percentAllocations, distributorFee) returns (address split) { bytes32 splitHash = _hashSplit(accounts, percentAllocations, distributorFee); if (controller == address(0)) { // create immutable split split = Clones.cloneDeterministic(walletImplementation, splitHash); } else { // create mutable split split = Clones.clone(walletImplementation); splits[split].controller = controller; } // store split's hash in storage for future verification splits[split].hash = splitHash; emit CreateSplit(split); }

Pay license mint fee

Inside ROYALTY_MODULE.payLicenseMintingFee:
  • check token is whitelisted
  • check royalty policy is whitelisted
  • call licenseRoyaltyPolicy.onRoyaltyPayment to handle the payment.
// story-protocol/protocol-core/contracts/modules/royalty/RoyaltyModule.sol /// @notice Allows to pay the minting fee for a license /// @param receiverIpId The ipId that receives the royalties /// @param payerAddress The address that pays the royalties /// @param licenseRoyaltyPolicy The royalty policy of the license being minted /// @param token The token to use to pay the royalties /// @param amount The amount to pay function payLicenseMintingFee( address receiverIpId, address payerAddress, address licenseRoyaltyPolicy, address token, uint256 amount ) external onlyLicensingModule { if (!isWhitelistedRoyaltyToken[token]) revert Errors.RoyaltyModule__NotWhitelistedRoyaltyToken(); if (licenseRoyaltyPolicy == address(0)) revert Errors.RoyaltyModule__NoRoyaltyPolicySet(); if (!isWhitelistedRoyaltyPolicy[licenseRoyaltyPolicy]) revert Errors.RoyaltyModule__NotWhitelistedRoyaltyPolicy(); IRoyaltyPolicy(licenseRoyaltyPolicy).onRoyaltyPayment(payerAddress, receiverIpId, token, amount); emit LicenseMintingFeePaid(receiverIpId, payerAddress, token, amount); }
 
Inside the RoyaltyPolicyLAP.onRoyaltyPayment, it transfers fee to splitClone of the licensor IP because the mint fee is regarded as revenue of the licensor IP.
// story-protocol/protocol-core/contracts/modules/royalty/policies/RoyaltyPolicyLAP.sol /// @notice Allows the caller to pay royalties to the given IP asset /// @param caller The caller is the address from which funds will transferred from /// @param ipId The ipId of the receiver of the royalties /// @param token The token to pay /// @param amount The amount to pay function onRoyaltyPayment(address caller, address ipId, address token, uint256 amount) external onlyRoyaltyModule { address destination = royaltyData[ipId].splitClone; IERC20(token).safeTransferFrom(caller, destination, amount); }
 

Verify mint

Check whether the licensor is derivative, if it is and derivativesReciprocal is false, then it can’t issue license. derivativesReciprocal control whether derivatives of a derivative can be created indefinitely, if derivativesReciprocal is true, then as long as they have the exact same terms, derivative can have indefinite derivatives. Otherwise child derivative is not allowed.
Check whether commercialization is allowed for this licensee based on commercializerChecker and policy.commercializerCheckerData.
// story-protocol/protocol-core/contracts/modules/licensing/PILPolicyFrameworkManager.sol /// @notice Verify policy parameters for minting a license. /// @dev Enforced to be only callable by LicenseRegistry /// @param licensee the address that holds the license and is executing the mint /// @param mintingFromADerivative true if the license is minting from a derivative IPA /// @param licensorIpId the IP id of the licensor /// @param receiver the address receiving the license /// @param mintAmount the amount of licenses to mint /// @param policyData the encoded framework policy data to verify /// @return verified True if the link is verified function verifyMint( address licensee, bool mintingFromADerivative, address licensorIpId, address receiver, uint256 mintAmount, bytes memory policyData ) external nonReentrant onlyLicensingModule returns (bool) { PILPolicy memory policy = abi.decode(policyData, (PILPolicy)); // If the policy defines no reciprocal derivatives are allowed (no derivatives of derivatives), // and we are mintingFromADerivative we don't allow minting if (!policy.derivativesReciprocal && mintingFromADerivative) { return false; } if (policy.commercializerChecker != address(0)) { // No need to check if the commercializerChecker supports the IHookModule interface, as it was checked // when the policy was registered. if (!IHookModule(policy.commercializerChecker).verify(licensee, policy.commercializerCheckerData)) { return false; } } return true; }
 

Mint license

LicensingModule calls LicenseRegistry to mint license.
LicenseRegistry implements ERC1155, each license is a NFT of type ERC1155. Each combination of policy, licensor ID and transferrable data corresponds to unique NFT id.
LicenseRegistry mints NFTs which represent licensor’s sepecific policy’s license to receiver.
// story-protocol/protocol-core/contracts/registries/LicenseRegistry.sol /// @dev Maps the hash of the license data to the licenseId mapping(bytes32 licenseHash => uint256 licenseId) private _hashedLicenses; /// @dev Maps the licenseId to the license data mapping(uint256 licenseId => Licensing.License licenseData) private _licenses; /// @dev Tracks the number of licenses registered in the protocol, it will not decrease when a license is burnt. uint256 private _mintedLicenses; /// @notice Mints license NFTs representing a policy granted by a set of ipIds (licensors). This NFT needs to be /// burned in order to link a derivative IP with its parents. If this is the first combination of policy and /// licensors, a new licenseId will be created. If not, the license is fungible and an id will be reused. /// @dev Only callable by the licensing module. /// @param policyId The ID of the policy to be minted /// @param licensorIpId_ The ID of the IP granting the license (ie. licensor) /// @param transferable True if the license is transferable /// @param amount Number of licenses to mint. License NFT is fungible for same policy and same licensors /// @param receiver Receiver address of the minted license NFT(s). /// @return licenseId The ID of the minted license NFT(s). function mintLicense( uint256 policyId, address licensorIpId_, bool transferable, uint256 amount, // mint amount address receiver ) external onlyLicensingModule returns (uint256 licenseId) { Licensing.License memory licenseData = Licensing.License({ policyId: policyId, licensorIpId: licensorIpId_, transferable: transferable }); bool isNew; (licenseId, isNew) = DataUniqueness.addIdOrGetExisting( abi.encode(licenseData), _hashedLicenses, _mintedLicenses ); if (isNew) { _mintedLicenses = licenseId; _licenses[licenseId] = licenseData; emit LicenseMinted(msg.sender, receiver, licenseId, amount, licenseData); } _mint(receiver, licenseId, amount, ""); return licenseId; }
// story-protocol/protocol-core/contracts/lib/DataUniqueness.sol /// Stores data without repetition, assigning an id to it if new or reusing existing one if already stored /// @param data raw bytes, abi.encode() a value to be hashed /// @param _hashToIds storage ref to the mapping of hash -> data id /// @param existingIds amount of distinct data stored. /// @return id new sequential id if new data, reused id if not new /// @return isNew True if a new id was generated, signaling the value was stored in _hashToIds. /// False if id is reused and data was not stored function addIdOrGetExisting( bytes memory data, mapping(bytes32 => uint256) storage _hashToIds, uint256 existingIds ) internal returns (uint256 id, bool isNew) { // We could just use the hash as id to save some gas, but the UX/DX of having huge random // numbers for ID is bad enough to justify the cost, plus we have a counter if we need to. bytes32 hash = keccak256(data); id = _hashToIds[hash]; if (id != 0) { return (id, false); } id = existingIds + 1; _hashToIds[hash] = id; return (id, true); }

Register derivative IP asset

User can call RegistrationModule.registerDerivativeIp to register an IP as a derivative IP of certain parent IP. By passing license ids , user specify which IPs this IP wants to be derivative of.
notion image
Inside the RegistrationModule.registerDerivativeIp:
  • check the caller is owner of the NFT or has been approved by ther owner
  • encode metadata and register IP account for the NFT
  • call LICENSING_MODULE.linkIpToParents to link this NFT to parent NFT.
// story-protocol/protocol-core/contracts/modules/RegistrationModule.sol // TODO: Replace all metadata with a generic bytes parameter type, and do encoding on the periphery contract // level instead. /// @notice Registers derivative IPs into the protocol. Derivative IPs are IP assets that inherit policies from /// parent IPs by burning acquired license NFTs. /// @param licenseIds The licenses to incorporate for the new IP. /// @param tokenContract The address of the NFT bound to the derivative IP. /// @param tokenId The token id of the NFT bound to the derivative IP. /// @param ipName The name assigned to the new IP. /// @param contentHash The content hash of the IP being registered. /// @param externalURL An external URI to link to the IP. /// @param royaltyContext The royalty context for the derivative IP. /// TODO: Replace all metadata with a generic bytes parameter type, and do /// encoding on the periphery contract level instead. function registerDerivativeIp( uint256[] calldata licenseIds, address tokenContract, uint256 tokenId, string memory ipName, bytes32 contentHash, string calldata externalURL, bytes calldata royaltyContext ) external { // Check that the caller is authorized to perform the registration. // TODO: Perform additional registration authorization logic, allowing // registrants or IP creators to specify their own auth logic. address owner = IERC721(tokenContract).ownerOf(tokenId); if (msg.sender != owner && !IERC721(tokenContract).isApprovedForAll(owner, msg.sender)) { revert Errors.RegistrationModule__InvalidOwner(); } bytes memory metadata = abi.encode( IP.MetadataV1({ name: ipName, hash: contentHash, registrationDate: uint64(block.timestamp), registrant: msg.sender, uri: externalURL }) ); address ipId = _IP_ASSET_REGISTRY.register( block.chainid, tokenContract, tokenId, address(ipResolver), true, metadata ); // Perform core IP derivative licensing - the license must be owned by the caller. _LICENSING_MODULE.linkIpToParents(licenseIds, ipId, royaltyContext); emit DerivativeIPRegistered(msg.sender, ipId, licenseIds); }
 
Inside the LicensingModule.linkIpToParents:
  • check whether caller has permission to call linkIpToParents.
  • check child IP has no dispute.
  • loop parent IP to check the license hasn’t been revoked, verify royalty policy and link(require parent IPs have same royalty policy).
  • call ROYALTY_MODULE.onLinkToParents to set royalty.
  • call LICENSE_REGISTRY.burnLicenses to burn used licenses(each license can only be used once).
// story-protocol/protocol-core/contracts/modules/licensing/LicensingModule.sol /// @notice Links an IP to the licensors listed in the license NFTs, if their policies allow it. Burns the license /// NFTs in the proccess. The caller must be the owner of the IP asset and license NFTs. /// @param licenseIds The id of the licenses to burn /// @param childIpId The id of the child IP to be linked /// @param royaltyContext The context for the royalty module to process function linkIpToParents( uint256[] calldata licenseIds, address childIpId, bytes calldata royaltyContext ) external nonReentrant verifyPermission(childIpId) { _verifyIpNotDisputed(childIpId); address holder = IIPAccount(payable(childIpId)).owner(); address[] memory licensors = new address[](licenseIds.length); bytes[] memory royaltyData = new bytes[](licenseIds.length); // If royalty policy address is address(0), this means no royalty policy to set. address royaltyAddressAcc = address(0); for (uint256 i = 0; i < licenseIds.length; i++) { if (LICENSE_REGISTRY.isLicenseRevoked(licenseIds[i])) { revert Errors.LicensingModule__LinkingRevokedLicense(); } // This function: // - Verifies the license holder is the caller // - Verifies the license is valid (through IPolicyFrameworkManager) // - Verifies all licenses must have either no royalty policy or the same one. // (That's why we send the royaltyAddressAcc and get it as a return value). // Finally, it will add the policy to the child IP, and set the parent. (licensors[i], royaltyAddressAcc, royaltyData[i]) = _verifyRoyaltyAndLink( i, licenseIds[i], childIpId, holder, royaltyAddressAcc ); } emit IpIdLinkedToParents(msg.sender, childIpId, licensors); // Licenses unanimously require royalty, so we can call the royalty module if (royaltyAddressAcc != address(0)) { ROYALTY_MODULE.onLinkToParents(childIpId, royaltyAddressAcc, licensors, royaltyData, royaltyContext); } // Burn licenses LICENSE_REGISTRY.burnLicenses(holder, licenseIds); }
 

Verify royalty policy

Inside LicensingModule._verifyRoyaltyAndLink:
  • call LICENSE_REGISTRY.isLicensee to verify the child IP’s owner(child IP NFT owner) has license.
  • get license information
  • get policy information of the license
  • check licensor IP differs with child IP
  • call policyFramework.verifyLink to verify link’s validity
  • check policy compatibility and add new policy to IP
// story-protocol/protocol-core/contracts/modules/licensing/LicensingModule.sol /// @notice A particular configuration (flavor) of a Policy Framework, setting values for the licensing /// terms (parameters) of the framework. /// @param isLicenseTransferable Whether or not the license is transferable /// @param policyFramework address of the IPolicyFrameworkManager this policy is based on /// @param frameworkData Data to be used by the policy framework to verify minting and linking /// @param royaltyPolicy address of the royalty policy to be used by the policy framework, if any /// @param royaltyData Data to be used by the royalty policy (for example, encoding of the royalty percentage) /// @param mintingFee Fee to be paid when minting a license /// @param mintingFeeToken Token to be used to pay the minting fee struct Policy { bool isLicenseTransferable; address policyFramework; bytes frameworkData; address royaltyPolicy; bytes royaltyData; uint256 mintingFee; address mintingFeeToken; } /// @notice Data that define a License Agreement NFT /// @param policyId Id of the policy this license is based on, which will be set in the derivative IP when the /// license is burnt for linking /// @param licensorIpId Id of the IP this license is for /// @param transferable Whether or not the license is transferable struct License { uint256 policyId; address licensorIpId; bool transferable; // TODO: support for transfer hooks } /// @dev Verifies royalty and link params, and returns the licensor, new royalty policy and royalty data /// This function was added to avoid stack too deep error. function _verifyRoyaltyAndLink( uint256 i, uint256 licenseId, address childIpId, address holder, address royaltyAddressAcc ) private returns (address licensor, address newRoyaltyAcc, bytes memory royaltyData) { if (!LICENSE_REGISTRY.isLicensee(licenseId, holder)) { revert Errors.LicensingModule__NotLicensee(); } Licensing.License memory licenseData = LICENSE_REGISTRY.license(licenseId); Licensing.Policy memory pol = policy(licenseData.policyId); // Check if all licenses have the same policy. if (i > 0 && pol.royaltyPolicy != royaltyAddressAcc) { revert Errors.LicensingModule__IncompatibleLicensorCommercialPolicy(); } _linkIpToParent(i, licenseId, licenseData.policyId, pol, licenseData.licensorIpId, childIpId, holder); return (licenseData.licensorIpId, pol.royaltyPolicy, pol.royaltyData); }
// story-protocol/protocol-core/contracts/modules/licensing/LicensingModule.sol /// @dev Link IP to a parent IP using the license NFT. function _linkIpToParent( uint256 iteration, uint256 licenseId, uint256 policyId, Licensing.Policy memory pol, address licensor, address childIpId, address licensee ) private { // TODO: check licensor not part of a branch tagged by disputer if (licensor == childIpId) { revert Errors.LicensingModule__ParentIdEqualThanChild(); } // Verify linking params if ( !IPolicyFrameworkManager(pol.policyFramework).verifyLink( licenseId, licensee, childIpId, licensor, pol.frameworkData ) ) { revert Errors.LicensingModule__LinkParentParamFailed(); } // Add the policy of licenseIds[i] to the child. If the policy's already set from previous parents, // then the addition will be skipped. _addPolicyIdToIp({ ipId: childIpId, policyId: policyId, isInherited: true, skipIfDuplicate: true }); // Set parent. We ignore the return value, since there are some cases where the same licensor gives the child // a License with another policy. _ipIdParents[childIpId].add(licensor); }
// story-protocol/protocol-core/contracts/registries/LicenseRegistry.sol /// @notice Returns true if holder has positive balance for the given license ID. /// @return isLicensee True if holder is the licensee for the license (owner of the license NFT), or derivative IP /// owner if the license was added to the IP by linking (burning a license). function isLicensee(uint256 licenseId, address holder) external view returns (bool) { return balanceOf(holder, licenseId) > 0; }
 
Inside PILPolicyFrameworkManager.verifyLink:
  • check whether derivative is allowed
  • check derivative approval
  • if the policy has defined commercializerChecker, then call the hook to verify this licensee’s link.
// story-protocol/protocol-core/contracts/modules/licensing/PILPolicyFrameworkManager.sol /// @notice Verify policy parameters for linking a child IP to a parent IP (licensor) by burning a license NFT. /// @dev Enforced to be only callable by LicenseRegistry /// @param licenseId the license id to burn /// @param licensee the address that holds the license and is executing the linking /// @param ipId the IP id of the IP being linked /// @param parentIpId the IP id of the parent IP /// @param policyData the encoded framework policy data to verify /// @return verified True if the link is verified function verifyLink( uint256 licenseId, address licensee, address ipId, address parentIpId, bytes calldata policyData ) external override nonReentrant onlyLicensingModule returns (bool) { PILPolicy memory policy = abi.decode(policyData, (PILPolicy)); // Trying to burn a license to create a derivative, when the license doesn't allow derivatives. if (!policy.derivativesAllowed) { return false; } // If the policy defines the licensor must approve derivatives, check if the // derivative is approved by the licensor if (policy.derivativesApproval && !isDerivativeApproved(licenseId, ipId)) { return false; } // Check if the commercializerChecker allows the link if (policy.commercializerChecker != address(0)) { // No need to check if the commercializerChecker supports the IHookModule interface, as it was checked // when the policy was registered. if (!IHookModule(policy.commercializerChecker).verify(licensee, policy.commercializerCheckerData)) { return false; } } return true; }
// story-protocol/protocol-core/contracts/modules/licensing/parameter-helpers/LicensorApprovalChecker.sol /// @notice Checks if a derivative IP account is approved by the licensor. /// @param licenseId The ID of the license NFT issued from a policy of the licensor /// @param childIpId The ID of the derivative IP to be approved /// @return approved True if the derivative IP account using the license is approved function isDerivativeApproved(uint256 licenseId, address childIpId) public view returns (bool) { address licensorIpId = LICENSE_REGISTRY.licensorIpId(licenseId); return _approvals[licenseId][licensorIpId][childIpId]; }
 

Verify policy compatibility and add

Can refer to analysis here.
/// @dev Adds a policy id to the ipId policy set. Reverts if policy set already has policyId function _addPolicyIdToIp( address ipId, uint256 policyId, bool isInherited, bool skipIfDuplicate ) private returns (uint256 index) { _verifyCanAddPolicy(policyId, ipId, isInherited); // Try and add the policy into the set. EnumerableSet.UintSet storage _pols = _policySetPerIpId(isInherited, ipId); if (!_pols.add(policyId)) { if (skipIfDuplicate) { return _policySetups[ipId][policyId].index; } revert Errors.LicensingModule__PolicyAlreadySetForIpId(); } index = _pols.length() - 1; PolicySetup storage setup = _policySetups[ipId][policyId]; // This should not happen, but just in case if (setup.isSet) { revert Errors.LicensingModule__PolicyAlreadySetForIpId(); } setup.index = index; setup.isSet = true; setup.active = true; setup.isInherited = isInherited; emit PolicyAddedToIpId(msg.sender, ipId, policyId, index, isInherited); return index; }
 

Set royalty

// story-protocol/protocol-core/contracts/modules/royalty/RoyaltyModule.sol /// @notice Executes royalty related logic on linking to parents /// @dev Enforced to be only callable by LicensingModule /// @param ipId The children ipId that is being linked to parents /// @param royaltyPolicy The common royalty policy address of all the licenses being burned /// @param parentIpIds The parent ipIds that the children ipId is being linked to /// @param licenseData The license data custom to each the royalty policy /// @param externalData The external data custom to each the royalty policy function onLinkToParents( address ipId, address royaltyPolicy, address[] calldata parentIpIds, bytes[] memory licenseData, bytes calldata externalData ) external nonReentrant onlyLicensingModule { if (!isWhitelistedRoyaltyPolicy[royaltyPolicy]) revert Errors.RoyaltyModule__NotWhitelistedRoyaltyPolicy(); if (parentIpIds.length == 0) revert Errors.RoyaltyModule__NoParentsOnLinking(); for (uint32 i = 0; i < parentIpIds.length; i++) { address parentRoyaltyPolicy = royaltyPolicies[parentIpIds[i]]; // if the parent node has a royalty policy set, then the derivative node should have the same royalty // policy if the parent node does not have a royalty policy set, then the derivative node can set any type // of royalty policy as long as the children ip obtained and is burning all licenses with that royalty type // from each parent (was checked in licensing module before calling this function) if (parentRoyaltyPolicy != royaltyPolicy && parentRoyaltyPolicy != address(0)) revert Errors.RoyaltyModule__IncompatibleRoyaltyPolicy(); } royaltyPolicies[ipId] = royaltyPolicy; IRoyaltyPolicy(royaltyPolicy).onLinkToParents(ipId, parentIpIds, licenseData, externalData); }
 
// story-protocol/protocol-core/contracts/modules/royalty/policies/RoyaltyPolicyLAP.sol /// @notice Executes royalty related logic on linking to parents /// @dev Enforced to be only callable by RoyaltyModule /// @param ipId The children ipId that is being linked to parents /// @param parentIpIds The parent ipIds that the children ipId is being linked to /// @param licenseData The license data custom to each the royalty policy /// @param externalData The external data custom to each the royalty policy function onLinkToParents( address ipId, address[] calldata parentIpIds, bytes[] memory licenseData, bytes calldata externalData ) external onlyRoyaltyModule { if (royaltyData[ipId].isUnlinkableToParents) revert Errors.RoyaltyPolicyLAP__UnlinkableToParents(); _initPolicy(ipId, parentIpIds, licenseData, externalData); }

Royalty

Story protocol uses RNFT(Royalty NFT) to control royalty flow. When an IP inherits a parent IP, it will need to give same royalty to parents of the parent IP plus new royalty given to the parent. Each IP has 1000 RNFT each represents 0.1% of the IP revenue. When IP links to parent, it will reserve RNFT to its ancestors in AncesterVault. Ancestors can claim RNFT on their will and use those RNFTs to claim royalty deposited into the splitContract of the derivative IP.
For example, if inherits which inherits . can claim revenue from which will be stored in ’s splitContract which can in turn be claimed by and the IP account of .
 

Claim RNFT

Anyone can can call certain IP’s AncestorsVaultLAP.claim to distribtute RNFT. User can specify the claimer IP and derivative IP. Claimer IP will claim RNFT from derivative IP. Also user needs to pass ancestors and ancestorsRoyalties information because contract only stores the hash representation of those information for gas consideration.
 
Inside the AncestorsVaultLAP.claim:
  • get derivative IP’s information, check the provided ancestors information is correct.
  • check claimer IP hasn’t claimed RNFT of this derivative IP before.
  • transfer RNFT.
// story-protocol/protocol-core/contracts/modules/royalty/policies/AncestorsVaultLAP.sol /// @notice Claims all available royalty nfts and accrued royalties for an ancestor of a given ipId /// @param ipId The ipId of the ancestors vault to claim from /// @param claimerIpId The claimer ipId is the ancestor address that wants to claim /// @param ancestors The ancestors for the selected ipId /// @param ancestorsRoyalties The royalties of the ancestors for the selected ipId /// @param withdrawETH Indicates if the claimer wants to withdraw ETH /// @param tokens The ERC20 tokens to withdraw function claim( address ipId, address claimerIpId, address[] calldata ancestors, uint32[] calldata ancestorsRoyalties, bool withdrawETH, ERC20[] calldata tokens ) external nonReentrant { (, address splitClone, address ancestorsVault, , bytes32 ancestorsHash) = ROYALTY_POLICY_LAP.royaltyData(ipId); if (isClaimed[ipId][claimerIpId]) revert Errors.AncestorsVaultLAP__AlreadyClaimed(); if (address(this) != ancestorsVault) revert Errors.AncestorsVaultLAP__InvalidClaimer(); if (keccak256(abi.encodePacked(ancestors, ancestorsRoyalties)) != ancestorsHash) revert Errors.AncestorsVaultLAP__InvalidAncestorsHash(); // transfer the rnfts to the claimer accrued royalties to the claimer split clone _transferRnftsAndAccruedTokens(claimerIpId, splitClone, ancestors, ancestorsRoyalties, withdrawETH, tokens); isClaimed[ipId][claimerIpId] = true; emit Claimed(ipId, claimerIpId, withdrawETH, tokens); }
 
 
Inside the AncestorsVaultLAP._transferRnftsAndAccruedTokens :
  • check the claimer IP is indeed one of the ancestors of the IP.
  • transfe RNFT to claimer IP’s splitClone based on the claimer IP’s royalty. Note here the RNFT is transferred to the splitClone not the claimer IP account, because the revenue should also be split to ancestors of the claimer IP.
  • transfer accrued tokens if any. (TODO: Not sure where the accrued token comes from)
// story-protocol/protocol-core/contracts/modules/royalty/policies/AncestorsVaultLAP.sol /// @dev Transfers the Royalty NFTs and accrued tokens to the claimer /// @param claimerIpId The claimer ipId /// @param splitClone The split clone address /// @param ancestors The ancestors of the IP /// @param ancestorsRoyalties The royalties of each of the ancestors /// @param withdrawETH Indicates if the claimer wants to withdraw ETH /// @param tokens The ERC20 tokens to withdraw function _transferRnftsAndAccruedTokens( address claimerIpId, address splitClone, address[] calldata ancestors, uint32[] calldata ancestorsRoyalties, bool withdrawETH, ERC20[] calldata tokens ) internal { (uint32 index, bool isIn) = ArrayUtils.indexOf(ancestors, claimerIpId); if (!isIn) revert Errors.AncestorsVaultLAP__ClaimerNotAnAncestor(); // transfer the rnfts from the ancestors vault to the claimer split clone // the rnfts that are meant for the ancestors were transferred to the ancestors vault at its deployment // and each ancestor can claim their share of the rnfts only once ILiquidSplitClone rnft = ILiquidSplitClone(splitClone); uint256 totalUnclaimedRnfts = rnft.balanceOf(address(this), 0); (, address claimerSplitClone, , , ) = ROYALTY_POLICY_LAP.royaltyData(claimerIpId); uint32 rnftAmountToTransfer = ancestorsRoyalties[index]; rnft.safeTransferFrom(address(this), claimerSplitClone, 0, rnftAmountToTransfer, ""); // transfer the accrued tokens to the claimer split clone _claimAccruedTokens(rnftAmountToTransfer, totalUnclaimedRnfts, claimerSplitClone, withdrawETH, tokens); }
// story-protocol/protocol-core/contracts/modules/royalty/policies/AncestorsVaultLAP.sol /// @dev Claims the accrued tokens (if any) /// @param rnftClaimAmount The amount of rnfts to claim /// @param totalUnclaimedRnfts The total unclaimed rnfts /// @param claimerSplitClone The claimer's split clone /// @param withdrawETH Indicates if the claimer wants to withdraw ETH /// @param tokens The ERC20 tokens to withdraw function _claimAccruedTokens( uint256 rnftClaimAmount, uint256 totalUnclaimedRnfts, address claimerSplitClone, bool withdrawETH, ERC20[] calldata tokens ) internal { ILiquidSplitMain splitMain = ILiquidSplitMain(ROYALTY_POLICY_LAP.LIQUID_SPLIT_MAIN()); if (withdrawETH) { if (splitMain.getETHBalance(address(this)) != 0) revert Errors.AncestorsVaultLAP__ETHBalanceNotZero(); uint256 ethBalance = address(this).balance; // when totalUnclaimedRnfts is 0, claim() call will revert as expected behaviour so no need to check for it uint256 ethClaimAmount = (ethBalance * rnftClaimAmount) / totalUnclaimedRnfts; _safeTransferETH(claimerSplitClone, ethClaimAmount); } for (uint256 i = 0; i < tokens.length; ++i) { // When withdrawing ERC20, 0xSplits sets the value to 1 to have warm storage access. // But this still means 0 amount left. So, in the check below, we use `> 1`. if (splitMain.getERC20Balance(address(this), tokens[i]) > 1) revert Errors.AncestorsVaultLAP__ERC20BalanceNotZero(); IERC20 IToken = IERC20(tokens[i]); uint256 tokenBalance = IToken.balanceOf(address(this)); // when totalUnclaimedRnfts is 0, claim() call will revert as expected behaviour so no need to check for it uint256 tokenClaimAmount = (tokenBalance * rnftClaimAmount) / totalUnclaimedRnfts; IToken.safeTransfer(claimerSplitClone, tokenClaimAmount); } }
 

Distribute IP funds

Anyone can call RoyaltyPolicyLAP.distributeIpPoolFunds to distribute revenue deposited into the splitContract of IP.
// story-protocol/protocol-core/contracts/modules/royalty/policies/RoyaltyPolicyLAP.sol /// @notice Distributes funds internally so that accounts holding the royalty nfts at distribution moment can /// claim afterwards /// @dev This call will revert if the caller holds all the royalty nfts of the ipId - in that case can call /// claimFromIpPoolAsTotalRnftOwner() instead /// @param ipId The ipId whose received funds will be distributed /// @param token The token to distribute /// @param accounts The accounts to distribute to /// @param distributorAddress The distributor address (if any) function distributeIpPoolFunds( address ipId, address token, address[] calldata accounts, address distributorAddress ) external { ILiquidSplitClone(royaltyData[ipId].splitClone).distributeFunds(token, accounts, distributorAddress); }
 
splitClone.distributeFunds is under developmenet, currently it has no implementation of the scaledPercentBalanceOf. But I guess the calculation of royalty percent should depend on the RNFTs holded by accounts.
Also I think there should be implementation to verify the provided accounts included all ancestors so that the revenue generated within a certain period can be fully and proportionally distributed among all ancestors and the IP account itself.
It transfer asset(Ether/ERC20) to splitMain to update receiver and allocation percent and update the ledger in the splitMain which records claimable fund of each receiver.
// story-protocol/protocol-core/contracts/LiquidSplitFactory/src/CloneImpl/LiquidSplitCloneImpl.sol /// distributes ETH & ERC20s to NFT holders /// @param token ETH (0x0) or ERC20 token to distribute /// @param accounts Ordered, unique list of NFT holders /// @param distributorAddress Address to receive distributorFee function distributeFunds(address token, address[] calldata accounts, address distributorAddress) external virtual { uint256 numRecipients = accounts.length; uint32[] memory percentAllocations = new uint32[](numRecipients); for (uint256 i; i < numRecipients;) { percentAllocations[i] = scaledPercentBalanceOf(accounts[i]); unchecked { ++i; } } // atomically deposit funds, update recipients to reflect current NFT holders, and distribute if (token == ETH_ADDRESS) { payoutSplit.safeTransferETH(address(this).balance); splitMain.updateAndDistributeETH({ split: payoutSplit, accounts: accounts, percentAllocations: percentAllocations, distributorFee: distributorFee(), distributorAddress: distributorAddress }); } else { token.safeTransfer(payoutSplit, ERC20(token).balanceOf(address(this))); splitMain.updateAndDistributeERC20({ split: payoutSplit, token: ERC20(token), accounts: accounts, percentAllocations: percentAllocations, distributorFee: distributorFee(), distributorAddress: distributorAddress }); } }
 

Claim IP funds

Anyone can call RoyaltyPolicyLAP.claimFromIpPool to withdraw revenue. It calls 0xSplit’s splitMain to withdraw fund.
// story-protocol/protocol-core/contracts/modules/royalty/policies/RoyaltyPolicyLAP.sol /// @notice Claims the available royalties for a given address /// @dev If there are no funds available in split main contract but there are funds in the split clone contract /// then a distributeIpPoolFunds() call should precede this call /// @param account The account to claim for /// @param withdrawETH The amount of ETH to withdraw /// @param tokens The tokens to withdraw function claimFromIpPool(address account, uint256 withdrawETH, ERC20[] calldata tokens) external { ILiquidSplitMain(LIQUID_SPLIT_MAIN).withdraw(account, withdrawETH, tokens); }
// SplitMain/contracts/SplitMain.sol /** @notice Withdraw ETH &/ ERC20 balances for account `account` * @param account Address to withdraw on behalf of * @param withdrawETH Withdraw all ETH if nonzero * @param tokens Addresses of ERC20s to withdraw */ function withdraw(address account, uint256 withdrawETH, ERC20[] calldata tokens) external override { uint256[] memory tokenAmounts = new uint256[](tokens.length); uint256 ethAmount; if (withdrawETH != 0) { ethAmount = _withdraw(account); } unchecked { // overflow should be impossible in for-loop index for (uint256 i = 0; i < tokens.length; ++i) { // overflow should be impossible in array length math tokenAmounts[i] = _withdrawERC20(account, tokens[i]); } emit Withdrawal(account, ethAmount, tokens, tokenAmounts); } } /** @notice Withdraw ETH for account `account` * @param account Account to withdrawn ETH for * @return withdrawn Amount of ETH withdrawn */ function _withdraw(address account) internal returns (uint256 withdrawn) { // leave balance of 1 for gas efficiency // underflow if ethBalance is 0 withdrawn = ethBalances[account] - 1; ethBalances[account] = 1; account.safeTransferETH(withdrawn); } /** @notice Withdraw ERC20 `token` for account `account` * @param account Account to withdrawn ERC20 `token` for * @return withdrawn Amount of ERC20 `token` withdrawn */ function _withdrawERC20(address account, ERC20 token) internal returns (uint256 withdrawn) { // leave balance of 1 for gas efficiency // underflow if erc20Balance is 0 withdrawn = erc20Balances[token][account] - 1; erc20Balances[token][account] = 1; token.safeTransfer(account, withdrawn); }

Dispute

Story protocol supports to tag problematic IP. It uses DisputeModule to manage dispute.

Raise dispute

Any account can call DisputeModule.raiseDispute to raise dispute.
Inside the DisputeModule.raiseDispute:
  • sanity check on target IP, targetTag and linkToDisputeEvidence
  • record dispute related information
  • call arbitrationPolicy.onRaiseDispute hook. In the implementation of ArbitrationPolicySP , it requires dispute raiser to transfer certain fee to raise dispute.
// story-protocol/protocol-core/contracts/modules/dispute/DisputeModule.sol /// @notice Raises a dispute on a given ipId /// @param targetIpId The ipId that is the target of the dispute /// @param linkToDisputeEvidence The link of the dispute evidence /// @param targetTag The target tag of the dispute /// @param data The data to initialize the policy /// @return disputeId The id of the newly raised dispute function raiseDispute( address targetIpId, string memory linkToDisputeEvidence, bytes32 targetTag, bytes calldata data ) external nonReentrant returns (uint256) { if (!IP_ASSET_REGISTRY.isRegistered(targetIpId)) revert Errors.DisputeModule__NotRegisteredIpId(); if (!isWhitelistedDisputeTag[targetTag]) revert Errors.DisputeModule__NotWhitelistedDisputeTag(); bytes32 linkToDisputeEvidenceBytes = ShortStringOps.stringToBytes32(linkToDisputeEvidence); if (linkToDisputeEvidenceBytes == bytes32(0)) revert Errors.DisputeModule__ZeroLinkToDisputeEvidence(); address arbitrationPolicy = arbitrationPolicies[targetIpId]; if (!isWhitelistedArbitrationPolicy[arbitrationPolicy]) arbitrationPolicy = baseArbitrationPolicy; uint256 disputeId_ = ++disputeCounter; disputes[disputeId_] = Dispute({ targetIpId: targetIpId, disputeInitiator: msg.sender, arbitrationPolicy: arbitrationPolicy, linkToDisputeEvidence: linkToDisputeEvidenceBytes, targetTag: targetTag, currentTag: IN_DISPUTE }); IArbitrationPolicy(arbitrationPolicy).onRaiseDispute(msg.sender, data); emit DisputeRaised( disputeId_, targetIpId, msg.sender, arbitrationPolicy, linkToDisputeEvidenceBytes, targetTag, data ); return disputeId_; }
// story-protocol/protocol-core/contracts/modules/dispute/policies/ArbitrationPolicySP.sol /// @notice Executes custom logic on raising dispute. /// @dev Enforced to be only callable by the DisputeModule. /// @param caller Address of the caller /// @param data The arbitrary data used to raise the dispute function onRaiseDispute(address caller, bytes calldata data) external onlyDisputeModule { // requires that the caller has given approve() to this contract IERC20(PAYMENT_TOKEN).safeTransferFrom(caller, address(this), ARBITRATION_PRICE); }
 

Cancel dispute

disputeInitiator can call DisputeModule.cancelDispute to cancel dispute. It requires the dispute is in dispute stage and will mark the tag to be canceled(0 bytes).
// story-protocol/protocol-core/contracts/modules/dispute/DisputeModule.sol /// @notice Cancels an ongoing dispute /// @param disputeId The dispute id /// @param data The data to cancel the dispute function cancelDispute(uint256 disputeId, bytes calldata data) external nonReentrant { Dispute memory dispute = disputes[disputeId]; if (dispute.currentTag != IN_DISPUTE) revert Errors.DisputeModule__NotInDisputeState(); if (msg.sender != dispute.disputeInitiator) revert Errors.DisputeModule__NotDisputeInitiator(); IArbitrationPolicy(dispute.arbitrationPolicy).onDisputeCancel(msg.sender, disputeId, data); disputes[disputeId].currentTag = bytes32(0); emit DisputeCancelled(disputeId, data); }
// story-protocol/protocol-core/contracts/modules/dispute/policies/ArbitrationPolicySP.sol /// @notice Executes custom logic on disputing cancel. /// @dev Enforced to be only callable by the DisputeModule. /// @param caller Address of the caller /// @param disputeId The dispute id /// @param data The arbitrary data used to cancel the dispute function onDisputeCancel(address caller, uint256 disputeId, bytes calldata data) external onlyDisputeModule {}
 

Set dispute judgement

arbitrationRelayer can call DisputeModule.setDisputeJudgement to set judgement of dispute. This will update the successfulDisputes of the IP which will effect some actions related to the IP, such as license mint.
// story-protocol/protocol-core/contracts/modules/dispute/DisputeModule.sol /// @notice Sets the dispute judgement on a given dispute. Only whitelisted arbitration relayers can call to judge. /// @param disputeId The dispute id /// @param decision The decision of the dispute /// @param data The data to set the dispute judgement function setDisputeJudgement(uint256 disputeId, bool decision, bytes calldata data) external nonReentrant { Dispute memory dispute = disputes[disputeId]; if (dispute.currentTag != IN_DISPUTE) revert Errors.DisputeModule__NotInDisputeState(); if (!isWhitelistedArbitrationRelayer[dispute.arbitrationPolicy][msg.sender]) { revert Errors.DisputeModule__NotWhitelistedArbitrationRelayer(); } if (decision) { disputes[disputeId].currentTag = dispute.targetTag; successfulDisputesPerIp[dispute.targetIpId]++; } else { disputes[disputeId].currentTag = bytes32(0); } IArbitrationPolicy(dispute.arbitrationPolicy).onDisputeJudgement(disputeId, decision, data); emit DisputeJudgementSet(disputeId, decision, data); }
 

Resolve dispute

disputeInitiator can call DisputeModule.resolveDispute to resolve dispute. This may happen after the disputing parties reach a settlement, the dispute can be rescinded by the administrator.
// story-protocol/protocol-core/contracts/modules/dispute/DisputeModule.sol /// @notice Resolves a dispute after it has been judged /// @param disputeId The dispute id function resolveDispute(uint256 disputeId) external { Dispute memory dispute = disputes[disputeId]; if (msg.sender != dispute.disputeInitiator) revert Errors.DisputeModule__NotDisputeInitiator(); if (dispute.currentTag == IN_DISPUTE || dispute.currentTag == bytes32(0)) revert Errors.DisputeModule__NotAbleToResolve(); successfulDisputesPerIp[dispute.targetIpId]--; disputes[disputeId].currentTag = bytes32(0); emit DisputeResolved(disputeId); }

Appendix

ERC6551

ERC6551 implements Non-fungible Token Bound Accounts
 
It implements a registry to help deploy account contract whose owner is the owner of certain ERC721 NFT.

Transfer of License

LicenseRegistry implements ERC1155. Each license is NFT of type ERC1155.
LicenseRegistry overrides the _update function of ERC1155, and control the transferability based on the license’s transferable field. IP account has authority to transfer license freely.
Also if the licensor IP has dispute tag, license of this IP cant be tranferred.
// story-protocol/protocol-core/contracts/registries/LicenseRegistry.sol /// @dev Pre-hook for ERC1155's _update() called on transfers. function _update( address from, address to, uint256[] memory ids, uint256[] memory values ) internal virtual override { // We are interested in transfers, minting and burning are checked in mintLicense and // linkIpToParent in LicensingModule respectively if (from != address(0) && to != address(0)) { for (uint256 i = 0; i < ids.length; i++) { Licensing.License memory lic = _licenses[ids[i]]; // TODO: Hook for verify transfer params if (isLicenseRevoked(ids[i])) { revert Errors.LicenseRegistry__RevokedLicense(); } if (!lic.transferable) { // True if from == licensor if (from != lic.licensorIpId) { revert Errors.LicenseRegistry__NotTransferable(); } } } } super._update(from, to, ids, values); } /// @notice Returns true if the license has been revoked (licensor tagged after a dispute in /// the dispute module). If the tag is removed, the license is not revoked anymore. /// @param licenseId The id of the license to check /// @return isRevoked True if the license is revoked function isLicenseRevoked(uint256 licenseId) public view returns (bool) { // For beta, any tag means revocation, for mainnet we need more context. // TODO: signal metadata update when tag changes. return DISPUTE_MODULE.isIpTagged(_licenses[licenseId].licensorIpId); }
 

Conclusion

Story protocol use smart contract to implement IP’s license, royalty, dispute logic. So that creators can automate those processes on chain, which reduces cost and can help improve the creativity.
Due the complexity of license and royalty system, current implementation has some limitation and can’t deal some complicated situation. For example, due to gas cost, IP can only have max 14 ancestors, also royalty can’t be modified after the issuance of license, etc.
Overall, this is an innovative protocol which can reduce the cost in the area of IP creation.

Reference