diff --git a/README.md b/README.md
index fdb3d451..4c6de347 100644
--- a/README.md
+++ b/README.md
@@ -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).
@@ -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:
@@ -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
diff --git a/contracts/evm/contracts/ArtistRuntimeFactory.sol b/contracts/evm/contracts/ArtistRuntimeFactory.sol
index dad302e2..02015756 100644
--- a/contracts/evm/contracts/ArtistRuntimeFactory.sol
+++ b/contracts/evm/contracts/ArtistRuntimeFactory.sol
@@ -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
diff --git a/contracts/evm/contracts/libraries/LibMusicRoyalties.sol b/contracts/evm/contracts/libraries/LibMusicRoyalties.sol
index 4e43b135..2ae85fd2 100644
--- a/contracts/evm/contracts/libraries/LibMusicRoyalties.sol
+++ b/contracts/evm/contracts/libraries/LibMusicRoyalties.sol
@@ -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;
@@ -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) {
@@ -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];
}
}
diff --git a/contracts/evm/contracts/pallets/MusicRoyaltiesPallet.sol b/contracts/evm/contracts/pallets/MusicRoyaltiesPallet.sol
index 26bfb732..4c7888d6 100644
--- a/contracts/evm/contracts/pallets/MusicRoyaltiesPallet.sol
+++ b/contracts/evm/contracts/pallets/MusicRoyaltiesPallet.sol
@@ -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
@@ -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);
@@ -70,12 +82,12 @@ 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);
}
@@ -83,6 +95,34 @@ contract MusicRoyaltiesPallet {
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 {
@@ -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);
+ }
}
diff --git a/contracts/evm/contracts/test/RoyaltyFailureRecipients.sol b/contracts/evm/contracts/test/RoyaltyFailureRecipients.sol
new file mode 100644
index 00000000..5296d5dc
--- /dev/null
+++ b/contracts/evm/contracts/test/RoyaltyFailureRecipients.sol
@@ -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));
+ }
+}
diff --git a/contracts/evm/package.json b/contracts/evm/package.json
index 66be05a8..47799748 100644
--- a/contracts/evm/package.json
+++ b/contracts/evm/package.json
@@ -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"
diff --git a/contracts/evm/tasks/registryUpgrade.ts b/contracts/evm/tasks/registryUpgrade.ts
index b7a052e8..a2471b4e 100644
--- a/contracts/evm/tasks/registryUpgrade.ts
+++ b/contracts/evm/tasks/registryUpgrade.ts
@@ -1,4 +1,4 @@
-import { task, types } from 'hardhat/config';
+import { task, types, vars } from 'hardhat/config';
import type { HardhatRuntimeEnvironment } from 'hardhat/types';
import * as fs from 'node:fs';
import * as path from 'node:path';
@@ -27,6 +27,7 @@ import {
canonicalJson,
hashCanonical,
isRegistryRuntimeSafe,
+ registrySelectorsFromAbi,
registryUpgradePlanDigest,
requireAddress,
type RegistrySelector
@@ -93,6 +94,34 @@ type RegistryUpgradePlanBody = {
type RegistryUpgradePlan = RegistryUpgradePlanBody & { digest: Hex; evidenceDigest: Hex };
+type RuntimeFacetSelectorRoute = RegistrySelector & {
+ facet: Address;
+ codeHash: Hex | null;
+ action: 'add' | 'replace' | 'keep';
+};
+
+type RuntimeRoyaltiesUpgradePlanBody = {
+ schema: 'dotify.runtime-royalties-upgrade.v1';
+ chainId: number;
+ capturedBlockNumber: string;
+ capturedBlockHash: Hex;
+ runtime: Address;
+ owner: Address;
+ targetFacet: Address;
+ targetCodeHash: Hex;
+ trackStateHash: Hex;
+ selectorRoutes: RuntimeFacetSelectorRoute[];
+ addSelectors: RegistrySelector[];
+ replaceSelectors: RegistrySelector[];
+ transaction: {
+ to: Address;
+ value: '0x0';
+ data: Hex;
+ } | null;
+};
+
+type RuntimeRoyaltiesUpgradePlan = RuntimeRoyaltiesUpgradePlanBody & { digest: Hex; evidenceDigest: Hex };
+
const bootstrapStartedEvent = parseAbiItem('event ArtistRuntimeBootstrapStarted(address indexed artist, address indexed runtime)');
task('registry:audit', 'Read-only audit of the configured registry facet and every finalized or pending runtime')
@@ -464,6 +493,343 @@ task('registry:upgrade', 'Prepare, simulate, or explicitly apply the owner-only
console.log(formatJson(verification));
});
+task('runtime:export', 'Read-only snapshot of one artist SmartRuntime catalogue and royalty splits')
+ .addParam('runtime', 'One SmartRuntime proxy address', undefined, types.string)
+ .addOptionalParam('recipient', 'Optional royalty recipient to include claimable balance for', '', types.string)
+ .addOptionalParam('out', 'Snapshot output path. Refuses to overwrite existing files.', '', types.string)
+ .setAction(async (args: { runtime: string; recipient: string; out: string }, hre) => {
+ await hre.run('compile');
+ const publicClient = await getPublicClient(hre);
+ const runtime = requireAddress('runtime', args.runtime);
+ const recipient = args.recipient ? requireAddress('recipient', args.recipient) : null;
+ const chainId = await publicClient.getChainId();
+ const capturedBlock = await publicClient.getBlock({ blockTag: 'finalized' });
+ const snapshot = await readCatalogueSnapshot(hre, publicClient, runtime, capturedBlock.number);
+ const royalties = await getReadContract(hre, publicClient, 'MusicRoyaltiesPallet', runtime);
+ const recipientClaimable =
+ recipient === null
+ ? undefined
+ : await royalties.read
+ .musicRoyClaimable([recipient], { blockNumber: capturedBlock.number })
+ .then(value => ({ recipient, amount: value.toString(), status: 'read' as const }))
+ .catch(error => ({ recipient, amount: null, status: 'unavailable' as const, detail: firstLine(errorDetails(error)) }));
+ const report = {
+ schema: 'dotify.runtime-catalogue-snapshot.v1',
+ chainId,
+ capturedBlockNumber: capturedBlock.number.toString(),
+ capturedBlockHash: capturedBlock.hash,
+ runtime,
+ ...snapshot,
+ trackStateHash: hashCanonical(snapshot),
+ ...(recipientClaimable ? { recipientClaimable } : {})
+ };
+
+ console.log(formatJson(report));
+ if (args.out) writeJson(args.out, report);
+ });
+
+task('runtime:deploy-royalties-facet', 'Compile or explicitly deploy only the W05 MusicRoyaltiesPallet facet')
+ .addFlag('execute', 'Broadcast the deployment transaction')
+ .addOptionalParam('confirmChainId', 'Required chain ID confirmation when --execute is set', '', types.string)
+ .addOptionalParam('confirmCodeHash', 'Required local deployed-bytecode hash when --execute is set', '', types.string)
+ .addOptionalParam('out', 'Deployment manifest path; required with --execute', '', types.string)
+ .setAction(async (args: { execute: boolean; confirmChainId: string; confirmCodeHash: string; out: string }, hre) => {
+ await hre.run('compile');
+ const publicClient = await getPublicClient(hre);
+ const chainId = await publicClient.getChainId();
+ const source = await getPalletSource(hre, 'MusicRoyaltiesPallet');
+
+ if (!args.execute) {
+ console.log(
+ formatJson({
+ action: 'dry-run',
+ chainId,
+ contract: 'MusicRoyaltiesPallet',
+ localSourceCodeHash: source.codeHash,
+ next: `Re-run with --execute --confirm-chain-id ${chainId} --confirm-code-hash ${source.codeHash} --out `,
+ warning:
+ 'This deploys only a stateless facet. It never edits deployments.json and never upgrades a runtime. Pass the resulting facet to runtime:royalties-upgrade --facet .'
+ })
+ );
+ return;
+ }
+
+ if (args.confirmChainId !== String(chainId)) {
+ throw new Error(`Refusing deployment: --confirm-chain-id must equal ${chainId}.`);
+ }
+ if (args.confirmCodeHash.toLowerCase() !== source.codeHash.toLowerCase()) {
+ throw new Error(`Refusing deployment: --confirm-code-hash must equal ${source.codeHash}.`);
+ }
+ if (!args.out) throw new Error('Refusing deployment: --out is required so the transaction evidence cannot be lost.');
+ reserveJsonOutput(args.out, {
+ schema: 'dotify.runtime-royalties-facet-deployment.v1',
+ status: 'reserved-before-broadcast',
+ chainId,
+ expectedCodeHash: source.codeHash,
+ note: 'If this file remains reserved, inspect the deployer account and chain before retrying.'
+ });
+
+ const wallet = await getOwnerWallet(hre, 'deployer');
+ const deployer = getAddress(wallet.account.address);
+ const nonce = await publicClient.getTransactionCount({ address: deployer, blockTag: 'pending' });
+ const predictedFacet = getContractAddress({ from: deployer, nonce: BigInt(nonce) });
+ overwriteReservedJson(args.out, {
+ schema: 'dotify.runtime-royalties-facet-deployment.v1',
+ status: 'prepared-before-broadcast',
+ chainId,
+ expectedCodeHash: source.codeHash,
+ deployer,
+ nonce,
+ predictedFacet,
+ note: 'No signed transaction hash means no broadcast-safe payload was persisted. Inspect the deployer nonce before retrying.'
+ });
+ const signed = await signRawTransaction(hre, wallet, {
+ data: source.bytecode,
+ nonce,
+ value: 0n
+ });
+ if (signed.nonce !== nonce) throw new Error(`Prepared deployment nonce ${signed.nonce} differs from reserved nonce ${nonce}.`);
+ overwriteReservedJson(args.out, {
+ schema: 'dotify.runtime-royalties-facet-deployment.v1',
+ status: 'signed-before-broadcast',
+ chainId,
+ expectedCodeHash: source.codeHash,
+ deployer,
+ nonce,
+ predictedFacet,
+ transactionHash: signed.transactionHash,
+ note: 'The transaction hash is derived from locally signed bytes. If broadcast response is lost, inspect this hash and nonce before retrying.'
+ });
+ const broadcastHash = await wallet.sendRawTransaction({
+ serializedTransaction: signed.serializedTransaction
+ });
+ if (broadcastHash !== signed.transactionHash) {
+ throw new Error(`RPC returned transaction hash ${broadcastHash}, but locally signed bytes hash to ${signed.transactionHash}.`);
+ }
+ overwriteReservedJson(args.out, {
+ schema: 'dotify.runtime-royalties-facet-deployment.v1',
+ status: 'broadcast',
+ chainId,
+ expectedCodeHash: source.codeHash,
+ deployer,
+ nonce,
+ predictedFacet,
+ transactionHash: signed.transactionHash,
+ broadcastHash
+ });
+ const receipt = await publicClient.waitForTransactionReceipt({ hash: signed.transactionHash });
+ if (receipt.status !== 'success' || !receipt.contractAddress) {
+ throw new Error(`Royalties facet deployment ${signed.transactionHash} did not produce a successful contract receipt.`);
+ }
+ const { receipt: finalizedReceipt, finalizedBlockNumber } = await waitForCanonicalFinality(hre, publicClient, signed.transactionHash, receipt);
+ const facetAddress = getAddress(receipt.contractAddress);
+ if (predictedFacet !== facetAddress) throw new Error(`Predicted facet ${predictedFacet} differs from receipt address ${facetAddress}.`);
+ const deployedCodeHash = await readCodeHash(publicClient, facetAddress, finalizedReceipt.blockNumber);
+ if (deployedCodeHash !== source.codeHash) {
+ throw new Error(`Deployed royalties facet code hash ${deployedCodeHash ?? 'missing'} does not match ${source.codeHash}.`);
+ }
+
+ const manifest = {
+ schema: 'dotify.runtime-royalties-facet-deployment.v1',
+ status: 'deployed-finalized-bytecode-verified',
+ chainId,
+ transactionHash: signed.transactionHash,
+ blockNumber: finalizedReceipt.blockNumber.toString(),
+ blockHash: finalizedReceipt.blockHash,
+ finalizedBlockNumber: finalizedBlockNumber.toString(),
+ deployer,
+ nonce,
+ facet: facetAddress,
+ codeHash: deployedCodeHash,
+ explorerSourceVerified: false,
+ next: `npm run runtime:royalties-upgrade:testnet -- --runtime --facet ${facetAddress} --out `,
+ note: 'Not active until each runtime owner applies a separately verified royalties selector upgrade.'
+ };
+ overwriteReservedJson(args.out, manifest);
+ console.log(formatJson(manifest));
+ });
+
+task('runtime:royalties-upgrade', 'Prepare, simulate, or explicitly apply the W05 royalties facet upgrade to one runtime')
+ .addParam('runtime', 'One SmartRuntime proxy address', undefined, types.string)
+ .addOptionalParam('facet', 'Target MusicRoyaltiesPallet address; defaults to deployments.json pallets.royaltiesPallet', '', types.string)
+ .addFlag('execute', 'Broadcast the owner-signed diamond cut')
+ .addOptionalParam('confirmPlan', 'Exact plan digest required when --execute is set', '', types.string)
+ .addOptionalParam('out', 'Plan/evidence path; required with --execute. Refuses to overwrite on first write.', '', types.string)
+ .setAction(async (args: { runtime: string; facet: string; execute: boolean; confirmPlan: string; out: string }, hre) => {
+ await hre.run('compile');
+ const publicClient = await getPublicClient(hre);
+ const runtime = requireAddress('runtime', args.runtime);
+ const deployments = readDeployments();
+ const targetFacet = requireAddress('facet', args.facet || deployments.pallets.royaltiesPallet || '');
+ const source = await getPalletSource(hre, 'MusicRoyaltiesPallet');
+ const capturedBlock = await publicClient.getBlock({ blockTag: 'finalized' });
+ const targetCodeHash = await readCodeHash(publicClient, targetFacet, capturedBlock.number);
+ if (targetCodeHash !== source.codeHash) {
+ const sourceLabel = args.facet ? '--facet' : 'deployments.json pallets.royaltiesPallet';
+ throw new Error(
+ `Target royalties facet code hash ${targetCodeHash ?? 'missing'} from ${sourceLabel} does not match local source ${source.codeHash}. ` +
+ 'Deploy the current MusicRoyaltiesPallet facet first with runtime:deploy-royalties-facet, then rerun runtime:royalties-upgrade with --facet .'
+ );
+ }
+ if (args.execute && !args.out) throw new Error('Refusing upgrade: --out is required with --execute so broadcast evidence cannot be lost.');
+ if (args.out) assertOutputPathAvailable(args.out);
+
+ const before = await readCatalogueSnapshot(hre, publicClient, runtime, capturedBlock.number);
+ const plan = await buildRoyaltiesUpgradePlan(
+ hre,
+ publicClient,
+ runtime,
+ targetFacet,
+ targetCodeHash,
+ source.selectors,
+ before,
+ capturedBlock.number,
+ capturedBlock.hash
+ );
+ console.log(formatJson(plan));
+
+ if (!plan.transaction) {
+ if (args.out) writeJson(args.out, { ...plan, status: 'already-current-no-transaction' });
+ console.log('No transaction needed: every MusicRoyaltiesPallet selector already routes to the target facet.');
+ return;
+ }
+
+ await simulateRawCall(publicClient, before.owner, plan.transaction.to, plan.transaction.data, 'owner royalties upgrade simulation', capturedBlock.number);
+ const preflight = { ownerCutSimulation: 'succeeded' as const };
+ if (!args.execute) {
+ if (args.out) writeJson(args.out, { ...plan, status: 'simulated-dry-run', preflight });
+ console.log(`\nDry-run only. The runtime owner may re-run with --execute --confirm-plan ${plan.digest}`);
+ return;
+ }
+
+ if (args.confirmPlan.toLowerCase() !== plan.digest.toLowerCase()) {
+ throw new Error(`Refusing upgrade: --confirm-plan must equal the fresh digest ${plan.digest}.`);
+ }
+
+ const ownerCode = await publicClient.getCode({ address: before.owner, blockNumber: capturedBlock.number });
+ if (ownerCode && ownerCode !== '0x') {
+ throw new Error('Runtime owner is a contract. Submit the generated calldata through that contract governance; this task will not impersonate it.');
+ }
+
+ const wallet = await getOwnerWallet(hre, 'runtime owner');
+ if (getAddress(wallet.account.address) !== before.owner) {
+ throw new Error(`Configured signer ${wallet.account.address} is not current runtime owner ${before.owner}.`);
+ }
+
+ const ownerNonce = await publicClient.getTransactionCount({ address: before.owner, blockTag: 'pending' });
+ writeJson(args.out, {
+ ...plan,
+ status: 'prepared-before-broadcast',
+ preflight,
+ signer: getAddress(wallet.account.address),
+ nonce: ownerNonce,
+ note: 'No signed transaction hash means no broadcast-safe payload was persisted. Inspect the owner nonce before retrying.'
+ });
+
+ const signed = await signRawTransaction(hre, wallet, {
+ to: plan.transaction.to,
+ data: plan.transaction.data,
+ value: 0n,
+ nonce: ownerNonce
+ });
+ if (signed.nonce !== ownerNonce) throw new Error(`Prepared owner nonce ${signed.nonce} differs from reserved nonce ${ownerNonce}.`);
+ overwriteReservedJson(args.out, {
+ ...plan,
+ status: 'signed-before-broadcast',
+ preflight,
+ signer: getAddress(wallet.account.address),
+ nonce: ownerNonce,
+ transactionHash: signed.transactionHash,
+ note: 'The transaction hash is derived from locally signed bytes. If broadcast response is lost, inspect this hash and nonce before retrying.'
+ });
+ const broadcastHash = await wallet.sendRawTransaction({
+ serializedTransaction: signed.serializedTransaction
+ });
+ if (broadcastHash !== signed.transactionHash) {
+ throw new Error(`RPC returned transaction hash ${broadcastHash}, but locally signed bytes hash to ${signed.transactionHash}.`);
+ }
+ overwriteReservedJson(args.out, {
+ ...plan,
+ status: 'broadcast',
+ preflight,
+ signer: getAddress(wallet.account.address),
+ nonce: ownerNonce,
+ transactionHash: signed.transactionHash,
+ broadcastHash
+ });
+ const receipt = await publicClient.waitForTransactionReceipt({ hash: signed.transactionHash });
+ if (receipt.status !== 'success') throw new Error(`Royalties upgrade transaction ${signed.transactionHash} failed.`);
+ const { receipt: finalizedReceipt, finalizedBlockNumber } = await waitForCanonicalFinality(hre, publicClient, signed.transactionHash, receipt);
+
+ const after = await readCatalogueSnapshot(hre, publicClient, runtime, finalizedReceipt.blockNumber);
+ if (hashCanonical(after) !== plan.trackStateHash) {
+ throw new Error('Post-upgrade catalogue snapshot differs from the pre-upgrade state. Quarantine this runtime and investigate immediately.');
+ }
+ const loupe = await getReadContract(hre, publicClient, 'DiamondLoupePallet', runtime);
+ for (const selector of source.selectors) {
+ const actual = getAddress(await loupe.read.facetAddress([selector.selector], { blockNumber: finalizedReceipt.blockNumber }));
+ if (actual !== targetFacet) throw new Error(`Selector ${selector.name} routes to ${actual}, expected ${targetFacet}.`);
+ }
+
+ const verification = {
+ ...plan,
+ status: 'verified-finalized',
+ preflight,
+ signer: getAddress(wallet.account.address),
+ nonce: ownerNonce,
+ transactionHash: signed.transactionHash,
+ receiptBlockNumber: finalizedReceipt.blockNumber.toString(),
+ receiptBlockHash: finalizedReceipt.blockHash,
+ finalizedBlockNumber: finalizedBlockNumber.toString(),
+ catalogueStatePreserved: true
+ };
+ overwriteReservedJson(args.out, verification);
+ console.log(formatJson(verification));
+ });
+
+task('runtime:migration-plan', 'Render replay calldata from a saved runtime snapshot for a clean new runtime')
+ .addParam('snapshot', 'Path produced by runtime:export', undefined, types.string)
+ .addParam('targetRuntime', 'New SmartRuntime proxy address that will receive replayed registrations', undefined, types.string)
+ .addFlag('allowEncryptedAudioReuse', 'Allow calldata for encrypted audio refs. Usually unsafe because v2 content keys are runtime-bound.')
+ .addOptionalParam('out', 'Migration plan output path. Refuses to overwrite existing files.', '', types.string)
+ .setAction(async (args: { snapshot: string; targetRuntime: string; allowEncryptedAudioReuse: boolean; out: string }, hre) => {
+ await hre.run('compile');
+ const targetRuntime = requireAddress('targetRuntime', args.targetRuntime);
+ const snapshot = JSON.parse(fs.readFileSync(path.resolve(args.snapshot), 'utf8')) as {
+ chainId?: number;
+ runtime?: Address;
+ owner?: Address;
+ tracks?: unknown[];
+ };
+ if (!Array.isArray(snapshot.tracks)) throw new Error('Snapshot does not contain a tracks array.');
+ const registryArtifact = await hre.artifacts.readArtifact('MusicRegistryPallet');
+ const transactions = [];
+ const blockedTracks = [];
+
+ for (const track of snapshot.tracks) {
+ const migration = buildTrackMigrationTransaction(registryArtifact.abi as Abi, targetRuntime, track, args.allowEncryptedAudioReuse);
+ if (migration.status === 'blocked') blockedTracks.push(migration);
+ else transactions.push(migration);
+ }
+
+ const plan = {
+ schema: 'dotify.runtime-track-migration-plan.v1',
+ sourceRuntime: snapshot.runtime,
+ targetRuntime,
+ owner: snapshot.owner,
+ chainId: snapshot.chainId,
+ transactionCount: transactions.length,
+ blockedTrackCount: blockedTracks.length,
+ warning:
+ 'Prefer an in-place diamond upgrade when possible. Replaying registrations to a new runtime does not move paid-access state or claimable balances, and protected audio v2 refs usually need re-encryption for the new runtime.',
+ transactions,
+ blockedTracks
+ };
+ console.log(formatJson(plan));
+ if (args.out) writeJson(args.out, plan);
+ if (blockedTracks.length > 0) process.exitCode = 2;
+ });
+
async function getPublicClient(hre: HardhatRuntimeEnvironment): Promise {
return (
hre.network.name === 'polkadotTestnet' ? await hre.viem.getPublicClient({ chain: POLKADOT_TESTNET_CHAIN }) : await hre.viem.getPublicClient()
@@ -497,7 +863,9 @@ async function signRawTransaction(
? wallet.account
: hre.network.name === 'hardhat'
? privateKeyToAccount(HARDHAT_FIRST_ACCOUNT_PRIVATE_KEY)
- : undefined;
+ : vars.has('PRIVATE_KEY')
+ ? privateKeyToAccount(vars.get('PRIVATE_KEY') as Hex)
+ : undefined;
if (!signer) {
throw new Error('Configured wallet is not a local signer. Use a local PRIVATE_KEY so transaction bytes and hash can be persisted before broadcast.');
}
@@ -689,6 +1057,174 @@ async function buildUpgradePlan(
return { ...body, digest: registryUpgradePlanDigest(body), evidenceDigest: hashCanonical(body) };
}
+async function buildRoyaltiesUpgradePlan(
+ hre: HardhatRuntimeEnvironment,
+ publicClient: PublicClient,
+ runtime: Address,
+ targetFacet: Address,
+ targetCodeHash: Hex,
+ selectors: RegistrySelector[],
+ snapshot: CatalogueSnapshot,
+ capturedBlockNumber: bigint,
+ capturedBlockHash: Hex
+): Promise {
+ const chainId = await publicClient.getChainId();
+ const loupe = await getReadContract(hre, publicClient, 'DiamondLoupePallet', runtime);
+ const codeHashes = new Map();
+ const selectorRoutes: RuntimeFacetSelectorRoute[] = [];
+
+ for (const selector of selectors) {
+ const facet = getAddress(await loupe.read.facetAddress([selector.selector], { blockNumber: capturedBlockNumber }));
+ let codeHash = codeHashes.get(facet);
+ if (codeHash === undefined) {
+ codeHash = facet === zeroAddress ? null : await readCodeHash(publicClient, facet, capturedBlockNumber);
+ codeHashes.set(facet, codeHash);
+ }
+ selectorRoutes.push({
+ ...selector,
+ facet,
+ codeHash,
+ action: facet === zeroAddress ? 'add' : facet === targetFacet ? 'keep' : 'replace'
+ });
+ }
+
+ const addSelectors = selectorRoutes.filter(route => route.action === 'add').map(({ name, selector }) => ({ name, selector }));
+ const replaceSelectors = selectorRoutes.filter(route => route.action === 'replace').map(({ name, selector }) => ({ name, selector }));
+ const transaction =
+ addSelectors.length === 0 && replaceSelectors.length === 0
+ ? null
+ : {
+ to: runtime,
+ value: '0x0' as const,
+ data: await buildRuntimeRoyaltiesUpgradeCalldata(hre, targetFacet, addSelectors, replaceSelectors)
+ };
+ const body: RuntimeRoyaltiesUpgradePlanBody = {
+ schema: 'dotify.runtime-royalties-upgrade.v1',
+ chainId,
+ capturedBlockNumber: capturedBlockNumber.toString(),
+ capturedBlockHash,
+ runtime,
+ owner: snapshot.owner,
+ targetFacet,
+ targetCodeHash,
+ trackStateHash: hashCanonical(snapshot),
+ selectorRoutes,
+ addSelectors,
+ replaceSelectors,
+ transaction
+ };
+ return { ...body, digest: registryUpgradePlanDigest(body), evidenceDigest: hashCanonical(body) };
+}
+
+async function buildRuntimeRoyaltiesUpgradeCalldata(
+ hre: HardhatRuntimeEnvironment,
+ targetFacet: Address,
+ addSelectors: RegistrySelector[],
+ replaceSelectors: RegistrySelector[]
+): Promise {
+ const diamondCutArtifact = await hre.artifacts.readArtifact('DiamondCutPallet');
+ const cuts: Array<{ facetAddress: Address; action: 0 | 1; functionSelectors: Hex[] }> = [];
+ if (replaceSelectors.length > 0) {
+ cuts.push({
+ facetAddress: targetFacet,
+ action: 1,
+ functionSelectors: replaceSelectors.map(selector => selector.selector)
+ });
+ }
+ if (addSelectors.length > 0) {
+ cuts.push({
+ facetAddress: targetFacet,
+ action: 0,
+ functionSelectors: addSelectors.map(selector => selector.selector)
+ });
+ }
+ return encodeFunctionData({
+ abi: diamondCutArtifact.abi as Abi,
+ functionName: 'diamondCut',
+ args: [cuts, zeroAddress, '0x']
+ });
+}
+
+async function getPalletSource(hre: HardhatRuntimeEnvironment, contractName: string) {
+ const artifact = await hre.artifacts.readArtifact(contractName);
+ const abi = artifact.abi as Abi;
+ const selectors = registrySelectorsFromAbi(abi);
+ const bytecode = artifact.bytecode as Hex;
+ const deployedBytecode = artifact.deployedBytecode as Hex;
+ if (!bytecode || bytecode === '0x') throw new Error(`${contractName} creation bytecode is missing. Compile contracts first.`);
+ if (!deployedBytecode || deployedBytecode === '0x') throw new Error(`${contractName} deployed bytecode is missing. Compile contracts first.`);
+ assertRoyaltiesSelectors(contractName, selectors);
+ return { abi, selectors, bytecode, deployedBytecode, codeHash: keccak256(deployedBytecode) };
+}
+
+function assertRoyaltiesSelectors(contractName: string, selectors: RegistrySelector[]) {
+ if (contractName !== 'MusicRoyaltiesPallet') return;
+ const expected = new Set([
+ 'musicRoyClaim',
+ 'musicRoyClaimable',
+ 'musicRoyPayAccess',
+ 'musicRoyRecordListen',
+ 'musicRoySplitAt',
+ 'musicRoySplitCount',
+ 'musicRoyTotalBps'
+ ]);
+ const actual = new Set(selectors.map(selector => selector.name));
+ const missing = Array.from(expected).filter(name => !actual.has(name));
+ if (missing.length > 0) {
+ throw new Error(`MusicRoyaltiesPallet ABI is missing expected selector(s): ${missing.join(', ')}.`);
+ }
+}
+
+function buildTrackMigrationTransaction(abi: Abi, targetRuntime: Address, snapshotTrack: unknown, allowEncryptedAudioReuse: boolean) {
+ const track = snapshotTrack as {
+ hash?: Hex;
+ record?: Record;
+ royaltySplits?: Array<{ recipient?: Address; bps?: string | number | bigint }>;
+ };
+ if (!track.hash || !track.record) throw new Error('Snapshot track is missing hash or record.');
+ const audioRef = String(track.record.audioRef ?? '');
+ if (!allowEncryptedAudioReuse && audioRef.startsWith('dotify:enc:v2:')) {
+ return {
+ status: 'blocked' as const,
+ hash: track.hash,
+ title: String(track.record.title ?? ''),
+ reason: 'Protected audio v2 keys are runtime-bound. Re-encrypt the audio for the target runtime before replaying this registration.'
+ };
+ }
+
+ const royaltySplits = track.royaltySplits ?? [];
+ const royaltyRecipients = royaltySplits.map(split => requireAddress('royalty recipient', String(split.recipient ?? '')));
+ const royaltyShares = royaltySplits.map(split => Number(split.bps ?? 0));
+ const registration = {
+ contentHash: track.hash,
+ title: String(track.record.title ?? ''),
+ artistName: String(track.record.artistName ?? ''),
+ description: String(track.record.description ?? ''),
+ imageRef: String(track.record.imageRef ?? ''),
+ audioRef,
+ metadataRef: String(track.record.metadataRef ?? ''),
+ artistContractRef: String(track.record.artistContractRef ?? ''),
+ accessMode: Number(track.record.accessMode ?? 0),
+ pricePlanck: BigInt(String(track.record.pricePlanck ?? 0)),
+ requiredPersonhood: Number(track.record.requiredPersonhood ?? 0)
+ };
+
+ return {
+ status: 'ready' as const,
+ hash: track.hash,
+ title: registration.title,
+ transaction: {
+ to: targetRuntime,
+ value: '0x0' as const,
+ data: encodeFunctionData({
+ abi,
+ functionName: 'musicRegRegister',
+ args: [registration, royaltyRecipients, royaltyShares]
+ })
+ }
+ };
+}
+
async function probeOwnerGuard(publicClient: PublicClient, runtime: Address, registryAbi: Abi, owner: Address, blockNumber: bigint): Promise {
const outsider =
owner.toLowerCase() === '0x000000000000000000000000000000000000dead'
diff --git a/contracts/evm/test/ArtistRuntime.test.ts b/contracts/evm/test/ArtistRuntime.test.ts
index 1075ab2c..dde9e8b4 100644
--- a/contracts/evm/test/ArtistRuntime.test.ts
+++ b/contracts/evm/test/ArtistRuntime.test.ts
@@ -52,6 +52,26 @@ function selectorsFromAbi(abi: Abi): `0x${string}`[] {
const TRACK_HASH = keccak256(toBytes('dotify:track:001')) as `0x${string}`;
const TRACK_HASH2 = keccak256(toBytes('dotify:track:002')) as `0x${string}`;
const musicRoyRefundedEvent = parseAbiItem('event MusicRoyRefunded(bytes32 indexed contentHash, address indexed listener, uint256 amount)');
+const musicRoyRoyaltyPaidEvent = parseAbiItem(
+ 'event MusicRoyRoyaltyPaid(bytes32 indexed contentHash, address indexed listener, address indexed recipient, uint256 amount)'
+);
+const musicRoyRoyaltyPayoutFailedEvent = parseAbiItem(
+ 'event MusicRoyRoyaltyPayoutFailed(bytes32 indexed contentHash, address indexed listener, address indexed recipient, uint256 amount)'
+);
+const musicRoyRoyaltyClaimableEvent = parseAbiItem(
+ 'event MusicRoyRoyaltyClaimable(bytes32 indexed contentHash, address indexed listener, address indexed recipient, uint256 amount, uint256 pendingTotal)'
+);
+const musicRoyRoyaltyClaimedEvent = parseAbiItem('event MusicRoyRoyaltyClaimed(address indexed recipient, uint256 amount)');
+const musicRoyRoyaltyClaimFailedEvent = parseAbiItem('event MusicRoyRoyaltyClaimFailed(address indexed recipient, uint256 amount)');
+
+async function expectRevertMessage(promise: Promise, fragment: string) {
+ try {
+ await promise;
+ expect.fail('Should have reverted');
+ } catch (e: unknown) {
+ expect((e as Error).message).to.include(fragment);
+ }
+}
function sampleRegistration(
overrides: Partial<{
@@ -840,6 +860,67 @@ describe('Forkless upgrade — artist replaces their music pallets', () => {
expect(await access.read.musicAccCanAccess([TRACK_HASH, ctx.listener.account.address])).to.equal(false);
});
+ it('replaces royalties pallet and adds claim selectors without losing splits or paid access', async () => {
+ const ctx = await loadFixture(deployDotifySystemFixture);
+ await createArtistRuntime(ctx.factory, ctx.artistA);
+ const runtimeAddr = (await ctx.directory.read.runtimeOf([ctx.artistA.account.address])) as `0x${string}`;
+ const price = parseEther('0.5');
+
+ const registry = await hre.viem.getContractAt('MusicRegistryPallet', runtimeAddr, { client: { wallet: ctx.artistA } });
+ const royalties = await hre.viem.getContractAt('MusicRoyaltiesPallet', runtimeAddr);
+ const access = await hre.viem.getContractAt('MusicAccessPallet', runtimeAddr);
+ const loupe = await hre.viem.getContractAt('DiamondLoupePallet', runtimeAddr);
+ const replacementArtifact = await hre.artifacts.readArtifact('MusicRoyaltiesPallet');
+ const existingSelectors = ['musicRoyPayAccess', 'musicRoyRecordListen', 'musicRoySplitCount', 'musicRoySplitAt', 'musicRoyTotalBps'].map(name =>
+ toFunctionSelector((replacementArtifact.abi as Abi).find(item => item.type === 'function' && item.name === name) as AbiFunction)
+ );
+ const claimSelectors = ['musicRoyClaimable', 'musicRoyClaim'].map(name =>
+ toFunctionSelector((replacementArtifact.abi as Abi).find(item => item.type === 'function' && item.name === name) as AbiFunction)
+ );
+
+ await registry.write.musicRegRegister([sampleRegistration({ pricePlanck: price }), [ctx.royaltyRecip.account.address], [8_000]]);
+ const listenerRoyalties = await hre.viem.getContractAt('MusicRoyaltiesPallet', runtimeAddr, { client: { wallet: ctx.listener } });
+ await listenerRoyalties.write.musicRoyPayAccess([TRACK_HASH], { value: price });
+
+ const before = {
+ splitCount: await royalties.read.musicRoySplitCount([TRACK_HASH]),
+ split: await royalties.read.musicRoySplitAt([TRACK_HASH, 0n]),
+ totalBps: await royalties.read.musicRoyTotalBps([TRACK_HASH]),
+ claimable: await royalties.read.musicRoyClaimable([ctx.royaltyRecip.account.address]),
+ hasPaid: await access.read.musicAccHasPaid([TRACK_HASH, ctx.listener.account.address]),
+ canAccess: await access.read.musicAccCanAccess([TRACK_HASH, ctx.listener.account.address])
+ };
+
+ const cut = await hre.viem.getContractAt('DiamondCutPallet', runtimeAddr, { client: { wallet: ctx.artistA } });
+ await cut.write.diamondCut([[{ facetAddress: ZERO_ADDR, action: FacetCutAction.Remove, functionSelectors: claimSelectors }], ZERO_ADDR, '0x']);
+ for (const selector of claimSelectors) {
+ expect((await loupe.read.facetAddress([selector])).toLowerCase()).to.equal(ZERO_ADDR);
+ }
+
+ const replacement = await hre.viem.deployContract('MusicRoyaltiesPallet');
+ const replacementSelectors = selectorsFromAbi(replacementArtifact.abi as Abi);
+
+ await cut.write.diamondCut([
+ [
+ { facetAddress: replacement.address, action: FacetCutAction.Replace, functionSelectors: existingSelectors },
+ { facetAddress: replacement.address, action: FacetCutAction.Add, functionSelectors: claimSelectors }
+ ],
+ ZERO_ADDR,
+ '0x'
+ ]);
+
+ for (const selector of replacementSelectors) {
+ expect((await loupe.read.facetAddress([selector])).toLowerCase()).to.equal(replacement.address.toLowerCase());
+ }
+
+ expect(await royalties.read.musicRoySplitCount([TRACK_HASH])).to.equal(before.splitCount);
+ expect(await royalties.read.musicRoySplitAt([TRACK_HASH, 0n])).to.deep.equal(before.split);
+ expect(await royalties.read.musicRoyTotalBps([TRACK_HASH])).to.equal(before.totalBps);
+ expect(await royalties.read.musicRoyClaimable([ctx.royaltyRecip.account.address])).to.equal(before.claimable);
+ expect(await access.read.musicAccHasPaid([TRACK_HASH, ctx.listener.account.address])).to.equal(before.hasPaid);
+ expect(await access.read.musicAccCanAccess([TRACK_HASH, ctx.listener.account.address])).to.equal(before.canAccess);
+ });
+
it('hotfixes only musicRegRegister, preserves runtime state, and rejects outsider registration', async () => {
const ctx = await loadFixture(deployDotifySystemFixture);
await createArtistRuntime(ctx.factory, ctx.artistA);
@@ -1027,6 +1108,191 @@ describe('MusicRoyaltiesPallet — payment security', () => {
expect(refundEvents[0].args.amount).to.equal(OVERPAY - PRICE);
});
+ it('keeps access purchase valid when royalty recipients reject or exhaust bounded gas', async () => {
+ const PRICE = 101n;
+ const { registry, royalties, access, artistA, listener, royaltyRecip, publicClient } = await withArtistRuntime();
+ const rejecting = await hre.viem.deployContract('RejectingRoyaltyRecipient');
+ const gasConsumer = await hre.viem.deployContract('GasConsumingRoyaltyRecipient');
+
+ const artistRegistry = await hre.viem.getContractAt('MusicRegistryPallet', registry.address, { client: { wallet: artistA } });
+ await artistRegistry.write.musicRegRegister([
+ sampleRegistration({ pricePlanck: PRICE }),
+ [rejecting.address, gasConsumer.address, royaltyRecip.account.address],
+ [2_500, 2_500, 2_500]
+ ]);
+
+ const artistBefore = await publicClient.getBalance({ address: artistA.account.address });
+ const recipBefore = await publicClient.getBalance({ address: royaltyRecip.account.address });
+ const listenerRoyalties = await hre.viem.getContractAt('MusicRoyaltiesPallet', royalties.address, { client: { wallet: listener } });
+ const txHash = await listenerRoyalties.write.musicRoyPayAccess([TRACK_HASH], { value: PRICE });
+ const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
+
+ expect(await access.read.musicAccHasPaid([TRACK_HASH, listener.account.address])).to.equal(true);
+ expect(await access.read.musicAccCanAccess([TRACK_HASH, listener.account.address])).to.equal(true);
+
+ const splitShare = 25n;
+ const artistRemainder = 26n;
+ expect(await royalties.read.musicRoyClaimable([rejecting.address])).to.equal(splitShare);
+ expect(await royalties.read.musicRoyClaimable([gasConsumer.address])).to.equal(splitShare);
+ expect(await royalties.read.musicRoyClaimable([royaltyRecip.account.address])).to.equal(0n);
+ expect((await publicClient.getBalance({ address: royaltyRecip.account.address })) - recipBefore).to.equal(splitShare);
+ expect((await publicClient.getBalance({ address: artistA.account.address })) - artistBefore).to.equal(artistRemainder);
+
+ const paidLogs = await publicClient.getLogs({
+ address: royalties.address,
+ event: musicRoyRoyaltyPaidEvent,
+ fromBlock: receipt.blockNumber,
+ toBlock: receipt.blockNumber
+ });
+ const failedLogs = await publicClient.getLogs({
+ address: royalties.address,
+ event: musicRoyRoyaltyPayoutFailedEvent,
+ fromBlock: receipt.blockNumber,
+ toBlock: receipt.blockNumber
+ });
+ const claimableLogs = await publicClient.getLogs({
+ address: royalties.address,
+ event: musicRoyRoyaltyClaimableEvent,
+ fromBlock: receipt.blockNumber,
+ toBlock: receipt.blockNumber
+ });
+
+ expect(paidLogs.map(log => log.args.recipient?.toLowerCase()).sort()).to.deep.equal(
+ [artistA.account.address.toLowerCase(), royaltyRecip.account.address.toLowerCase()].sort()
+ );
+ expect(failedLogs.map(log => log.args.recipient?.toLowerCase()).sort()).to.deep.equal(
+ [gasConsumer.address.toLowerCase(), rejecting.address.toLowerCase()].sort()
+ );
+ expect(claimableLogs.map(log => log.args.pendingTotal)).to.deep.equal([splitShare, splitShare]);
+
+ const immediatePaid =
+ (await publicClient.getBalance({ address: royaltyRecip.account.address })) -
+ recipBefore +
+ ((await publicClient.getBalance({ address: artistA.account.address })) - artistBefore);
+ const pending = (await royalties.read.musicRoyClaimable([rejecting.address])) + (await royalties.read.musicRoyClaimable([gasConsumer.address]));
+ expect(immediatePaid + pending).to.equal(PRICE);
+ });
+
+ it('lets recipients claim pending royalties once and rejects unauthorized claims', async () => {
+ const PRICE = 100n;
+ const { registry, royalties, artistA, listener, other, publicClient } = await withArtistRuntime();
+ const gasConsumer = await hre.viem.deployContract('GasConsumingRoyaltyRecipient');
+
+ const artistRegistry = await hre.viem.getContractAt('MusicRegistryPallet', registry.address, { client: { wallet: artistA } });
+ await artistRegistry.write.musicRegRegister([sampleRegistration({ pricePlanck: PRICE }), [gasConsumer.address], [10_000]]);
+
+ const listenerRoyalties = await hre.viem.getContractAt('MusicRoyaltiesPallet', royalties.address, { client: { wallet: listener } });
+ await listenerRoyalties.write.musicRoyPayAccess([TRACK_HASH], { value: PRICE });
+ expect(await royalties.read.musicRoyClaimable([gasConsumer.address])).to.equal(PRICE);
+
+ const otherRoyalties = await hre.viem.getContractAt('MusicRoyaltiesPallet', royalties.address, { client: { wallet: other } });
+ await expectRevertMessage(otherRoyalties.write.musicRoyClaim([gasConsumer.address]), 'MusicRoyalties: claim self only');
+
+ await gasConsumer.write.setBurnGas([false]);
+ const before = await publicClient.getBalance({ address: gasConsumer.address });
+ const txHash = await gasConsumer.write.claimFrom([royalties.address]);
+ const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
+ const claimedLogs = await publicClient.getLogs({
+ address: royalties.address,
+ event: musicRoyRoyaltyClaimedEvent,
+ fromBlock: receipt.blockNumber,
+ toBlock: receipt.blockNumber
+ });
+
+ expect(await royalties.read.musicRoyClaimable([gasConsumer.address])).to.equal(0n);
+ expect((await publicClient.getBalance({ address: gasConsumer.address })) - before).to.equal(PRICE);
+ expect(claimedLogs).to.have.lengthOf(1);
+ expect(claimedLogs[0].args.recipient?.toLowerCase()).to.equal(gasConsumer.address.toLowerCase());
+ expect(claimedLogs[0].args.amount).to.equal(PRICE);
+
+ await expectRevertMessage(gasConsumer.write.claimFrom([royalties.address]), 'MusicRoyalties: nothing to claim');
+ });
+
+ it('keeps pending royalties claimable when the recipient still rejects a claim', async () => {
+ const PRICE = 100n;
+ const { registry, royalties, artistA, listener, publicClient } = await withArtistRuntime();
+ const rejecting = await hre.viem.deployContract('RejectingRoyaltyRecipient');
+
+ const artistRegistry = await hre.viem.getContractAt('MusicRegistryPallet', registry.address, { client: { wallet: artistA } });
+ await artistRegistry.write.musicRegRegister([sampleRegistration({ pricePlanck: PRICE }), [rejecting.address], [10_000]]);
+
+ const listenerRoyalties = await hre.viem.getContractAt('MusicRoyaltiesPallet', royalties.address, { client: { wallet: listener } });
+ await listenerRoyalties.write.musicRoyPayAccess([TRACK_HASH], { value: PRICE });
+ expect(await royalties.read.musicRoyClaimable([rejecting.address])).to.equal(PRICE);
+
+ const txHash = await rejecting.write.claimFrom([royalties.address]);
+ const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
+ const failedLogs = await publicClient.getLogs({
+ address: royalties.address,
+ event: musicRoyRoyaltyClaimFailedEvent,
+ fromBlock: receipt.blockNumber,
+ toBlock: receipt.blockNumber
+ });
+
+ expect(await royalties.read.musicRoyClaimable([rejecting.address])).to.equal(PRICE);
+ expect(failedLogs).to.have.lengthOf(1);
+ expect(failedLogs[0].args.recipient?.toLowerCase()).to.equal(rejecting.address.toLowerCase());
+ expect(failedLogs[0].args.amount).to.equal(PRICE);
+ });
+
+ it('keeps repeated purchase accounting exact across paid and claimable royalty shares', async () => {
+ const PRICE = 101n;
+ const { registry, royalties, artistA, listener, royaltyRecip, other, publicClient } = await withArtistRuntime();
+ const rejecting = await hre.viem.deployContract('RejectingRoyaltyRecipient');
+
+ const artistRegistry = await hre.viem.getContractAt('MusicRegistryPallet', registry.address, { client: { wallet: artistA } });
+ await artistRegistry.write.musicRegRegister([
+ sampleRegistration({ pricePlanck: PRICE }),
+ [rejecting.address, royaltyRecip.account.address],
+ [3_333, 3_333]
+ ]);
+
+ const artistBefore = await publicClient.getBalance({ address: artistA.account.address });
+ const recipBefore = await publicClient.getBalance({ address: royaltyRecip.account.address });
+
+ const listenerRoyalties = await hre.viem.getContractAt('MusicRoyaltiesPallet', royalties.address, { client: { wallet: listener } });
+ const otherRoyalties = await hre.viem.getContractAt('MusicRoyaltiesPallet', royalties.address, { client: { wallet: other } });
+ await listenerRoyalties.write.musicRoyPayAccess([TRACK_HASH], { value: PRICE });
+ await otherRoyalties.write.musicRoyPayAccess([TRACK_HASH], { value: PRICE });
+
+ const splitShare = 33n;
+ const artistRemainder = 35n;
+ const immediatePaid =
+ (await publicClient.getBalance({ address: royaltyRecip.account.address })) -
+ recipBefore +
+ ((await publicClient.getBalance({ address: artistA.account.address })) - artistBefore);
+ const pending = await royalties.read.musicRoyClaimable([rejecting.address]);
+
+ expect(immediatePaid).to.equal((splitShare + artistRemainder) * 2n);
+ expect(pending).to.equal(splitShare * 2n);
+ expect(immediatePaid + pending).to.equal(PRICE * 2n);
+ });
+
+ it('keeps dust rounding exact when tiny split shares round down', async () => {
+ const PRICE = 2n;
+ const { registry, royalties, artistA, listener, publicClient } = await withArtistRuntime();
+ const rejecting = await hre.viem.deployContract('RejectingRoyaltyRecipient');
+
+ const artistRegistry = await hre.viem.getContractAt('MusicRegistryPallet', registry.address, { client: { wallet: artistA } });
+ await artistRegistry.write.musicRegRegister([sampleRegistration({ pricePlanck: PRICE }), [rejecting.address], [1]]);
+
+ const artistBefore = await publicClient.getBalance({ address: artistA.account.address });
+ const listenerRoyalties = await hre.viem.getContractAt('MusicRoyaltiesPallet', royalties.address, { client: { wallet: listener } });
+ const txHash = await listenerRoyalties.write.musicRoyPayAccess([TRACK_HASH], { value: PRICE });
+ const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
+
+ const claimableLogs = await publicClient.getLogs({
+ address: royalties.address,
+ event: musicRoyRoyaltyClaimableEvent,
+ fromBlock: receipt.blockNumber,
+ toBlock: receipt.blockNumber
+ });
+
+ expect(await royalties.read.musicRoyClaimable([rejecting.address])).to.equal(0n);
+ expect((await publicClient.getBalance({ address: artistA.account.address })) - artistBefore).to.equal(PRICE);
+ expect(claimableLogs).to.have.lengthOf(0);
+ });
+
it('reentrancy: a malicious royalty recipient that re-enters for another track cannot bypass the guard', async () => {
const PRICE = parseEther('0.5');
const ctx = await withArtistRuntime();
diff --git a/contracts/evm/test/RegistryUpgradeTasks.test.ts b/contracts/evm/test/RegistryUpgradeTasks.test.ts
index f949866c..22343cc4 100644
--- a/contracts/evm/test/RegistryUpgradeTasks.test.ts
+++ b/contracts/evm/test/RegistryUpgradeTasks.test.ts
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import hre from 'hardhat';
-import { getAddress, keccak256, type Hex } from 'viem';
+import { getAddress, keccak256, toBytes, type Hex } from 'viem';
import { MUSIC_REGISTRY_REGISTER_SELECTOR, buildRegistryHotfixCalldata } from '../scripts/registryUpgrade';
async function deployTaskFixture() {
@@ -36,7 +36,23 @@ async function deployTaskFixture() {
for (let step = 0; step < 7; step += 1) await ownerFactory.write.installRuntimeStep();
const runtime = getAddress(await factory.read.runtimeOf([owner.account.address]));
- return { owner, other, publicClient, directory, factory, runtime };
+ return { owner, other, publicClient, directory, factory, royaltiesPallet, runtime };
+}
+
+function taskRegistration(overrides: Partial<{ contentHash: Hex; audioRef: string; pricePlanck: bigint }> = {}) {
+ return {
+ contentHash: overrides.contentHash ?? keccak256(toBytes('dotify:registry-upgrade-task-track')),
+ title: 'Registry task track',
+ artistName: 'Task Artist',
+ description: 'Runtime upgrade task fixture',
+ imageRef: 'ipfs://cover',
+ audioRef: overrides.audioRef ?? 'ipfs://audio',
+ metadataRef: 'ipfs://metadata',
+ artistContractRef: 'artist://task',
+ accessMode: 1,
+ pricePlanck: overrides.pricePlanck ?? 1_000n,
+ requiredPersonhood: 0
+ };
}
async function installUnsafeRegisterFacet(
@@ -166,6 +182,219 @@ describe('Registry remediation Hardhat tasks', () => {
}
});
+ it('dry-runs and executes a royalties facet upgrade without losing catalogue state', async () => {
+ const { owner, other, runtime } = await deployTaskFixture();
+ const registry = await hre.viem.getContractAt('MusicRegistryPallet', runtime, { client: { wallet: owner } });
+ const registration = taskRegistration();
+ await registry.write.musicRegRegister([registration, [other.account.address], [10_000]]);
+ const replacementRoyalties = await hre.viem.deployContract('MusicRoyaltiesPallet');
+ const evidenceDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'dotify-runtime-royalties-upgrade-'));
+ const planPath = path.join(evidenceDirectory, 'plan.json');
+ const executionPath = path.join(evidenceDirectory, 'execution.json');
+
+ try {
+ await withSilentConsole(() =>
+ hre.run('runtime:royalties-upgrade', {
+ runtime,
+ facet: replacementRoyalties.address,
+ execute: false,
+ confirmPlan: '',
+ out: planPath
+ })
+ );
+ const plan = JSON.parse(fs.readFileSync(planPath, 'utf8')) as {
+ digest: Hex;
+ status: string;
+ trackStateHash: Hex;
+ replaceSelectors: Array<{ name: string; selector: Hex }>;
+ transaction: { data: Hex } | null;
+ };
+ expect(plan.status).to.equal('simulated-dry-run');
+ expect(plan.replaceSelectors.map(selector => selector.name)).to.include.members(['musicRoyClaim', 'musicRoyClaimable', 'musicRoyPayAccess']);
+ expect(plan.transaction?.data).to.match(/^0x[0-9a-f]+$/i);
+
+ await withSilentConsole(() =>
+ hre.run('runtime:royalties-upgrade', {
+ runtime,
+ facet: replacementRoyalties.address,
+ execute: true,
+ confirmPlan: plan.digest,
+ out: executionPath
+ })
+ );
+ const execution = JSON.parse(fs.readFileSync(executionPath, 'utf8')) as {
+ status: string;
+ transactionHash: Hex;
+ catalogueStatePreserved: boolean;
+ trackStateHash: Hex;
+ };
+ expect(execution.status).to.equal('verified-finalized');
+ expect(execution.transactionHash).to.match(/^0x[0-9a-f]{64}$/i);
+ expect(execution.catalogueStatePreserved).to.equal(true);
+ expect(execution.trackStateHash).to.equal(plan.trackStateHash);
+
+ const loupe = await hre.viem.getContractAt('DiamondLoupePallet', runtime);
+ const selectorsByName = new Map(plan.replaceSelectors.map(selector => [selector.name, selector.selector] as const));
+ for (const name of ['musicRoyClaim', 'musicRoyClaimable', 'musicRoyPayAccess']) {
+ const selector = selectorsByName.get(name);
+ expect(selector).to.be.a('string');
+ expect(getAddress(await loupe.read.facetAddress([selector as Hex]))).to.equal(getAddress(replacementRoyalties.address));
+ }
+
+ const readRegistry = await hre.viem.getContractAt('MusicRegistryPallet', runtime);
+ expect(await readRegistry.read.musicRegTrackCount()).to.equal(1n);
+ const [record] = await readRegistry.read.musicRegGetTrack([registration.contentHash]);
+ expect(record.title).to.equal(registration.title);
+ } finally {
+ fs.rmSync(evidenceDirectory, { recursive: true, force: true });
+ }
+ });
+
+ it('exports a runtime snapshot and blocks runtime-bound encrypted replay by default', async () => {
+ const { owner, other, runtime } = await deployTaskFixture();
+ const registry = await hre.viem.getContractAt('MusicRegistryPallet', runtime, { client: { wallet: owner } });
+ const registration = taskRegistration({
+ contentHash: keccak256(toBytes('dotify:registry-upgrade-task-encrypted-track')),
+ audioRef: 'dotify:enc:v2:420420417:0x1111111111111111111111111111111111111111:0x2222:audio'
+ });
+ await registry.write.musicRegRegister([registration, [other.account.address], [10_000]]);
+ const evidenceDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'dotify-runtime-export-'));
+ const snapshotPath = path.join(evidenceDirectory, 'snapshot.json');
+ const migrationPath = path.join(evidenceDirectory, 'migration.json');
+ const previousExitCode = process.exitCode;
+
+ try {
+ await withSilentConsole(() =>
+ hre.run('runtime:export', {
+ runtime,
+ recipient: other.account.address,
+ out: snapshotPath
+ })
+ );
+ const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf8')) as {
+ schema: string;
+ runtime: string;
+ trackCount: string;
+ tracks: Array<{ hash: Hex; royaltySplits: Array<{ recipient: string; bps: string }> }>;
+ recipientClaimable: { recipient: string; amount: string; status: string };
+ };
+ expect(snapshot.schema).to.equal('dotify.runtime-catalogue-snapshot.v1');
+ expect(getAddress(snapshot.runtime)).to.equal(runtime);
+ expect(snapshot.trackCount).to.equal('1');
+ expect(snapshot.tracks[0].hash).to.equal(registration.contentHash);
+ expect(snapshot.tracks[0].royaltySplits[0]).to.deep.equal({ recipient: getAddress(other.account.address), bps: '10000' });
+ expect(snapshot.recipientClaimable).to.deep.equal({ recipient: getAddress(other.account.address), amount: '0', status: 'read' });
+
+ process.exitCode = undefined;
+ await withSilentConsole(() =>
+ hre.run('runtime:migration-plan', {
+ snapshot: snapshotPath,
+ targetRuntime: '0x3333333333333333333333333333333333333333',
+ allowEncryptedAudioReuse: false,
+ out: migrationPath
+ })
+ );
+ expect(process.exitCode).to.equal(2);
+ const migration = JSON.parse(fs.readFileSync(migrationPath, 'utf8')) as {
+ schema: string;
+ transactionCount: number;
+ blockedTrackCount: number;
+ blockedTracks: Array<{ hash: Hex; reason: string }>;
+ };
+ expect(migration.schema).to.equal('dotify.runtime-track-migration-plan.v1');
+ expect(migration.transactionCount).to.equal(0);
+ expect(migration.blockedTrackCount).to.equal(1);
+ expect(migration.blockedTracks[0].hash).to.equal(registration.contentHash);
+ expect(migration.blockedTracks[0].reason).to.include('runtime-bound');
+ } finally {
+ process.exitCode = previousExitCode;
+ fs.rmSync(evidenceDirectory, { recursive: true, force: true });
+ }
+ });
+
+ it('dry-runs a royalties facet deployment with explicit execute instructions', async () => {
+ const output: string[] = [];
+ const originalLog = console.log;
+ console.log = (...values: unknown[]) => output.push(values.map(String).join(' '));
+
+ try {
+ await hre.run('runtime:deploy-royalties-facet', {
+ execute: false,
+ confirmChainId: '',
+ confirmCodeHash: '',
+ out: ''
+ });
+ } finally {
+ console.log = originalLog;
+ }
+
+ const reportText = output.find(value => value.includes('"action": "dry-run"'));
+ expect(reportText).to.be.a('string');
+ const report = JSON.parse(reportText!) as { action: string; chainId: number; contract: string; localSourceCodeHash: Hex; next: string; warning: string };
+ expect(report.action).to.equal('dry-run');
+ expect(report.chainId).to.equal(31337);
+ expect(report.contract).to.equal('MusicRoyaltiesPallet');
+ expect(report.localSourceCodeHash).to.match(/^0x[0-9a-f]{64}$/i);
+ expect(report.next).to.include(`--confirm-chain-id ${report.chainId}`);
+ expect(report.next).to.include(`--confirm-code-hash ${report.localSourceCodeHash}`);
+ expect(report.warning).to.include('runtime:royalties-upgrade --facet ');
+ });
+
+ it('executes runtime:deploy-royalties-facet and writes a bytecode-verified manifest', async () => {
+ const artifact = await hre.artifacts.readArtifact('MusicRoyaltiesPallet');
+ const expectedCodeHash = keccak256(artifact.deployedBytecode as Hex);
+ const evidenceDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'dotify-runtime-royalties-facet-'));
+ const manifestPath = path.join(evidenceDirectory, 'facet.json');
+
+ try {
+ await withSilentConsole(() =>
+ hre.run('runtime:deploy-royalties-facet', {
+ execute: true,
+ confirmChainId: '31337',
+ confirmCodeHash: expectedCodeHash,
+ out: manifestPath
+ })
+ );
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as {
+ schema: string;
+ status: string;
+ chainId: number;
+ transactionHash: Hex;
+ facet: string;
+ codeHash: Hex;
+ next: string;
+ };
+ expect(manifest.schema).to.equal('dotify.runtime-royalties-facet-deployment.v1');
+ expect(manifest.status).to.equal('deployed-finalized-bytecode-verified');
+ expect(manifest.chainId).to.equal(31337);
+ expect(manifest.transactionHash).to.match(/^0x[0-9a-f]{64}$/i);
+ expect(getAddress(manifest.facet)).to.equal(manifest.facet);
+ expect(manifest.codeHash).to.equal(expectedCodeHash);
+ expect(manifest.next).to.include(`--facet ${manifest.facet}`);
+ } finally {
+ fs.rmSync(evidenceDirectory, { recursive: true, force: true });
+ }
+ });
+
+ it('explains royalties upgrade hash mismatches before planning a selector cut', async () => {
+ const { runtime } = await deployTaskFixture();
+ const wrongFacet = await hre.viem.deployContract('MusicRegistryPallet');
+
+ await expectRejection(
+ () =>
+ withSilentConsole(() =>
+ hre.run('runtime:royalties-upgrade', {
+ runtime,
+ facet: wrongFacet.address,
+ execute: false,
+ confirmPlan: '',
+ out: ''
+ })
+ ),
+ /Target royalties facet code hash 0x[0-9a-f]{64} from --facet does not match local source 0x[0-9a-f]{64}\. Deploy the current MusicRoyaltiesPallet facet first with runtime:deploy-royalties-facet, then rerun runtime:royalties-upgrade with --facet \./i
+ );
+ });
+
it('rejects registry:upgrade execution when --confirm-plan does not match the fresh digest', async () => {
const { owner, publicClient, runtime } = await deployTaskFixture();
await installUnsafeRegisterFacet(owner, publicClient, runtime);
diff --git a/docs/README.md b/docs/README.md
index 343fb063..b29fefb3 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -20,7 +20,7 @@ Conceptual documents that help you understand why Dotify works the way it does.
| [Architecture Overview](./explanation/architecture-overview.md) | All | How the six system layers (identity, IPFS, EVM, Bulletin, WebRTC, frontend) connect |
| [Access Control Model](./explanation/access-control-model.md) | All | Human free vs Classic — what they mean for artists and listeners |
| [Content Protection](./explanation/content-protection.md) | All | Audio encryption pipeline, what it protects, and what it does not |
-| [Royalty Settlement](./explanation/royalty-settlement.md) | All | How native runtime payments flow from listener wallet to artist wallet, and why Product CASH settlement remains a separate rail |
+| [Royalty Settlement](./explanation/royalty-settlement.md) | All | How native runtime payments settle, become claimable on recipient failure, and stay separate from Product CASH |
| [Listening Rooms](./explanation/listening-rooms.md) | All | WebRTC peer-to-peer streaming, signaling protocol, known limitations |
| [Product DevNet Architecture](./explanation/product-devnet-architecture.md) | Maintainers | Dual-host boundaries, Product account capabilities, rooms, storage, and the proposed contract port |
diff --git a/docs/backlog/backlog.json b/docs/backlog/backlog.json
index 8a007140..904e8e4b 100644
--- a/docs/backlog/backlog.json
+++ b/docs/backlog/backlog.json
@@ -323,6 +323,16 @@
"track": "Production spine",
"phase": "Now",
"note": "Activated implementation sequence for truthful Classic access, ownership, and royalty recipient promises."
+ },
+ {
+ "id": "W05",
+ "doc": "docs/backlog/implementation/W05-royalty-failure-isolation.md",
+ "issue": 145,
+ "kind": "work",
+ "priority": "P1",
+ "track": "Production spine",
+ "phase": "Now",
+ "note": "Activated implementation sequence for royalty failure isolation, bounded payout fallback, and claimable receipt UI."
}
]
}
diff --git a/docs/backlog/implementation/W05-royalty-failure-isolation.md b/docs/backlog/implementation/W05-royalty-failure-isolation.md
index 5d98c3ee..f8651201 100644
--- a/docs/backlog/implementation/W05-royalty-failure-isolation.md
+++ b/docs/backlog/implementation/W05-royalty-failure-isolation.md
@@ -9,7 +9,7 @@ Implement **W05** only. Read `AGENTS.md`, `docs/backlog/implementation/common.md
- Branch: `feat/royalty-failure-isolation` created from the latest tested `origin/dev` when work starts.
- Dependencies: W04.
- Release stage: Pilot.
-- Existing issue: No dedicated issue assigned yet; reuse a matching open issue or create a scoped ticket when this sequence starts.
+- Existing issue: #145.
- Product purpose: effortless shared listening, meaningful artist control, transparent value, and a coherent poetic interface. Product DevNet is a first-class target alongside ordinary web.
## Outcome
diff --git a/docs/backlog/implementation/evidence/W05.md b/docs/backlog/implementation/evidence/W05.md
new file mode 100644
index 00000000..82092451
--- /dev/null
+++ b/docs/backlog/implementation/evidence/W05.md
@@ -0,0 +1,221 @@
+# W05 — evidence and handoff
+
+## Identity
+
+- Sequence and scope: W05 — prevent one royalty recipient from blocking everyone.
+- Date: 2026-09-08.
+- Starting dev SHA: `d7a2ee3196204e9414483289ae26bbe9d62b4fd0`.
+- Implementation SHA actually tested: `f1f8b624bcff38eb352512f571d1b02ec5d15a71`;
+ CI repair SHA `b8432ba` restored web formatting and the deterministic Product
+ catalog fixture.
+- Branch / PR / issue: `feat/royalty-failure-isolation`; PR #146 (`https://github.com/knzeng-e/dotify/pull/146`); issue #145.
+- Related dependency evidence: W04, `docs/backlog/implementation/evidence/W04.md`, merged to `dev` before W05 started.
+- Code readiness: locally verified.
+- Release readiness: not deployed.
+- Review follow-up SHA: `5d814f34fdb56a18f0b90d659cd5058980aef5fd`;
+ PR comments on #146 addressed locally on 2026-09-08.
+- Operator follow-up SHA: `5dafb285bb443ccb53301ab032cdb7ec44239dfb`;
+ added explicit W05 royalties facet deployment tooling after a live dry-run
+ caught a stale target facet code-hash mismatch.
+
+## Result and decisions
+
+W05 keeps Classic access purchases valid when one royalty recipient cannot
+receive a native-token transfer. `musicRoyPayAccess` still grants paid access
+and settles normal recipients immediately, but every recipient transfer uses a
+bounded native call. A failed recipient share is accrued in
+`LibMusicRoyalties.claimable[recipient]` and emitted separately from paid
+settlement.
+
+The chosen design is immediate distribution plus bounded-gas claimable fallback,
+not an all-pull settlement rewrite. That keeps the current listener payment
+flow and successful recipient behavior intact while isolating the specific
+failure mode from rejecting, gas-consuming, or otherwise incompatible
+recipients.
+
+New runtime facts:
+
+- `MusicRoyAccessPaid` remains the listener access-payment event.
+- `MusicRoyRoyaltyPaid` means one recipient was paid immediately.
+- `MusicRoyRoyaltyPayoutFailed` and `MusicRoyRoyaltyClaimable` mean one
+ recipient share stayed in the runtime.
+- `musicRoyClaimable(recipient)` reads the pending native-token balance.
+- `musicRoyClaim(recipient)` requires `recipient == msg.sender`; failed claim
+ transfers restore the pending balance and emit `MusicRoyRoyaltyClaimFailed`
+ instead of reverting.
+
+The artist console now treats paid, claimable, claimed, and legacy royalty rows
+as different states. `totalRoyaltyWei` sums settled direct rows plus claimable
+rows that were later cleared by `MusicRoyRoyaltyClaimed`; current claimable
+balances are shown separately from direct runtime reads. Collaborators who only
+appear in another artist's royalty splits can inspect and claim from that
+originating runtime instead of being forced through `directory.runtimeOf` for
+their own wallet. Product CDM uses the same runtime writer port for claim
+writes, while historical royalty event reads remain unsupported until a Product
+event/indexer source is wired.
+
+The PR review follow-up fixed three ledger gaps:
+
+- `MusicRoyRoyaltyClaimed` logs are read and applied FIFO per recipient so
+ cleared accruals become `settlement: 'claimed'` instead of staying
+ indefinitely `claimable`.
+- Pre-W05 `MusicRoyAccessPaid` rows remain visible as
+ `settlement: 'legacy'`; they are intentionally excluded from settled totals
+ because they do not include per-recipient evidence.
+- Known royalty runtimes are discovered from the connected wallet's own artist
+ runtime plus catalog tracks where the wallet is listed as a split recipient,
+ and claim writes run against each known runtime that still reports a pending
+ recipient balance.
+
+The upgrade path is now explicit. Existing SmartRuntimes should receive an
+owner-signed in-place royalties facet cut whenever possible. That preserves the
+runtime address, protected-audio key binding, catalogue storage, paid-access
+state, royalty splits, and claimable balances. `runtime:deploy-royalties-facet`
+deploys the current W05 `MusicRoyaltiesPallet` as a stateless facet first, and
+`runtime:royalties-upgrade` then takes that facet via `--facet` to plan/simulate
+or execute the owner-signed `diamondCut`. A target facet code-hash mismatch is
+expected when `deployments.json` still points at an older on-chain royalties
+facet; the task now reports that specific remediation path. A clean redeploy is
+documented as fallback only: `runtime:export` saves the catalogue/splits, and
+`runtime:migration-plan` renders replay calldata for a new runtime, but it does
+not move paid-access grants or claimable native-token balances. Encrypted
+`dotify:enc:v2:` audio refs are blocked by default during replay planning
+because their content keys are runtime-bound.
+
+## Verification
+
+| Command or real scenario | Environment and build | Observed result | Artifact |
+| ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
+| `npm --prefix contracts/evm test` | Node 22/npm 10; Hardhat local network; implementation SHA `f1f8b624bcff38eb352512f571d1b02ec5d15a71` | Pass: 66 tests. Covers rejecting receiver, gas-consuming receiver, reentrant receiver, dust/rounding, multi-recipient exact accounting, unauthorized claim, double-claim, local facet upgrade rehearsal, explicit royalties facet deployment, owner-signed royalties upgrade task execution, and runtime export/migration-plan guardrails. | Console output in this run |
+| `npm --prefix contracts/evm run fmt:check` | Same | Pass: all matched Solidity/TS contract files use Prettier style. | Console output in this run |
+| `npm exec -- hardhat help runtime:export` | Contract package, PR review follow-up | Pass: Hardhat loads the read-only runtime snapshot task. | Console output in this run |
+| `npm exec -- hardhat help runtime:deploy-royalties-facet` | Contract package, PR review follow-up | Pass: Hardhat loads the explicit W05 royalties facet deployment task. | Console output in this run |
+| `npm exec -- hardhat help runtime:royalties-upgrade` | Contract package, PR review follow-up | Pass: Hardhat loads the dry-run/default owner-signed royalties facet upgrade task. | Console output in this run |
+| `npm exec -- hardhat help runtime:migration-plan` | Contract package, PR review follow-up | Pass: Hardhat loads the clean-redeploy fallback replay-plan task. | Console output in this run |
+| `npm --prefix contracts/evm run generate:abis` | Same | Pass: regenerated 6 ABI modules and index. `musicRoyalties.ts` includes W05 events and selectors. | `web/src/generated/contracts/musicRoyalties.ts` |
+| `npm --prefix web run generate:cdm` | Web package | Pass: regenerated `cdm.json`, `smartRuntime.ts` with 49 merged entries, and `cdm.d.ts` with `musicRoyClaimable`/`musicRoyClaim`. | `web/src/generated/contracts/smartRuntime.ts`, `web/src/generated/contracts/cdm.d.ts` |
+| `npm --prefix web run generate:cdm-metadata` | Web package | Pass: deterministic fixed-package metadata regenerated. CIDs unchanged for `@dotify/artist-directory` and `@dotify/artist-runtime-factory`. | Console output in this run |
+| `npm --prefix web run fmt:check` | Web package | Pass. Added during the PR CI repair after Prettier reported drift in `productCdmRuntimeAdapter.ts` and `useArtistConsole.ts`. | Console output in this run |
+| `npm --prefix web run test:unit` | Web package | Pass: 52 files, 400 tests. Runtime adapter tests cover paid/claimable/claimed/legacy logs, claimable reads, claim write routing for viem/Product CDM, and split-recipient runtime discovery. | Console output in this run |
+| `npm --prefix web run test:unit -- src/features/runtime/viemRuntimeAdapter.test.ts src/features/runtime/royaltyRuntimeClaims.test.ts` | Web package, PR review follow-up | Pass. Covers claim reconciliation to `claimed`, pre-W05 legacy access rows, W05 access-row dedupe, and split-recipient runtime discovery. | Console output in this run |
+| `npm --prefix web run lint` | Web package | Exit 0 with 3 pre-existing React hook dependency warnings in `web/src/App.tsx` and `web/src/views/ArtistShell.tsx`; no errors. | Console output in this run |
+| `npm --prefix web run build` | Web package, production Vite build | Pass. Rollup warnings remain for `@scure/base` annotation, `protectedAudio` dynamic/static import, and large chunks. | `web/dist` generated locally |
+| `npm --prefix web run generate:product-catalog-bootstrap:strict -- --input fixtures/product-devnet-catalog.json` | Web package | Pass: wrote the deterministic 8-item fixture bootstrap expected by Product DevNet CI. | `web/src/services/productDevnetCatalogBootstrap.ts` |
+| `CATALOG_API_URL=http://127.0.0.1:9 npm --prefix web run build:product-devnet` | Web package, Product DevNet build after strict fixture generation with live catalog refresh disabled | Pass. Generator logged the expected fetch-failed fallback, kept the checked-in fixture bootstrap, and built `web/dist-product`; same Rollup warnings as standard build. | Console output in this run |
+| Live `runtime:deploy-royalties-facet:testnet --execute` | Polkadot testnet chain `420420417`; signer `0xC3571714248588C6E19cDECe2778B75341b2c288` | Pass: deployed W05 `MusicRoyaltiesPallet` facet `0xc7dD816feeb3A9602653268545A474d9C6686236` with bytecode hash `0x48040d01067044740be4276d4bfec6e606ac5c149f2ce390930a8a9edc37ec94`; finalized at block `13214106`; explorer source verification remains false. | `/tmp/dotify-royalties-facet-3.json`, tx `0x4be40c86bd5f9412eb500c5d09ddef02e37f00fdbe66dcc5847edc89ac354702` |
+| Live `runtime:royalties-upgrade:testnet --execute` | Runtime `0xB60e91CcAcD08B6cb0Ddb2E678F90791901e9338`; owner/signer `0xC3571714248588C6E19cDECe2778B75341b2c288`; target facet above | Pass: owner-signed in-place diamond cut finalized at block `13214178`; added `musicRoyClaim`/`musicRoyClaimable`, replaced five W05 royalties selectors from old facet `0x504AD3fEf36e2E7f9865cBd9C8eea8e44358B71a`, and preserved catalogue state hash `0x631c287d6301e1b8d96b959983e499836e5db26115418e346ee15e1bdbc32449`. | `/tmp/dotify-royalties-upgrade-final.json`, tx `0x64ad130a1aca6681a25d4df61826c0151d74754e7e2a017b76e4d1d5aba4bfef` |
+| `node scripts/backlog-sync.mjs --check --offline` | Repo root | Pass with existing offline warnings: 24 active items without GitHub mapping; duplicate numbered docs for `08`. | Console output in this run |
+| `git diff --check` | Repo root | Pass. | Console output in this run |
+
+Initial W05 implementation work did not execute a live Product host transaction
+or public deploy. Post-review operator validation on 2026-09-12 executed the
+live Polkadot testnet facet deployment and one owner-signed runtime upgrade
+listed above.
+
+## Compatibility and operations
+
+- Supported devices, browsers, and Product host versions: locally verified in
+ contract/unit/build environments only. No real-device Product host evidence
+ was collected for W05.
+- New config/permissions and documented defaults: no new env var. Product
+ executable version bumped to `[0, 1, 14]` because the Product bundle exposes
+ the W05 claim writer path.
+- Storage/key/contract migration and compatibility evidence:
+ `LibMusicRoyalties.Storage` appends `claimable` after existing splits in the
+ same namespaced Diamond storage slot. The local forkless upgrade test simulates
+ a pre-W05 runtime by removing claim selectors, then replaces the royalties
+ facet, adds claim selectors, and verifies royalty splits plus paid access
+ survive.
+- Deployment identifiers: none; not deployed.
+- Rollback procedure and rehearsal evidence: before any W05 payment, reinstall
+ the previous royalties facet or redeploy the previous factory/runtime set.
+ After W05 payments may have created claimable balances, rollback must keep a
+ claim-capable facet available until those balances are settled or explicitly
+ migrated; otherwise funds can become inaccessible.
+- Data collected, retention, and user controls: no new off-chain data. The
+ final Product bootstrap snapshot contains 8 catalog items from the committed
+ fixture so CI remains deterministic; no live catalog refresh was committed.
+
+## Acceptance mapping
+
+| Sequence criterion | Passed / failed / not run | Supporting evidence or exact blocker |
+| -------------------------------------------------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Recipient unable to receive transfer cannot make valid purchase fail | Passed | `keeps access purchase valid when royalty recipients reject or exhaust bounded gas` |
+| Rejecting receiver | Passed | `RejectingRoyaltyRecipient` fixture and payment/claim tests |
+| Gas-consuming receiver | Passed | `GasConsumingRoyaltyRecipient` fixture and payment/claim tests |
+| Reentrant receiver | Passed | Existing reentrancy fixture still passes against W05 bounded settlement |
+| Dust/rounding | Passed | `keeps dust rounding exact when tiny split shares round down` |
+| Multiple recipients | Passed | Mixed rejecting, gas-consuming, and normal recipient payment test |
+| Double-claim | Passed | Claim test reverts on second claim with `MusicRoyalties: nothing to claim` |
+| Unauthorized claim | Passed | Claim test rejects caller mismatch with `MusicRoyalties: claim self only` |
+| Sum of paid plus claimable equals distributable amount across repeated purchases | Passed | Repeated purchase accounting test verifies paid recipient balances plus claimable balances equal all distributable shares |
+| Old runtime fixtures and local upgraded runtime retain prior state and access behavior | Passed | Forkless upgrade rehearsal preserves splits and paid access while adding W05 selectors |
+| Claim/receipt UI does not display accrued amount as received | Passed | Artist provider sums only paid/claimed settlements; Royalties tab displays settled, claimable, claimed, and legacy metrics separately with a claim action |
+| Claim refresh does not keep cleared accruals labeled claimable | Passed | `viemRuntimeAdapter.test.ts` reconciles `MusicRoyRoyaltyClaimed` against claimable rows and reports `settlement: 'claimed'` |
+| Split recipients can reach runtimes that hold their funds | Passed | `royaltyRuntimeClaims.test.ts` discovers originating runtimes from catalog split metadata even without a collaborator-owned runtime |
+| Pre-W05 royalty history is preserved after frontend upgrade | Passed | `viemRuntimeAdapter.test.ts` keeps non-W05 `MusicRoyAccessPaid` rows as `settlement: 'legacy'` and dedupes W05 access rows |
+| Operator can deploy the matching W05 royalties facet before upgrading an old runtime | Passed | `RegistryUpgradeTasks.test.ts` dry-runs and executes `runtime:deploy-royalties-facet`, verifies deployed bytecode, and checks the manifest points to the next `runtime:royalties-upgrade --facet` command |
+| Royalties upgrade gives an actionable error for stale target facets | Passed | `RegistryUpgradeTasks.test.ts` rejects a wrong `--facet` with the deploy-current-facet remediation message |
+
+## Remaining gates
+
+- No Product publish, redeploy, or hosted smoke was run in W05.
+- Runtime `0xB60e91CcAcD08B6cb0Ddb2E678F90791901e9338` is W05 royalties-ready
+ after the live owner-signed facet cut above. Other existing live artist
+ runtimes are not W05-ready until their royalties facet is upgraded or they are
+ recreated from a W05 factory.
+- Use `runtime:deploy-royalties-facet` first when the current W05 facet is not
+ already deployed. Then use `runtime:royalties-upgrade --facet ` for
+ an in-place royalties facet cut when the artist still controls the old
+ SmartRuntime. Use `runtime:export` plus `runtime:migration-plan` only for
+ clean-redeploy fallback planning; that path cannot move paid-access grants or
+ claimable balances.
+- If a deploy/upgrade task writes an evidence file and then fails before
+ `signed-before-broadcast`, no transaction was signed or broadcast by that
+ task. Retry with a new `--out` path or remove the stale prepared manifest
+ after inspection.
+- Product CDM claim writes are wired through the shared writer port, but Product
+ payment history still needs an event/indexer API before the full royalty
+ ledger is available in Product mode.
+- Native value forwarding and host-signed transaction evidence remain required
+ before selecting `VITE_DOTIFY_RUNTIME_ADAPTER=product-cdm` for the tracked
+ Product deployment.
+- `npm --prefix web run lint` keeps the existing React hook dependency warnings
+ in `web/src/App.tsx` and `web/src/views/ArtistShell.tsx`.
+
+## Next agent
+
+- Next eligible sequence(s), based on merged prerequisites: after W05 merges,
+ the solo order continues with W06 — returning identity. W07 is already
+ dependency-eligible from W03, and W11 remains blocked until W06 and W05 are
+ both merged.
+- Files/interfaces changed that the next agent must inspect:
+ `contracts/evm/contracts/pallets/MusicRoyaltiesPallet.sol`,
+ `contracts/evm/contracts/libraries/LibMusicRoyalties.sol`,
+ `contracts/evm/contracts/ArtistRuntimeFactory.sol`,
+ `web/src/features/runtime/runtimePorts.ts`,
+ `web/src/features/runtime/viemRuntimeAdapter.ts`,
+ `web/src/features/runtime/productCdmRuntimeAdapter.ts`,
+ `web/src/hooks/useArtistConsole.ts`, and
+ `docs/explanation/royalty-settlement.md`.
+- Decisions that must not be silently reversed: claimable funds are not received
+ funds; `MusicRoyAccessPaid` is an access-payment fact, not a per-recipient
+ receipt; Product CDM history remains unsupported without an event/indexer
+ source; post-W05 rollback must preserve access to claimable balances.
+- Exact command/scenario to reproduce a remaining issue: run a real Product CDM
+ native payment/claim smoke only after a W05-ready runtime is deployed and
+ funded, then compare `musicRoyClaimable(activeEvmAddress)` before and after
+ `musicRoyClaim(activeEvmAddress)`.
+- Project/metadata updates completed and any inaccessible fields: issue #145
+ created and mapped in `docs/backlog/backlog.json` and `sequence.json`; issue
+ #145 and PR #146 were added to Project 5 with Status `In Progress`, Type
+ `Work`, Phase `Now`, Track `Production spine`, Priority `P1`, and Backlog doc
+ `docs/backlog/implementation/W05-royalty-failure-isolation.md`. PR #146 has
+ assignee `knzeng-e` and labels `P1`, `contracts`, `frontend`, `product`, and
+ `dotify-backlog`. No milestone exists, and no specific reviewer was known for
+ this scope.
+
+Implementation summary: W05 isolates failed royalty recipients with bounded
+native transfers, claimable runtime balances, explicit settlement events, ABI
+bindings, adapter support, minimal claim UI, documentation, and local upgrade
+evidence. Exact next step after PR review/merge: start W06 from fresh `dev`.
diff --git a/docs/backlog/implementation/sequence.json b/docs/backlog/implementation/sequence.json
index 6a2d1c5c..e7ebcaf4 100644
--- a/docs/backlog/implementation/sequence.json
+++ b/docs/backlog/implementation/sequence.json
@@ -43,7 +43,7 @@
"dependencies": [
"W01"
],
- "issue": null,
+ "issue": 145,
"phase": "Pilot"
},
{
diff --git a/docs/context/dotify-technical-memory.md b/docs/context/dotify-technical-memory.md
index c93728cc..5028f6a9 100644
--- a/docs/context/dotify-technical-memory.md
+++ b/docs/context/dotify-technical-memory.md
@@ -166,7 +166,7 @@ Maintain and test:
- track registration;
- deactivation;
- Classic payment;
-- royalty distribution;
+- royalty settlement with claimable fallback for failed recipients;
- personhood-gated access;
- NFT transfer gating;
- isolation between artist runtimes;
diff --git a/docs/explanation/access-control-model.md b/docs/explanation/access-control-model.md
index 6d751066..b0997b63 100644
--- a/docs/explanation/access-control-model.md
+++ b/docs/explanation/access-control-model.md
@@ -160,13 +160,13 @@ contract verifies the release exists, is active, is currently Classic, the walle
has not already paid, and value >= price
|
v
-records paid access and distributes the stored price across royalty splits
+records paid access and settles royalty shares, making failed recipient transfers claimable
|
v
refunds any overpayment
|
v
-emits MusicRoyAccessPaid(contentHash, listener, amount) event
+emits payment and per-recipient settlement events
```
The frontend watches for inclusion, then re-reads `musicAccHasPaid()` and
diff --git a/docs/explanation/product-devnet-architecture.md b/docs/explanation/product-devnet-architecture.md
index 30120d7f..d812d77f 100644
--- a/docs/explanation/product-devnet-architecture.md
+++ b/docs/explanation/product-devnet-architecture.md
@@ -210,11 +210,14 @@ Hub runtimes, so the Product-native path needs an explicit receipt or bridge
model before any listener payment can execute. Dotify must not silently convert
CASH to native runtime value or mark access paid without runtime evidence.
-The CDM adapter has one deliberate gap: royalty payment history is not read
-through Product contract handles because the current SDK surface exposes
-method queries and transactions, not the viem-style historical log query used
-by the artist console. Product mode must use the backend catalog/read-model
-indexer, or a future Product event/indexer API, for that history.
+The CDM adapter has one deliberate gap: historical royalty payment events are
+not read through Product contract handles because the current SDK surface
+exposes method queries and transactions, not the viem-style historical log
+query used by the artist console. W05 claimable balances are readable through
+`musicRoyClaimable(recipient)`, and `musicRoyClaim(recipient)` is routed through
+the shared writer port, but Product mode still needs the backend
+catalog/read-model indexer or a future Product event/indexer API for the full
+settlement history.
### The CDM Manifest Is Generated, Not Installed
diff --git a/docs/explanation/royalty-settlement.md b/docs/explanation/royalty-settlement.md
index c12fa7fa..3afef65d 100644
--- a/docs/explanation/royalty-settlement.md
+++ b/docs/explanation/royalty-settlement.md
@@ -7,35 +7,51 @@
## How royalties work for artists
When a listener pays to unlock a Classic-access track, the configured chain's
-native token goes directly from their wallet to yours. On the current Product
-DevNet/Paseo Asset Hub runtime rail, that token is PAS. There is no platform
-account, no holding period, and no payout schedule. The smart contract
-distributes the payment the moment the transaction is confirmed.
+native token leaves their wallet and is settled by the artist's SmartRuntime.
+On the current Product DevNet/Paseo Asset Hub runtime rail, that token is PAS.
+There is no platform payout account and no off-chain payout schedule.
-You can also split royalties with collaborators. When you register a track, you specify a list of recipient addresses and a share for each (expressed in basis points, where 10,000 = 100 %). The contract distributes the payment proportionally in the same transaction.
+Recipients that can receive native transfers are paid immediately in the
+listener's payment transaction. If one recipient rejects native tokens or uses
+too much gas in its receive hook, that recipient's share becomes claimable in
+the runtime instead of reverting the listener's purchase or blocking the other
+recipients.
-Everything is verifiable on-chain. Any listener can inspect the payment records using a block explorer like Blockscout.
+You can also split royalties with collaborators. When you register a track, you specify a list of recipient addresses and a share for each (expressed in basis points, where 10,000 = 100 %). The contract calculates the split exactly on every payment. Each share is either paid immediately or recorded as claimable for that recipient.
-A Classic payment receipt proves that the runtime accepted and settled the
-support transaction. It does not promise perpetual media availability. Dotify
-opens protected playback only after the current runtime read-back confirms both
-the paid record and playable access for that wallet.
+Everything is verifiable on-chain. Any listener can inspect the payment, settled-share, claimable-share, and claim records using a block explorer like Blockscout.
+
+A Classic payment receipt proves that the runtime accepted the support
+transaction and granted paid access. It does not prove that every recipient
+received native tokens immediately, and it does not promise perpetual media
+availability. Dotify opens protected playback only after the current runtime
+read-back confirms both the paid record and playable access for that wallet.
---
## What you see in the artist studio
-After claiming an artist profile on `/artists`, the **Royalties** tab in the
-artist studio shows you a ledger of every paid unlock recorded against your
-SmartRuntime. For each entry you can see:
+After connecting a wallet on `/artists`, the **Royalties** tab shows the
+connected recipient wallet's settlement ledger across known SmartRuntimes. That
+includes the wallet's own artist runtime and other artist runtimes where the
+catalogue lists the connected wallet as a royalty split recipient. For each
+entry you can see:
- The track that was unlocked.
- The listener's wallet address.
-- The amount paid in the configured runtime-native token.
+- The recipient wallet.
+- Whether that recipient share is `Paid`, `Claimable`, `Claimed`, or a
+ pre-upgrade `Legacy access` record.
+- The amount in the configured runtime-native token.
- The date and time of the transaction.
- A link to the transaction receipt on Blockscout.
-The ledger is populated by reading `MusicRoyAccessPaid` events emitted by your SmartRuntime from block 0. This means the full payment history is always available and cannot be deleted.
+The settled total counts only immediately paid rows and claimable rows that were
+later cleared by `MusicRoyRoyaltyClaimed`. Current claimable balances are shown
+separately per runtime, and the claim action calls `musicRoyClaim(recipient)` on
+each known runtime with a pending balance. Dotify does not display pending
+claimable funds as already received. Pre-W05 access-payment records are kept as
+legacy history because they do not contain per-recipient settlement evidence.
---
@@ -70,10 +86,13 @@ These splits are stored in the runtime and applied on every payment.
When a listener calls `musicRoyPayAccess(contentHash)`, the contract:
1. Verifies `msg.value >= pricePlanck`.
-2. Iterates the royalty recipient list and transfers `(value * bps) / 10_000` to each.
-3. Sends any remainder to the original artist address stored on the track.
-4. Sets `paidAccess[contentHash][msg.sender] = true`.
-5. Emits `MusicRoyAccessPaid(contentHash, listener, amount)`.
+2. Sets `paidAccess[contentHash][msg.sender] = true`.
+3. Iterates the royalty recipient list and calculates `(price * bps) / 10_000` for each.
+4. Sends each share with a bounded-gas native transfer.
+5. Records any failed share as claimable for that recipient.
+6. Sends any rounding remainder to the original artist address through the same bounded settlement path.
+7. Refunds any overpayment to the caller.
+8. Emits `MusicRoyAccessPaid(contentHash, listener, price)` plus per-recipient settlement events.
The `pricePlanck` field name is historical. The active EVM path stores and pays
prices as 18-decimal native token units. The frontend uses `parseEther()` for
@@ -96,9 +115,98 @@ event MusicRoyAccessPaid(
address indexed listener,
uint256 amount
);
+
+event MusicRoyRoyaltyPaid(
+ 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 MusicRoyRoyaltyPayoutFailed(
+ bytes32 indexed contentHash,
+ address indexed listener,
+ address indexed recipient,
+ uint256 amount
+);
+
+event MusicRoyRoyaltyClaimed(address indexed recipient, uint256 amount);
+event MusicRoyRoyaltyClaimFailed(address indexed recipient, uint256 amount);
```
-The frontend fetches these events using `client.getLogs()` with `fromBlock: 0n` and the artist's SmartRuntime address. Block timestamps are fetched separately to display human-readable dates.
+`MusicRoyAccessPaid` records the access payment. The artist studio reads
+`MusicRoyRoyaltyPaid`, `MusicRoyRoyaltyClaimable`, and
+`MusicRoyRoyaltyClaimed` for the connected recipient address so recipient
+settlement is never inferred from the full payment amount. It also keeps
+pre-upgrade `MusicRoyAccessPaid` rows that have no W05 per-recipient settlement
+event in the same transaction, labeling them as `Legacy access`. Block
+timestamps are fetched separately to display human-readable dates.
+
+### Claiming pending royalties
+
+`musicRoyClaimable(recipient)` returns the pending native-token amount for that
+recipient. `musicRoyClaim(recipient)` requires `recipient == msg.sender`, sends
+the pending amount with the same bounded native-transfer helper, and either:
+
+- emits `MusicRoyRoyaltyClaimed` and clears the pending balance; or
+- emits `MusicRoyRoyaltyClaimFailed` and restores the balance for a later retry.
+
+The failed-claim path does not revert, because a reverted transaction would also
+discard the failure event. The caller must read `musicRoyClaimable` again before
+treating the money as received.
+
+### Runtime upgrades and clean migration
+
+Artist SmartRuntimes are Diamond proxies. The preferred upgrade path is an
+owner-signed `diamondCut` that replaces or adds only the affected pallet
+selectors while preserving the runtime address, catalogue storage, paid-access
+state, claimable balances, and content-key binding.
+
+W05 adds Hardhat tasks for that path:
+
+```bash
+cd contracts/evm
+npm run runtime:export:testnet -- --runtime --recipient --out /tmp/runtime-snapshot.json
+npm run runtime:deploy-royalties-facet:testnet
+npm run runtime:deploy-royalties-facet:testnet -- --execute --confirm-chain-id 420420417 --confirm-code-hash --out /tmp/royalties-facet.json
+npm run runtime:royalties-upgrade:testnet -- --runtime --facet --out /tmp/royalties-upgrade-plan.json
+npm run runtime:royalties-upgrade:testnet -- --runtime --facet --execute --confirm-plan --out /tmp/royalties-upgrade-final.json
+```
+
+Both deploy and upgrade tasks are dry-run by default. The deploy task emits the
+local source code hash that must be passed back as `--confirm-code-hash`, then
+writes the new stateless facet address to the deployment manifest. The upgrade
+task execution requires the current runtime owner key, the new facet via
+`--facet`, an output evidence path, and an exact fresh plan digest. It snapshots
+track state before the cut, simulates the owner call, records signed/broadcast
+evidence, waits for finality, verifies every royalties selector, and compares
+the post-upgrade catalogue hash with the pre-upgrade hash.
+
+If `runtime:royalties-upgrade` reports a target royalties facet code-hash
+mismatch, the facet address being used is not the locally compiled W05 facet.
+Deploy the current facet first, then rerun the upgrade with `--facet
+`.
+
+Clean redeploy is a fallback, not the default. You can save a runtime snapshot
+and render replay calldata for a new runtime:
+
+```bash
+npm run runtime:migration-plan -- --snapshot /tmp/runtime-snapshot.json --target-runtime --out /tmp/runtime-migration-plan.json
+```
+
+That plan does not move paid-access state or claimable balances. It also blocks
+encrypted `dotify:enc:v2:` audio refs by default because content-key derivation
+is bound to `chainId + runtimeAddress + contentHash`; migrating those releases
+requires re-encrypting/re-uploading audio for the new runtime or an explicit
+key-recovery flow.
### Human free tracking
@@ -116,19 +224,30 @@ This function is not yet wired in the current frontend but the contract supports
refreshArtistRoyalties() in useArtistConsole
│
▼
-client.getLogs({ address: artistRuntimeAddress, event: musicRoyAccessPaidEvent })
+discover known royalty runtimes for the connected recipient
+ │
+ ▼
+musicRoyClaimable(runtime, recipient) for each runtime
+client.getLogs({ address: runtime, event: MusicRoyRoyaltyPaid, recipient })
+client.getLogs({ address: runtime, event: MusicRoyRoyaltyClaimable, recipient })
+client.getLogs({ address: runtime, event: MusicRoyRoyaltyClaimed, recipient })
+client.getLogs({ address: runtime, event: MusicRoyAccessPaid }) for legacy rows
│
▼
for each log → fetch block timestamp
│
▼
+reconcile claimable accruals with successful claim events
+ │
+ ▼
build RoyaltyPayment[] sorted by blockNumber desc, logIndex desc
│
▼
compute aggregates:
- totalRoyaltyWei = sum of amountWei
+ totalRoyaltyWei = sum of paid + claimed amountWei
+ claimableRoyaltyWei = sum of direct runtime reads
uniqueRoyaltyListeners = distinct listener addresses
- paidRoyaltyTracks = distinct track hashes
+ paidRoyaltyTracks = distinct track hashes with a paid or claimed settlement
```
-The `RoyaltyPayment` type is defined in `src/types.ts`.
+The `RoyaltyPayment` type is defined in `web/src/shared/types.ts`.
diff --git a/docs/index.html b/docs/index.html
index 7c0d851c..2d80a9bc 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -1429,8 +1429,8 @@
Rights and splits that can be audited
Runtime rules keep royalties, collaborator shares, and access decisions inspectable without making chain mechanics the listener's first
experience. Classic payments now move through an explicit native runtime intent, while Product CASH remains a separate settlement rail until
- the cross-chain receipt model is verified. Payment receipts, NFT ownership, runtime ownership, and royalty beneficiaries remain distinct
- facts.
+ the cross-chain receipt model is verified. Recipient transfers are gas-bounded: failed recipient shares stay claimable in the runtime instead
+ of blocking the listener's access. Payment receipts, NFT ownership, runtime ownership, and royalty beneficiaries remain distinct facts.
@@ -1530,9 +1530,9 @@
One musical experience, across supported hosts
wallet-free entry, typed runtime ports around the current viem implementation, API-side Product sr25519 verification with frontend Product proof
submission for protected key/session requests, and an opt-in CDM/PAPI path that can route runtime reads and write submissions through the
Product host signer. The tracked deployment still defaults to viem; Product writes now fail closed on Product public-key / pallet-revive H160
- mismatch, poll post-payment access read-back before success, and preserve transaction receipts when verification fails. They still need native
- payment forwarding, host-signed transaction evidence, Product CASH receipt design, and CDM-installed runtime packages before becoming default.
- Product Mobile in-app live audio remains pending a host-exposed WebRTC capability.
+ mismatch, poll post-payment access read-back before success, preserve transaction receipts when verification fails, and expose runtime royalty
+ claims through the shared writer port. They still need native payment forwarding, host-signed transaction evidence, Product CASH receipt design,
+ and CDM-installed runtime packages before becoming default. Product Mobile in-app live audio remains pending a host-exposed WebRTC capability.
Product DevNet is a first-class delivery target alongside ordinary mobile and desktop web. The next pilot must demonstrate discovery, listening,
diff --git a/docs/operations/deployment-configuration.md b/docs/operations/deployment-configuration.md
index f23572c0..1cdd2091 100644
--- a/docs/operations/deployment-configuration.md
+++ b/docs/operations/deployment-configuration.md
@@ -157,14 +157,15 @@ Required Product values:
| `VITE_DOTIFY_ROOM_BEACONS` | `off` |
| `VITE_PINATA_GATEWAY` | `https://gateway.pinata.cloud` |
| `VITE_IPFS_READ_GATEWAYS` | `https://ipfs.io,https://dweb.link,https://devnet-ipfs.api.polkadotcommunity.foundation,https://bulletin-kubo.tservices.es:9443` |
-| Product executable `appVersion` | `[0, 1, 13]` in `web/polkadot-app-deploy.config.ts` |
+| Product executable `appVersion` | `[0, 1, 14]` in `web/polkadot-app-deploy.config.ts` |
The Product executable version is part of the published Product manifest. Bump
it whenever the Product bundle changes runtime behavior, host SDK integration,
permissions, metadata, or cache-sensitive assets. A new CID alone proves the
bundle changed on-chain, but the mobile host can still use executable metadata
when deciding whether to refresh a previously opened app.
-Version `[0, 1, 13]` carries the Product room guest audio recovery fix.
+Version `[0, 1, 14]` carries the Product room guest audio recovery fix and the
+W05 royalty claim runtime writer path.
Current Product host SDK dependencies:
@@ -365,6 +366,17 @@ artists and zero releases. If `GET /api/catalog` still returns runtime
`0x84D5062F2195758E42100845151c3f80BfAA5482` or blocks near `11269xxx`, the
hosted API is still serving the previous environment.
+W05 changes the `MusicRoyaltiesPallet` ABI and appends claimable-recipient
+storage under the existing namespaced Diamond storage slot. New factory
+deployments install the claim selectors automatically. Existing artist runtimes
+need a royalties facet cut that replaces `musicRoyPayAccess` and adds
+`musicRoyClaimable(address)` plus `musicRoyClaim(address)` before native
+Classic payments are enabled on that runtime. There is no new environment
+variable for this behavior. Rollback before any W05 payment can reinstall the
+previous royalties facet; rollback after W05 payments may have created
+claimable balances must keep a claim-capable facet available until those
+balances are settled or explicitly migrated.
+
TURN relay variables:
| Key | Default | When to set |
@@ -412,7 +424,10 @@ pallet-revive H160 address that Dotify connected for key/session requests, and
Classic unlocks in a `product-cdm` build must poll `musicAccHasPaid` plus
`musicAccCanAccess` for that H160 before showing success. If the transaction is
included but verification fails, Dotify preserves the transaction hash in a
-**Payment included, access not verified** error. Enable
+**Payment included, access not verified** error. Royalty claim writes use the
+same adapter boundary through `musicRoyClaim(activeEvmAddress)`, but payment
+history still needs an event/indexer source before Product can show the full
+settlement ledger. Enable
`VITE_DOTIFY_DEBUG_PANEL=true` only on that smoke build to export the safe
browser-side evidence bundle with `amountPlanck`, payment read-back, Product
sr25519 key/session outcomes, and the operator-marked host approval observation.
diff --git a/docs/operations/product-devnet-deployment.md b/docs/operations/product-devnet-deployment.md
index e72116e4..0a2c3f20 100644
--- a/docs/operations/product-devnet-deployment.md
+++ b/docs/operations/product-devnet-deployment.md
@@ -4,13 +4,55 @@ This runbook publishes the Product build to Bulletin/DotNS and connects it to
the existing Fly API and signaling services. It does not deploy contracts or
change production secrets.
-## Contracts Need No Redeploy
+## Frontend Publish Does Not Deploy Contracts
Product DevNet is a preset over the Paseo system parachains - Asset Hub (1000),
People (1004), Bulletin (1010) - at EVM chain `420420417`. Dotify's contracts
-are already deployed on that chain, so porting to DevNet is a configuration
-change, not a migration. The addresses in `deployments.json` are DevNet
-addresses.
+live on that chain. Publishing the Product frontend is therefore separate from
+deploying or upgrading contracts, and the addresses in `deployments.json` must
+already point at the intended DevNet contracts.
+
+When a change modifies contract code or ABI, deploy or upgrade the affected
+contracts first, then regenerate the Product CDM manifest and metadata before
+publishing the frontend. W05 changes `MusicRoyaltiesPallet` by adding
+claimable-recipient settlement, so existing artist runtimes need a royalties
+facet cut or a clean factory/runtime redeploy before native Classic payments are
+treated as W05-ready. `npm run smoke:devnet` checks configured chain/bytecode
+availability; it does not prove every runtime has the new selectors installed.
+
+Prefer the in-place facet cut when the current artist runtime is owned by the
+artist wallet. It keeps the runtime address, protected-audio key binding,
+catalogue storage, paid-access state, and claimable balances intact:
+
+```bash
+cd contracts/evm
+npm run runtime:export:testnet -- --runtime --recipient --out /tmp/dotify-runtime-snapshot.json
+npm run runtime:deploy-royalties-facet:testnet
+npm run runtime:deploy-royalties-facet:testnet -- --execute --confirm-chain-id 420420417 --confirm-code-hash --out /tmp/dotify-royalties-facet.json
+npm run runtime:royalties-upgrade:testnet -- --runtime --facet --out /tmp/dotify-royalties-upgrade-plan.json
+npm run runtime:royalties-upgrade:testnet -- --runtime --facet --execute --confirm-plan --out /tmp/dotify-royalties-upgrade-final.json
+```
+
+The facet deploy and runtime upgrade commands are dry-run by default. A
+code-hash mismatch means the target facet is still an older on-chain deployment
+or an unrelated contract; deploy the current `MusicRoyaltiesPallet` facet first,
+then pass the manifest's `facet` address to the upgrade command with `--facet`.
+The deploy command never edits `deployments.json`, and the upgrade command
+refuses execution without a fresh plan digest plus an evidence file. Clean
+redeploy is a fallback:
+
+If a command writes an evidence file and then fails before the manifest reaches
+`signed-before-broadcast`, no transaction was signed or broadcast by that task.
+Inspect the file, then retry with a new `--out` path or remove the stale
+prepared manifest.
+
+```bash
+npm run runtime:migration-plan -- --snapshot --target-runtime --out
+```
+
+Use that command to render replay calldata, but do not treat it as a state
+migration. It does not move paid-access grants or claimable balances, and
+encrypted `dotify:enc:v2:` audio must be re-encrypted for the new runtime.
Confirm before every publish:
@@ -319,9 +361,9 @@ behavior, host SDK integration, permissions, metadata, or cache-sensitive
assets. A successful `pad` publish writes a new CID, but the mobile host can
also use executable metadata while refreshing an already-opened app.
-The current Product executable is `[0, 1, 13]`. This version keeps blocked
-guest audio recovery visible in Product-hosted rooms, so a listener whose first
-remote stream `play()` call is blocked can still press `Start audio`. Product
+The current Product executable is `[0, 1, 14]`. This version keeps blocked
+guest audio recovery visible in Product-hosted rooms and exposes W05 runtime
+claim writes through the shared runtime writer port. Product
host containers use Engine.IO Fetch polling without a WebSocket upgrade, so
room signaling stays on the remote-network primitive proven to remain available
in Product Mobile. Standalone browsers retain Fetch-first with an optional
@@ -496,6 +538,7 @@ Then verify in the Product host:
In every rejected case, playback must stop and offer a passkey/EVM wallet.
No path may release a key without a verified signature.
+
6. Only for an explicit Product CDM write smoke build, set
`VITE_DOTIFY_RUNTIME_ADAPTER=product-cdm` and
`VITE_DOTIFY_DEBUG_PANEL=true`, then use a funded Product account that has
@@ -520,7 +563,7 @@ Then verify in the Product host:
- the backend then releases the full key through the same Product identity.
After the unlock attempt, open `You` -> `Production readiness` -> `Product
- CDM host smoke`, mark **Host approval prompt captured** if the host showed
+CDM host smoke`, mark **Host approval prompt captured** if the host showed
an explicit transaction approval, then copy or download the smoke JSON. The
JSON is stored only in browser session storage and deliberately excludes
content keys, signatures, nonces, and session tokens. Attach it with the
@@ -532,6 +575,7 @@ Then verify in the Product host:
`product-cdm` build must report **Payment included, access not verified**
with the transaction hash instead of marking the track open.
Keep the shipped profile on `viem`.
+
7. A Product-origin host creates a room and copies a
`https://dotify-test01.dev-dot.li/#/rooms/` link.
8. A wallet-free browser joins that link from outside the Product host.
@@ -540,8 +584,8 @@ Then verify in the Product host:
not room creation.
10. A Netlify-origin host and Product-origin guest also connect.
11. Briefly interrupting the mobile network preserves and resumes the same room
- within 120 seconds; it must disappear from public discovery while the host
- is offline and return with the same code after reconnecting.
+ within 120 seconds; it must disappear from public discovery while the host
+ is offline and return with the same code after reconnecting.
12. Explicitly leaving ends the room immediately. Force-closing the host leaves
the room private until the 120-second resume window expires.
@@ -646,16 +690,17 @@ active.
step for `product-sr25519-v1` records which shape the live host actually
produced - that observation is the evidence, and until it is captured the
accepted set stays deliberately wide.
-- Contract writes still require passkey/EVM signing in the shipped UI. The
- Product CDM/PAPI runtime adapter now has its generated manifest, contract
- types, and a live resolver, so the only thing still missing before it can be
- selected is real host-signed transaction evidence. Dotify now validates that
- the selected Product host signer public key maps to the same `pallet-revive`
- H160 account used by the connected Product identity before a CDM write can be
- submitted, and Product CDM Classic unlocks poll `musicAccHasPaid` and
- `musicAccCanAccess` for that H160 account before surfacing success. If the
- payment was included but verification fails, the UI preserves the transaction
- hash in the error state.
+- Contract writes use the shared runtime writer port, with viem still selected
+ for the tracked deployment. The Product CDM/PAPI runtime adapter now has its
+ generated manifest, contract types, live resolver, signer-account mapping
+ checks, Classic unlock write path, and W05 royalty claim write path. It still
+ needs real host-signed transaction evidence before Product CDM becomes the
+ default. Dotify validates that the selected Product host signer public key
+ maps to the same `pallet-revive` H160 account used by the connected Product
+ identity before a CDM write can be submitted, and Product CDM Classic unlocks
+ poll `musicAccHasPaid` and `musicAccCanAccess` for that H160 account before
+ surfacing success. If the payment was included but verification fails, the UI
+ preserves the transaction hash in the error state.
- Rooms still depend on one in-memory Fly signaling machine.
- Product-host cloud storage does not hold Dotify audio or content keys.
- Product personhood is not yet an access decision source.
@@ -665,5 +710,7 @@ active.
catalog reads and runtime write submissions inside the Product host. The
tracked deployment still keeps the default `viem` adapter until
native value forwarding, host approval UX, and successful post-payment
- read-back evidence are captured. Use `VITE_DOTIFY_DEBUG_PANEL=true` on that
- smoke build to export the Product CDM host evidence JSON.
+ read-back evidence are captured. Product payment history is still not exposed
+ by the current Product contract handle API, so the full royalty settlement
+ ledger needs an event/indexer source. Use `VITE_DOTIFY_DEBUG_PANEL=true` on
+ that smoke build to export the Product CDM host evidence JSON.
diff --git a/docs/reference/contracts-api.md b/docs/reference/contracts-api.md
index 650cc552..bce491b2 100644
--- a/docs/reference/contracts-api.md
+++ b/docs/reference/contracts-api.md
@@ -342,12 +342,50 @@ CASH settlement requires a future receipt or bridge model.
On success:
1. Records `paidAccess[contentHash][msg.sender] = true`.
-2. Distributes the stored track price across royalty splits (basis points).
-3. Sends remainder to the original artist address stored on the track.
-4. Refunds any overpayment to the caller.
-5. Emits `MusicRoyAccessPaid`.
+2. Settles the stored track price across royalty splits (basis points).
+3. Sends each recipient share with bounded gas.
+4. Records any failed recipient transfer as claimable.
+5. Sends remainder to the original artist address stored on the track.
+6. Refunds any overpayment to the caller.
+7. Emits `MusicRoyAccessPaid` and per-recipient settlement events.
-**Emits:** `MusicRoyAccessPaid(bytes32 indexed contentHash, address indexed listener, uint256 amount)`
+**Emits:**
+
+- `MusicRoyAccessPaid(bytes32 indexed contentHash, address indexed listener, uint256 amount)`
+- `MusicRoyRoyaltyPaid(bytes32 indexed contentHash, address indexed listener, address indexed recipient, uint256 amount)`
+- `MusicRoyRoyaltyPayoutFailed(bytes32 indexed contentHash, address indexed listener, address indexed recipient, uint256 amount)`
+- `MusicRoyRoyaltyClaimable(bytes32 indexed contentHash, address indexed listener, address indexed recipient, uint256 amount, uint256 pendingTotal)`
+
+---
+
+### `musicRoyClaimable(address recipient)`
+
+```solidity
+function musicRoyClaimable(address recipient) external view returns (uint256)
+```
+
+Returns the native-token amount currently waiting in the runtime for
+`recipient`.
+
+---
+
+### `musicRoyClaim(address recipient)`
+
+```solidity
+function musicRoyClaim(address recipient) external returns (uint256 amount, bool settled)
+```
+
+Claims pending native-token royalties. The caller must be the same address as
+`recipient`; third-party claim helpers cannot drain another recipient's balance.
+
+The claim transfer uses the same bounded-gas native transfer helper as immediate
+settlement. If the recipient still cannot receive the transfer, the transaction
+does not revert; the balance is restored and remains claimable for a later retry.
+
+**Emits:**
+
+- `MusicRoyRoyaltyClaimed(address indexed recipient, uint256 amount)`
+- `MusicRoyRoyaltyClaimFailed(address indexed recipient, uint256 amount)`
---
diff --git a/docs/reference/hooks-api.md b/docs/reference/hooks-api.md
index 28e1d30a..2a09368d 100644
--- a/docs/reference/hooks-api.md
+++ b/docs/reference/hooks-api.md
@@ -212,9 +212,13 @@ const artist = useArtistConsole({
| `isRefreshingArtistRuntime` | `boolean` | `true` while `refreshArtistRuntime()` is running |
| `bulletinManifestRef` | `string` | Bulletin archive ref for the last registered track |
| `rightsStatus` | `string` | Human-readable status of the current release operation |
-| `royaltyPayments` | `RoyaltyPayment[]` | All payment events for the artist's tracks |
+| `royaltyPayments` | `RoyaltyPayment[]` | Per-recipient paid, claimable, claimed, and legacy royalty events |
+| `claimableRoyaltyWei` | `bigint` | Native-token royalty amount currently claimable across known runtimes by the connected recipient |
+| `royaltyRuntimeSummaries` | `RoyaltyRuntimeSummary[]` | Known runtimes where the connected wallet can inspect claimable recipient balances |
+| `hasKnownRoyaltyRuntime` | `boolean` | `true` when the wallet has an artist runtime or appears in known royalty splits |
| `royaltyStatus` | `string` | Human-readable royalty ledger status |
| `isRefreshingRoyalties` | `boolean` | `true` while royalties are being fetched |
+| `isClaimingRoyalties` | `boolean` | `true` while a royalty claim transaction is being confirmed |
| `expandedRoyaltyPaymentId` | `string \| null` | ID of the royalty entry currently expanded in the UI |
#### Functions
@@ -224,7 +228,8 @@ const artist = useArtistConsole({
| `registerArtist` | `() => Promise` | Bootstrap and finalize a SmartRuntime for the active address via `ArtistRuntimeFactory`. |
| `refreshArtistRuntime` | `(showBusy?: boolean) => Promise<0x${string} \| null>` | Check `ArtistDirectory` for the active address. Updates `artistRuntimeAddress`. |
| `registerRights` | `() => Promise` | Full release publish flow: IPFS upload → optional Bulletin → `musicRegRegister()`. |
-| `refreshArtistRoyalties` | `(showBusy?: boolean) => Promise` | Fetch all `MusicRoyAccessPaid` logs for the artist's runtime. |
+| `refreshArtistRoyalties` | `(showBusy?: boolean) => Promise` | Fetch settlement history and direct claimable balances across known runtimes for the active recipient. |
+| `claimRoyalties` | `() => Promise` | Submit `musicRoyClaim(activeEvmAddress)` for every known runtime with pending funds, wait for confirmation, and re-read balances. |
| `updateArtistName` | `(name: string) => void` | Update artist name in state and persist to `localStorage`. |
| `getActiveWalletClient` | `() => Promise` | Returns the viem `WalletClient` for the connected artist wallet. Throws if no wallet is connected. |
| `setUploadToBulletinEnabled` | `(enabled: boolean) => void` | Toggle Bulletin archival for the next release. |
diff --git a/docs/reference/types.md b/docs/reference/types.md
index 71df0d12..84b820b4 100644
--- a/docs/reference/types.md
+++ b/docs/reference/types.md
@@ -1,9 +1,9 @@
# TypeScript Types Reference
-All shared types are defined in `src/types.ts` and re-exported from there. Import them directly:
+All shared web types are defined in `web/src/shared/types.ts`. Import them directly:
```typescript
-import type { CatalogTrack, AccessMode, RoyaltyPayment } from './types';
+import type { AccessMode, CatalogTrack, RoyaltyPayment } from '../shared/types';
```
---
@@ -251,8 +251,6 @@ Classic unlock payments use `pricePlanck` when it is present so the submitted
`msg.value` matches the runtime's stored price exactly, and the visible payment
symbol comes from the configured chain's native currency.
----
-
### `PlayerState`
```typescript
@@ -387,13 +385,21 @@ protected audio should play. `actionType` controls which CTA is shown:
### `RoyaltyPayment`
```typescript
+type RoyaltySettlementState = 'paid' | 'claimable' | 'claimed' | 'legacy';
+
type RoyaltyPayment = {
- id: string; // "-"
+ id: string; // "--"
+ runtimeAddress: `0x${string}`;
trackHash: `0x${string}`;
trackTitle: string;
listener: `0x${string}`;
+ recipient: `0x${string}`;
amountWei: bigint;
amountDot: string; // Formatted for display
+ settlement: RoyaltySettlementState;
+ pendingTotalWei?: bigint; // Present on claimable settlement rows
+ claimedAtMs?: number | null;
+ claimTransactionHash?: `0x${string}`;
paidAtMs: number | null; // null if block timestamp unavailable
transactionHash: `0x${string}`;
blockNumber: bigint;
@@ -401,8 +407,30 @@ type RoyaltyPayment = {
};
```
-A single royalty payment event parsed from `MusicRoyAccessPaid` logs. Used in the
-Royalties tab of the artist studio.
+A single per-recipient royalty ledger row. `paid` means the runtime transferred
+that share in the listener payment transaction. `claimable` means the transfer
+failed and the amount is still pending in the runtime. `claimed` means a later
+`MusicRoyRoyaltyClaimed` event cleared that historical accrual. `legacy` keeps
+pre-W05 `MusicRoyAccessPaid` history where per-recipient settlement state is
+unknown. The artist studio sums only `paid` and `claimed` rows as received
+money.
+
+### `RoyaltyRuntimeSummary`
+
+```typescript
+type RoyaltyRuntimeSummary = {
+ runtimeAddress: `0x${string}`;
+ artistAddress?: `0x${string}`;
+ artistName: string;
+ trackCount: number;
+ trackTitles: string[];
+ claimableWei: bigint;
+};
+```
+
+Current claimable balance for the connected recipient in one known artist
+runtime. The artist studio builds this list from the connected wallet's own
+runtime plus catalogue tracks where that wallet appears in royalty splits.
---
diff --git a/spec.md b/spec.md
index e6ce14d7..b9cab825 100644
--- a/spec.md
+++ b/spec.md
@@ -181,7 +181,8 @@ The runtime includes:
- `OwnershipPallet`: runtime ownership.
- `MusicRegistryPallet`: track registration, reads, and deactivation.
- `MusicNFTPallet`: per-track NFT ownership and transfer state.
-- `MusicRoyaltiesPallet`: Classic access payment and royalty distribution.
+- `MusicRoyaltiesPallet`: Classic access payment, bounded royalty settlement,
+ and claimable failed recipient shares.
- `MusicAccessPallet`: access checks and personhood-level state.
### 6.3 Track Record
@@ -217,7 +218,8 @@ A registered track stores:
- requires payment through `musicRoyPayAccess(contentHash)`;
- records paid access for the listener with no fixed expiry in the current
runtime;
-- distributes payment according to royalty splits.
+- settles payment according to royalty splits, while failed recipient transfers
+ remain claimable instead of blocking access.
Artists and track NFT owners are expected to have access to their own active
tracks. Inactive tracks deny playback for everyone, including the original
@@ -230,7 +232,7 @@ The active runtime tests cover:
- runtime factory deployment;
- artist runtime creation;
- track registration and deactivation;
-- paid access and royalty distribution;
+- paid access, royalty settlement, and claimable recipient fallback;
- personhood-gated access;
- NFT transfer gating;
- isolation between artist runtimes.
diff --git a/web/polkadot-app-deploy.config.ts b/web/polkadot-app-deploy.config.ts
index 92a6ad0c..8238d5e4 100644
--- a/web/polkadot-app-deploy.config.ts
+++ b/web/polkadot-app-deploy.config.ts
@@ -10,7 +10,7 @@ export default {
{
kind: 'app',
path: './dist-product',
- appVersion: [0, 1, 13]
+ appVersion: [0, 1, 14]
}
]
};
diff --git a/web/src/app/providers/ArtistStudioProvider.tsx b/web/src/app/providers/ArtistStudioProvider.tsx
index bb8854c1..31e05e3d 100644
--- a/web/src/app/providers/ArtistStudioProvider.tsx
+++ b/web/src/app/providers/ArtistStudioProvider.tsx
@@ -68,9 +68,10 @@ export function ArtistStudioProvider({ children }: { children: ReactNode }) {
coverUploadRef: catalog.coverUploadRef
});
- const totalRoyaltyWei = artistConsole.royaltyPayments.reduce((total, payment) => total + payment.amountWei, 0n);
+ const settledRoyaltyPayments = artistConsole.royaltyPayments.filter(payment => payment.settlement === 'paid' || payment.settlement === 'claimed');
+ const totalRoyaltyWei = settledRoyaltyPayments.reduce((total, payment) => total + payment.amountWei, 0n);
const uniqueRoyaltyListeners = new Set(artistConsole.royaltyPayments.map(payment => payment.listener.toLowerCase())).size;
- const paidRoyaltyTracks = new Set(artistConsole.royaltyPayments.map(payment => payment.trackHash.toLowerCase())).size;
+ const paidRoyaltyTracks = new Set(settledRoyaltyPayments.map(payment => payment.trackHash.toLowerCase())).size;
const value = useMemo(
() => ({ artistConsole, totalRoyaltyWei, uniqueRoyaltyListeners, paidRoyaltyTracks }),
diff --git a/web/src/features/runtime/productCdmRuntimeAdapter.test.ts b/web/src/features/runtime/productCdmRuntimeAdapter.test.ts
index f2de93c0..02d9be6d 100644
--- a/web/src/features/runtime/productCdmRuntimeAdapter.test.ts
+++ b/web/src/features/runtime/productCdmRuntimeAdapter.test.ts
@@ -193,6 +193,20 @@ describe('createProductCdmRuntimeReader', () => {
await expect(reader.listRoyaltyPaymentLogs(runtime)).rejects.toThrow(ProductCdmRuntimeUnsupportedOperationError);
});
+
+ it('reads the Product runtime claimable royalty balance', async () => {
+ const musicRoyClaimable = queryMethod(25n);
+ const reader = createProductCdmRuntimeReader({
+ contracts: {
+ getDirectoryContract: () => ({}),
+ getFactoryContract: () => ({}),
+ getRuntimeContract: () => ({ musicRoyClaimable })
+ }
+ });
+
+ await expect(reader.getRoyaltyClaimable(runtime, splitRecipient)).resolves.toBe(25n);
+ expect(musicRoyClaimable.query).toHaveBeenCalledWith(splitRecipient);
+ });
});
describe('createProductCdmRuntimeWriter', () => {
@@ -201,6 +215,7 @@ describe('createProductCdmRuntimeWriter', () => {
const installRuntimeStep = txMethod();
const musicRegRegister = txMethod();
const musicRoyPayAccess = txMethod();
+ const musicRoyClaim = txMethod();
const musicRegSetAccessMode = txMethod();
const musicRegDeactivate = txMethod();
const writer = createProductCdmRuntimeWriter({
@@ -210,6 +225,7 @@ describe('createProductCdmRuntimeWriter', () => {
getRuntimeContract: () => ({
musicRegRegister,
musicRoyPayAccess,
+ musicRoyClaim,
musicRegSetAccessMode,
musicRegDeactivate
})
@@ -245,6 +261,7 @@ describe('createProductCdmRuntimeWriter', () => {
})
)
).resolves.toBe(txHash);
+ await expect(writer.claimRoyalty(runtime, splitRecipient)).resolves.toBe(txHash);
await expect(
writer.setAccessMode(runtime, {
contentHash: hash,
@@ -257,6 +274,7 @@ describe('createProductCdmRuntimeWriter', () => {
await expect(writer.waitForTransaction(txHash)).resolves.toBeUndefined();
expect(musicRoyPayAccess.tx).toHaveBeenCalledWith(hash, { value: 3n });
+ expect(musicRoyClaim.tx).toHaveBeenCalledWith(splitRecipient);
expect(musicRegSetAccessMode.tx).toHaveBeenCalledWith(hash, 2, 0n, 1);
expect(musicRegDeactivate.tx).toHaveBeenCalledWith(hash);
});
diff --git a/web/src/features/runtime/productCdmRuntimeAdapter.ts b/web/src/features/runtime/productCdmRuntimeAdapter.ts
index fb9b7894..1928a2fd 100644
--- a/web/src/features/runtime/productCdmRuntimeAdapter.ts
+++ b/web/src/features/runtime/productCdmRuntimeAdapter.ts
@@ -219,6 +219,12 @@ export function createProductCdmRuntimeReader(deps: ProductCdmRuntimeAdapterDeps
throw new ProductCdmRuntimeUnsupportedOperationError(
'Product CDM runtime payment history is not available through the current contract handle API. Use the catalog/read-model indexer until a Product event API or backend indexer is wired.'
);
+ },
+
+ async getRoyaltyClaimable(runtimeAddress, recipientAddress) {
+ return toBigInt(
+ await queryContract(deps.contracts.getRuntimeContract(runtimeAddress), 'musicRoyClaimable', [recipientAddress])
+ );
}
};
}
@@ -263,6 +269,10 @@ export function createProductCdmRuntimeWriter(deps: ProductCdmRuntimeAdapterDeps
return txContract(deps.contracts.getRuntimeContract(intent.runtimeAddress), 'musicRoyPayAccess', [intent.contentHash, { value: intent.amountPlanck }]);
},
+ claimRoyalty(runtimeAddress, recipientAddress) {
+ return txContract(deps.contracts.getRuntimeContract(runtimeAddress), 'musicRoyClaim', [recipientAddress]);
+ },
+
setAccessMode(runtimeAddress, update: RuntimeAccessPolicyUpdate) {
return txContract(deps.contracts.getRuntimeContract(runtimeAddress), 'musicRegSetAccessMode', [
update.contentHash,
diff --git a/web/src/features/runtime/royaltyRuntimeClaims.test.ts b/web/src/features/runtime/royaltyRuntimeClaims.test.ts
new file mode 100644
index 00000000..9b95b9c0
--- /dev/null
+++ b/web/src/features/runtime/royaltyRuntimeClaims.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it } from 'vitest';
+import { listKnownRoyaltyRuntimeCandidates } from './royaltyRuntimeClaims';
+import type { CatalogTrack } from '../../shared/types';
+
+const artistRuntime = '0x3000000000000000000000000000000000000000' as const;
+const collaboratorRuntime = '0x4000000000000000000000000000000000000000' as const;
+const artistAddress = '0x5000000000000000000000000000000000000000' as const;
+const collaboratorAddress = '0x6000000000000000000000000000000000000000' as const;
+const otherAddress = '0x7000000000000000000000000000000000000000' as const;
+
+function track(patch: Partial = {}): CatalogTrack {
+ return {
+ id: `${artistRuntime}:0x${'ab'.repeat(32)}`,
+ zone: 'Studio',
+ title: 'Shared song',
+ artist: 'Runtime artist',
+ artistAddress,
+ audioRef: 'ipfs://audio',
+ imageRef: 'ipfs://cover',
+ priceDot: '1',
+ hash: `0x${'ab'.repeat(32)}`,
+ description: '',
+ bulletinRef: '',
+ metadataRef: 'ipfs://metadata',
+ royaltyBps: 10_000,
+ durationLabel: '03:00',
+ accessMode: 'classic',
+ active: true,
+ source: 'artist',
+ royaltySplits: [{ label: 'Collaborator', recipient: collaboratorAddress, bps: 2500 }],
+ personhoodLevel: 'DIM1',
+ encrypted: true,
+ ...patch
+ };
+}
+
+describe('listKnownRoyaltyRuntimeCandidates', () => {
+ it('finds runtimes where the connected wallet is only a split recipient', () => {
+ expect(listKnownRoyaltyRuntimeCandidates([track()], collaboratorAddress, null)).toEqual([
+ {
+ runtimeAddress: artistRuntime,
+ artistAddress,
+ artistName: 'Runtime artist',
+ trackCount: 1,
+ trackTitles: ['Shared song']
+ }
+ ]);
+ });
+
+ it('keeps an owner runtime visible even before indexed releases are present', () => {
+ expect(listKnownRoyaltyRuntimeCandidates([], artistAddress, artistRuntime)).toEqual([
+ {
+ runtimeAddress: artistRuntime,
+ artistName: 'Unknown artist',
+ trackCount: 0,
+ trackTitles: []
+ }
+ ]);
+ });
+
+ it('deduplicates runtime candidates across multiple split tracks', () => {
+ const secondTrack = track({
+ id: `${artistRuntime}:0x${'cd'.repeat(32)}`,
+ hash: `0x${'cd'.repeat(32)}`,
+ title: 'Second shared song'
+ });
+ const unrelatedTrack = track({
+ id: `${collaboratorRuntime}:0x${'ef'.repeat(32)}`,
+ hash: `0x${'ef'.repeat(32)}`,
+ artistAddress: otherAddress,
+ royaltySplits: [{ label: 'Other', recipient: otherAddress, bps: 10_000 }]
+ });
+
+ expect(listKnownRoyaltyRuntimeCandidates([track(), secondTrack, unrelatedTrack], collaboratorAddress, null)).toEqual([
+ {
+ runtimeAddress: artistRuntime,
+ artistAddress,
+ artistName: 'Runtime artist',
+ trackCount: 2,
+ trackTitles: ['Shared song', 'Second shared song']
+ }
+ ]);
+ });
+});
diff --git a/web/src/features/runtime/royaltyRuntimeClaims.ts b/web/src/features/runtime/royaltyRuntimeClaims.ts
new file mode 100644
index 00000000..beb3c62e
--- /dev/null
+++ b/web/src/features/runtime/royaltyRuntimeClaims.ts
@@ -0,0 +1,64 @@
+import { getAddress } from 'viem';
+import { runtimeAddressFromTrackId } from '../catalog/trackModel';
+import type { CatalogTrack, RoyaltyRuntimeSummary } from '../../shared/types';
+
+type RuntimeCandidate = Omit;
+
+export function listKnownRoyaltyRuntimeCandidates(
+ tracks: CatalogTrack[],
+ recipientAddress: `0x${string}`,
+ ownRuntimeAddress: `0x${string}` | null
+): RuntimeCandidate[] {
+ const recipient = recipientAddress.toLowerCase();
+ const ownRuntime = ownRuntimeAddress?.toLowerCase() ?? null;
+ const candidates = new Map();
+
+ function ensureCandidate(runtimeAddress: `0x${string}`, patch: Partial = {}): RuntimeCandidate {
+ const normalizedRuntime = getAddress(runtimeAddress);
+ const key = normalizedRuntime.toLowerCase();
+ const current =
+ candidates.get(key) ??
+ ({
+ runtimeAddress: normalizedRuntime,
+ artistName: 'Unknown artist',
+ trackCount: 0,
+ trackTitles: []
+ } satisfies RuntimeCandidate);
+
+ const next = {
+ ...current,
+ ...patch,
+ artistName: patch.artistName?.trim() || current.artistName,
+ trackTitles: [...current.trackTitles]
+ };
+ candidates.set(key, next);
+ return next;
+ }
+
+ if (ownRuntimeAddress) ensureCandidate(ownRuntimeAddress);
+
+ for (const track of tracks) {
+ const runtimeAddress = runtimeAddressFromTrackId(track);
+ if (!runtimeAddress) continue;
+
+ const isOwnRuntime = ownRuntime === runtimeAddress.toLowerCase();
+ const isRecipient = track.royaltySplits.some(split => split.recipient.toLowerCase() === recipient);
+ if (!isOwnRuntime && !isRecipient) continue;
+
+ const candidate = ensureCandidate(runtimeAddress, {
+ artistAddress: track.artistAddress,
+ artistName: track.artist
+ });
+ candidate.trackCount += 1;
+ if (!candidate.trackTitles.includes(track.title)) {
+ candidate.trackTitles.push(track.title);
+ }
+ }
+
+ return Array.from(candidates.values()).sort((left, right) => {
+ const leftPendingSignals = left.trackCount > 0 ? 0 : 1;
+ const rightPendingSignals = right.trackCount > 0 ? 0 : 1;
+ if (leftPendingSignals !== rightPendingSignals) return leftPendingSignals - rightPendingSignals;
+ return left.runtimeAddress.localeCompare(right.runtimeAddress);
+ });
+}
diff --git a/web/src/features/runtime/runtimePorts.ts b/web/src/features/runtime/runtimePorts.ts
index bc144101..4eadf02f 100644
--- a/web/src/features/runtime/runtimePorts.ts
+++ b/web/src/features/runtime/runtimePorts.ts
@@ -13,10 +13,18 @@ export type RuntimeTrackSnapshot = {
royaltySplits: Array>;
};
+export type RuntimeRoyaltySettlementState = 'paid' | 'claimable' | 'claimed' | 'legacy';
+
export type RuntimeRoyaltyPaymentLog = {
+ runtimeAddress: Address;
trackHash: Hash;
listener: Address;
+ recipient: Address;
amountWei: bigint;
+ settlement: RuntimeRoyaltySettlementState;
+ pendingTotalWei?: bigint;
+ claimedAtMs?: number | null;
+ claimTransactionHash?: Hash;
paidAtMs: number | null;
transactionHash: Hash;
blockNumber: bigint;
@@ -77,7 +85,8 @@ export interface RuntimeReadPort {
hasPaid(runtimeAddress: Address, contentHash: Hash, listenerAddress: Address): Promise;
pendingRuntimeOf(factoryAddress: Address, artistAddress: Address): Promise;
pendingRuntimeStageOf(factoryAddress: Address, artistAddress: Address): Promise;
- listRoyaltyPaymentLogs(runtimeAddress: Address): Promise;
+ listRoyaltyPaymentLogs(runtimeAddress: Address, recipientAddress?: Address): Promise;
+ getRoyaltyClaimable(runtimeAddress: Address, recipientAddress: Address): Promise;
}
export interface RuntimeWritePort {
@@ -85,6 +94,7 @@ export interface RuntimeWritePort {
installRuntimeStep(factoryAddress: Address): Promise;
registerTrack(runtimeAddress: Address, registration: RuntimeTrackRegistration): Promise;
payForAccess(intent: ExecutableTrackAccessPaymentIntent): Promise;
+ claimRoyalty(runtimeAddress: Address, recipientAddress: Address): Promise;
setAccessMode(runtimeAddress: Address, update: RuntimeAccessPolicyUpdate): Promise;
setReleaseActive(runtimeAddress: Address, contentHash: Hash, active: boolean): Promise;
waitForTransaction(txHash: Hash): Promise;
diff --git a/web/src/features/runtime/runtimeReaderProvider.ts b/web/src/features/runtime/runtimeReaderProvider.ts
index a61b1b94..2f12f4ac 100644
--- a/web/src/features/runtime/runtimeReaderProvider.ts
+++ b/web/src/features/runtime/runtimeReaderProvider.ts
@@ -73,6 +73,7 @@ export function createRuntimeReader(deps: RuntimeReaderDeps): RuntimeReadPort {
hasPaid: (...args) => portPromise.then(port => port.hasPaid(...args)),
pendingRuntimeOf: (...args) => portPromise.then(port => port.pendingRuntimeOf(...args)),
pendingRuntimeStageOf: (...args) => portPromise.then(port => port.pendingRuntimeStageOf(...args)),
- listRoyaltyPaymentLogs: (...args) => portPromise.then(port => port.listRoyaltyPaymentLogs(...args))
+ listRoyaltyPaymentLogs: (...args) => portPromise.then(port => port.listRoyaltyPaymentLogs(...args)),
+ getRoyaltyClaimable: (...args) => portPromise.then(port => port.getRoyaltyClaimable(...args))
};
}
diff --git a/web/src/features/runtime/runtimeWriterProvider.test.ts b/web/src/features/runtime/runtimeWriterProvider.test.ts
index 7d18d968..81bcdb95 100644
--- a/web/src/features/runtime/runtimeWriterProvider.test.ts
+++ b/web/src/features/runtime/runtimeWriterProvider.test.ts
@@ -15,6 +15,7 @@ const viemWriter = {
installRuntimeStep: vi.fn(async () => txHash),
registerTrack: vi.fn(async () => txHash),
payForAccess: vi.fn(async () => txHash),
+ claimRoyalty: vi.fn(async () => txHash),
setAccessMode: vi.fn(async () => txHash),
setReleaseActive: vi.fn(async () => txHash),
waitForTransaction: vi.fn(async () => undefined)
@@ -25,6 +26,7 @@ const productWriter = {
installRuntimeStep: vi.fn(async () => txHash),
registerTrack: vi.fn(async () => txHash),
payForAccess: vi.fn(async () => txHash),
+ claimRoyalty: vi.fn(async () => txHash),
setAccessMode: vi.fn(async () => txHash),
setReleaseActive: vi.fn(async () => txHash),
waitForTransaction: vi.fn(async () => undefined)
@@ -105,13 +107,15 @@ describe('createRuntimeWriter', () => {
const intent = accessIntent(42n);
await expect(writer.payForAccess(intent)).resolves.toBe(txHash);
+ await expect(writer.claimRoyalty(runtime, productH160Address)).resolves.toBe(txHash);
await expect(writer.waitForTransaction(txHash)).resolves.toBeUndefined();
const { createViemRuntimeWriter } = await import('./viemRuntimeAdapter');
- expect(createViemRuntimeWriter).toHaveBeenCalledTimes(2);
+ expect(createViemRuntimeWriter).toHaveBeenCalledTimes(3);
expect(createViemRuntimeWriter).toHaveBeenCalledWith({ ethRpcUrl: 'https://rpc.example', walletClient });
- expect(getViemWalletClient).toHaveBeenCalledTimes(2);
+ expect(getViemWalletClient).toHaveBeenCalledTimes(3);
expect(viemWriter.payForAccess).toHaveBeenCalledWith(intent);
+ expect(viemWriter.claimRoyalty).toHaveBeenCalledWith(runtime, productH160Address);
});
it('explains that Product writes are absent when the build did not opt in', async () => {
@@ -154,6 +158,7 @@ describe('createRuntimeWriter', () => {
await expect(writer.createRuntime(factory)).resolves.toBe(txHash);
const intent = accessIntent(7n);
await expect(writer.payForAccess(intent)).resolves.toBe(txHash);
+ await expect(writer.claimRoyalty(runtime, productH160Address)).resolves.toBe(txHash);
const { createProductCdmContracts } = await import('./productCdmContracts');
const { createProductCdmRuntimeWriter } = await import('./productCdmRuntimeAdapter');
@@ -178,6 +183,7 @@ describe('createRuntimeWriter', () => {
expect(createProductCdmRuntimeWriter).toHaveBeenCalledWith({ contracts: resolver });
expect(productWriter.createRuntime).toHaveBeenCalledWith(factory);
expect(productWriter.payForAccess).toHaveBeenCalledWith(intent);
+ expect(productWriter.claimRoyalty).toHaveBeenCalledWith(runtime, productH160Address);
expect(createViemRuntimeWriter).not.toHaveBeenCalled();
expect(getViemWalletClient).not.toHaveBeenCalled();
});
diff --git a/web/src/features/runtime/runtimeWriterProvider.ts b/web/src/features/runtime/runtimeWriterProvider.ts
index 53f19939..8052cf3a 100644
--- a/web/src/features/runtime/runtimeWriterProvider.ts
+++ b/web/src/features/runtime/runtimeWriterProvider.ts
@@ -168,6 +168,7 @@ export function createRuntimeWriter(deps: RuntimeWriterDeps): RuntimeWritePort {
installRuntimeStep: factoryAddress => portForWrite().then(port => port.installRuntimeStep(factoryAddress)),
registerTrack: (runtimeAddress, registration: RuntimeTrackRegistration) => portForWrite().then(port => port.registerTrack(runtimeAddress, registration)),
payForAccess: intent => portForWrite().then(port => port.payForAccess(intent)),
+ claimRoyalty: (runtimeAddress, recipientAddress) => portForWrite().then(port => port.claimRoyalty(runtimeAddress, recipientAddress)),
setAccessMode: (runtimeAddress, update: RuntimeAccessPolicyUpdate) => portForWrite().then(port => port.setAccessMode(runtimeAddress, update)),
setReleaseActive: (runtimeAddress, contentHash, active) => portForWrite().then(port => port.setReleaseActive(runtimeAddress, contentHash, active)),
waitForTransaction: txHash => portForWrite().then(port => port.waitForTransaction(txHash))
diff --git a/web/src/features/runtime/viemRuntimeAdapter.test.ts b/web/src/features/runtime/viemRuntimeAdapter.test.ts
index 8de04c47..b2782e7c 100644
--- a/web/src/features/runtime/viemRuntimeAdapter.test.ts
+++ b/web/src/features/runtime/viemRuntimeAdapter.test.ts
@@ -11,6 +11,7 @@ const listener = '0x5000000000000000000000000000000000000000' as const;
const splitRecipient = '0x6000000000000000000000000000000000000000' as const;
const hash = `0x${'ab'.repeat(32)}` as const;
const txHash = `0x${'cd'.repeat(32)}` as const;
+const legacyTxHash = `0x${'de'.repeat(32)}` as const;
function baseTrackRecord(patch: Partial = {}): OnchainTrackRecord {
return {
@@ -124,32 +125,206 @@ describe('createViemRuntimeReader', () => {
});
it('normalizes royalty payment logs with block timestamps', async () => {
- const getLogs = vi.fn(async () => [
+ const getLogs = vi.fn(async ({ event }: { event: { name?: string } }) => {
+ if (event.name === 'MusicRoyRoyaltyPaid') {
+ return [
+ {
+ args: { contentHash: hash, listener, recipient: splitRecipient, amount: 2_000_000_000_000_000_000n },
+ transactionHash: txHash,
+ blockNumber: 7n,
+ logIndex: 3
+ }
+ ];
+ }
+ if (event.name === 'MusicRoyRoyaltyClaimable') {
+ return [
+ {
+ args: { contentHash: hash, listener, recipient: splitRecipient, amount: 1_000_000_000_000_000_000n, pendingTotal: 1_500_000_000_000_000_000n },
+ transactionHash: txHash,
+ blockNumber: 8n,
+ logIndex: 4
+ }
+ ];
+ }
+ if (event.name === 'MusicRoyRoyaltyClaimed' || event.name === 'MusicRoyAccessPaid') {
+ return [];
+ }
+ throw new Error(`unexpected event ${event.name}`);
+ });
+ const getBlock = vi.fn(async ({ blockNumber }: { blockNumber: bigint }) => ({ timestamp: blockNumber === 7n ? 123n : 124n }));
+ const reader = createViemRuntimeReader({
+ ethRpcUrl: 'http://localhost:8545',
+ publicClient: { getLogs, getBlock } as never
+ });
+
+ await expect(reader.listRoyaltyPaymentLogs(runtime, splitRecipient)).resolves.toEqual([
{
- args: { contentHash: hash, listener, amount: 2_000_000_000_000_000_000n },
+ runtimeAddress: runtime,
+ trackHash: hash,
+ listener,
+ recipient: splitRecipient,
+ amountWei: 2_000_000_000_000_000_000n,
+ settlement: 'paid',
+ paidAtMs: 123_000,
transactionHash: txHash,
blockNumber: 7n,
logIndex: 3
+ },
+ {
+ runtimeAddress: runtime,
+ trackHash: hash,
+ listener,
+ recipient: splitRecipient,
+ amountWei: 1_000_000_000_000_000_000n,
+ settlement: 'claimable',
+ pendingTotalWei: 1_500_000_000_000_000_000n,
+ paidAtMs: 124_000,
+ transactionHash: txHash,
+ blockNumber: 8n,
+ logIndex: 4
}
]);
- const getBlock = vi.fn(async () => ({ timestamp: 123n }));
+ expect(getLogs).toHaveBeenCalledWith(expect.objectContaining({ args: { recipient: splitRecipient } }));
+ });
+
+ it('reconciles claimable accrual rows after successful royalty claims', async () => {
+ const getLogs = vi.fn(async ({ event }: { event: { name?: string } }) => {
+ if (event.name === 'MusicRoyRoyaltyPaid' || event.name === 'MusicRoyAccessPaid') return [];
+ if (event.name === 'MusicRoyRoyaltyClaimable') {
+ return [
+ {
+ args: { contentHash: hash, listener, recipient: splitRecipient, amount: 1_000_000_000_000_000_000n, pendingTotal: 1_000_000_000_000_000_000n },
+ transactionHash: txHash,
+ blockNumber: 7n,
+ logIndex: 3
+ },
+ {
+ args: { contentHash: hash, listener, recipient: splitRecipient, amount: 2_000_000_000_000_000_000n, pendingTotal: 2_000_000_000_000_000_000n },
+ transactionHash: legacyTxHash,
+ blockNumber: 9n,
+ logIndex: 1
+ }
+ ];
+ }
+ if (event.name === 'MusicRoyRoyaltyClaimed') {
+ return [
+ {
+ args: { recipient: splitRecipient, amount: 1_000_000_000_000_000_000n },
+ transactionHash: legacyTxHash,
+ blockNumber: 8n,
+ logIndex: 1
+ }
+ ];
+ }
+ throw new Error(`unexpected event ${event.name}`);
+ });
+ const getBlock = vi.fn(async ({ blockNumber }: { blockNumber: bigint }) => ({ timestamp: blockNumber + 100n }));
const reader = createViemRuntimeReader({
ethRpcUrl: 'http://localhost:8545',
publicClient: { getLogs, getBlock } as never
});
- await expect(reader.listRoyaltyPaymentLogs(runtime)).resolves.toEqual([
+ await expect(reader.listRoyaltyPaymentLogs(runtime, splitRecipient)).resolves.toMatchObject([
{
+ runtimeAddress: runtime,
trackHash: hash,
listener,
- amountWei: 2_000_000_000_000_000_000n,
- paidAtMs: 123_000,
- transactionHash: txHash,
+ recipient: splitRecipient,
+ amountWei: 1_000_000_000_000_000_000n,
+ settlement: 'claimed',
+ claimedAtMs: 108_000,
+ claimTransactionHash: legacyTxHash,
blockNumber: 7n,
logIndex: 3
+ },
+ {
+ runtimeAddress: runtime,
+ trackHash: hash,
+ listener,
+ recipient: splitRecipient,
+ amountWei: 2_000_000_000_000_000_000n,
+ settlement: 'claimable',
+ blockNumber: 9n,
+ logIndex: 1
+ }
+ ]);
+ });
+
+ it('preserves pre-upgrade access payments as legacy history without duplicating W05 payments', async () => {
+ const getLogs = vi.fn(async ({ event }: { event: { name?: string } }) => {
+ if (event.name === 'MusicRoyRoyaltyClaimable' || event.name === 'MusicRoyRoyaltyClaimed') return [];
+ if (event.name === 'MusicRoyRoyaltyPaid') {
+ return [
+ {
+ args: { contentHash: hash, listener, recipient: splitRecipient, amount: 2_000_000_000_000_000_000n },
+ transactionHash: txHash,
+ blockNumber: 10n,
+ logIndex: 2
+ }
+ ];
+ }
+ if (event.name === 'MusicRoyAccessPaid') {
+ return [
+ {
+ args: { contentHash: hash, listener, amount: 4_000_000_000_000_000_000n },
+ transactionHash: txHash,
+ blockNumber: 10n,
+ logIndex: 3
+ },
+ {
+ args: { contentHash: hash, listener, amount: 3_000_000_000_000_000_000n },
+ transactionHash: legacyTxHash,
+ blockNumber: 5n,
+ logIndex: 1
+ }
+ ];
+ }
+ throw new Error(`unexpected event ${event.name}`);
+ });
+ const getBlock = vi.fn(async ({ blockNumber }: { blockNumber: bigint }) => ({ timestamp: blockNumber + 100n }));
+ const reader = createViemRuntimeReader({
+ ethRpcUrl: 'http://localhost:8545',
+ publicClient: { getLogs, getBlock } as never
+ });
+
+ await expect(reader.listRoyaltyPaymentLogs(runtime, splitRecipient)).resolves.toEqual([
+ {
+ runtimeAddress: runtime,
+ trackHash: hash,
+ listener,
+ recipient: splitRecipient,
+ amountWei: 2_000_000_000_000_000_000n,
+ settlement: 'paid',
+ paidAtMs: 110_000,
+ transactionHash: txHash,
+ blockNumber: 10n,
+ logIndex: 2
+ },
+ {
+ runtimeAddress: runtime,
+ trackHash: hash,
+ listener,
+ recipient: splitRecipient,
+ amountWei: 3_000_000_000_000_000_000n,
+ settlement: 'legacy',
+ paidAtMs: 105_000,
+ transactionHash: legacyTxHash,
+ blockNumber: 5n,
+ logIndex: 1
}
]);
});
+
+ it('reads the claimable balance for one royalty recipient', async () => {
+ const readContract = vi.fn(async () => 33n);
+ const reader = createViemRuntimeReader({
+ ethRpcUrl: 'http://localhost:8545',
+ publicClient: { readContract } as never
+ });
+
+ await expect(reader.getRoyaltyClaimable(runtime, splitRecipient)).resolves.toBe(33n);
+ expect(readContract).toHaveBeenCalledWith(expect.objectContaining({ functionName: 'musicRoyClaimable', args: [splitRecipient] }));
+ });
});
describe('createViemRuntimeWriter', () => {
@@ -176,6 +351,7 @@ describe('createViemRuntimeWriter', () => {
})
)
).resolves.toBe(`${txHash}:musicRoyPayAccess`);
+ await expect(writer.claimRoyalty(runtime, artist)).resolves.toBe(`${txHash}:musicRoyClaim`);
await expect(
writer.registerTrack(runtime, {
contentHash: hash,
@@ -205,7 +381,7 @@ describe('createViemRuntimeWriter', () => {
await writer.waitForTransaction(txHash);
expect(waitForTransactionReceipt).toHaveBeenCalledWith({ hash: txHash });
- expect(writeContract).toHaveBeenCalledTimes(6);
+ expect(writeContract).toHaveBeenCalledTimes(7);
expect(writeContract).toHaveBeenCalledWith(
expect.objectContaining({
address: runtime,
@@ -214,5 +390,6 @@ describe('createViemRuntimeWriter', () => {
value: 1n
})
);
+ expect(writeContract).toHaveBeenCalledWith(expect.objectContaining({ address: runtime, functionName: 'musicRoyClaim', args: [artist] }));
});
});
diff --git a/web/src/features/runtime/viemRuntimeAdapter.ts b/web/src/features/runtime/viemRuntimeAdapter.ts
index 7223d51b..5e6fb960 100644
--- a/web/src/features/runtime/viemRuntimeAdapter.ts
+++ b/web/src/features/runtime/viemRuntimeAdapter.ts
@@ -24,6 +24,13 @@ import type { OnchainTrackRecord } from '../../shared/types';
type ViemPublicClient = ReturnType;
type ViemWalletClient = Awaited>;
+const musicRoyRoyaltyPaidEvent = parseAbiItem(
+ 'event MusicRoyRoyaltyPaid(bytes32 indexed contentHash, address indexed listener, address indexed recipient, uint256 amount)'
+);
+const musicRoyRoyaltyClaimableEvent = parseAbiItem(
+ 'event MusicRoyRoyaltyClaimable(bytes32 indexed contentHash, address indexed listener, address indexed recipient, uint256 amount, uint256 pendingTotal)'
+);
+const musicRoyRoyaltyClaimedEvent = parseAbiItem('event MusicRoyRoyaltyClaimed(address indexed recipient, uint256 amount)');
const musicRoyAccessPaidEvent = parseAbiItem('event MusicRoyAccessPaid(bytes32 indexed contentHash, address indexed listener, uint256 amount)');
export type ViemRuntimeReaderDeps = {
@@ -44,6 +51,66 @@ async function blockTimestampMs(client: ViemPublicClient, blockNumber: bigint):
return Number(block.timestamp) * 1000;
}
+type ClaimSettlementLog = {
+ recipient: Address;
+ amountWei: bigint;
+ paidAtMs: number | null;
+ transactionHash: Hash;
+ blockNumber: bigint;
+ logIndex: number;
+};
+
+function accessSettlementKey(transactionHash: Hash, trackHash: Hash, listener: Address): string {
+ return `${transactionHash.toLowerCase()}:${trackHash.toLowerCase()}:${listener.toLowerCase()}`;
+}
+
+function compareLogOrder(left: { blockNumber: bigint; logIndex: number }, right: { blockNumber: bigint; logIndex: number }): number {
+ if (left.blockNumber !== right.blockNumber) return left.blockNumber < right.blockNumber ? -1 : 1;
+ return left.logIndex - right.logIndex;
+}
+
+function reconcileClaimablePayments(claimablePayments: RuntimeRoyaltyPaymentLog[], claimLogs: ClaimSettlementLog[]): RuntimeRoyaltyPaymentLog[] {
+ const claimsByRecipient = new Map>();
+
+ for (const claim of [...claimLogs].sort(compareLogOrder)) {
+ const key = claim.recipient.toLowerCase();
+ const claims = claimsByRecipient.get(key) ?? [];
+ claims.push({ ...claim, remainingWei: claim.amountWei });
+ claimsByRecipient.set(key, claims);
+ }
+
+ return [...claimablePayments].sort(compareLogOrder).map(payment => {
+ const claims = claimsByRecipient.get(payment.recipient.toLowerCase()) ?? [];
+ let remainingPaymentWei = payment.amountWei;
+ let lastClaim: ClaimSettlementLog | null = null;
+
+ while (remainingPaymentWei > 0n && claims.length > 0) {
+ const claim = claims[0];
+ if (!claim) break;
+ lastClaim = claim;
+
+ if (claim.remainingWei >= remainingPaymentWei) {
+ claim.remainingWei -= remainingPaymentWei;
+ remainingPaymentWei = 0n;
+ if (claim.remainingWei === 0n) claims.shift();
+ break;
+ }
+
+ remainingPaymentWei -= claim.remainingWei;
+ claims.shift();
+ }
+
+ if (remainingPaymentWei > 0n || !lastClaim) return payment;
+
+ return {
+ ...payment,
+ settlement: 'claimed',
+ claimedAtMs: lastClaim.paidAtMs,
+ claimTransactionHash: lastClaim.transactionHash
+ };
+ });
+}
+
export function createViemRuntimeReader(deps: ViemRuntimeReaderDeps): RuntimeReadPort {
const client = () => resolvePublicClient(deps);
@@ -193,30 +260,131 @@ export function createViemRuntimeReader(deps: ViemRuntimeReaderDeps): RuntimeRea
);
},
- async listRoyaltyPaymentLogs(runtimeAddress) {
- const logs = await client().getLogs({
- address: runtimeAddress,
- event: musicRoyAccessPaidEvent,
- fromBlock: 0n,
- toBlock: 'latest'
- });
+ async listRoyaltyPaymentLogs(runtimeAddress, recipientAddress) {
+ const eventArgs = recipientAddress ? { recipient: recipientAddress } : undefined;
+ const [paidLogs, claimableLogs, claimedLogs, legacyAccessLogs] = await Promise.all([
+ client().getLogs({
+ address: runtimeAddress,
+ event: musicRoyRoyaltyPaidEvent,
+ args: eventArgs,
+ fromBlock: 0n,
+ toBlock: 'latest'
+ }),
+ client().getLogs({
+ address: runtimeAddress,
+ event: musicRoyRoyaltyClaimableEvent,
+ args: eventArgs,
+ fromBlock: 0n,
+ toBlock: 'latest'
+ }),
+ client().getLogs({
+ address: runtimeAddress,
+ event: musicRoyRoyaltyClaimedEvent,
+ args: eventArgs,
+ fromBlock: 0n,
+ toBlock: 'latest'
+ }),
+ client().getLogs({
+ address: runtimeAddress,
+ event: musicRoyAccessPaidEvent,
+ fromBlock: 0n,
+ toBlock: 'latest'
+ })
+ ]);
+ const allLogs = [...paidLogs, ...claimableLogs, ...claimedLogs, ...legacyAccessLogs];
const timestampsByBlock = new Map();
await Promise.all(
- Array.from(new Set(logs.map(log => log.blockNumber.toString()))).map(async blockNumber => {
+ Array.from(new Set(allLogs.map(log => log.blockNumber.toString()))).map(async blockNumber => {
timestampsByBlock.set(blockNumber, await blockTimestampMs(client(), BigInt(blockNumber)));
})
);
- return logs
+ const payments = paidLogs
+ .map((log): RuntimeRoyaltyPaymentLog | null => {
+ const trackHash = log.args.contentHash;
+ const listener = log.args.listener;
+ const recipient = log.args.recipient;
+ const amountWei = log.args.amount;
+ if (!trackHash || !listener || !recipient || amountWei === undefined) return null;
+ return {
+ runtimeAddress,
+ trackHash,
+ listener,
+ recipient,
+ amountWei,
+ settlement: 'paid',
+ paidAtMs: timestampsByBlock.get(log.blockNumber.toString()) ?? null,
+ transactionHash: log.transactionHash,
+ blockNumber: log.blockNumber,
+ logIndex: log.logIndex
+ };
+ })
+ .filter((payment): payment is RuntimeRoyaltyPaymentLog => Boolean(payment));
+
+ const claimablePayments = claimableLogs
+ .map((log): RuntimeRoyaltyPaymentLog | null => {
+ const trackHash = log.args.contentHash;
+ const listener = log.args.listener;
+ const recipient = log.args.recipient;
+ const amountWei = log.args.amount;
+ const pendingTotalWei = log.args.pendingTotal;
+ if (!trackHash || !listener || !recipient || amountWei === undefined || pendingTotalWei === undefined) return null;
+ return {
+ runtimeAddress,
+ trackHash,
+ listener,
+ recipient,
+ amountWei,
+ settlement: 'claimable',
+ pendingTotalWei,
+ paidAtMs: timestampsByBlock.get(log.blockNumber.toString()) ?? null,
+ transactionHash: log.transactionHash,
+ blockNumber: log.blockNumber,
+ logIndex: log.logIndex
+ };
+ })
+ .filter((payment): payment is RuntimeRoyaltyPaymentLog => Boolean(payment));
+
+ const claimLogs = claimedLogs
+ .map((log): ClaimSettlementLog | null => {
+ const recipient = log.args.recipient;
+ const amountWei = log.args.amount;
+ if (!recipient || amountWei === undefined) return null;
+ return {
+ recipient,
+ amountWei,
+ paidAtMs: timestampsByBlock.get(log.blockNumber.toString()) ?? null,
+ transactionHash: log.transactionHash,
+ blockNumber: log.blockNumber,
+ logIndex: log.logIndex
+ };
+ })
+ .filter((claim): claim is ClaimSettlementLog => Boolean(claim));
+
+ const currentSettlementKeys = new Set(
+ [...paidLogs, ...claimableLogs]
+ .map(log => {
+ const trackHash = log.args.contentHash;
+ const listener = log.args.listener;
+ return trackHash && listener ? accessSettlementKey(log.transactionHash, trackHash, listener) : null;
+ })
+ .filter((key): key is string => Boolean(key))
+ );
+
+ const legacyPayments = legacyAccessLogs
.map((log): RuntimeRoyaltyPaymentLog | null => {
const trackHash = log.args.contentHash;
const listener = log.args.listener;
const amountWei = log.args.amount;
if (!trackHash || !listener || amountWei === undefined) return null;
+ if (currentSettlementKeys.has(accessSettlementKey(log.transactionHash, trackHash, listener))) return null;
return {
+ runtimeAddress,
trackHash,
listener,
+ recipient: recipientAddress ?? zeroAddress,
amountWei,
+ settlement: 'legacy',
paidAtMs: timestampsByBlock.get(log.blockNumber.toString()) ?? null,
transactionHash: log.transactionHash,
blockNumber: log.blockNumber,
@@ -224,6 +392,17 @@ export function createViemRuntimeReader(deps: ViemRuntimeReaderDeps): RuntimeRea
};
})
.filter((payment): payment is RuntimeRoyaltyPaymentLog => Boolean(payment));
+
+ return [...payments, ...reconcileClaimablePayments(claimablePayments, claimLogs), ...legacyPayments];
+ },
+
+ async getRoyaltyClaimable(runtimeAddress, recipientAddress) {
+ return (await client().readContract({
+ address: runtimeAddress,
+ abi: musicRoyaltiesAbi,
+ functionName: 'musicRoyClaimable',
+ args: [recipientAddress]
+ })) as bigint;
}
};
}
@@ -284,6 +463,15 @@ export function createViemRuntimeWriter(deps: ViemRuntimeWriterDeps): RuntimeWri
});
},
+ claimRoyalty(runtimeAddress, recipientAddress) {
+ return walletClient.writeContract({
+ address: runtimeAddress,
+ abi: musicRoyaltiesAbi,
+ functionName: 'musicRoyClaim',
+ args: [recipientAddress]
+ });
+ },
+
setAccessMode(runtimeAddress, update: RuntimeAccessPolicyUpdate) {
return walletClient.writeContract({
address: runtimeAddress,
diff --git a/web/src/generated/contracts/cdm.d.ts b/web/src/generated/contracts/cdm.d.ts
index 0b157faf..cb128749 100644
--- a/web/src/generated/contracts/cdm.d.ts
+++ b/web/src/generated/contracts/cdm.d.ts
@@ -47,6 +47,8 @@ declare module "@parity/product-sdk-contracts" {
musicRegSetAccessMode: { args: [contentHash: SizedHex<32>, accessMode: number, pricePlanck: bigint, requiredPersonhood: number]; response: undefined };
musicRegTrackCount: { args: []; response: bigint };
musicRegTrackHashAtIndex: { args: [index: bigint]; response: SizedHex<32> };
+ musicRoyClaim: { args: [recipient: HexString]; response: { amount: bigint; settled: boolean } };
+ musicRoyClaimable: { args: [recipient: HexString]; response: bigint };
musicRoyPayAccess: { args: [contentHash: SizedHex<32>]; response: undefined };
musicRoyRecordListen: { args: [contentHash: SizedHex<32>]; response: undefined };
musicRoySplitAt: { args: [contentHash: SizedHex<32>, index: bigint]; response: { recipient: HexString; bps: number } };
diff --git a/web/src/generated/contracts/musicRoyalties.ts b/web/src/generated/contracts/musicRoyalties.ts
index 222dffb0..a326d90e 100644
--- a/web/src/generated/contracts/musicRoyalties.ts
+++ b/web/src/generated/contracts/musicRoyalties.ts
@@ -77,6 +77,186 @@ export const musicRoyaltiesAbi = [
"name": "MusicRoyRefunded",
"type": "event"
},
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "recipient",
+ "type": "address"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "amount",
+ "type": "uint256"
+ }
+ ],
+ "name": "MusicRoyRoyaltyClaimFailed",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "bytes32",
+ "name": "contentHash",
+ "type": "bytes32"
+ },
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "listener",
+ "type": "address"
+ },
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "recipient",
+ "type": "address"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "amount",
+ "type": "uint256"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "pendingTotal",
+ "type": "uint256"
+ }
+ ],
+ "name": "MusicRoyRoyaltyClaimable",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "recipient",
+ "type": "address"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "amount",
+ "type": "uint256"
+ }
+ ],
+ "name": "MusicRoyRoyaltyClaimed",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "bytes32",
+ "name": "contentHash",
+ "type": "bytes32"
+ },
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "listener",
+ "type": "address"
+ },
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "recipient",
+ "type": "address"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "amount",
+ "type": "uint256"
+ }
+ ],
+ "name": "MusicRoyRoyaltyPaid",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "bytes32",
+ "name": "contentHash",
+ "type": "bytes32"
+ },
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "listener",
+ "type": "address"
+ },
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "recipient",
+ "type": "address"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "amount",
+ "type": "uint256"
+ }
+ ],
+ "name": "MusicRoyRoyaltyPayoutFailed",
+ "type": "event"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "recipient",
+ "type": "address"
+ }
+ ],
+ "name": "musicRoyClaim",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "amount",
+ "type": "uint256"
+ },
+ {
+ "internalType": "bool",
+ "name": "settled",
+ "type": "bool"
+ }
+ ],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "recipient",
+ "type": "address"
+ }
+ ],
+ "name": "musicRoyClaimable",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
{
"inputs": [
{
diff --git a/web/src/generated/contracts/smartRuntime.ts b/web/src/generated/contracts/smartRuntime.ts
index 7d5f1fc8..0adb0a3b 100644
--- a/web/src/generated/contracts/smartRuntime.ts
+++ b/web/src/generated/contracts/smartRuntime.ts
@@ -615,6 +615,186 @@ export const smartRuntimeAbi = [
"name": "MusicRoyRefunded",
"type": "event"
},
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "recipient",
+ "type": "address"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "amount",
+ "type": "uint256"
+ }
+ ],
+ "name": "MusicRoyRoyaltyClaimFailed",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "bytes32",
+ "name": "contentHash",
+ "type": "bytes32"
+ },
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "listener",
+ "type": "address"
+ },
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "recipient",
+ "type": "address"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "amount",
+ "type": "uint256"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "pendingTotal",
+ "type": "uint256"
+ }
+ ],
+ "name": "MusicRoyRoyaltyClaimable",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "recipient",
+ "type": "address"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "amount",
+ "type": "uint256"
+ }
+ ],
+ "name": "MusicRoyRoyaltyClaimed",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "bytes32",
+ "name": "contentHash",
+ "type": "bytes32"
+ },
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "listener",
+ "type": "address"
+ },
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "recipient",
+ "type": "address"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "amount",
+ "type": "uint256"
+ }
+ ],
+ "name": "MusicRoyRoyaltyPaid",
+ "type": "event"
+ },
+ {
+ "anonymous": false,
+ "inputs": [
+ {
+ "indexed": true,
+ "internalType": "bytes32",
+ "name": "contentHash",
+ "type": "bytes32"
+ },
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "listener",
+ "type": "address"
+ },
+ {
+ "indexed": true,
+ "internalType": "address",
+ "name": "recipient",
+ "type": "address"
+ },
+ {
+ "indexed": false,
+ "internalType": "uint256",
+ "name": "amount",
+ "type": "uint256"
+ }
+ ],
+ "name": "MusicRoyRoyaltyPayoutFailed",
+ "type": "event"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "recipient",
+ "type": "address"
+ }
+ ],
+ "name": "musicRoyClaim",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "amount",
+ "type": "uint256"
+ },
+ {
+ "internalType": "bool",
+ "name": "settled",
+ "type": "bool"
+ }
+ ],
+ "stateMutability": "nonpayable",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "address",
+ "name": "recipient",
+ "type": "address"
+ }
+ ],
+ "name": "musicRoyClaimable",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
{
"inputs": [
{
diff --git a/web/src/hooks/useArtistConsole.ts b/web/src/hooks/useArtistConsole.ts
index 6e0a991d..e6f5c096 100644
--- a/web/src/hooks/useArtistConsole.ts
+++ b/web/src/hooks/useArtistConsole.ts
@@ -17,10 +17,13 @@ import {
import { chainMismatchMessage } from '../features/wallet/network';
import { localAudioRef, priceDotForAccessMode, runtimeAddressFromTrackId } from '../features/catalog/trackModel';
import { encodeAccessMode, encodeRequiredPersonhood, manifestRequiredPersonhood } from '../features/runtime/accessEncoding';
+import { listKnownRoyaltyRuntimeCandidates } from '../features/runtime/royaltyRuntimeClaims';
import { resolvePreparedAudioUploadForRuntime } from '../features/uploads/preparedAudioUpload';
import { resolvePreparedUpload, type PreparedUploadRef } from '../features/uploads/preparedUpload';
-import { createViemRuntimeWriter } from '../features/runtime/viemRuntimeAdapter';
import { createRuntimeReader } from '../features/runtime/runtimeReaderProvider';
+import { createRuntimeWriter } from '../features/runtime/runtimeWriterProvider';
+import { resolveRuntimeAdapterConfig } from '../features/runtime/runtimeAdapterConfig';
+import { resolveProductHostConfig } from '../features/productHost/productHost';
import { resolveConfiguredArtistPublicationSafety } from '../shared/config/deploymentSafety';
import { describeArtistRegistrationError, formatWeiAsDot, shorten, dotToPlanck } from '../shared/utils/format';
import {
@@ -37,10 +40,21 @@ import {
publishArtistPublishE2eTrack,
recordArtistPublishTransactionFailure
} from '../e2e/artistPublishMock';
-import type { AccessMode, CatalogTrack, PersonhoodLevel, ReleaseRoyaltySplitDraft, RoyaltyPayment, TransactionFeedback } from '../shared/types';
+import type {
+ AccessMode,
+ CatalogTrack,
+ PersonhoodLevel,
+ ReleaseRoyaltySplitDraft,
+ RoyaltyPayment,
+ RoyaltyRuntimeSummary,
+ TransactionFeedback
+} from '../shared/types';
import type { ConnectedWallet } from './useWallet';
import type { PolkadotSigner } from 'polkadot-api';
+const runtimeAdapterConfig = resolveRuntimeAdapterConfig(import.meta.env);
+const productHostConfig = resolveProductHostConfig(import.meta.env);
+
const runtimeBootstrapSteps = [
{
label: 'Claim your artist space',
@@ -250,8 +264,11 @@ export function useArtistConsole(deps: UseArtistConsoleDeps) {
const [isRefreshingArtistRuntime, setIsRefreshingArtistRuntime] = useState(false);
const [rightsStatus, setRightsStatus] = useState('No audio file selected');
const [royaltyPayments, setRoyaltyPayments] = useState([]);
+ const [claimableRoyaltyWei, setClaimableRoyaltyWei] = useState(0n);
+ const [royaltyRuntimeSummaries, setRoyaltyRuntimeSummaries] = useState([]);
const [royaltyStatus, setRoyaltyStatus] = useState('No artist profile selected');
const [isRefreshingRoyalties, setIsRefreshingRoyalties] = useState(false);
+ const [isClaimingRoyalties, setIsClaimingRoyalties] = useState(false);
const [expandedRoyaltyPaymentId, setExpandedRoyaltyPaymentId] = useState(null);
const [bulletinManifestRef, setBulletinManifestRef] = useState('');
const [isRegistering, setIsRegistering] = useState(false);
@@ -280,6 +297,25 @@ export function useArtistConsole(deps: UseArtistConsoleDeps) {
return connectedWallet.createEvmClient(chain, ethRpcUrl) as Awaited>;
}
+ function getRuntimeWriter() {
+ const signer = connectedWallet?.keyRequestSigner;
+ const productAccount =
+ connectedWallet?.method === 'product-host'
+ ? {
+ productId: productHostConfig.productId,
+ evmAddress: connectedWallet.evmAddress,
+ publicKey: signer && 'productPublicKey' in signer ? signer.productPublicKey : undefined
+ }
+ : undefined;
+
+ return createRuntimeWriter({
+ ethRpcUrl,
+ getViemWalletClient: getActiveWalletClient,
+ config: runtimeAdapterConfig,
+ productAccount
+ });
+ }
+
async function getUploadIdentity(): Promise {
if (!isBackendConfigured()) return undefined;
if (!connectedWallet) throw new Error('Connect the artist wallet before uploading release assets.');
@@ -436,8 +472,7 @@ export function useArtistConsole(deps: UseArtistConsoleDeps) {
return;
}
- const walletClient = await getActiveWalletClient();
- const runtimeWriter = createViemRuntimeWriter({ ethRpcUrl, walletClient });
+ const runtimeWriter = getRuntimeWriter();
let pendingRuntime = await runtimeReader.pendingRuntimeOf(factoryAddress!, activeEvmAddress);
@@ -534,40 +569,88 @@ export function useArtistConsole(deps: UseArtistConsoleDeps) {
}
}
- async function refreshArtistRoyalties(showBusy = false) {
- if (!artistRuntimeAddress) {
- setRoyaltyPayments([]);
- setRoyaltyStatus('Create an artist profile to track payments');
- return;
- }
+ function getKnownRoyaltyRuntimeCandidates() {
+ return listKnownRoyaltyRuntimeCandidates(artistTracks, activeEvmAddress, artistRuntimeAddress);
+ }
+
+ async function readRoyaltyRuntimeSummaries(): Promise {
+ const candidates = getKnownRoyaltyRuntimeCandidates();
+ return Promise.all(
+ candidates.map(async candidate => ({
+ ...candidate,
+ claimableWei: await runtimeReader.getRoyaltyClaimable(candidate.runtimeAddress, activeEvmAddress).catch(() => 0n)
+ }))
+ );
+ }
+ async function refreshArtistRoyalties(showBusy = false) {
if (showBusy) {
setIsRefreshingRoyalties(true);
}
- setRoyaltyStatus('Reading artist runtime payments');
+ setRoyaltyStatus('Reading royalty runtime payments');
try {
+ const candidates = getKnownRoyaltyRuntimeCandidates();
+ if (candidates.length === 0) {
+ setRoyaltyPayments([]);
+ setClaimableRoyaltyWei(0n);
+ setRoyaltyRuntimeSummaries([]);
+ setRoyaltyStatus('No artist or split royalty runtime found for this wallet');
+ return;
+ }
+
const trackByHash = new Map(artistTracks.map(track => [track.hash.toLowerCase(), track]));
- const logs = await runtimeReader.listRoyaltyPaymentLogs(artistRuntimeAddress);
+ const trackByRuntimeHash = new Map(
+ artistTracks.flatMap(track => {
+ const runtimeAddress = runtimeAddressFromTrackId(track);
+ return runtimeAddress ? [[`${runtimeAddress.toLowerCase()}:${track.hash.toLowerCase()}`, track] as const] : [];
+ })
+ );
+ const runtimeResults = await Promise.all(
+ candidates.map(async candidate => {
+ const claimableWei = await runtimeReader.getRoyaltyClaimable(candidate.runtimeAddress, activeEvmAddress).catch(() => 0n);
+ try {
+ return {
+ candidate,
+ claimableWei,
+ logs: await runtimeReader.listRoyaltyPaymentLogs(candidate.runtimeAddress, activeEvmAddress),
+ error: null
+ };
+ } catch (error) {
+ return { candidate, claimableWei, logs: [], error };
+ }
+ })
+ );
+ const summaries = runtimeResults.map(({ candidate, claimableWei }) => ({ ...candidate, claimableWei }));
+ const claimableWei = summaries.reduce((total, summary) => total + summary.claimableWei, 0n);
+ setClaimableRoyaltyWei(claimableWei);
+ setRoyaltyRuntimeSummaries(summaries);
+ const logs = runtimeResults.flatMap(result => result.logs);
const payments = logs
.map(log => {
- const track = trackByHash.get(log.trackHash.toLowerCase());
+ const track =
+ trackByRuntimeHash.get(`${log.runtimeAddress.toLowerCase()}:${log.trackHash.toLowerCase()}`) ?? trackByHash.get(log.trackHash.toLowerCase());
return {
- id: `${log.transactionHash}-${log.logIndex}`,
+ id: `${log.runtimeAddress}-${log.transactionHash}-${log.logIndex}`,
+ runtimeAddress: log.runtimeAddress,
trackHash: log.trackHash,
trackTitle: track?.title ?? shorten(log.trackHash, 14),
listener: log.listener,
+ recipient: log.recipient,
amountWei: log.amountWei,
amountDot: formatWeiAsDot(log.amountWei),
+ settlement: log.settlement,
+ ...(log.pendingTotalWei !== undefined ? { pendingTotalWei: log.pendingTotalWei } : {}),
+ ...(log.claimedAtMs !== undefined ? { claimedAtMs: log.claimedAtMs } : {}),
+ ...(log.claimTransactionHash !== undefined ? { claimTransactionHash: log.claimTransactionHash } : {}),
paidAtMs: log.paidAtMs,
transactionHash: log.transactionHash,
blockNumber: log.blockNumber,
logIndex: log.logIndex
} satisfies RoyaltyPayment;
})
- .filter((payment): payment is RoyaltyPayment => Boolean(payment))
.sort((left, right) => {
if (left.blockNumber !== right.blockNumber) {
return left.blockNumber > right.blockNumber ? -1 : 1;
@@ -576,10 +659,17 @@ export function useArtistConsole(deps: UseArtistConsoleDeps) {
});
setRoyaltyPayments(payments);
- setRoyaltyStatus(payments.length > 0 ? 'Payments indexed from your runtime' : 'No access payments received yet');
+ const failedRuntimeCount = runtimeResults.filter(result => result.error).length;
+ if (failedRuntimeCount > 0) {
+ setRoyaltyStatus(`Royalty balances loaded; ${failedRuntimeCount} runtime ledger${failedRuntimeCount === 1 ? '' : 's'} need event indexing`);
+ } else {
+ setRoyaltyStatus(payments.length > 0 || claimableWei > 0n ? 'Royalty settlement indexed from known runtimes' : 'No access payments received yet');
+ }
} catch (royaltyError) {
const message = royaltyError instanceof Error ? royaltyError.message : 'Unable to load royalty payments';
setRoyaltyPayments([]);
+ setClaimableRoyaltyWei(0n);
+ setRoyaltyRuntimeSummaries([]);
setRoyaltyStatus(message);
} finally {
if (showBusy) {
@@ -851,8 +941,7 @@ export function useArtistConsole(deps: UseArtistConsoleDeps) {
return;
}
- const walletClient = await getActiveWalletClient();
- const runtimeWriter = createViemRuntimeWriter({ ethRpcUrl, walletClient });
+ const runtimeWriter = getRuntimeWriter();
const ipfsAudioRef = resolvedAudioRef || localAudioRef(fileHash);
const ipfsCoverRef = resolvedCoverCID ? `ipfs://${resolvedCoverCID}` : `dotify:cover:${fileHash}`;
@@ -938,8 +1027,7 @@ export function useArtistConsole(deps: UseArtistConsoleDeps) {
setReleaseActionId(`${track.id}:access`);
try {
- const walletClient = await getActiveWalletClient();
- const runtimeWriter = createViemRuntimeWriter({ ethRpcUrl, walletClient });
+ const runtimeWriter = getRuntimeWriter();
setTransactionFeedback({
tone: 'pending',
title: 'Updating access',
@@ -990,8 +1078,7 @@ export function useArtistConsole(deps: UseArtistConsoleDeps) {
setReleaseActionId(`${track.id}:active`);
try {
- const walletClient = await getActiveWalletClient();
- const runtimeWriter = createViemRuntimeWriter({ ethRpcUrl, walletClient });
+ const runtimeWriter = getRuntimeWriter();
setTransactionFeedback({
tone: 'pending',
title: active ? 'Reactivating release' : 'Deactivating release',
@@ -1015,6 +1102,108 @@ export function useArtistConsole(deps: UseArtistConsoleDeps) {
}
}
+ async function claimRoyalties() {
+ if (!connectedWallet) {
+ setTransactionFeedback({
+ tone: 'error',
+ title: 'Wallet required',
+ message: 'Connect the royalty recipient wallet before claiming pending royalties.'
+ });
+ return;
+ }
+
+ setIsClaimingRoyalties(true);
+ try {
+ const summaries = await readRoyaltyRuntimeSummaries();
+ const claimTargets = summaries.filter(summary => summary.claimableWei > 0n);
+ const totalClaimableWei = summaries.reduce((total, summary) => total + summary.claimableWei, 0n);
+ setRoyaltyRuntimeSummaries(summaries);
+ setClaimableRoyaltyWei(totalClaimableWei);
+
+ if (summaries.length === 0) {
+ setTransactionFeedback({
+ tone: 'error',
+ title: 'Royalty runtime missing',
+ message: 'No known artist or split royalty runtime was found for this wallet.'
+ });
+ return;
+ }
+
+ if (claimTargets.length === 0) {
+ setTransactionFeedback({
+ tone: 'success',
+ title: 'No pending royalties',
+ message: 'This wallet has no claimable royalty balance in the known runtimes.'
+ });
+ return;
+ }
+
+ const runtimeWriter = getRuntimeWriter();
+ let lastTxHash: `0x${string}` | undefined;
+ const unresolvedRuntimes: string[] = [];
+ const unreadableRuntimes: string[] = [];
+
+ setTransactionFeedback({
+ tone: 'pending',
+ title: 'Claiming royalties',
+ message: `Submitting ${claimTargets.length} pending runtime claim${claimTargets.length === 1 ? '' : 's'}.`
+ });
+
+ for (const target of claimTargets) {
+ const txHash = await runtimeWriter.claimRoyalty(target.runtimeAddress, activeEvmAddress);
+ lastTxHash = txHash;
+ setTransactionFeedback({
+ tone: 'pending',
+ title: 'Royalty claim submitted',
+ message: `Waiting for ${shorten(target.runtimeAddress, 10)} before refreshing the settlement ledger.`,
+ txHash
+ });
+ await runtimeWriter.waitForTransaction(txHash);
+ const remainingClaimableWei = await runtimeReader.getRoyaltyClaimable(target.runtimeAddress, activeEvmAddress).catch(() => null);
+ if (remainingClaimableWei === null) {
+ unreadableRuntimes.push(target.runtimeAddress);
+ } else if (remainingClaimableWei > 0n) {
+ unresolvedRuntimes.push(target.runtimeAddress);
+ }
+ }
+
+ await refreshArtistRoyalties();
+
+ if (unreadableRuntimes.length > 0) {
+ setTransactionFeedback({
+ tone: 'error',
+ title: 'Royalty claim readback unavailable',
+ message:
+ 'One or more transactions were included, but Dotify could not confirm every pending balance. Refresh the ledger before treating it as received.',
+ txHash: lastTxHash
+ });
+ return;
+ }
+
+ if (unresolvedRuntimes.length > 0) {
+ setTransactionFeedback({
+ tone: 'error',
+ title: 'Royalty claim still pending',
+ message: 'At least one runtime still could not transfer the native-token balance. The amount remains claimable.',
+ txHash: lastTxHash
+ });
+ return;
+ }
+
+ setTransactionFeedback({
+ tone: 'success',
+ title: 'Royalties claimed',
+ message: 'All known pending royalty balances were cleared.',
+ txHash: lastTxHash
+ });
+ } catch (claimError) {
+ const message = claimError instanceof Error ? claimError.message : 'Royalty claim failed';
+ setTransactionFeedback({ tone: 'error', title: 'Royalty claim failed', message });
+ } finally {
+ setIsClaimingRoyalties(false);
+ }
+ }
+
function updateArtistName(nextName: string, setArtistName: (name: string) => void) {
setArtistName(nextName);
if (connectedWallet) {
@@ -1022,6 +1211,8 @@ export function useArtistConsole(deps: UseArtistConsoleDeps) {
}
}
+ const hasKnownRoyaltyRuntime = getKnownRoyaltyRuntimeCandidates().length > 0;
+
return {
// State
artistRuntimeAddress,
@@ -1031,8 +1222,12 @@ export function useArtistConsole(deps: UseArtistConsoleDeps) {
rightsStatus,
setRightsStatus,
royaltyPayments,
+ claimableRoyaltyWei,
+ royaltyRuntimeSummaries,
+ hasKnownRoyaltyRuntime,
royaltyStatus,
isRefreshingRoyalties,
+ isClaimingRoyalties,
expandedRoyaltyPaymentId,
setExpandedRoyaltyPaymentId,
bulletinManifestRef,
@@ -1049,6 +1244,7 @@ export function useArtistConsole(deps: UseArtistConsoleDeps) {
registerRights,
updateReleaseAccessMode,
setReleaseActive,
+ claimRoyalties,
refreshArtistRoyalties,
createRightsManifest,
getActiveWalletClient,
diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts
index fbc0cff7..37f50051 100644
--- a/web/src/shared/types.ts
+++ b/web/src/shared/types.ts
@@ -108,6 +108,17 @@ export type RegistryCatalogTrack = CatalogTrack & {
registeredAtBlock: number;
};
+export type RoyaltySettlementState = 'paid' | 'claimable' | 'claimed' | 'legacy';
+
+export type RoyaltyRuntimeSummary = {
+ runtimeAddress: `0x${string}`;
+ artistAddress?: `0x${string}`;
+ artistName: string;
+ trackCount: number;
+ trackTitles: string[];
+ claimableWei: bigint;
+};
+
export type RoomPlaybackMode = 'full' | 'preview';
export type OpenRoom = {
@@ -225,11 +236,17 @@ export type AccessGate = {
export type RoyaltyPayment = {
id: string;
+ runtimeAddress: `0x${string}`;
trackHash: `0x${string}`;
trackTitle: string;
listener: `0x${string}`;
+ recipient: `0x${string}`;
amountWei: bigint;
amountDot: string;
+ settlement: RoyaltySettlementState;
+ pendingTotalWei?: bigint;
+ claimedAtMs?: number | null;
+ claimTransactionHash?: `0x${string}`;
paidAtMs: number | null;
transactionHash: `0x${string}`;
blockNumber: bigint;
diff --git a/web/src/styles/artist.css b/web/src/styles/artist.css
index 28525b16..176234b9 100644
--- a/web/src/styles/artist.css
+++ b/web/src/styles/artist.css
@@ -1401,7 +1401,7 @@ button.you-panel:hover {
.royalty-summary-grid {
display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
+ grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
}
@@ -1422,9 +1422,20 @@ button.you-panel:hover {
}
.royalty-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
margin-top: 1rem;
}
+.royalty-toolbar-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 0.6rem;
+}
+
.royalty-ledger-list {
display: grid;
margin-top: 0.75rem;
@@ -1457,6 +1468,48 @@ button.you-panel:hover {
font-size: var(--text-xs);
}
+.royalty-entry[data-settlement='claimable'] .royalty-row-side strong,
+.studio-support-row[data-settlement='claimable'] .studio-support-amount strong {
+ color: var(--artist);
+}
+
+.royalty-entry[data-settlement='claimed'] .royalty-row-side strong,
+.studio-support-row[data-settlement='claimed'] .studio-support-amount strong {
+ color: var(--success);
+}
+
+.royalty-entry[data-settlement='legacy'] .royalty-row-side strong,
+.studio-support-row[data-settlement='legacy'] .studio-support-amount strong {
+ color: var(--muted);
+}
+
+.royalty-runtime-list {
+ display: grid;
+ gap: 0.5rem;
+ margin: 0.75rem 0;
+}
+
+.royalty-runtime-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 0.75rem;
+ align-items: center;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 0.75rem;
+}
+
+.royalty-runtime-row > div {
+ display: grid;
+ gap: 0.15rem;
+ min-width: 0;
+}
+
+.royalty-runtime-row span {
+ color: var(--muted);
+ font-size: var(--text-xs);
+}
+
.royalty-details {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
diff --git a/web/src/styles/responsive.css b/web/src/styles/responsive.css
index 5f9c6c44..e3a234f9 100644
--- a/web/src/styles/responsive.css
+++ b/web/src/styles/responsive.css
@@ -574,6 +574,7 @@
.fields-grid,
.release-stepper,
.royalty-summary-grid,
+ .royalty-runtime-row,
.release-detail-grid,
.royalty-details,
.artist-grid {
@@ -584,6 +585,19 @@
border: 1px solid var(--line);
}
+ .royalty-toolbar {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .royalty-toolbar-actions {
+ justify-content: stretch;
+ }
+
+ .royalty-toolbar-actions .compact-action {
+ flex: 1 1 10rem;
+ }
+
.release-stepper button {
justify-content: flex-start;
border-right: 0;
diff --git a/web/src/views/ArtistShell.tsx b/web/src/views/ArtistShell.tsx
index 2c90da55..d22ecd01 100644
--- a/web/src/views/ArtistShell.tsx
+++ b/web/src/views/ArtistShell.tsx
@@ -30,5 +30,9 @@ export function ArtistShell() {
if (storedName) setArtistName(storedName);
}, [activeEvmAddress, setArtistName]);
- return {connectedWallet && artistConsole.artistRuntimeAddress ? : };
+ return (
+
+ {connectedWallet && (artistConsole.artistRuntimeAddress || artistConsole.hasKnownRoyaltyRuntime) ? : }
+
+ );
}
diff --git a/web/src/views/artist/ArtistConsole.tsx b/web/src/views/artist/ArtistConsole.tsx
index 59f7b1ca..922bf224 100644
--- a/web/src/views/artist/ArtistConsole.tsx
+++ b/web/src/views/artist/ArtistConsole.tsx
@@ -41,7 +41,7 @@ const artistTabs: Array<{ id: ArtistTab; label: string; description: string }> =
{ id: 'overview', label: 'Overview', description: 'Identity and next step' },
{ id: 'new', label: 'New Release', description: 'Publish under your own terms' },
{ id: 'releases', label: 'Releases', description: 'Catalog you control' },
- { id: 'royalties', label: 'Royalties', description: 'Payments received' },
+ { id: 'royalties', label: 'Royalties', description: 'Settlement ledger' },
{ id: 'advanced', label: 'Advanced', description: 'Proofs, contracts, and archives' }
];
@@ -442,6 +442,9 @@ export function ArtistConsole() {
royaltyPayments={royaltyPayments}
royaltyStatus={royaltyStatus}
isRefreshingRoyalties={isRefreshingRoyalties}
+ claimableRoyaltyWei={artistConsole.claimableRoyaltyWei}
+ royaltyRuntimeSummaries={artistConsole.royaltyRuntimeSummaries}
+ isClaimingRoyalties={artistConsole.isClaimingRoyalties}
artistRuntimeAddress={artistRuntimeAddress}
expandedRoyaltyPaymentId={expandedRoyaltyPaymentId}
totalRoyaltyWei={totalRoyaltyWei}
@@ -450,6 +453,7 @@ export function ArtistConsole() {
nativePaymentSymbol={nativePaymentSymbol}
onSetExpandedRoyaltyPaymentId={onSetExpandedRoyaltyPaymentId}
onRefreshRoyalties={onRefreshRoyalties}
+ onClaimRoyalties={artistConsole.claimRoyalties}
/>
)}
diff --git a/web/src/views/artist/OverviewTab.tsx b/web/src/views/artist/OverviewTab.tsx
index 096dea2a..8ea2d6c4 100644
--- a/web/src/views/artist/OverviewTab.tsx
+++ b/web/src/views/artist/OverviewTab.tsx
@@ -48,6 +48,18 @@ export function OverviewTab({
onOpenRelease
}: OverviewTabProps) {
const earnedDot = formatWeiAsDot(totalRoyaltyWei);
+ function supportSettlementLabel(payment: RoyaltyPayment): string {
+ switch (payment.settlement) {
+ case 'paid':
+ return 'settled';
+ case 'claimable':
+ return 'claimable';
+ case 'claimed':
+ return 'claimed';
+ case 'legacy':
+ return 'legacy access';
+ }
+ }
return (
@@ -65,7 +77,7 @@ export function OverviewTab({
{earnedDot} {nativePaymentSymbol}
- Earned - paid direct
+ Earned - settled
@@ -95,21 +107,28 @@ export function OverviewTab({
- Payment history
- Every row is a listener supporting and opening one of your releases.
+ Recipient isolation
+ A recipient that rejects native transfers cannot block a listener from opening the release.
- Listener record
- Support stays visible without forcing listeners into platform accounts.
+ Claimable balance
+ Failed recipient payouts stay in the runtime until the recipient claims them successfully.
Open accounting
- Amounts are shown in {nativePaymentSymbol} and each payment links back to Blockscout.
+ Settled and claimable amounts are separate receipts, each linked back to Blockscout.