Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ viem implementation and an experimental Product CDM/PAPI adapter boundary.
The backend key-delivery protocol now has an explicit Product sr25519
signature scheme that binds the Product account public key to the derived H160
requester before access checks. The Product frontend can now submit that
Product proof after explicit host-account connection; contract writes remain
passkey/EVM until CDM-installed runtime packages and host-signed transaction
evidence are proven. See
Product proof after explicit host-account connection; contract writes now share
the same runtime writer port, but the Product path still needs host-signed
transaction evidence before becoming the default. See
[`docs/explanation/product-devnet-architecture.md`](docs/explanation/product-devnet-architecture.md)
and the
[`Product roadmap`](docs/backlog/polkadot-product-readiness-and-killer-dapp-roadmap.md).
Expand Down Expand Up @@ -330,8 +330,10 @@ authorization failures.
exposes it; otherwise Human free fails closed.
- **Classic**: paid access in the configured runtime-native token. On the
current Product DevNet/Paseo Asset Hub rail, that token is PAS. The runtime
records the price and distributes payments to configured royalty recipients on
`musicRoyPayAccess`.
records the price and settles configured recipient shares on
`musicRoyPayAccess`. Recipients that reject or exhaust the bounded native
transfer do not block the listener's purchase; their share remains claimable
in the artist runtime.

A Classic payment creates an on-chain paid-access record with no fixed expiry in
the current runtime. It is not a guarantee of perpetual media availability:
Expand Down Expand Up @@ -459,7 +461,8 @@ handle:
- NFT mint with `ownerOf`, `balanceOf`, and transfer events;
- cover, audio, metadata, and Bulletin manifest references stored on-chain;
- Human free or Classic access mode with PoP gating;
- DOT payment and royalty distribution on `musicRoyPayAccess`.
- native-token access payment, bounded royalty settlement, and claimable failed
recipient shares on `musicRoyPayAccess`.

## Structure

Expand Down
4 changes: 3 additions & 1 deletion contracts/evm/contracts/ArtistRuntimeFactory.sol
Original file line number Diff line number Diff line change
Expand Up @@ -245,12 +245,14 @@ contract ArtistRuntimeFactory {
}

function _musicRoyaltiesSelectors() private pure returns (bytes4[] memory selectors) {
selectors = new bytes4[](5);
selectors = new bytes4[](7);
selectors[0] = MusicRoyaltiesPallet.musicRoyPayAccess.selector;
selectors[1] = MusicRoyaltiesPallet.musicRoyRecordListen.selector;
selectors[2] = MusicRoyaltiesPallet.musicRoySplitCount.selector;
selectors[3] = MusicRoyaltiesPallet.musicRoySplitAt.selector;
selectors[4] = MusicRoyaltiesPallet.musicRoyTotalBps.selector;
selectors[5] = MusicRoyaltiesPallet.musicRoyClaimable.selector;
selectors[6] = MusicRoyaltiesPallet.musicRoyClaim.selector;
}

