Skip to content

fix: isolate failed royalty recipients - #146

Open
knzeng-e wants to merge 9 commits into
devfrom
feat/royalty-failure-isolation
Open

fix: isolate failed royalty recipients#146
knzeng-e wants to merge 9 commits into
devfrom
feat/royalty-failure-isolation

Conversation

@knzeng-e

@knzeng-e knzeng-e commented Sep 8, 2026

Copy link
Copy Markdown
Owner

Outcome

W05 prevents one failed royalty recipient from reverting an otherwise valid Classic access purchase. Normal recipients still receive native-token shares immediately, while rejecting or gas-consuming recipients get a claimable balance in the artist runtime.

Issue and context

Closes #145.

Local scope document: docs/backlog/implementation/W05-royalty-failure-isolation.md

Before this PR, musicRoyPayAccess used unbounded recipient calls and required every transfer to succeed. A single collaborator contract with a reverting receive() hook, an expensive hook, or an incompatible native-token path could block the listener's purchase and all other recipients.

Dotify needs Classic support to remain simple for the listener, but payment submission, access finality, and per-recipient receipt are distinct facts. This PR keeps those facts separate.

Architecture and key concepts

Classic payment now has one access-payment fact and multiple per-recipient settlement facts:

listener -> musicRoyPayAccess(contentHash)
  -> validate Classic track, active state, price, and not already paid
  -> set paidAccess for the listener
  -> for each royalty share:
       bounded native transfer succeeds -> MusicRoyRoyaltyPaid
       bounded native transfer fails    -> claimable[recipient] += share
  -> refund overpayment with bounded native transfer
  -> emit MusicRoyAccessPaid

MusicRoyAccessPaid remains the listener access-payment event. It is not used as proof that every recipient was paid. The artist ledger now reads MusicRoyRoyaltyPaid, MusicRoyRoyaltyClaimable, MusicRoyRoyaltyClaimed, and pre-W05 MusicRoyAccessPaid legacy rows. totalRoyaltyWei sums only paid rows plus claimable rows that were later cleared by claim events.

How it works

Contracts:

  • LibMusicRoyalties appends claimable to the existing namespaced Diamond storage and exposes bounded native transfer/accounting helpers.
  • MusicRoyaltiesPallet settles each share through that helper, emits paid/failed/claimable events, and exposes musicRoyClaimable(address) plus musicRoyClaim(address).
  • ArtistRuntimeFactory installs the two new royalties selectors for newly created runtimes.
  • Contract tests include rejecting, gas-consuming, and reentrant recipients plus forkless upgrade rehearsals that preserve existing splits and paid access.

Frontend:

  • Generated viem and Product CDM runtime bindings include the W05 selectors/events.
  • Runtime ports now expose getRoyaltyClaimable and claimRoyalty.
  • The viem reader reads paid, claimable, claimed, and legacy settlement rows instead of treating every MusicRoyAccessPaid as recipient income.
  • Claimable rows are reconciled with MusicRoyRoyaltyClaimed FIFO per recipient so a refreshed ledger does not keep cleared accruals labeled claimable.
  • The artist console discovers known royalty runtimes from the connected wallet's own artist runtime plus catalog tracks where the wallet appears as a split recipient. Claim writes run against every known runtime that still reports a pending balance.
  • The Product CDM reader can query claimable balances and the writer can submit musicRoyClaim; historical Product event reads remain unsupported until an indexer/event API exists.
  • The artist console shows settled and claimable balances separately and only claims receipt after post-claim read-back confirms pending balances cleared.

Operations:

  • runtime:export saves one SmartRuntime catalogue, royalty splits, track-state hash, and optional recipient claimable balance.
  • runtime:deploy-royalties-facet deploys the current W05 MusicRoyaltiesPallet as a stateless facet with code-hash confirmation and durable evidence.
  • runtime:royalties-upgrade plans, simulates, and optionally executes an owner-signed W05 royalties facet cut with an exact digest and durable evidence file.
  • runtime:migration-plan renders replay calldata from an export snapshot for clean-redeploy fallback, while blocking runtime-bound encrypted audio refs by default.

Design decisions and tradeoffs

Chosen: immediate distribution plus bounded-gas claimable fallback.

This is smaller and less disruptive than converting all royalty settlement to a pull-only model. Artists and compatible collaborators keep immediate settlement, while incompatible recipients no longer block access purchases.

Chosen upgrade path: in-place Diamond facet cut when the artist still controls the runtime.

This preserves the runtime address, protected-audio key binding, catalogue storage, paid-access state, royalty splits, and claimable balances. Clean redeploy remains a fallback only; replaying track registrations to a new runtime cannot move paid-access grants or claimable native-token balances, and encrypted dotify:enc:v2: audio requires re-encryption or explicit key recovery.

