Revenue Composition (developer)
Technical reference for ContributionRole and ProductContribution - schema, validation, position freezing, and substrate-derived creator slots.
ContributionRole
type ContributionRole =
| "CREATOR"
| "COLLABORATOR"
| "TEMPLATE_ARTIST"
| "STICKER_ARTIST"
| "FUNDING_GROUP"
| "PLATFORM"
| "CONTRACT_TEMPLATE_DEVELOPER" // supply-chain cap table (mint axis)
| "APP_HOST"; // supply-chain cap table (deploy axis)Two tiers:
- Creator-allocated (
CREATOR,COLLABORATOR): the creator sets the recipient address and percentage directly. - Protocol-routed (
TEMPLATE_ARTIST,STICKER_ARTIST,FUNDING_GROUP,PLATFORM,CONTRACT_TEMPLATE_DEVELOPER,APP_HOST): the recipient and rate are derived by the server from the picked asset (StyleSet, Sticker, FundingGroup), the contract template, or the source app record. The two supply-chain roles are capture-now-pay-later - they derive at royalty 0 by default.
ProductContribution schema
interface ProductContribution {
id: number;
smartProductId: number;
role: ContributionRole;
percentage: number; // 0-100
position: number | null; // 1-10, assigned by freezePositions()
address: string; // denormalized on-chain payout address
displayName: string;
userId: number | null; // recipient FK (CREATOR/COLLABORATOR/artist)
fundingGroupId: number | null; // FUNDING_GROUP source + recipient FK
sourceStyleSetId: number | null; // TEMPLATE_ARTIST source FK
sourceStickerId: number | null; // STICKER_ARTIST source FK
sourceContractTemplateId: number | null; // CONTRACT_TEMPLATE_DEVELOPER source FK
}Validation rules
- At least one
CREATORrow is required. MultipleCREATORrows are allowed - the substrate enforces the role budget, not the row count. - Total percentages must equal the domain-specific target sum (see below).
- Max 10 contributions total (matches the Clarity template's
creator-1..creator-10slot count). FUNDING_GROUPmust reference a FundingGroup inAPPROVEDorVERIFIEDstatus.COLLABORATORrequiresrecipientAddress.TEMPLATE_ARTISTrequiresstyleSetId;STICKER_ARTISTrequiresstickerId.
Target sum per domain
contributionService.targetSumForProduct(product) returns:
| Domain | Target sum | Why |
|---|---|---|
PAGE | 100 | All revenue routes through distribute-fees on every collect. |
EVENT | 100 | Same. Ticket fees flow through the same on-chain path. |
PREDICTION | 100 - poolPercentage | The pool share is paid by tournament settlement, not by distribute-fees. On-chain creator-N slots only need to cover the remainder. |
GAME | 100 - poolPct | The pool share of every buy-in stays in the contract as the match kitty. Contributions cover the remainder. |
A bracket tournament with poolPercentage: 80 has ProductContribution rows summing to 20. A page with no pool concept has rows summing to 100.
Substrate-derived creator slots
The canonical create flow does not hand-roll a creators[] array. Instead, picker payloads feed into ContributionPickInput on the create body. The server derives ProductContribution rows from the picks inside the create transaction:
await rtClient.smartProducts.createPage({
// ... required fields
contributions: {
styleSetId: 7, // server derives TEMPLATE_ARTIST row at StyleSet.royaltyPercentage
stickers: [ // server derives one STICKER_ARTIST row per sticker
{ stickerId: 12, x: 100, y: 200, scale: 1, rotation: 0 },
],
collaborators: [ // free-form, creator sets address + percentage
{ address: "SP123...", displayName: "Collaborator", percentage: 10 },
],
fundingGroups: [ // registry-backed pick
{ id: 3, percentage: 5 },
],
},
});The CREATOR row is auto-derived as the remainder after protocol-routed rows are allocated. No client-side rebalancing.
Position freezing
contributionService.freezePositions(productId) runs inside /api/contracts/generate before template evaluation. It assigns position values 1..N to all non-PLATFORM rows in insertion order, inside a single transaction. Idempotent - already-frozen rows are skipped.
Positions are passed to the deployed contract as creator-1..creator-N. At mint time distribute-fees reads each slot and pays out the configured percentage. The Chainhook position-verifier later matches on-chain transfer events back to ProductContribution.position to prove the split executed correctly.
Reading contributions
// Frozen rows in deploy order (position ASC), or insertion order if not yet frozen.
const { data: contributions } = await rtClient.smartProducts.getContributions(productId);For mutations the SDK does not expose a single-row addContribution() - contributions are always created together with the SmartProduct draft via the contributions payload above. Sticker placements are the one exception: they have rtClient.smartProducts.addSticker() / updateStickerPlacement() / removeSticker(), which atomically maintain the ProductSticker row and its matching STICKER_ARTIST ProductContribution row. Mutating any contributions after position has been frozen is rejected (409) because the on-chain creator-info map would diverge.