/// @dev The two registrar selectors are retained so already-deployed runtimes keep a
Expand Down
37 changes: 22 additions & 15 deletions contracts/evm/contracts/libraries/LibMusicRoyalties.sol
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pragma solidity ^0.8.28;
/// Storage slot: keccak256("smart.runtime.pallet.music-royalties.storage")
library LibMusicRoyalties {
bytes32 constant STORAGE_POSITION = keccak256('smart.runtime.pallet.music-royalties.storage');
uint256 internal constant NATIVE_TRANSFER_GAS_LIMIT = 100_000;

struct RoyaltySplit {
address recipient;
Expand All @@ -16,6 +17,7 @@ library LibMusicRoyalties {

struct Storage {
mapping(bytes32 => RoyaltySplit[]) splits; // contentHash → splits
mapping(address => uint256) claimable; // recipient → pending native-token amount
}

function store() internal pure returns (Storage storage s) {
Expand All @@ -42,23 +44,28 @@ library LibMusicRoyalties {
}
}

/// @dev Distributes `amount` across splits; remainder goes to `artist`.
function distribute(Storage storage s, bytes32 contentHash, address artist, uint256 amount) internal {
RoyaltySplit[] storage sp = s.splits[contentHash];
uint256 distributed;
for (uint256 i = 0; i < sp.length; i++) {
uint256 share = (amount * sp[i].bps) / 10_000;
distributed += share;
_send(sp[i].recipient, share);
}
if (amount > distributed) {
_send(artist, amount - distributed);
function trySendNative(address recipient, uint256 amount) internal returns (bool) {
if (amount == 0) return true;
(bool ok, ) = payable(recipient).call{ value: amount, gas: NATIVE_TRANSFER_GAS_LIMIT }('');
return ok;
}

function addClaimable(Storage storage s, address recipient, uint256 amount) internal returns (uint256 pendingTotal) {
if (amount == 0) return s.claimable[recipient];
s.claimable[recipient] += amount;
return s.claimable[recipient];
}

function takeClaimable(Storage storage s, address recipient) internal returns (uint256 amount) {
amount = s.claimable[recipient];
if (amount > 0) {
s.claimable[recipient] = 0;
}
}

function _send(address recipient, uint256 amount) private {
if (amount == 0) return;
(bool ok, ) = payable(recipient).call{ value: amount }('');
require(ok, 'MusicRoyalties: transfer failed');
function restoreClaimable(Storage storage s, address recipient, uint256 amount) internal returns (uint256 pendingTotal) {
if (amount == 0) return s.claimable[recipient];
s.claimable[recipient] += amount;
return s.claimable[recipient];
}
}
87 changes: 81 additions & 6 deletions contracts/evm/contracts/pallets/MusicRoyaltiesPallet.sol
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import { LibReentrancyGuard } from '../libraries/LibReentrancyGuard.sol';
/// @notice Smart Pallet for on-chain payment collection and royalty distribution.
///
/// Classic tracks: listeners call `musicRoyPayAccess` with the track price
/// in native currency. Payment is immediately split across all royalty
/// recipients according to their basis-point allocation; any rounding
/// remainder goes to the original artist.
/// in native currency. Payment is settled across all royalty recipients
/// according to their basis-point allocation; any rounding remainder goes
/// to the original artist. Immediate native transfers are gas-bounded. If
/// a recipient cannot receive its share in the payment transaction, that
/// share becomes claimable instead of reverting the listener's purchase.
///
/// HumanFree tracks: no payment required, but the pallet exposes
/// `musicRoyRecordListen` as an on-chain analytics hook — it verifies the
Expand All @@ -29,6 +31,16 @@ contract MusicRoyaltiesPallet {

event MusicRoyAccessPaid(bytes32 indexed contentHash, address indexed listener, uint256 amount);

event MusicRoyRoyaltyPaid(bytes32 indexed contentHash, address indexed listener, address indexed recipient, uint256 amount);

event MusicRoyRoyaltyPayoutFailed(bytes32 indexed contentHash, address indexed listener, address indexed recipient, uint256 amount);

event MusicRoyRoyaltyClaimable(bytes32 indexed contentHash, address indexed listener, address indexed recipient, uint256 amount, uint256 pendingTotal);

event MusicRoyRoyaltyClaimed(address indexed recipient, uint256 amount);

event MusicRoyRoyaltyClaimFailed(address indexed recipient, uint256 amount);

event MusicRoyRefunded(bytes32 indexed contentHash, address indexed listener, uint256 amount);

event MusicRoyListenRecorded(bytes32 indexed contentHash, address indexed listener, LibMusicRegistry.PersonhoodLevel requiredPersonhood);
Expand Down Expand Up @@ -70,19 +82,47 @@ contract MusicRoyaltiesPallet {

accessStore.paidAccess[contentHash][msg.sender] = true;

// Distribute exactly the track price; refund any overpayment.
LibMusicRoyalties.distribute(LibMusicRoyalties.store(), contentHash, track.artist, price);
// Settle exactly the track price; refund any overpayment.
_settleRoyalties(contentHash, msg.sender, track.artist, price);

uint256 refund = msg.value - price;
if (refund > 0) {
(bool refunded, ) = payable(msg.sender).call{ value: refund }('');
bool refunded = LibMusicRoyalties.trySendNative(msg.sender, refund);
require(refunded, 'MusicRoyalties: refund failed');
emit MusicRoyRefunded(contentHash, msg.sender, refund);
}

emit MusicRoyAccessPaid(contentHash, msg.sender, price);
}

/// @notice Native-token royalty amount currently waiting for `recipient` to claim.
function musicRoyClaimable(address recipient) external view returns (uint256) {
return LibMusicRoyalties.store().claimable[recipient];
}

/// @notice Claim pending native-token royalties for the caller.
/// @dev The recipient argument is intentional: it makes the authorization check
/// explicit in receipts and prevents a caller from draining someone else's
/// pending balance through a helper contract. A failed transfer is recorded
/// and left claimable; the transaction itself does not revert.
function musicRoyClaim(address recipient) external nonReentrant returns (uint256 amount, bool settled) {
require(recipient == msg.sender, 'MusicRoyalties: claim self only');

LibMusicRoyalties.Storage storage royaltyStore = LibMusicRoyalties.store();
amount = LibMusicRoyalties.takeClaimable(royaltyStore, recipient);
require(amount > 0, 'MusicRoyalties: nothing to claim');

settled = LibMusicRoyalties.trySendNative(recipient, amount);
if (settled) {
emit MusicRoyRoyaltyClaimed(recipient, amount);
return (amount, true);
}

LibMusicRoyalties.restoreClaimable(royaltyStore, recipient, amount);
emit MusicRoyRoyaltyClaimFailed(recipient, amount);
return (amount, false);
}

/// @notice Record a listen event for a HumanFree track (analytics; no charge).
/// Reverts if the caller does not hold the required personhood level.
function musicRoyRecordListen(bytes32 contentHash) external {
Expand Down Expand Up @@ -117,4 +157,39 @@ contract MusicRoyaltiesPallet {
total += sp[i].bps;
}
}

function _settleRoyalties(bytes32 contentHash, address listener, address artist, uint256 amount) private {
LibMusicRoyalties.Storage storage royaltyStore = LibMusicRoyalties.store();
LibMusicRoyalties.RoyaltySplit[] storage sp = royaltyStore.splits[contentHash];
uint256 distributed;

for (uint256 i = 0; i < sp.length; i++) {
uint256 share = (amount * sp[i].bps) / 10_000;
distributed += share;
_settleRoyaltyShare(royaltyStore, contentHash, listener, sp[i].recipient, share);
}

if (amount > distributed) {
_settleRoyaltyShare(royaltyStore, contentHash, listener, artist, amount - distributed);
}
}

function _settleRoyaltyShare(
LibMusicRoyalties.Storage storage royaltyStore,
bytes32 contentHash,
address listener,
address recipient,
uint256 amount
) private {
if (amount == 0) return;

if (LibMusicRoyalties.trySendNative(recipient, amount)) {
emit MusicRoyRoyaltyPaid(contentHash, listener, recipient, amount);
return;
}

uint256 pendingTotal = LibMusicRoyalties.addClaimable(royaltyStore, recipient, amount);
emit MusicRoyRoyaltyPayoutFailed(contentHash, listener, recipient, amount);
emit MusicRoyRoyaltyClaimable(contentHash, listener, recipient, amount, pendingTotal);
}
}
36 changes: 36 additions & 0 deletions contracts/evm/contracts/test/RoyaltyFailureRecipients.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: GPL-3.0-only WITH Classpath-exception-2.0
pragma solidity ^0.8.28;

interface IRoyaltyClaimRuntime {
function musicRoyClaim(address recipient) external returns (uint256 amount, bool settled);
}

contract RejectingRoyaltyRecipient {
receive() external payable {
revert('RejectingRoyaltyRecipient: rejected');
}

function claimFrom(address runtime) external returns (uint256 amount, bool settled) {
return IRoyaltyClaimRuntime(runtime).musicRoyClaim(address(this));
}
}

contract GasConsumingRoyaltyRecipient {
uint256 public sink;
bool public burnGas = true;

function setBurnGas(bool nextBurnGas) external {
burnGas = nextBurnGas;
}

receive() external payable {
if (!burnGas) return;
for (uint256 i = 0; i < 300; i++) {
sink += i + msg.value;
}
}

function claimFrom(address runtime) external returns (uint256 amount, bool settled) {
return IRoyaltyClaimRuntime(runtime).musicRoyClaim(address(this));
}
}
4 changes: 4 additions & 0 deletions contracts/evm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
"registry:audit:testnet": "npx hardhat registry:audit --network polkadotTestnet",
"registry:deploy-facet:testnet": "npx hardhat registry:deploy-facet --network polkadotTestnet",
"registry:upgrade:testnet": "npx hardhat registry:upgrade --network polkadotTestnet",
"runtime:export:testnet": "npx hardhat runtime:export --network polkadotTestnet",
"runtime:deploy-royalties-facet:testnet": "npx hardhat runtime:deploy-royalties-facet --network polkadotTestnet",
"runtime:royalties-upgrade:testnet": "npx hardhat runtime:royalties-upgrade --network polkadotTestnet",
"runtime:migration-plan": "npx hardhat runtime:migration-plan",
"fmt": "prettier --plugin=prettier-plugin-solidity --write 'contracts/**/*.sol' 'scripts/**/*.ts' 'tasks/**/*.ts' 'test/**/*.ts' hardhat.config.ts",
"fmt:check": "prettier --plugin=prettier-plugin-solidity --check 'contracts/**/*.sol' 'scripts/**/*.ts' 'tasks/**/*.ts' 'test/**/*.ts' hardhat.config.ts",
"cdm:publish:testnet": "npx hardhat cdm:publish --network polkadotTestnet"
Expand Down
Loading
Loading