The first live operator attempt showed why the explicit facet-deploy step matters: runtime:royalties-upgrade correctly rejects a stale target facet when deployments.json still points to an older on-chain MusicRoyaltiesPallet. The corrected sequence is deploy current facet, then pass its manifest facet address to runtime:royalties-upgrade --facet <NEW_FACET>.

Deferred:

  • Product CASH settlement remains a separate rail.
  • No automatic live facet cut or redeploy is included.
  • Product CDM payment history still needs an event/indexer source.

Security, failure, and operations

  • No production secrets or frontend payment bypasses are introduced.
  • Every ETH-sending external entry point stays behind the shared non-reentrancy guard.
  • Recipient transfers are bounded to avoid unbounded gas consumption.
  • Failed recipient payouts stay in runtime storage and are not counted as received.
  • musicRoyClaim(recipient) requires recipient == msg.sender, so a helper cannot drain another recipient's pending balance.
  • A failed claim does not revert; it restores the pending balance and emits MusicRoyRoyaltyClaimFailed, allowing the failure to be auditable.
  • Existing runtimes need a royalties facet upgrade or clean redeploy before W05 native-payment semantics are live.
  • Rollback after W05 payments may have created claimable balances must keep a claim-capable facet available until balances are settled or migrated.
  • runtime:deploy-royalties-facet and runtime:royalties-upgrade are dry-run by default. The deploy task refuses execution without chain/code-hash confirmation and --out. The upgrade task refuses execution without --out, verifies target facet bytecode, simulates the owner call, persists signed/broadcast evidence, waits for finality, verifies selector routing, and compares the post-upgrade catalogue hash with the pre-upgrade hash.

Review guide

