27 August 2025
This technical guide explains how to set up custom royalty policies on Story.
Story’s Royalty Module enables automated revenue sharing between IP Assets (IPAs) based on their derivative relationships and license terms.
The following technical guide explains how revenue flows through the protocol, as well as how IP owners are paid and can claim their share.
Each IPA has one royalty vault. This royalty vault has 100 Royalty Tokens associated with it, where each token represents the right to 1% of the total revenue generated by the IP Asset and thus deposited into the Royalty Vault.
The way revenue is distributed from derivatives to their ancestors varies depending on the royalty policy relationship between them. Currently, in the protocol, there are two royalty policies that already exist:
There can be as many flavors and variations of royalty distribution rules as we observe in the real world. The same can be expected on-chain. LAP or LRP may cover a significant amount of use cases, but many use cases have their own specificities for which neither would be a fit.
Whenever a use case requires unique and specific royalty rules, that set of rules can be registered as an External Royalty Policy.
When developing your smart contract in order to register your new External Royalty Policy by calling registerExternalRoyaltyPolicy
function in RoyaltyModule.sol make sure that your new external royalty policy smart contract:
IExternalRoyaltyPolicy.sol
(Github link), which implements the view function below:/// @notice Returns the amount of royalty tokens required to link a child to a given IP asset
/// @param ipId The ipId of the IP asset
/// @param licensePercent The percentage of the license
/// @return The amount of royalty tokens required to link a child to a given IP asset
function getPolicyRtsRequiredToLink(address ipId, uint32 licensePercent) external view returns (uint32);
ERC165
and implements the function below. For further details, you can check the smart contract example shown in section 2./// @notice IERC165 interface support.
function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) {
return interfaceId == this.getPolicyRtsRequiredToLink.selector || super.supportsInterface(interfaceId);
}
Let’s imagine a situation where a scientist is raising 1M USD to build a new type of IP related to a new pharmaceutical product.
There is an investor who agrees to invest in the production of the new pharmaceutical but demands that the first 1M of profits from this venture be returned to them with priority. After the first 1M of profits, any additional profit is split 70% for the scientist and 30% for the investor.
These are profit/royalties distribution rules that are custom and more specific than the generic percentage split. In this case, there is an additional nuance in which up to a certain threshold, all profit goes to the investor until the investor breaks even on the investment.
In terms of IP registration, there would be a root IP with the Pharmaceutical Formula and a derivative IP with the Pharmaceutical Product. The license used to create IP2 has as its royalty policy the address of the external royalty policy smart contract.
Given that this use case is a custom case, it would require a new external royalty policy smart contract to be developed. As an example of the implementation of the external royalty policy above, let’s take a look at the smart contract below:
// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.26;
import { IERC165, ERC165 } from "@openzeppelin/contracts/utils/introspection/ERC165.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import { IIpRoyaltyVault } from "../../../../contracts/interfaces/modules/royalty/policies/IIpRoyaltyVault.sol";
import { IRoyaltyModule } from "../../../../contracts/interfaces/modules/royalty/IRoyaltyModule.sol";
import { IExternalRoyaltyPolicy } from "../../../../contracts/interfaces/modules/royalty/policies/IExternalRoyaltyPolicy.sol";
contract ExternalRoyaltyPolicy is ERC165, IExternalRoyaltyPolicy, ReentrancyGuard {
using SafeERC20 for IERC20;
/// @notice Returns the percentage scale - represents 100%
uint32 public constant MAX_PERCENT = 100_000_000;
/// @notice The address of the IP asset
address public constant WIP = 0x1514000000000000000000000000000000000000;
/// @notice The address of the royalty module
address public immutable ROYALTY_MODULE;
struct Product {
address scientist;
address investor;
uint32 thresholdPercent;
uint256 thresholdValue;
}
mapping(address ipId => Product) public products;
mapping(address ipId => uint256) public totalClaimed;
error NotAuthorized();
error ProductAlreadyAssigned();
error ProductNotAssigned();
error InvalidScientist();
error InvalidInvestor();
error InvalidThresholdPercent();
error InvalidThresholdValue();
error InsufficientBalanceToAssign();
error InvalidRoyaltyModule();
error InsufficientBalanceToClaim();
/// @notice Constructor
/// @param royaltyModule The address of the royalty module
constructor(address royaltyModule) {
if (royaltyModule == address(0)) revert InvalidRoyaltyModule();
ROYALTY_MODULE = royaltyModule;
}
/// @notice Assigns a product to an IP asset
/// @param ipId The address of the IP asset
/// @param scientist The address of the scientist
/// @param investor The address of the investor
/// @param thresholdPercent The threshold percentage
/// @param thresholdValue The threshold value
function assign(
address ipId,
address scientist,
address investor,
uint32 thresholdPercent,
uint256 thresholdValue)
external {
if (msg.sender != ipId) revert NotAuthorized(); // It is assumed for this example contract that the IP owner is trusted by all parties for the setup
if (products[ipId].scientist != address(0)) revert ProductAlreadyAssigned();
address royaltyToken = IRoyaltyModule(ROYALTY_MODULE).ipRoyaltyVaults(ipId);
if (IERC20(royaltyToken).balanceOf(address(this)) == 0) revert InsufficientBalanceToAssign();
if (scientist == address(0)) revert InvalidScientist();
if (investor == address(0)) revert InvalidInvestor();
if (thresholdPercent > MAX_PERCENT) revert InvalidThresholdPercent();
if (thresholdValue == 0) revert InvalidThresholdValue();
products[ipId] = Product(scientist, investor, thresholdPercent, thresholdValue);
}
/// @notice Claims revenue for an IP asset
/// @param ipId The address of the IP asset
function claim(address ipId) external nonReentrant {
address scientist = products[ipId].scientist;
if (scientist == address(0)) revert ProductNotAssigned();
address royaltyToken = IRoyaltyModule(ROYALTY_MODULE).ipRoyaltyVaults(ipId);
uint256 amountClaimed = IIpRoyaltyVault(royaltyToken).claimRevenueOnBehalf(address(this), WIP);
if (amountClaimed == 0) revert InsufficientBalanceToClaim();
uint256 amountToInvestor;
uint256 amountToScientist;
uint256 totalClaimedAmount = totalClaimed[ipId];
uint256 thresholdValue = products[ipId].thresholdValue;
uint32 thresholdPercent = products[ipId].thresholdPercent;
if (totalClaimedAmount > thresholdValue) {
amountToInvestor = amountClaimed * thresholdPercent / MAX_PERCENT;
amountToScientist = amountClaimed - amountToInvestor;
} else {
if (totalClaimedAmount + amountClaimed > thresholdValue) {
uint256 amountAboveThreshold = totalClaimedAmount + amountClaimed - thresholdValue;
uint256 amountAboveThresholdForInvestor = amountAboveThreshold * thresholdPercent / MAX_PERCENT;
amountToInvestor = thresholdValue - totalClaimedAmount + amountAboveThresholdForInvestor;
amountToScientist = amountAboveThreshold - amountAboveThresholdForInvestor;
} else {
amountToInvestor = amountClaimed;
amountToScientist = 0;
}
}
totalClaimed[ipId] += amountClaimed;
IERC20(WIP).safeTransfer(scientist, amountToScientist);
IERC20(WIP).safeTransfer(products[ipId].investor, amountToInvestor);
}
/// @notice Returns the amount of royalty tokens required to link a child to a given IP asset
/// @param ipId The ipId of the IP asset
/// @param licensePercent The percentage of the license
/// @return The amount of royalty tokens required to link a child to a given IP asset
function getPolicyRtsRequiredToLink(address ipId, uint32 licensePercent) external view returns (uint32) {
return MAX_PERCENT;
}
/// @notice IERC165 interface support
function supportsInterface(bytes4 interfaceId) public view override(ERC165, IERC165) returns (bool) {
return interfaceId == this.getPolicyRtsRequiredToLink.selector || super.supportsInterface(interfaceId);
}
}
You can observe the implementation getPolicyRtsRequiredToLink,
which in this case has been set to always return 100% (MAX_PERCENT
). What this means is that every derivative IP that was created with this external royalty policy has to give 100% of its royalty tokens to the “ExternalRoyaltyPolicy” smart contract.
Once the royalty tokens are in a smart contract, they can be used to claim royalties via the claim
function. The final flow of capital, assuming 2M yearly revenue, would look like the diagram below:
As it’s possible to imagine by now, there is an entire universe of different royalty policies yet to be created. The External Royalty Policy allows for high expressiveness of breadth of possible royalty rules while keeping the value of having access/benefiting from the licensing and dispute modules, among other benefits of being part of the Story Ecosystem.
If you have any feedback, comments, or ideas, feel free to reach out on Discord or DM the Story Engineers account on X → https://x.com/StoryEngs.
We're excited to see what projects you'll build!