Suggested order

  1. contracts/evm/contracts/libraries/LibMusicRoyalties.sol and contracts/evm/contracts/pallets/MusicRoyaltiesPallet.sol - verify settlement accounting, gas-bounded calls, claim restore behavior, and non-reentrancy assumptions.
  2. contracts/evm/tasks/registryUpgrade.ts and contracts/evm/test/RegistryUpgradeTasks.test.ts - verify runtime export, royalties facet deployment, royalties upgrade evidence, finality/readback, and migration-plan guardrails.
  3. contracts/evm/test/ArtistRuntime.test.ts and contracts/evm/contracts/test/RoyaltyFailureRecipients.sol - verify the failure fixtures, repeated purchase accounting, dust/rounding, and forkless upgrade rehearsal.
  4. contracts/evm/contracts/ArtistRuntimeFactory.sol plus generated ABI files - verify new runtime selector installation and binding drift.
  5. web/src/features/runtime/* - verify viem/Product CDM ports preserve adapter boundaries, claim reconciliation, split-recipient runtime discovery, and legacy history.
  6. web/src/hooks/useArtistConsole.ts, ArtistStudioProvider.tsx, and artist view files - verify claimable values are never displayed as settled income.
  7. Docs and runbooks - verify deployment/rollback guidance is accurate and does not imply live deployment happened.

Verify carefully

  • Can one rejecting recipient ever revert another listener's valid Classic purchase?
  • Does paidAccess remain accurate when one recipient share becomes claimable?
  • Does paid plus claimable always equal the distributable amount across repeated purchases?
  • Can an unauthorized wallet claim someone else's balance?
  • Does the UI ever display a claimable amount as money already received?
  • Are claimed accruals shown as historical received rows after a successful claim refresh?
  • Can a collaborator without their own artist runtime reach the originating runtime that holds their split balance?
  • Are pre-W05 access-payment rows retained as legacy history without being counted as settled recipient income?
  • Does the operator path deploy the matching royalties facet before planning a runtime selector cut?
  • Does rollback/upgrade guidance preserve claimable funds after W05 payments exist?
  • Does Product CDM stay honest about the missing historical event API?

Validation

Evidence What it proves
npm --prefix contracts/evm test -> 66 passing Contract behavior, recipient failure isolation, claims, accounting, reentrancy, upgrade rehearsal, explicit royalties facet deployment, runtime export, royalties upgrade task execution, and migration-plan guardrails
npm --prefix contracts/evm run fmt:check -> pass Solidity/contract package formatting
npm --prefix contracts/evm run compile -> pass Hardhat compilation/task loading remains valid
npm exec -- hardhat help runtime:export -> pass Snapshot task is registered
npm exec -- hardhat help runtime:deploy-royalties-facet -> pass Royalties facet deployment task is registered
npm exec -- hardhat help runtime:royalties-upgrade -> pass Royalties upgrade task is registered
npm exec -- hardhat help runtime:migration-plan -> pass Clean-redeploy fallback plan task is registered
npm --prefix contracts/evm run generate:abis -> pass viem ABI bindings match compiled contracts
npm --prefix web run generate:cdm -> pass Product CDM runtime manifest/types include W05 selectors
npm --prefix web run generate:cdm-metadata -> pass Fixed CDM package metadata remains deterministic
npm --prefix web run fmt:check -> pass Web formatting remains clean
npm --prefix web run test:unit -> 52 files, 400 tests passing Runtime adapters, claim routing, claim reconciliation, split-recipient discovery, existing frontend regressions
npm --prefix web run test:unit -- src/features/runtime/viemRuntimeAdapter.test.ts src/features/runtime/royaltyRuntimeClaims.test.ts -> 12 passing PR review regressions for claimed/legacy ledger rows and split-recipient runtime discovery
npm --prefix web run lint -> exit 0, 3 warnings No lint errors; existing React hook dependency warnings remain in App.tsx and ArtistShell.tsx
npm --prefix web run build -> pass Standard production build compiles
npm --prefix web run generate:product-catalog-bootstrap:strict -- --input fixtures/product-devnet-catalog.json -> pass Product bootstrap matches the deterministic 8-item fixture expected by CI
CATALOG_API_URL=http://127.0.0.1:9 npm --prefix web run build:product-devnet -> pass Product DevNet bundle compiles offline and keeps the fixture bootstrap
node scripts/backlog-sync.mjs --check --offline -> pass with existing warnings Backlog metadata remains parseable
git diff --check -> pass No whitespace errors

Detailed handoff: docs/backlog/implementation/evidence/W05.md

Known limitations and follow-ups

  • This PR does not deploy contracts, perform a live facet cut, publish Product, or execute a real Product host payment/claim smoke.
  • Existing live runtimes are not W05-ready until upgraded or recreated from a W05-ready factory.
  • Prefer runtime:deploy-royalties-facet:testnet, then runtime:royalties-upgrade:testnet -- --facet <NEW_FACET> for old SmartRuntimes owned by the artist. Use runtime:export plus runtime:migration-plan only for clean-redeploy fallback planning.
  • Product CDM claim writes are wired, but the Product settlement ledger still needs an event/indexer source.
  • The Product bootstrap catalog is pinned to the deterministic 8-item fixture; live index freshness metadata was not committed so CI remains stable.

Metadata checklist

  • Backlog issue linked with correct close/reference semantics
  • Local backlog document linked
  • Added to Project 5 (Dotify sprints)
  • Project Priority, Track, Phase, Type, and Backlog doc mirror the issue
  • Workflow status matches draft/review state
  • Assignee set
  • Applicable labels set
  • Applicable milestone set, or confirmed none exists
  • Reviewers requested when ownership is known (none known for this scope)
  • Draft/ready state is intentional

@netlify

netlify Bot commented Sep 8, 2026

Copy link
Copy Markdown

Deploy Preview for muzinga canceled.

Name Link
🔨 Latest commit 78c28e9
🔍 Latest deploy log https://app.netlify.com/projects/muzinga/deploys/6a9fe09c99ad01000864d42e

@knzeng-e knzeng-e added contracts dotify-backlog Tracked by docs/backlog/backlog.json and Project 5 frontend P1 product labels Sep 8, 2026
@knzeng-e knzeng-e self-assigned this Sep 8, 2026
@knzeng-e knzeng-e moved this from Todo to In Progress in Dotify sprints Sep 8, 2026
@knzeng-e
knzeng-e marked this pull request as ready for review September 8, 2026 08:08
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-08T08:15:38.646671Z a2712f2 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a2712f25f5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

listener,
recipient,
amountWei,
settlement: 'claimable',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reconcile claims before labeling accruals as claimable

After musicRoyClaim succeeds, the pending balance is cleared and MusicRoyRoyaltyClaimed is emitted, but this reader never fetches claim events and permanently maps every historical accrual to settlement: 'claimable'. Refreshing after a claim therefore leaves those rows labeled Claimable and excludes the claimed amounts from the settled total, even while the current claimable metric is zero. Consume claim events to reconcile the ledger, or represent these rows as historical accruals rather than current pending funds.

Useful? React with 👍 / 👎.

Comment thread web/src/hooks/useArtistConsole.ts Outdated
title: 'Claiming royalties',
message: 'Submitting the pending royalty claim from your SmartRuntime.'
});
const txHash = await runtimeWriter.claimRoyalty(artistRuntimeAddress, activeEvmAddress);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Let split recipients select the runtime holding their funds

When the connected wallet is a collaborator listed in another artist's royalty splits, artistRuntimeAddress was resolved through directory.runtimeOf(activeEvmAddress) at useArtistConsole.ts:361, so it is null or points to the collaborator's own runtime rather than the runtime that accrued the royalty. The new claim path consequently cannot reach the balance held in the originating artist's runtime, and ArtistShell.tsx:33 shows onboarding instead of the claim UI when the collaborator has no runtime. The claim flow needs a way to discover or select runtimes where the connected recipient has pending funds.

Useful? React with 👍 / 👎.

const [paidLogs, claimableLogs] = await Promise.all([
client().getLogs({
address: runtimeAddress,
event: musicRoyRoyaltyPaidEvent,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve pre-upgrade royalty history

For an existing runtime, including one upgraded in place, purchases made before W05 emitted only MusicRoyAccessPaid; the new per-recipient events are not retroactive. Because the reader now queries only MusicRoyRoyaltyPaid and MusicRoyRoyaltyClaimable, all pre-upgrade payment rows disappear permanently after this frontend change, even after the facet is upgraded. Retain a legacy access-payment query and present those records with an explicit legacy/unknown-settlement state rather than reporting an empty ledger.

Useful? React with 👍 / 👎.

@knzeng-e

knzeng-e commented Sep 8, 2026

Copy link
Copy Markdown
Owner Author

Review follow-up pushed in 5d814f3 with evidence update in 33e191b.

Addressed the three inline comments:

  • claimable rows are reconciled with MusicRoyRoyaltyClaimed and become claimed after successful claim refreshes;
  • split recipients can now see/claim from known originating artist runtimes even if they do not have their own runtime;
  • pre-W05 MusicRoyAccessPaid rows remain visible as explicit legacy history without being counted as settled recipient income.

Also added the upgrade/migration operator path:

  • runtime:export:testnet snapshots one SmartRuntime catalogue/splits and optional recipient claimable balance;
  • runtime:royalties-upgrade:testnet dry-runs by default, then can execute an owner-signed W05 royalties facet cut with digest confirmation and durable evidence;
  • runtime:migration-plan renders replay calldata for clean-redeploy fallback and blocks runtime-bound encrypted audio refs by default.

Validated with contracts/web format checks, npm --prefix contracts/evm test (63 passing), full web unit tests (52 files / 400 tests), lint, standard build, Product DevNet offline build, backlog sync, and whitespace check. No live facet cut, redeploy, Product publish, or hosted smoke was executed.

@knzeng-e

knzeng-e commented Sep 8, 2026

Copy link
Copy Markdown
Owner Author

Follow-up for the code-hash mismatch seen during the live dry-run.

The error was correct: runtime:royalties-upgrade defaulted to deployments.json -> pallets.royaltiesPallet, but that on-chain address still has the old royalties facet bytecode. The task must refuse that, otherwise it could cut a runtime to a facet that does not match the reviewed W05 source.

Pushed 5dafb28 and 78c28e9:

  • added runtime:deploy-royalties-facet:testnet to deploy the current W05 MusicRoyaltiesPallet as a stateless facet with chain/code-hash confirmation and durable evidence;
  • improved the mismatch error so it tells the operator to deploy the current facet and rerun upgrade with --facet <NEW_FACET>;
  • updated the Product DevNet runbook and W05 evidence;
  • added tests for deploy dry-run, deploy execute, upgrade mismatch messaging, and migration guardrails.

Correct sequence for the runtime from the failed attempt:

cd contracts/evm
npm run runtime:export:testnet -- --runtime 0xB60e91CcAcD08B6cb0Ddb2E678F90791901e9338 --recipient <ARTIST_OR_SPLIT_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 <LOCAL_CODE_HASH_FROM_DRY_RUN> --out /tmp/dotify-royalties-facet.json
npm run runtime:royalties-upgrade:testnet -- --runtime 0xB60e91CcAcD08B6cb0Ddb2E678F90791901e9338 --facet <FACET_FROM_/tmp/dotify-royalties-facet.json> --out /tmp/dotify-royalties-upgrade-plan.json
npm run runtime:royalties-upgrade:testnet -- --runtime 0xB60e91CcAcD08B6cb0Ddb2E678F90791901e9338 --facet <FACET_FROM_/tmp/dotify-royalties-facet.json> --execute --confirm-plan <PLAN_DIGEST_FROM_DRY_RUN> --out /tmp/dotify-royalties-upgrade-final.json

Latest local validation includes npm --prefix contracts/evm test -> 66 passing, npm --prefix contracts/evm run fmt:check, npm --prefix contracts/evm run compile, npm --prefix web run fmt:check, and git diff --check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contracts dotify-backlog Tracked by docs/backlog/backlog.json and Project 5 frontend P1 product

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

1 participant