From e4424bb5a19bc52122a48191106e32d496b63c48 Mon Sep 17 00:00:00 2001 From: Abhishek Krishna Date: Sun, 24 May 2026 17:18:58 +0530 Subject: [PATCH 1/4] =?UTF-8?q?feat(rights):=20MuzixRightsOffering=20?= =?UTF-8?q?=E2=80=94=20on-chain=20term-sheet=20registry=20+=20Sapta=20pilo?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Artist publishes a draft offering of distribution / sync / master / publishing rights (single asset or whole catalogue) with base economics. Labels, IP buyers, distributors, and sync agencies submit counters: either accept the base outright via acceptBaseTerms, or propose modified Economics with an off-chain memo. Counters can post a refundable MUSD bond as earnest. Artist picks one counter to accept; offering flips to Accepted and acceptedCounterId is the canonical on-chain commitment reference. Losing counters remain Pending until each bidder calls withdrawCounter to recover their bond. Contract is settlement-token-agnostic (IERC20 + SafeERC20), so it deploys unchanged against MUSD on whichever L1 holds the token — create-protocol L1 in production or muzix L1 for early testnet runs. Sapta pilot ships two drafts via script/DeploySaptaPilot.s.sol: - Album: 3-yr exclusive distribution, $25K upfront / $50K MG / 6500 bps to artist, worldwide, 30-day reply window. - Catalogue: 5-yr exclusive full rights, $150K upfront / $400K MG / 7000 bps to artist, worldwide, 45-day reply window. Numbers are placeholders for the pilot bootstrap — tune in the script or update the draft via updateDraft before publishing. docs/sapta-pilot.md walks bidders through how to participate (acceptBaseTerms vs submitCounter), how acceptance works, and how the off-chain subject manifest hooks into the on-chain subjectHash + subjectURI fields. Out of scope (downstream contracts wire these against an accepted offering's terms): upfront / MG settlement, royalty execution against MuzixStreamingOracle data, KYC gating, rights NFT issuance. 31 new Foundry tests cover authoring lifecycle (create/update/publish/ withdraw/expire), counter intake (custom terms + acceptBaseTerms + zero-bond + below-minimum-bond), acceptance/rejection flows with bond accounting, and access control (NotArtist / NotBidder paths). Full suite: 46/46 passing. --- docs/sapta-pilot.md | 140 +++++++++ script/DeploySaptaPilot.s.sol | 137 +++++++++ src/MuzixRightsOffering.sol | 462 ++++++++++++++++++++++++++++++ test/MuzixRightsOffering.t.sol | 509 +++++++++++++++++++++++++++++++++ 4 files changed, 1248 insertions(+) create mode 100644 docs/sapta-pilot.md create mode 100644 script/DeploySaptaPilot.s.sol create mode 100644 src/MuzixRightsOffering.sol create mode 100644 test/MuzixRightsOffering.t.sol diff --git a/docs/sapta-pilot.md b/docs/sapta-pilot.md new file mode 100644 index 00000000..aea13fd1 --- /dev/null +++ b/docs/sapta-pilot.md @@ -0,0 +1,140 @@ +# Sapta Rights-Offering Pilot + +A live pilot of [`MuzixRightsOffering`](../src/MuzixRightsOffering.sol) with two +drafts published under the artist Sapta: + +| Draft | Scope | Term | Upfront | Min Guarantee | Artist Royalty | Reply Window | +|------|------|------|---------|---------------|----------------|--------------| +| Album | Single album, exclusive distribution, worldwide | 3 years | $25,000 | $50,000 | 65% | 30 days | +| Catalogue | Whole catalogue, exclusive full rights (dist + sync + master + publishing), worldwide | 5 years | $150,000 | $400,000 | 70% | 45 days | + +These are the artist's **base terms**. Labels, IP buyers, distributors, and +sync agencies can either accept the base outright or submit a counter that +modifies any subset of the economics. Numbers above are placeholders for the +pilot bootstrap and can be tuned before publishing — see +[`script/DeploySaptaPilot.s.sol`](../script/DeploySaptaPilot.s.sol). + +## How to participate as a bidder + +Bidders need: +1. An EVM wallet with MUSD on the target chain. +2. The deployed `MuzixRightsOffering` address (announced post-deploy). +3. The relevant `offeringId` for the draft you're responding to. + +### Accept the base terms + +```solidity +// 1. Approve the offering contract to pull your bond +musd.approve(offeringAddr, bondAmount); + +// 2. Submit your commitment to the artist's exact base terms +uint256 counterId = offering.acceptBaseTerms( + offeringId, + "ipfs://", + bondAmount // must be >= offering.minBondUsd +); +``` + +### Counter with modified economics + +```solidity +MuzixRightsOffering.Economics memory myTerms = MuzixRightsOffering.Economics({ + upfrontUsd: 30_000e6, // beating the artist's $25K floor + minGuaranteeUsd: 60_000e6, + artistRoyaltyBps: 6000, // proposing 60/40 split + advanceRecoupCapUsd: 30_000e6 +}); + +musd.approve(offeringAddr, bondAmount); +uint256 counterId = offering.submitCounter( + offeringId, + myTerms, + "ipfs://", + bondAmount +); +``` + +The bond is **earnest, not payment**. Posting it signals you have the capital +and are serious. It is escrowed in the contract and refunded when: +- the artist accepts your counter (winning bond returns to you; upfront + settlement is handled separately by a downstream contract); +- the artist accepts someone else's counter (call `withdrawCounter`); +- the artist explicitly rejects your counter; +- the artist withdraws the offering; +- the deadline passes and anyone calls `markExpired`. + +## How acceptance works + +1. The artist (Sapta's wallet) reviews counters off-chain. +2. The artist calls `acceptCounter(counterId)` to lock in the winning bid. +3. The offering flips to `Accepted`; the accepted counter's id is recorded as + `acceptedCounterId` on the offering. +4. Losing counters remain `Pending` until each bidder calls + `withdrawCounter` to recover their bond. + +The on-chain record is the **commitment**, not the settlement. A downstream +contract reads `acceptedCounterId` to execute the upfront payment and to +register the licensee against the catalog. Off-chain, the parties sign the +prose agreement referenced by the bidder's `memoURI` and the artist's +`subjectURI`. + +## Subject manifests + +Each draft references an off-chain manifest by IPFS URI and keccak256 hash. +The album manifest describes the specific album (ISRC list, track titles, +masters status, existing encumbrances). The catalogue manifest describes the +full set of works and any carve-outs. Suggested manifest schema: + +```jsonc +{ + "type": "album" | "catalogue", + "artist": "Sapta", + "subjectName": "", + "tracks": [ + { "isrc": "AAXX000000001", "title": "...", "duration": 213 } + ], + "existingEncumbrances": [], + "createdAt": "2026-05-24", + "version": 1 +} +``` + +Pin the manifest to IPFS, compute `keccak256` of the bytes, and pass both as +`subjectURI` / `subjectHash` to `createOffering`. + +## Deploy + +```bash +export SAPTA_ARTIST=0x... # Sapta's wallet +export MUSD_TOKEN=0x... # MUSD on the target L1 +export SAPTA_ALBUM_URI=ipfs://... +export SAPTA_ALBUM_HASH=0x... +export SAPTA_CAT_URI=ipfs://... +export SAPTA_CAT_HASH=0x... + +forge script script/DeploySaptaPilot.s.sol \ + --rpc-url $RPC_URL \ + --broadcast \ + --private-key $DEPLOYER_KEY +``` + +Then publish each draft from Sapta's wallet: + +```bash +cast send $OFFERING_ADDR "publishOffering(uint256)" $ALBUM_ID \ + --rpc-url $RPC_URL --private-key $SAPTA_KEY + +cast send $OFFERING_ADDR "publishOffering(uint256)" $CATALOGUE_ID \ + --rpc-url $RPC_URL --private-key $SAPTA_KEY +``` + +## What's deliberately out of scope + +This pilot ships the **commitment surface** only. The following are downstream +contracts that read an accepted offering's terms and execute: + +- Upfront / minimum-guarantee payment from licensee → artist. +- Royalty stream settlement (driven by `MuzixStreamingOracle` revenue data). +- KYC / accreditation gating of bidders. +- Rights NFT issuance (the `(offeringId, acceptedCounterId)` pair is the + canonical on-chain commitment reference until then). diff --git a/script/DeploySaptaPilot.s.sol b/script/DeploySaptaPilot.s.sol new file mode 100644 index 00000000..c5d3a0a5 --- /dev/null +++ b/script/DeploySaptaPilot.s.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import {MuzixRightsOffering} from "../src/MuzixRightsOffering.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/** + * @title DeploySaptaPilot + * @notice Deploys MuzixRightsOffering and seeds two draft offerings for artist + * Sapta: one for a single album, one catalogue-wide. Both drafts are + * created but not published — the artist (or their representative) + * calls `publishOffering` from their own wallet to make them visible + * to bidders. + * + * Numbers below are placeholders for the pilot; tune in the env vars + * or directly here before broadcasting. + * + * Required env: + * - SAPTA_ARTIST : EOA that the offerings will be created under + * (script `prank`s to this address so artist + * identity is correct in storage). + * - MUSD_TOKEN : settlement-token address (MUSD on the target + * chain — create-protocol L1 in production, + * muzix L1 in early testnet runs). + * - SAPTA_ALBUM_URI : ipfs:// manifest URI for the album subject. + * - SAPTA_ALBUM_HASH : keccak256 of the album manifest. + * - SAPTA_CAT_URI : ipfs:// manifest URI for the catalogue subject. + * - SAPTA_CAT_HASH : keccak256 of the catalogue manifest. + * + * Usage: + * forge script script/DeploySaptaPilot.s.sol \ + * --rpc-url $RPC_URL \ + * --broadcast \ + * --private-key $DEPLOYER_KEY + */ +contract DeploySaptaPilot is Script { + struct PilotConfig { + address sapta; + IERC20 musd; + bytes32 albumSubjectHash; + string albumSubjectURI; + bytes32 catalogueSubjectHash; + string catalogueSubjectURI; + } + + function run() external { + PilotConfig memory cfg = _loadConfig(); + + vm.startBroadcast(); + MuzixRightsOffering offering = new MuzixRightsOffering(); + vm.stopBroadcast(); + + // Author the two drafts under Sapta's identity. In a broadcast run + // these calls must be signed by Sapta's wallet — typically a separate + // forge script invocation from the artist's key. For the pilot + // bootstrap we surface the parameters here so they live in version + // control as the canonical reference; the artist (or a delegated + // operator with their key) then runs `publishOffering` to flip status. + vm.startBroadcast(cfg.sapta); + + uint256 albumId = offering.createOffering( + cfg.albumSubjectHash, + cfg.albumSubjectURI, + _albumRights(), + _albumEconomics(), + cfg.musd, + 5_000e6, // 5,000 MUSD minimum bond + uint64(block.timestamp + 30 days) + ); + + uint256 catalogueId = offering.createOffering( + cfg.catalogueSubjectHash, + cfg.catalogueSubjectURI, + _catalogueRights(), + _catalogueEconomics(), + cfg.musd, + 25_000e6, // 25,000 MUSD minimum bond for the bigger deal + uint64(block.timestamp + 45 days) + ); + + vm.stopBroadcast(); + + console2.log("MuzixRightsOffering deployed at:", address(offering)); + console2.log("Sapta album draft id:", albumId); + console2.log("Sapta catalogue draft id:", catalogueId); + } + + function _loadConfig() internal view returns (PilotConfig memory cfg) { + cfg.sapta = vm.envAddress("SAPTA_ARTIST"); + cfg.musd = IERC20(vm.envAddress("MUSD_TOKEN")); + cfg.albumSubjectHash = vm.envBytes32("SAPTA_ALBUM_HASH"); + cfg.albumSubjectURI = vm.envString("SAPTA_ALBUM_URI"); + cfg.catalogueSubjectHash = vm.envBytes32("SAPTA_CAT_HASH"); + cfg.catalogueSubjectURI = vm.envString("SAPTA_CAT_URI"); + } + + // ----------------------------------------------------------------------- + // Pilot terms — tune before broadcasting + // ----------------------------------------------------------------------- + + function _albumRights() internal pure returns (MuzixRightsOffering.RightsBundle memory) { + return MuzixRightsOffering.RightsBundle({ + rightsType: MuzixRightsOffering.RightsType.Distribution, + exclusive: true, + territoryHash: bytes32(0), // 0 = worldwide + termSeconds: uint64(365 days * 3) // 3 years + }); + } + + function _albumEconomics() internal pure returns (MuzixRightsOffering.Economics memory) { + return MuzixRightsOffering.Economics({ + upfrontUsd: 25_000e6, // $25,000 upfront (MUSD 6dp) + minGuaranteeUsd: 50_000e6, // $50,000 MG over term + artistRoyaltyBps: 6500, // 65% to artist, 35% to label + advanceRecoupCapUsd: 25_000e6 // advance recoups against royalty stream + }); + } + + function _catalogueRights() internal pure returns (MuzixRightsOffering.RightsBundle memory) { + return MuzixRightsOffering.RightsBundle({ + rightsType: MuzixRightsOffering.RightsType.FullRights, + exclusive: true, + territoryHash: bytes32(0), + termSeconds: uint64(365 days * 5) + }); + } + + function _catalogueEconomics() internal pure returns (MuzixRightsOffering.Economics memory) { + return MuzixRightsOffering.Economics({ + upfrontUsd: 150_000e6, // $150,000 upfront + minGuaranteeUsd: 400_000e6, // $400,000 MG over 5 years + artistRoyaltyBps: 7000, // 70% to artist + advanceRecoupCapUsd: 150_000e6 + }); + } +} diff --git a/src/MuzixRightsOffering.sol b/src/MuzixRightsOffering.sol new file mode 100644 index 00000000..f1a86c00 --- /dev/null +++ b/src/MuzixRightsOffering.sol @@ -0,0 +1,462 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +/** + * @title MuzixRightsOffering + * @author kcolbchain + * @notice On-chain "term sheet" registry. An artist publishes a draft offering + * of distribution / sync / master-licensing / publishing rights — for a + * single asset or a whole catalogue — with base economics. Labels, + * IP buyers, distributors, and sync agencies submit counters: either + * accept the base outright, or propose modified economics with an + * off-chain memo. Each counter may post a refundable MUSD bond as + * earnest. The artist picks one counter to accept; the on-chain + * record is the commitment, and downstream settlement contracts + * consume it for payment and rights-transfer execution. + * + * The contract intentionally tracks only the commitment surface: + * - Draft authoring + open publication. + * - Counter intake with optional bonded earnest. + * - Acceptance / withdrawal / expiry lifecycle. + * - Bond escrow + refund flows. + * + * Out of scope (downstream contracts wire these against an accepted + * offering's terms): + * - Upfront / minimum-guarantee payment execution. + * - Royalty stream settlement (handled by MUSD / oracle). + * - KYC / accreditation gating of bidders. + * - Rights NFT issuance (an accepted offering id is itself the + * canonical on-chain commitment reference). + * + * All monetary fields are denominated in the offering's settlement + * token (MUSD by convention) with that token's native decimals. + */ +contract MuzixRightsOffering is ReentrancyGuard { + using SafeERC20 for IERC20; + + // ----------------------------------------------------------------------- + // Types + // ----------------------------------------------------------------------- + + enum RightsType { + Distribution, + Sync, + MasterLicense, + Publishing, + FullRights + } + + enum OfferingStatus { + Draft, + Open, + Accepted, + Withdrawn, + Expired + } + + enum CounterStatus { + Pending, + Accepted, + Rejected, + Withdrawn + } + + /// @notice Scope of rights being licensed. `termSeconds == 0` means + /// perpetual; `territoryHash == 0` means worldwide. + struct RightsBundle { + RightsType rightsType; + bool exclusive; + bytes32 territoryHash; + uint64 termSeconds; + } + + /// @notice Economic terms — base terms set by the artist; counters + /// propose alternates of the same shape. + struct Economics { + uint256 upfrontUsd; + uint256 minGuaranteeUsd; + uint16 artistRoyaltyBps; + uint256 advanceRecoupCapUsd; + } + + struct Offering { + address artist; + bytes32 subjectHash; + string subjectURI; + RightsBundle rights; + Economics baseTerms; + IERC20 settlementToken; + uint256 minBondUsd; + uint64 repliesDueBy; + uint64 createdAt; + OfferingStatus status; + uint256 acceptedCounterId; + } + + struct Counter { + uint256 offeringId; + address bidder; + Economics terms; + string memoURI; + uint256 bondAmount; + CounterStatus status; + uint64 submittedAt; + } + + // ----------------------------------------------------------------------- + // Constants + // ----------------------------------------------------------------------- + + uint16 public constant BPS_DENOM = 10000; + + // ----------------------------------------------------------------------- + // Storage + // ----------------------------------------------------------------------- + + uint256 public nextOfferingId = 1; + uint256 public nextCounterId = 1; + + mapping(uint256 => Offering) internal _offerings; + mapping(uint256 => Counter) internal _counters; + mapping(uint256 => uint256[]) internal _offeringCounters; + + // ----------------------------------------------------------------------- + // Events + // ----------------------------------------------------------------------- + + event OfferingCreated(uint256 indexed offeringId, address indexed artist, bytes32 subjectHash, string subjectURI); + event OfferingDraftUpdated(uint256 indexed offeringId); + event OfferingPublished(uint256 indexed offeringId, uint64 repliesDueBy); + event OfferingWithdrawn(uint256 indexed offeringId); + event OfferingExpired(uint256 indexed offeringId); + + event CounterSubmitted( + uint256 indexed counterId, + uint256 indexed offeringId, + address indexed bidder, + bool acceptedBaseTerms, + uint256 bondAmount + ); + event CounterWithdrawn(uint256 indexed counterId); + event CounterAccepted(uint256 indexed counterId, uint256 indexed offeringId); + event CounterRejected(uint256 indexed counterId); + event BondRefunded(uint256 indexed counterId, address indexed bidder, uint256 amount); + + // ----------------------------------------------------------------------- + // Errors + // ----------------------------------------------------------------------- + + error NotArtist(uint256 offeringId, address caller); + error NotBidder(uint256 counterId, address caller); + error OfferingNotFound(uint256 offeringId); + error CounterNotFound(uint256 counterId); + error WrongOfferingStatus(uint256 offeringId, OfferingStatus current, OfferingStatus required); + error WrongCounterStatus(uint256 counterId, CounterStatus current); + error InvalidBps(uint256 bps); + error InvalidDeadline(uint64 repliesDueBy, uint256 nowTs); + error DeadlinePassed(uint256 offeringId, uint64 repliesDueBy); + error DeadlineNotReached(uint256 offeringId, uint64 repliesDueBy); + error BondBelowMinimum(uint256 provided, uint256 required); + error SubjectURIRequired(); + error SubjectHashRequired(); + error ZeroSettlementToken(); + + // ----------------------------------------------------------------------- + // Modifiers + // ----------------------------------------------------------------------- + + modifier onlyArtist(uint256 offeringId) { + Offering storage o = _offerings[offeringId]; + if (o.artist == address(0)) revert OfferingNotFound(offeringId); + if (o.artist != msg.sender) revert NotArtist(offeringId, msg.sender); + _; + } + + // ----------------------------------------------------------------------- + // Authoring & lifecycle + // ----------------------------------------------------------------------- + + /** + * @notice Create a new draft offering. Caller becomes the artist. + * The draft is not visible to bidders until `publish` is called. + */ + function createOffering( + bytes32 subjectHash, + string calldata subjectURI, + RightsBundle calldata rights, + Economics calldata baseTerms, + IERC20 settlementToken, + uint256 minBondUsd, + uint64 repliesDueBy + ) external returns (uint256 offeringId) { + if (subjectHash == bytes32(0)) revert SubjectHashRequired(); + if (bytes(subjectURI).length == 0) revert SubjectURIRequired(); + if (address(settlementToken) == address(0)) revert ZeroSettlementToken(); + if (baseTerms.artistRoyaltyBps > BPS_DENOM) revert InvalidBps(baseTerms.artistRoyaltyBps); + + offeringId = nextOfferingId++; + Offering storage o = _offerings[offeringId]; + o.artist = msg.sender; + o.subjectHash = subjectHash; + o.subjectURI = subjectURI; + o.rights = rights; + o.baseTerms = baseTerms; + o.settlementToken = settlementToken; + o.minBondUsd = minBondUsd; + o.repliesDueBy = repliesDueBy; + o.createdAt = uint64(block.timestamp); + o.status = OfferingStatus.Draft; + + emit OfferingCreated(offeringId, msg.sender, subjectHash, subjectURI); + } + + /** + * @notice Edit a draft offering. Only allowed while in `Draft` state; + * once published, the artist must withdraw and republish. + */ + function updateDraft( + uint256 offeringId, + bytes32 subjectHash, + string calldata subjectURI, + RightsBundle calldata rights, + Economics calldata baseTerms, + uint256 minBondUsd, + uint64 repliesDueBy + ) external onlyArtist(offeringId) { + Offering storage o = _offerings[offeringId]; + if (o.status != OfferingStatus.Draft) { + revert WrongOfferingStatus(offeringId, o.status, OfferingStatus.Draft); + } + if (subjectHash == bytes32(0)) revert SubjectHashRequired(); + if (bytes(subjectURI).length == 0) revert SubjectURIRequired(); + if (baseTerms.artistRoyaltyBps > BPS_DENOM) revert InvalidBps(baseTerms.artistRoyaltyBps); + + o.subjectHash = subjectHash; + o.subjectURI = subjectURI; + o.rights = rights; + o.baseTerms = baseTerms; + o.minBondUsd = minBondUsd; + o.repliesDueBy = repliesDueBy; + + emit OfferingDraftUpdated(offeringId); + } + + /** + * @notice Move a draft offering to `Open`, making it eligible to receive + * counters. Deadline must be in the future. + */ + function publishOffering(uint256 offeringId) external onlyArtist(offeringId) { + Offering storage o = _offerings[offeringId]; + if (o.status != OfferingStatus.Draft) { + revert WrongOfferingStatus(offeringId, o.status, OfferingStatus.Draft); + } + if (o.repliesDueBy <= block.timestamp) { + revert InvalidDeadline(o.repliesDueBy, block.timestamp); + } + o.status = OfferingStatus.Open; + emit OfferingPublished(offeringId, o.repliesDueBy); + } + + /** + * @notice Cancel an offering. Allowed in `Draft` or `Open`. Any pending + * counters become bond-refundable via `withdrawCounter`. + */ + function withdrawOffering(uint256 offeringId) external onlyArtist(offeringId) { + Offering storage o = _offerings[offeringId]; + if (o.status != OfferingStatus.Draft && o.status != OfferingStatus.Open) { + revert WrongOfferingStatus(offeringId, o.status, OfferingStatus.Open); + } + o.status = OfferingStatus.Withdrawn; + emit OfferingWithdrawn(offeringId); + } + + /** + * @notice Mark a passed-deadline open offering as expired. Permissionless + * so any bidder can clear the way for bond refunds without + * depending on the artist to do bookkeeping. + */ + function markExpired(uint256 offeringId) external { + Offering storage o = _offerings[offeringId]; + if (o.artist == address(0)) revert OfferingNotFound(offeringId); + if (o.status != OfferingStatus.Open) { + revert WrongOfferingStatus(offeringId, o.status, OfferingStatus.Open); + } + if (block.timestamp <= o.repliesDueBy) revert DeadlineNotReached(offeringId, o.repliesDueBy); + o.status = OfferingStatus.Expired; + emit OfferingExpired(offeringId); + } + + // ----------------------------------------------------------------------- + // Counters + // ----------------------------------------------------------------------- + + /** + * @notice Submit a counter with custom economics. Pulls `bondAmount` of + * the offering's settlement token from the caller (must be + * pre-approved). Counter is held in escrow and refundable via + * `withdrawCounter` until the offering closes. + */ + function submitCounter( + uint256 offeringId, + Economics calldata terms, + string calldata memoURI, + uint256 bondAmount + ) external nonReentrant returns (uint256 counterId) { + return _submitCounter(offeringId, terms, memoURI, bondAmount, false); + } + + /** + * @notice Shortcut: submit a counter that exactly matches the artist's + * base terms. Equivalent to `submitCounter(offeringId, base, memo, bond)` + * but the counter is flagged as "accepted base" in its event, so + * downstream UIs can group these distinctly from price-discovery + * counters. + */ + function acceptBaseTerms(uint256 offeringId, string calldata memoURI, uint256 bondAmount) + external + nonReentrant + returns (uint256 counterId) + { + Offering storage o = _offerings[offeringId]; + if (o.artist == address(0)) revert OfferingNotFound(offeringId); + Economics memory base = o.baseTerms; + return _submitCounter(offeringId, base, memoURI, bondAmount, true); + } + + function _submitCounter( + uint256 offeringId, + Economics memory terms, + string memory memoURI, + uint256 bondAmount, + bool acceptedBase + ) internal returns (uint256 counterId) { + Offering storage o = _offerings[offeringId]; + if (o.artist == address(0)) revert OfferingNotFound(offeringId); + if (o.status != OfferingStatus.Open) { + revert WrongOfferingStatus(offeringId, o.status, OfferingStatus.Open); + } + if (block.timestamp > o.repliesDueBy) revert DeadlinePassed(offeringId, o.repliesDueBy); + if (terms.artistRoyaltyBps > BPS_DENOM) revert InvalidBps(terms.artistRoyaltyBps); + if (bondAmount < o.minBondUsd) revert BondBelowMinimum(bondAmount, o.minBondUsd); + + counterId = nextCounterId++; + Counter storage c = _counters[counterId]; + c.offeringId = offeringId; + c.bidder = msg.sender; + c.terms = terms; + c.memoURI = memoURI; + c.bondAmount = bondAmount; + c.status = CounterStatus.Pending; + c.submittedAt = uint64(block.timestamp); + + _offeringCounters[offeringId].push(counterId); + + if (bondAmount > 0) { + o.settlementToken.safeTransferFrom(msg.sender, address(this), bondAmount); + } + + emit CounterSubmitted(counterId, offeringId, msg.sender, acceptedBase, bondAmount); + } + + /** + * @notice Bidder withdraws their pending counter. Bond refunded. + * Allowed regardless of the parent offering's current status, so + * bidders can recover their bond after expiry or withdrawal. + */ + function withdrawCounter(uint256 counterId) external nonReentrant { + Counter storage c = _counters[counterId]; + if (c.bidder == address(0)) revert CounterNotFound(counterId); + if (c.bidder != msg.sender) revert NotBidder(counterId, msg.sender); + if (c.status != CounterStatus.Pending) revert WrongCounterStatus(counterId, c.status); + + c.status = CounterStatus.Withdrawn; + emit CounterWithdrawn(counterId); + _refundBond(counterId); + } + + /** + * @notice Artist accepts a counter. The offering flips to `Accepted` and + * the accepted counter's `acceptedCounterId` is recorded. + * Losing counters remain `Pending` until their bidders call + * `withdrawCounter` to recover their bonds. + * + * @dev The accepted counter's bond is also refunded to the bidder. The + * bond is purely earnest; upfront settlement is handled by a + * downstream contract that reads `acceptedCounterId`. + */ + function acceptCounter(uint256 counterId) external nonReentrant { + Counter storage c = _counters[counterId]; + if (c.bidder == address(0)) revert CounterNotFound(counterId); + + uint256 offeringId = c.offeringId; + Offering storage o = _offerings[offeringId]; + if (o.artist != msg.sender) revert NotArtist(offeringId, msg.sender); + if (o.status != OfferingStatus.Open) { + revert WrongOfferingStatus(offeringId, o.status, OfferingStatus.Open); + } + if (c.status != CounterStatus.Pending) revert WrongCounterStatus(counterId, c.status); + + c.status = CounterStatus.Accepted; + o.status = OfferingStatus.Accepted; + o.acceptedCounterId = counterId; + + emit CounterAccepted(counterId, offeringId); + _refundBond(counterId); + } + + /** + * @notice Artist explicitly rejects a counter. Bond refunded. Other + * counters remain pending and the offering remains `Open` for + * further submissions until the deadline. + */ + function rejectCounter(uint256 counterId) external nonReentrant { + Counter storage c = _counters[counterId]; + if (c.bidder == address(0)) revert CounterNotFound(counterId); + + uint256 offeringId = c.offeringId; + Offering storage o = _offerings[offeringId]; + if (o.artist != msg.sender) revert NotArtist(offeringId, msg.sender); + if (c.status != CounterStatus.Pending) revert WrongCounterStatus(counterId, c.status); + + c.status = CounterStatus.Rejected; + emit CounterRejected(counterId); + _refundBond(counterId); + } + + function _refundBond(uint256 counterId) internal { + Counter storage c = _counters[counterId]; + uint256 amount = c.bondAmount; + if (amount == 0) return; + c.bondAmount = 0; + _offerings[c.offeringId].settlementToken.safeTransfer(c.bidder, amount); + emit BondRefunded(counterId, c.bidder, amount); + } + + // ----------------------------------------------------------------------- + // Reads + // ----------------------------------------------------------------------- + + function getOffering(uint256 offeringId) external view returns (Offering memory) { + Offering storage o = _offerings[offeringId]; + if (o.artist == address(0)) revert OfferingNotFound(offeringId); + return o; + } + + function getCounter(uint256 counterId) external view returns (Counter memory) { + Counter storage c = _counters[counterId]; + if (c.bidder == address(0)) revert CounterNotFound(counterId); + return c; + } + + function counterIdsFor(uint256 offeringId) external view returns (uint256[] memory) { + return _offeringCounters[offeringId]; + } + + function counterCountFor(uint256 offeringId) external view returns (uint256) { + return _offeringCounters[offeringId].length; + } +} diff --git a/test/MuzixRightsOffering.t.sol b/test/MuzixRightsOffering.t.sol new file mode 100644 index 00000000..00e8c58d --- /dev/null +++ b/test/MuzixRightsOffering.t.sol @@ -0,0 +1,509 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "../src/MuzixRightsOffering.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract MockMUSD is ERC20 { + constructor() ERC20("Mock MUSD", "mMUSD") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract MuzixRightsOfferingTest is Test { + MuzixRightsOffering internal offering; + MockMUSD internal musd; + + address internal sapta = address(0x5A97A); + address internal label = address(0x1ABE1); + address internal distributor = address(0xD157); + address internal stranger = address(0xC0DE); + + function setUp() public { + vm.warp(1_900_000_000); + offering = new MuzixRightsOffering(); + musd = new MockMUSD(); + + musd.mint(label, 1_000_000e6); + musd.mint(distributor, 1_000_000e6); + + vm.prank(label); + musd.approve(address(offering), type(uint256).max); + vm.prank(distributor); + musd.approve(address(offering), type(uint256).max); + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + function _baseRights() internal pure returns (MuzixRightsOffering.RightsBundle memory) { + return MuzixRightsOffering.RightsBundle({ + rightsType: MuzixRightsOffering.RightsType.Distribution, + exclusive: true, + territoryHash: bytes32(0), + termSeconds: uint64(365 days * 3) + }); + } + + function _baseEconomics() internal pure returns (MuzixRightsOffering.Economics memory) { + return MuzixRightsOffering.Economics({ + upfrontUsd: 25_000e6, + minGuaranteeUsd: 50_000e6, + artistRoyaltyBps: 6500, + advanceRecoupCapUsd: 25_000e6 + }); + } + + function _createDraftAsSapta(uint64 deadline) internal returns (uint256 id) { + vm.prank(sapta); + id = offering.createOffering( + keccak256("subject-sapta-album"), + "ipfs://bafy-sapta-album-v1", + _baseRights(), + _baseEconomics(), + IERC20(address(musd)), + 5_000e6, + deadline + ); + } + + function _openSaptaOffering() internal returns (uint256 id) { + id = _createDraftAsSapta(uint64(block.timestamp + 30 days)); + vm.prank(sapta); + offering.publishOffering(id); + } + + // --------------------------------------------------------------------- + // Authoring + // --------------------------------------------------------------------- + + function testCreateOfferingStoresDraft() public { + uint256 id = _createDraftAsSapta(uint64(block.timestamp + 30 days)); + + MuzixRightsOffering.Offering memory o = offering.getOffering(id); + assertEq(o.artist, sapta); + assertEq(o.subjectURI, "ipfs://bafy-sapta-album-v1"); + assertEq(uint8(o.status), uint8(MuzixRightsOffering.OfferingStatus.Draft)); + assertEq(o.baseTerms.artistRoyaltyBps, 6500); + assertEq(address(o.settlementToken), address(musd)); + } + + function testCreateRevertsOnMissingSubject() public { + vm.prank(sapta); + vm.expectRevert(MuzixRightsOffering.SubjectHashRequired.selector); + offering.createOffering( + bytes32(0), + "ipfs://x", + _baseRights(), + _baseEconomics(), + IERC20(address(musd)), + 0, + uint64(block.timestamp + 1 days) + ); + } + + function testCreateRevertsOnEmptyURI() public { + vm.prank(sapta); + vm.expectRevert(MuzixRightsOffering.SubjectURIRequired.selector); + offering.createOffering( + keccak256("x"), + "", + _baseRights(), + _baseEconomics(), + IERC20(address(musd)), + 0, + uint64(block.timestamp + 1 days) + ); + } + + function testCreateRevertsOnZeroSettlementToken() public { + vm.prank(sapta); + vm.expectRevert(MuzixRightsOffering.ZeroSettlementToken.selector); + offering.createOffering( + keccak256("x"), + "ipfs://x", + _baseRights(), + _baseEconomics(), + IERC20(address(0)), + 0, + uint64(block.timestamp + 1 days) + ); + } + + function testCreateRevertsOnInvalidBps() public { + MuzixRightsOffering.Economics memory bad = _baseEconomics(); + bad.artistRoyaltyBps = 10001; + vm.prank(sapta); + vm.expectRevert(abi.encodeWithSelector(MuzixRightsOffering.InvalidBps.selector, uint256(10001))); + offering.createOffering( + keccak256("x"), + "ipfs://x", + _baseRights(), + bad, + IERC20(address(musd)), + 0, + uint64(block.timestamp + 1 days) + ); + } + + function testUpdateDraftMutatesBeforePublish() public { + uint256 id = _createDraftAsSapta(uint64(block.timestamp + 30 days)); + + MuzixRightsOffering.Economics memory tweaked = _baseEconomics(); + tweaked.upfrontUsd = 30_000e6; + + vm.prank(sapta); + offering.updateDraft( + id, + keccak256("subject-sapta-album-v2"), + "ipfs://bafy-sapta-album-v2", + _baseRights(), + tweaked, + 5_000e6, + uint64(block.timestamp + 30 days) + ); + + MuzixRightsOffering.Offering memory o = offering.getOffering(id); + assertEq(o.subjectURI, "ipfs://bafy-sapta-album-v2"); + assertEq(o.baseTerms.upfrontUsd, 30_000e6); + } + + function testUpdateDraftRevertsAfterPublish() public { + uint256 id = _openSaptaOffering(); + vm.prank(sapta); + vm.expectRevert( + abi.encodeWithSelector( + MuzixRightsOffering.WrongOfferingStatus.selector, + id, + MuzixRightsOffering.OfferingStatus.Open, + MuzixRightsOffering.OfferingStatus.Draft + ) + ); + offering.updateDraft( + id, + keccak256("x"), + "ipfs://x", + _baseRights(), + _baseEconomics(), + 0, + uint64(block.timestamp + 30 days) + ); + } + + function testUpdateDraftRevertsForNonArtist() public { + uint256 id = _createDraftAsSapta(uint64(block.timestamp + 30 days)); + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(MuzixRightsOffering.NotArtist.selector, id, stranger)); + offering.updateDraft( + id, + keccak256("x"), + "ipfs://x", + _baseRights(), + _baseEconomics(), + 0, + uint64(block.timestamp + 30 days) + ); + } + + function testPublishRevertsOnPastDeadline() public { + uint256 id = _createDraftAsSapta(uint64(block.timestamp + 30 days)); + // Move clock past the configured deadline. + vm.warp(block.timestamp + 31 days); + vm.prank(sapta); + vm.expectRevert(); // InvalidDeadline — selector reverts always; explicit selector path tested elsewhere + offering.publishOffering(id); + } + + function testWithdrawDraftAllowed() public { + uint256 id = _createDraftAsSapta(uint64(block.timestamp + 30 days)); + vm.prank(sapta); + offering.withdrawOffering(id); + assertEq(uint8(offering.getOffering(id).status), uint8(MuzixRightsOffering.OfferingStatus.Withdrawn)); + } + + function testWithdrawOpenAllowed() public { + uint256 id = _openSaptaOffering(); + vm.prank(sapta); + offering.withdrawOffering(id); + assertEq(uint8(offering.getOffering(id).status), uint8(MuzixRightsOffering.OfferingStatus.Withdrawn)); + } + + function testWithdrawRevertsAfterAccepted() public { + uint256 id = _openSaptaOffering(); + vm.prank(label); + uint256 cid = offering.acceptBaseTerms(id, "ipfs://memo", 5_000e6); + vm.prank(sapta); + offering.acceptCounter(cid); + + vm.prank(sapta); + vm.expectRevert( + abi.encodeWithSelector( + MuzixRightsOffering.WrongOfferingStatus.selector, + id, + MuzixRightsOffering.OfferingStatus.Accepted, + MuzixRightsOffering.OfferingStatus.Open + ) + ); + offering.withdrawOffering(id); + } + + function testMarkExpiredAfterDeadline() public { + uint256 id = _openSaptaOffering(); + vm.warp(block.timestamp + 31 days); + offering.markExpired(id); + assertEq(uint8(offering.getOffering(id).status), uint8(MuzixRightsOffering.OfferingStatus.Expired)); + } + + function testMarkExpiredRevertsBeforeDeadline() public { + uint256 id = _openSaptaOffering(); + Offering_HelperReverts(id); + } + + function Offering_HelperReverts(uint256 id) internal { + MuzixRightsOffering.Offering memory o = offering.getOffering(id); + vm.expectRevert( + abi.encodeWithSelector(MuzixRightsOffering.DeadlineNotReached.selector, id, o.repliesDueBy) + ); + offering.markExpired(id); + } + + // --------------------------------------------------------------------- + // Counters + // --------------------------------------------------------------------- + + function testAcceptBaseTermsPullsBond() public { + uint256 id = _openSaptaOffering(); + uint256 labelBefore = musd.balanceOf(label); + + vm.prank(label); + uint256 cid = offering.acceptBaseTerms(id, "ipfs://label-memo", 5_000e6); + + MuzixRightsOffering.Counter memory c = offering.getCounter(cid); + assertEq(c.bidder, label); + assertEq(c.bondAmount, 5_000e6); + assertEq(uint8(c.status), uint8(MuzixRightsOffering.CounterStatus.Pending)); + assertEq(c.terms.upfrontUsd, _baseEconomics().upfrontUsd); + + assertEq(musd.balanceOf(label), labelBefore - 5_000e6); + assertEq(musd.balanceOf(address(offering)), 5_000e6); + } + + function testSubmitCounterCustomTerms() public { + uint256 id = _openSaptaOffering(); + MuzixRightsOffering.Economics memory counter = _baseEconomics(); + counter.upfrontUsd = 18_000e6; + counter.artistRoyaltyBps = 5500; + + vm.prank(distributor); + uint256 cid = offering.submitCounter(id, counter, "ipfs://dist-memo", 5_000e6); + + MuzixRightsOffering.Counter memory c = offering.getCounter(cid); + assertEq(c.terms.upfrontUsd, 18_000e6); + assertEq(c.terms.artistRoyaltyBps, 5500); + } + + function testSubmitRevertsBelowMinBond() public { + uint256 id = _openSaptaOffering(); + vm.prank(label); + vm.expectRevert(abi.encodeWithSelector(MuzixRightsOffering.BondBelowMinimum.selector, 1_000e6, 5_000e6)); + offering.submitCounter(id, _baseEconomics(), "ipfs://memo", 1_000e6); + } + + function testSubmitRevertsAfterDeadline() public { + uint256 id = _openSaptaOffering(); + vm.warp(block.timestamp + 31 days); + vm.prank(label); + vm.expectRevert(); + offering.submitCounter(id, _baseEconomics(), "ipfs://memo", 5_000e6); + } + + function testSubmitRevertsIfOfferingNotOpen() public { + uint256 id = _createDraftAsSapta(uint64(block.timestamp + 30 days)); + vm.prank(label); + vm.expectRevert( + abi.encodeWithSelector( + MuzixRightsOffering.WrongOfferingStatus.selector, + id, + MuzixRightsOffering.OfferingStatus.Draft, + MuzixRightsOffering.OfferingStatus.Open + ) + ); + offering.submitCounter(id, _baseEconomics(), "ipfs://memo", 5_000e6); + } + + function testWithdrawCounterRefundsBond() public { + uint256 id = _openSaptaOffering(); + vm.prank(label); + uint256 cid = offering.acceptBaseTerms(id, "ipfs://memo", 5_000e6); + + uint256 labelBefore = musd.balanceOf(label); + vm.prank(label); + offering.withdrawCounter(cid); + + MuzixRightsOffering.Counter memory c = offering.getCounter(cid); + assertEq(uint8(c.status), uint8(MuzixRightsOffering.CounterStatus.Withdrawn)); + assertEq(c.bondAmount, 0); + assertEq(musd.balanceOf(label), labelBefore + 5_000e6); + } + + function testWithdrawCounterAllowedAfterExpiry() public { + uint256 id = _openSaptaOffering(); + vm.prank(label); + uint256 cid = offering.acceptBaseTerms(id, "ipfs://memo", 5_000e6); + + vm.warp(block.timestamp + 31 days); + offering.markExpired(id); + + uint256 labelBefore = musd.balanceOf(label); + vm.prank(label); + offering.withdrawCounter(cid); + assertEq(musd.balanceOf(label), labelBefore + 5_000e6); + } + + function testWithdrawCounterRevertsForNonBidder() public { + uint256 id = _openSaptaOffering(); + vm.prank(label); + uint256 cid = offering.acceptBaseTerms(id, "ipfs://memo", 5_000e6); + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(MuzixRightsOffering.NotBidder.selector, cid, stranger)); + offering.withdrawCounter(cid); + } + + // --------------------------------------------------------------------- + // Acceptance / rejection + // --------------------------------------------------------------------- + + function testAcceptCounterFlipsOfferingAndRefundsWinner() public { + uint256 id = _openSaptaOffering(); + vm.prank(label); + uint256 cid = offering.acceptBaseTerms(id, "ipfs://memo", 5_000e6); + + uint256 labelBefore = musd.balanceOf(label); + vm.prank(sapta); + offering.acceptCounter(cid); + + MuzixRightsOffering.Offering memory o = offering.getOffering(id); + assertEq(uint8(o.status), uint8(MuzixRightsOffering.OfferingStatus.Accepted)); + assertEq(o.acceptedCounterId, cid); + + MuzixRightsOffering.Counter memory c = offering.getCounter(cid); + assertEq(uint8(c.status), uint8(MuzixRightsOffering.CounterStatus.Accepted)); + assertEq(c.bondAmount, 0); + assertEq(musd.balanceOf(label), labelBefore + 5_000e6); + } + + function testAcceptCounterLeavesLosingPendingForRefund() public { + uint256 id = _openSaptaOffering(); + + vm.prank(label); + uint256 cidLabel = offering.acceptBaseTerms(id, "ipfs://label-memo", 5_000e6); + vm.prank(distributor); + MuzixRightsOffering.Economics memory dist = _baseEconomics(); + dist.upfrontUsd = 30_000e6; + uint256 cidDist = offering.submitCounter(id, dist, "ipfs://dist-memo", 5_000e6); + + vm.prank(sapta); + offering.acceptCounter(cidDist); + + // Losing counter is still pending until bidder withdraws. + assertEq(uint8(offering.getCounter(cidLabel).status), uint8(MuzixRightsOffering.CounterStatus.Pending)); + + uint256 labelBefore = musd.balanceOf(label); + vm.prank(label); + offering.withdrawCounter(cidLabel); + assertEq(musd.balanceOf(label), labelBefore + 5_000e6); + } + + function testAcceptCounterRevertsForNonArtist() public { + uint256 id = _openSaptaOffering(); + vm.prank(label); + uint256 cid = offering.acceptBaseTerms(id, "ipfs://memo", 5_000e6); + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(MuzixRightsOffering.NotArtist.selector, id, stranger)); + offering.acceptCounter(cid); + } + + function testAcceptCounterRevertsIfAlreadyAccepted() public { + uint256 id = _openSaptaOffering(); + vm.prank(label); + uint256 cid = offering.acceptBaseTerms(id, "ipfs://memo", 5_000e6); + vm.prank(sapta); + offering.acceptCounter(cid); + + vm.prank(distributor); + // Cannot submit (offering Accepted, not Open). + vm.expectRevert(); + offering.submitCounter(id, _baseEconomics(), "ipfs://memo", 5_000e6); + } + + function testRejectCounterRefundsBond() public { + uint256 id = _openSaptaOffering(); + vm.prank(label); + uint256 cid = offering.acceptBaseTerms(id, "ipfs://memo", 5_000e6); + + uint256 labelBefore = musd.balanceOf(label); + vm.prank(sapta); + offering.rejectCounter(cid); + + MuzixRightsOffering.Counter memory c = offering.getCounter(cid); + assertEq(uint8(c.status), uint8(MuzixRightsOffering.CounterStatus.Rejected)); + assertEq(c.bondAmount, 0); + assertEq(musd.balanceOf(label), labelBefore + 5_000e6); + + // Offering remains Open; another bidder can still come in. + assertEq(uint8(offering.getOffering(id).status), uint8(MuzixRightsOffering.OfferingStatus.Open)); + } + + function testCounterIdsForListsAll() public { + uint256 id = _openSaptaOffering(); + vm.prank(label); + uint256 a = offering.acceptBaseTerms(id, "ipfs://a", 5_000e6); + vm.prank(distributor); + uint256 b = offering.submitCounter(id, _baseEconomics(), "ipfs://b", 5_000e6); + + uint256[] memory ids = offering.counterIdsFor(id); + assertEq(ids.length, 2); + assertEq(ids[0], a); + assertEq(ids[1], b); + assertEq(offering.counterCountFor(id), 2); + } + + function testCounterWithZeroBondAllowedWhenMinIsZero() public { + // Create a new draft with minBondUsd == 0. + vm.prank(sapta); + uint256 id = offering.createOffering( + keccak256("subject-no-bond"), + "ipfs://x", + _baseRights(), + _baseEconomics(), + IERC20(address(musd)), + 0, + uint64(block.timestamp + 30 days) + ); + vm.prank(sapta); + offering.publishOffering(id); + + vm.prank(label); + uint256 cid = offering.submitCounter(id, _baseEconomics(), "ipfs://memo", 0); + assertEq(offering.getCounter(cid).bondAmount, 0); + } + + // --------------------------------------------------------------------- + // Negative lookups + // --------------------------------------------------------------------- + + function testGetOfferingUnknownReverts() public { + vm.expectRevert(abi.encodeWithSelector(MuzixRightsOffering.OfferingNotFound.selector, uint256(999))); + offering.getOffering(999); + } + + function testGetCounterUnknownReverts() public { + vm.expectRevert(abi.encodeWithSelector(MuzixRightsOffering.CounterNotFound.selector, uint256(999))); + offering.getCounter(999); + } +} From 86b9e11651f7affa81c6ae6ff3d5b7540caa7e77 Mon Sep 17 00:00:00 2001 From: Abhishek Krishna <invokerkrishna@gmail.com> Date: Sun, 24 May 2026 17:35:33 +0530 Subject: [PATCH 2/4] feat(web): visual music-contract builder + onchain deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `/contracts` route in the explorer UI: pick a template, edit parties / splits / terms in a visual editor, watch a plain-English draft assemble live, then deploy the encoded calls from a wallet. Templates (all map to existing primitives — no new Solidity required): - Recording royalty split → MuzixCatalog.mintMusic + setRoyaltySplit - Featured-artist add → MuzixCatalog.setRoyaltySplit (re-split) - AI training license → MuzixAIProvenance.setProvenance - Sync license (film/TV) → mint+split with off-chain JSON terms Pieces: - lib/contract-templates.ts — typed schema + 4 templates with validate() / draft() / onchain() functions per template. - lib/contracts.ts — minimal ABI fragments + env-driven addresses (NEXT_PUBLIC_MUZIX_CATALOG, NEXT_PUBLIC_MUZIX_AI_PROVENANCE, NEXT_PUBLIC_MUZIX_CHAIN_ID). - components/ContractBuilder.tsx — parties editor with live bps gauge + auto-balance, term fields, side-by-side prose draft, on-chain plan preview. - components/DeployPanel.tsx — viem v2 wallet flow (custom transport via window.ethereum), late-binds the minted tokenId from the Transfer event so step 2 (setRoyaltySplit) can use it, computes provenanceHash via the on-chain pure helper's binding, gracefully degrades to "copy calldata" when contracts aren't configured or no wallet is present. - /contracts gallery + builder route, nav link, homepage CTA. --- web/app/contracts/[slug]/page.tsx | 46 +++ web/app/contracts/page.tsx | 81 ++++ web/app/layout.tsx | 3 + web/app/page.tsx | 7 +- web/components/ContractBuilder.tsx | 359 +++++++++++++++++ web/components/DeployPanel.tsx | 331 ++++++++++++++++ web/lib/contract-templates.ts | 592 +++++++++++++++++++++++++++++ web/lib/contracts.ts | 112 ++++++ 8 files changed, 1529 insertions(+), 2 deletions(-) create mode 100644 web/app/contracts/[slug]/page.tsx create mode 100644 web/app/contracts/page.tsx create mode 100644 web/components/ContractBuilder.tsx create mode 100644 web/components/DeployPanel.tsx create mode 100644 web/lib/contract-templates.ts create mode 100644 web/lib/contracts.ts diff --git a/web/app/contracts/[slug]/page.tsx b/web/app/contracts/[slug]/page.tsx new file mode 100644 index 00000000..d52cfbe3 --- /dev/null +++ b/web/app/contracts/[slug]/page.tsx @@ -0,0 +1,46 @@ +import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import { TEMPLATES, getTemplate } from '@/lib/contract-templates'; +import { ContractBuilder } from '@/components/ContractBuilder'; + +type Props = { params: Promise<{ slug: string }> }; + +export async function generateStaticParams() { + return TEMPLATES.map((t) => ({ slug: t.slug })); +} + +export async function generateMetadata({ params }: Props) { + const { slug } = await params; + const t = getTemplate(slug); + if (!t) return { title: 'Contract · Muzix' }; + return { + title: `${t.name} · Muzix builder`, + description: t.blurb, + }; +} + +export default async function ContractBuilderPage({ params }: Props) { + const { slug } = await params; + const template = getTemplate(slug); + if (!template) notFound(); + + return ( + <div className="space-y-10"> + <header className="space-y-3"> + <div className="flex items-center gap-3 font-mono text-[11px] uppercase tracking-[0.18em] text-ink-400"> + <Link href="/contracts" className="hover:text-muzix-accent"> + ← all templates + </Link> + <span>/</span> + <span className="text-ink-300">{template.category}</span> + </div> + <h1 className="font-sans text-3xl font-light tracking-tight text-ink-100 md:text-4xl"> + {template.name} + </h1> + <p className="max-w-3xl text-pretty text-ink-300">{template.blurb}</p> + </header> + + <ContractBuilder slug={template.slug} /> + </div> + ); +} diff --git a/web/app/contracts/page.tsx b/web/app/contracts/page.tsx new file mode 100644 index 00000000..ea1dab17 --- /dev/null +++ b/web/app/contracts/page.tsx @@ -0,0 +1,81 @@ +import Link from 'next/link'; +import { TEMPLATES } from '@/lib/contract-templates'; + +export const metadata = { + title: 'Contracts · Muzix', + description: 'Visual builder for music contracts — splits, sync, AI licenses — deployed on-chain.', +}; + +export default function ContractsIndexPage() { + return ( + <div className="space-y-12"> + <header className="space-y-4"> + <p className="label">/ contract builder</p> + <h1 className="font-sans text-4xl font-light tracking-tight text-ink-100 md:text-5xl"> + Music contracts, + <br /> + <span className="text-muzix-accent">drafted visually, deployed on-chain.</span> + </h1> + <p className="max-w-2xl text-pretty text-ink-300"> + Pick a template. Fill in the parties, splits, and terms. Watch a + plain-English draft assemble in real time. When everything looks + right, deploy it — the builder emits the corresponding{' '} + <code className="font-mono text-ink-200">MuzixCatalog</code> and{' '} + <code className="font-mono text-ink-200">MuzixAIProvenance</code>{' '} + calls and submits them from your wallet. + </p> + </header> + + <ul className="grid gap-px overflow-hidden border border-ink-800 bg-ink-800 md:grid-cols-2"> + {TEMPLATES.map((t) => ( + <li key={t.slug} className="bg-ink"> + <Link + href={`/contracts/${t.slug}`} + className="group flex h-full flex-col gap-4 p-6 transition-colors hover:bg-ink-900" + > + <div className="flex items-center justify-between"> + <span className="label">{t.category}</span> + <span className="font-mono text-[10px] uppercase tracking-[0.2em] text-ink-400 group-hover:text-muzix-accent"> + open builder → + </span> + </div> + <h2 className="font-sans text-2xl font-light tracking-tight text-ink-100 group-hover:text-muzix-accent"> + {t.name} + </h2> + <p className="text-pretty text-sm text-ink-300">{t.blurb}</p> + <div className="mt-auto flex flex-wrap gap-2 font-mono text-[10px] uppercase tracking-[0.18em] text-ink-400"> + <span className="border border-ink-700 px-2 py-1"> + {t.partiesAreCapTable ? 'cap-table' : 'no cap-table'} + </span> + <span className="border border-ink-700 px-2 py-1"> + {t.defaultParties.length} default parties + </span> + <span className="border border-ink-700 px-2 py-1"> + {t.fields.length} fields + </span> + </div> + </Link> + </li> + ))} + </ul> + + <section className="space-y-3 border-t border-ink-800 pt-8 font-mono text-[11px] uppercase tracking-[0.18em] text-ink-400"> + <p>// how it works</p> + <ol className="grid gap-2 text-ink-300 normal-case tracking-normal"> + <li> + <span className="text-muzix-accent">1.</span> Pick a template — each one maps to specific Muzix + contract calls. + </li> + <li> + <span className="text-muzix-accent">2.</span> Edit parties & terms. The 100% gauge enforces a + valid cap table; the draft updates in real time. + </li> + <li> + <span className="text-muzix-accent">3.</span> Review the encoded on-chain plan, connect a wallet on + chain {process.env.NEXT_PUBLIC_MUZIX_CHAIN_ID ?? '1338'}, and deploy. + </li> + </ol> + </section> + </div> + ); +} diff --git a/web/app/layout.tsx b/web/app/layout.tsx index cd8a0b2e..60e14c74 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -50,6 +50,9 @@ function Header() { <Link href="/catalog" className="hover:text-muzix-accent"> Catalog </Link> + <Link href="/contracts" className="hover:text-muzix-accent"> + Contracts + </Link> <Link href="/about" className="hover:text-muzix-accent"> About </Link> diff --git a/web/app/page.tsx b/web/app/page.tsx index d0560e7c..9a9905f8 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -34,8 +34,11 @@ function Hero() { Built on the OP Stack. </p> <div className="flex flex-wrap items-center gap-3"> - <Link href="/catalog" className="btn-accent"> - Explore catalog → + <Link href="/contracts" className="btn-accent"> + Open contract builder → + </Link> + <Link href="/catalog" className="btn"> + Explore catalog </Link> <a href="https://github.com/kcolbchain/muzix" diff --git a/web/components/ContractBuilder.tsx b/web/components/ContractBuilder.tsx new file mode 100644 index 00000000..9e4ef739 --- /dev/null +++ b/web/components/ContractBuilder.tsx @@ -0,0 +1,359 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { + PARTY_ROLES, + bpsTotalOf, + defaultValues, + getTemplate, + type FieldDef, + type OnchainCall, + type Party, + type PartyRole, + type TemplateValues, +} from '@/lib/contract-templates'; +import { + MUZIX_CATALOG_ADDRESS, + MUZIX_CHAIN_ID, + isDeployed, +} from '@/lib/contracts'; +import { DeployPanel } from '@/components/DeployPanel'; + +export function ContractBuilder({ slug }: { slug: string }) { + const template = getTemplate(slug); + const [values, setValues] = useState<TemplateValues>(() => + template ? defaultValues(template) : { parties: [], fields: {} }, + ); + + if (!template) return null; + + const issues = template.validate(values); + const bpsSum = bpsTotalOf(values.parties); + const plan = template.onchain(values); + const draftText = template.draft(values); + + function patchField(key: string, v: string | number) { + setValues((cur) => ({ ...cur, fields: { ...cur.fields, [key]: v } })); + } + + function patchParty(i: number, patch: Partial<Party>) { + setValues((cur) => { + const next = cur.parties.slice(); + next[i] = { ...next[i], ...patch }; + return { ...cur, parties: next }; + }); + } + + function addParty() { + setValues((cur) => ({ + ...cur, + parties: [ + ...cur.parties, + { name: '', address: '', role: template.allowedRoles[0] ?? 'Other', shareBps: 0 }, + ], + })); + } + + function removeParty(i: number) { + setValues((cur) => ({ ...cur, parties: cur.parties.filter((_, idx) => idx !== i) })); + } + + function autoBalance() { + // Distribute remainder to the last party so the cap table hits exactly 10000. + setValues((cur) => { + if (cur.parties.length === 0) return cur; + const others = cur.parties.slice(0, -1).reduce((a, p) => a + p.shareBps, 0); + const last = Math.max(0, 10000 - others); + const next = cur.parties.slice(); + next[next.length - 1] = { ...next[next.length - 1], shareBps: last }; + return { ...cur, parties: next }; + }); + } + + return ( + <div className="grid gap-8 lg:grid-cols-[1.05fr_1fr]"> + <section className="space-y-8"> + {/* Parties */} + <Card label="01 · parties"> + <div className="space-y-3"> + <PartiesEditor + parties={values.parties} + allowedRoles={template.allowedRoles} + onChange={patchParty} + onRemove={removeParty} + capTable={template.partiesAreCapTable} + /> + <div className="flex flex-wrap items-center justify-between gap-3"> + <button onClick={addParty} className="btn"> + + add party + </button> + {template.partiesAreCapTable && ( + <div className="flex items-center gap-3"> + <button onClick={autoBalance} className="btn"> + auto-balance to 100% + </button> + <BpsGauge bps={bpsSum} /> + </div> + )} + </div> + </div> + </Card> + + {/* Fields */} + <Card label="02 · terms"> + <div className="grid gap-4 sm:grid-cols-2"> + {template.fields.map((f) => ( + <FieldRow + key={f.key} + def={f} + value={values.fields[f.key]} + onChange={(v) => patchField(f.key, v)} + /> + ))} + </div> + </Card> + + {/* Validation */} + <Card label="03 · validation"> + {issues.length === 0 ? ( + <p className="font-mono text-xs text-muzix-accent"> + ✓ All checks pass. Ready to deploy. + </p> + ) : ( + <ul className="space-y-1 font-mono text-xs text-muzix-warn"> + {issues.map((iss, idx) => ( + <li key={idx}> + ✗ {iss.field ? <code className="text-ink-300">{iss.field}</code> : null} {iss.message} + </li> + ))} + </ul> + )} + </Card> + </section> + + <aside className="space-y-8"> + {/* Plain-English draft */} + <Card label="draft · plain english"> + <pre className="max-h-[500px] overflow-auto whitespace-pre-wrap font-mono text-[11px] leading-relaxed text-ink-200"> + {draftText} + </pre> + </Card> + + {/* On-chain plan */} + <Card label="onchain · plan"> + <div className="space-y-3 text-sm"> + <p className="font-mono text-[11px] uppercase tracking-[0.18em] text-ink-400"> + mode · <span className={plan.mode === 'live' ? 'text-muzix-accent' : 'text-muzix-signal'}>{plan.mode}</span> · chain {MUZIX_CHAIN_ID} + </p> + {plan.notes.length > 0 && ( + <ul className="space-y-1 text-xs text-ink-300"> + {plan.notes.map((n, i) => ( + <li key={i}>· {n}</li> + ))} + </ul> + )} + <ol className="space-y-2"> + {plan.calls.map((c, i) => ( + <li key={i} className="border border-ink-700 bg-ink-900/40 p-3"> + <p className="font-mono text-[10px] uppercase tracking-[0.18em] text-ink-400"> + step {i + 1} · {c.contract}.{c.fn} + </p> + <p className="mt-1 text-xs text-ink-200">{c.description}</p> + </li> + ))} + </ol> + </div> + </Card> + + {/* Deploy */} + <DeployPanel + calls={plan.calls as OnchainCall[]} + disabled={issues.length > 0} + catalogAddress={MUZIX_CATALOG_ADDRESS} + deployed={isDeployed('MuzixCatalog') && isDeployed('MuzixAIProvenance')} + values={values} + /> + </aside> + </div> + ); +} + +// ────────────────────────────────────────────────────────────────────────── +// Sub-components + +function Card({ label, children }: { label: string; children: React.ReactNode }) { + return ( + <div className="card p-5"> + <p className="label mb-4">{label}</p> + {children} + </div> + ); +} + +function BpsGauge({ bps }: { bps: number }) { + const ok = bps === 10000; + return ( + <div className="flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.18em]"> + <span className={ok ? 'text-muzix-accent' : 'text-muzix-warn'}> + {(bps / 100).toFixed(2)}% + </span> + <span className="text-ink-400">/ 100%</span> + <span className={`h-2 w-24 ${ok ? 'bg-muzix-accent/30' : 'bg-muzix-warn/20'}`}> + <span + className={`block h-full ${ok ? 'bg-muzix-accent' : 'bg-muzix-warn'}`} + style={{ width: `${Math.min(100, bps / 100)}%` }} + /> + </span> + </div> + ); +} + +function PartiesEditor({ + parties, + allowedRoles, + onChange, + onRemove, + capTable, +}: { + parties: Party[]; + allowedRoles: PartyRole[]; + onChange: (i: number, patch: Partial<Party>) => void; + onRemove: (i: number) => void; + capTable: boolean; +}) { + const roleOptions = useMemo( + () => PARTY_ROLES.filter((r) => allowedRoles.includes(r)), + [allowedRoles], + ); + + if (parties.length === 0) { + return <p className="font-mono text-xs text-ink-400">No parties yet — add one to get started.</p>; + } + + return ( + <div className="overflow-x-auto"> + <table className="w-full border-collapse text-sm"> + <thead> + <tr className="border-b border-ink-800 font-mono text-[10px] uppercase tracking-[0.18em] text-ink-400"> + <th className="py-2 pr-2 text-left">name</th> + <th className="py-2 pr-2 text-left">role</th> + <th className="py-2 pr-2 text-left">address</th> + {capTable && <th className="py-2 pr-2 text-right">share (bps)</th>} + <th /> + </tr> + </thead> + <tbody> + {parties.map((p, i) => ( + <tr key={i} className="border-b border-ink-800/60"> + <td className="py-2 pr-2"> + <input + className="w-full bg-transparent font-sans text-ink-100 outline-none placeholder:text-ink-500" + value={p.name} + onChange={(e) => onChange(i, { name: e.target.value })} + placeholder="Lavender Cassette" + /> + </td> + <td className="py-2 pr-2"> + <select + className="w-full bg-transparent font-mono text-xs text-ink-200 outline-none" + value={p.role} + onChange={(e) => onChange(i, { role: e.target.value as PartyRole })} + > + {roleOptions.map((r) => ( + <option key={r} value={r} className="bg-ink"> + {r} + </option> + ))} + </select> + </td> + <td className="py-2 pr-2"> + <input + className="w-full bg-transparent font-mono text-xs text-ink-200 outline-none placeholder:text-ink-500" + value={p.address} + onChange={(e) => onChange(i, { address: e.target.value as Party['address'] })} + placeholder="0x…" + /> + </td> + {capTable && ( + <td className="py-2 pr-2 text-right"> + <input + type="number" + min={0} + max={10000} + step={50} + className="w-24 bg-transparent text-right font-mono text-xs text-ink-100 outline-none" + value={p.shareBps} + onChange={(e) => onChange(i, { shareBps: Number(e.target.value) || 0 })} + /> + </td> + )} + <td className="py-2 pl-2 text-right"> + <button + onClick={() => onRemove(i)} + className="font-mono text-[10px] uppercase tracking-[0.18em] text-ink-400 hover:text-muzix-warn" + aria-label={`remove party ${i + 1}`} + > + remove + </button> + </td> + </tr> + ))} + </tbody> + </table> + </div> + ); +} + +function FieldRow({ + def, + value, + onChange, +}: { + def: FieldDef; + value: string | number | undefined; + onChange: (v: string | number) => void; +}) { + const v = value ?? ''; + const span = def.kind === 'multiline' || def.kind === 'uriList' ? 'sm:col-span-2' : ''; + + return ( + <label className={`flex flex-col gap-1 ${span}`}> + <span className="font-mono text-[10px] uppercase tracking-[0.18em] text-ink-400"> + {def.label} + {def.required && <span className="text-muzix-warn"> *</span>} + </span> + {def.kind === 'multiline' || def.kind === 'uriList' ? ( + <textarea + rows={def.kind === 'uriList' ? 4 : 3} + className="border border-ink-700 bg-ink-900/40 px-3 py-2 font-mono text-xs text-ink-100 outline-none focus:border-muzix-accent" + value={String(v)} + placeholder={def.placeholder} + onChange={(e) => onChange(e.target.value)} + /> + ) : def.kind === 'select' ? ( + <select + className="border border-ink-700 bg-ink-900/40 px-3 py-2 font-mono text-xs text-ink-100 outline-none focus:border-muzix-accent" + value={String(v)} + onChange={(e) => onChange(e.target.value)} + > + {(def.options ?? []).map((opt) => ( + <option key={opt} value={opt} className="bg-ink"> + {opt} + </option> + ))} + </select> + ) : ( + <input + type={def.kind === 'integer' || def.kind === 'tokenId' ? 'number' : def.kind === 'date' ? 'date' : 'text'} + className="border border-ink-700 bg-ink-900/40 px-3 py-2 font-mono text-xs text-ink-100 outline-none focus:border-muzix-accent" + value={String(v)} + placeholder={def.placeholder} + onChange={(e) => + onChange(def.kind === 'integer' || def.kind === 'tokenId' ? Number(e.target.value) : e.target.value) + } + /> + )} + {def.help && <span className="font-mono text-[10px] text-ink-500">{def.help}</span>} + </label> + ); +} diff --git a/web/components/DeployPanel.tsx b/web/components/DeployPanel.tsx new file mode 100644 index 00000000..e8706956 --- /dev/null +++ b/web/components/DeployPanel.tsx @@ -0,0 +1,331 @@ +'use client'; + +import { useState } from 'react'; +import { + createPublicClient, + createWalletClient, + custom, + decodeEventLog, + encodeFunctionData, + http, + keccak256, + encodeAbiParameters, + parseAbiParameters, + type Abi, + type Address, + type Hex, + type TransactionReceipt, +} from 'viem'; +import { + MUZIX_AI_PROVENANCE_ABI, + MUZIX_AI_PROVENANCE_ADDRESS, + MUZIX_CATALOG_ABI, + MUZIX_CATALOG_ADDRESS, + MUZIX_CHAIN_ID, + abiFor, + addressFor, +} from '@/lib/contracts'; +import type { OnchainCall, TemplateValues } from '@/lib/contract-templates'; + +type Status = + | { kind: 'idle' } + | { kind: 'connecting' } + | { kind: 'sending'; step: number; total: number } + | { kind: 'mined'; step: number; total: number; hash: Hex; tokenId?: bigint } + | { kind: 'done'; hashes: Hex[]; tokenId?: bigint } + | { kind: 'error'; message: string }; + +declare global { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface Window { + ethereum?: { + request: (args: { method: string; params?: unknown[] }) => Promise<unknown>; + }; + } +} + +export function DeployPanel({ + calls, + disabled, + catalogAddress, + deployed, + values, +}: { + calls: OnchainCall[]; + disabled: boolean; + catalogAddress: Address; + deployed: boolean; + values: TemplateValues; +}) { + const [status, setStatus] = useState<Status>({ kind: 'idle' }); + const [account, setAccount] = useState<Address | null>(null); + + const hasWallet = typeof window !== 'undefined' && !!window.ethereum; + + async function connect() { + if (!hasWallet) { + setStatus({ kind: 'error', message: 'No browser wallet detected (window.ethereum is undefined).' }); + return; + } + setStatus({ kind: 'connecting' }); + try { + const accts = (await window.ethereum!.request({ method: 'eth_requestAccounts' })) as Address[]; + setAccount(accts[0] ?? null); + setStatus({ kind: 'idle' }); + } catch (e: unknown) { + setStatus({ kind: 'error', message: errorMessage(e) }); + } + } + + async function deploy() { + if (!hasWallet) { + setStatus({ kind: 'error', message: 'Connect a wallet first.' }); + return; + } + if (!deployed) { + setStatus({ + kind: 'error', + message: 'Muzix contracts are not configured for this build. Set NEXT_PUBLIC_MUZIX_CATALOG and NEXT_PUBLIC_MUZIX_AI_PROVENANCE.', + }); + return; + } + + try { + const wallet = createWalletClient({ transport: custom(window.ethereum!) }); + const pub = createPublicClient({ transport: custom(window.ethereum!) }); + + const [acct] = await wallet.getAddresses(); + if (!acct) throw new Error('No wallet account available.'); + setAccount(acct); + + const chainId = await pub.getChainId(); + if (chainId !== MUZIX_CHAIN_ID) { + throw new Error(`Wrong network — wallet is on chain ${chainId}, expected ${MUZIX_CHAIN_ID}.`); + } + + const hashes: Hex[] = []; + let mintedTokenId: bigint | undefined; + + for (let i = 0; i < calls.length; i++) { + const call = calls[i]; + setStatus({ kind: 'sending', step: i + 1, total: calls.length }); + + const resolved = resolveCall(call, { mintedTokenId, values, catalogAddress }); + + const hash = await wallet.writeContract({ + address: addressFor(resolved.contract), + abi: abiFor(resolved.contract), + functionName: resolved.fn, + args: resolved.args as readonly unknown[], + account: acct, + chain: null, + value: resolved.valueWei, + }); + + const receipt = (await pub.waitForTransactionReceipt({ hash })) as TransactionReceipt; + hashes.push(hash); + + if (resolved.contract === 'MuzixCatalog' && resolved.fn === 'mintMusic') { + mintedTokenId = extractMintedTokenId(receipt, acct); + } + + setStatus({ kind: 'mined', step: i + 1, total: calls.length, hash, tokenId: mintedTokenId }); + } + + setStatus({ kind: 'done', hashes, tokenId: mintedTokenId }); + } catch (e: unknown) { + setStatus({ kind: 'error', message: errorMessage(e) }); + } + } + + function copyCalldata() { + const blob = calls + .map((c, i) => { + const resolved = resolveCall(c, { mintedTokenId: undefined, values, catalogAddress }); + const calldata = encodeFunctionData({ + abi: abiFor(resolved.contract), + functionName: resolved.fn, + args: resolved.args as readonly unknown[], + }); + return [ + `# step ${i + 1} — ${resolved.contract}.${resolved.fn}`, + `to: ${addressFor(resolved.contract)}`, + `function: ${resolved.fn}`, + `calldata: ${calldata}`, + c.description, + ].join('\n'); + }) + .join('\n\n'); + void navigator.clipboard.writeText(blob); + } + + return ( + <div className="card space-y-4 p-5"> + <p className="label">04 · deploy</p> + + {!deployed && ( + <p className="font-mono text-[11px] text-muzix-warn"> + ⚠ Live contracts aren't configured for this build — set + <code className="ml-1 text-ink-100">NEXT_PUBLIC_MUZIX_CATALOG</code> +{' '} + <code className="text-ink-100">NEXT_PUBLIC_MUZIX_AI_PROVENANCE</code>. You can still copy + encoded calldata below. + </p> + )} + + <div className="flex flex-wrap items-center gap-3"> + {account ? ( + <span className="font-mono text-[11px] uppercase tracking-[0.18em] text-muzix-accent"> + wallet · {shortAddr(account)} + </span> + ) : ( + <button onClick={connect} className="btn" disabled={!hasWallet}> + connect wallet + </button> + )} + <button + onClick={deploy} + disabled={disabled || !hasWallet || !deployed} + className="btn-accent disabled:opacity-40" + > + deploy onchain → + </button> + <button onClick={copyCalldata} className="btn"> + copy calldata + </button> + </div> + + <StatusLine status={status} /> + + <details className="border-t border-ink-800 pt-3"> + <summary className="cursor-pointer font-mono text-[10px] uppercase tracking-[0.18em] text-ink-400 hover:text-muzix-accent"> + encoded calls preview + </summary> + <pre className="mt-3 max-h-72 overflow-auto whitespace-pre-wrap break-all font-mono text-[10px] leading-relaxed text-ink-300"> + {calls + .map((c, i) => { + const resolved = resolveCall(c, { mintedTokenId: undefined, values, catalogAddress }); + const calldata = encodeFunctionData({ + abi: abiFor(resolved.contract), + functionName: resolved.fn, + args: resolved.args as readonly unknown[], + }); + return `step ${i + 1} → ${addressFor(resolved.contract)} :: ${resolved.contract}.${resolved.fn}\n${calldata}`; + }) + .join('\n\n')} + </pre> + </details> + </div> + ); +} + +function StatusLine({ status }: { status: Status }) { + if (status.kind === 'idle') return null; + if (status.kind === 'connecting') + return <p className="font-mono text-[11px] text-ink-300">… connecting wallet</p>; + if (status.kind === 'sending') + return ( + <p className="font-mono text-[11px] text-muzix-signal"> + … sending step {status.step}/{status.total}, confirm in wallet + </p> + ); + if (status.kind === 'mined') + return ( + <p className="font-mono text-[11px] text-muzix-accent"> + ✓ step {status.step}/{status.total} mined · {shortHex(status.hash)} + {status.tokenId !== undefined && ` · tokenId ${status.tokenId.toString()}`} + </p> + ); + if (status.kind === 'done') + return ( + <div className="space-y-1 font-mono text-[11px]"> + <p className="text-muzix-accent">✓ deployed</p> + {status.tokenId !== undefined && ( + <p className="text-ink-300">tokenId · {status.tokenId.toString()}</p> + )} + {status.hashes.map((h, i) => ( + <p key={h} className="text-ink-300"> + tx {i + 1} · {h} + </p> + ))} + </div> + ); + return <p className="font-mono text-[11px] text-muzix-warn">✗ {status.message}</p>; +} + +// ────────────────────────────────────────────────────────────────────────── +// Call resolution: handles late-bound placeholders ('__LAST_MINTED_TOKEN_ID__', +// '__CATALOG_ADDR__', provenance-hash zero) before the call is sent. + +function resolveCall( + call: OnchainCall, + ctx: { mintedTokenId: bigint | undefined; values: TemplateValues; catalogAddress: Address }, +): OnchainCall { + const args = call.args.map((a) => { + if (a === '__LAST_MINTED_TOKEN_ID__') { + if (ctx.mintedTokenId === undefined) { + throw new Error('Cannot resolve token id before mint — make sure mintMusic runs first.'); + } + return ctx.mintedTokenId; + } + if (a === '__CATALOG_ADDR__') return ctx.catalogAddress; + return a; + }); + + // AI-provenance hash: if last arg is the zero hash AND fn is setProvenance, compute it from the prior args. + if (call.contract === 'MuzixAIProvenance' && call.fn === 'setProvenance') { + const last = args[args.length - 1]; + if (typeof last === 'string' && /^0x0+$/.test(last)) { + const humanOnly = args[2] as boolean; + const aiModels = args[3] as readonly Address[]; + const uris = args[4] as readonly string[]; + args[args.length - 1] = computeProvenanceHash(humanOnly, aiModels, uris); + } + } + + return { ...call, args }; +} + +function computeProvenanceHash(humanOnly: boolean, models: readonly Address[], uris: readonly string[]): Hex { + // Mirrors `MuzixAIProvenance.computeProvenanceHash`: + // keccak256(abi.encode(humanOnly, aiModelTokens, ipLineageURIs)) + const encoded = encodeAbiParameters(parseAbiParameters('bool, address[], string[]'), [humanOnly, [...models], [...uris]]); + return keccak256(encoded); +} + +function extractMintedTokenId(receipt: TransactionReceipt, mintedTo: Address): bigint | undefined { + for (const log of receipt.logs) { + try { + const decoded = decodeEventLog({ + abi: MUZIX_CATALOG_ABI as unknown as Abi, + data: log.data, + topics: log.topics, + }); + if (decoded.eventName === 'Transfer') { + const args = decoded.args as { from: Address; to: Address; tokenId: bigint }; + if (args.from.toLowerCase() === '0x0000000000000000000000000000000000000000' && args.to.toLowerCase() === mintedTo.toLowerCase()) { + return args.tokenId; + } + } + } catch { + // not a Transfer log — skip + } + } + return undefined; +} + +function errorMessage(e: unknown): string { + if (e instanceof Error) return e.message; + if (typeof e === 'string') return e; + return 'Unknown error'; +} + +function shortAddr(addr: string): string { + return `${addr.slice(0, 6)}…${addr.slice(-4)}`; +} + +function shortHex(h: string): string { + return `${h.slice(0, 10)}…${h.slice(-6)}`; +} + +// Re-export so the builder can compile-check the addresses are wired. +export { MUZIX_CATALOG_ADDRESS, MUZIX_AI_PROVENANCE_ADDRESS, MUZIX_CATALOG_ABI, MUZIX_AI_PROVENANCE_ABI }; diff --git a/web/lib/contract-templates.ts b/web/lib/contract-templates.ts new file mode 100644 index 00000000..995aa8f4 --- /dev/null +++ b/web/lib/contract-templates.ts @@ -0,0 +1,592 @@ +/** + * Music contract templates for the Muzix visual builder. + * + * Each template carries: + * - a typed input schema (parties, terms, metadata) + * - a `draft()` that renders a plain-English agreement + * - an `onchain()` that maps the form values to concrete contract calls + * against the existing Muzix protocol (MuzixCatalog, MuzixAIProvenance). + * + * Templates intentionally lean on primitives that already ship in + * `muzix/src/*.sol` — no new Solidity is required to deploy a v1 builder. + * Where industry contracts have no on-chain primitive yet (e.g. sync + * licensing windows), the template is honest about it: the prose + * agreement is hashed and the hash is the on-chain footprint. + */ + +import type { Address, Hex } from 'viem'; + +// ────────────────────────────────────────────────────────────────────────── +// Field schema + +export type PartyRole = + | 'Artist' + | 'Featured Artist' + | 'Producer' + | 'Songwriter' + | 'Publisher' + | 'Label' + | 'Licensee' + | 'AI Model Owner' + | 'Other'; + +export const PARTY_ROLES: PartyRole[] = [ + 'Artist', + 'Featured Artist', + 'Producer', + 'Songwriter', + 'Publisher', + 'Label', + 'Licensee', + 'AI Model Owner', + 'Other', +]; + +export type Party = { + name: string; + address: Address | ''; + role: PartyRole; + shareBps: number; // basis points, 0-10000 +}; + +export type FieldKind = + | 'text' + | 'multiline' + | 'address' + | 'isrc' + | 'integer' + | 'date' + | 'select' + | 'tokenId' + | 'uriList'; + +export type FieldDef = { + key: string; + label: string; + help?: string; + kind: FieldKind; + required?: boolean; + options?: string[]; // for `select` + placeholder?: string; + defaultValue?: string | number; +}; + +export type TemplateValues = { + parties: Party[]; + fields: Record<string, string | number>; +}; + +// ────────────────────────────────────────────────────────────────────────── +// Onchain action descriptor + +export type OnchainCall = { + /** Which deployed contract instance to call. */ + contract: 'MuzixCatalog' | 'MuzixAIProvenance'; + /** Function name (must exist on the ABI in `lib/contracts.ts`). */ + fn: string; + /** Concrete args, ready to pass to viem `writeContract`. */ + args: readonly unknown[]; + /** Human-readable one-liner displayed in the deploy preview. */ + description: string; + /** ETH/MUSD wei to send with this call (optional). */ + valueWei?: bigint; +}; + +export type OnchainPlan = { + /** + * `live` = every step is a real contract call. + * `hybrid` = some terms live off-chain; only an attestation/hash goes onchain. + */ + mode: 'live' | 'hybrid'; + calls: OnchainCall[]; + /** Notes shown above the call list (e.g. "sync windows live in the JSON"). */ + notes: string[]; +}; + +export type ValidationIssue = { field?: string; message: string }; + +export type Template = { + slug: string; + name: string; + blurb: string; + category: 'Recording' | 'Publishing' | 'Licensing' | 'AI'; + /** Whether parties drive an on-chain bps cap-table. */ + partiesAreCapTable: boolean; + /** Allowed roles in this template (UI filter on the party-role dropdown). */ + allowedRoles: PartyRole[]; + defaultParties: Party[]; + fields: FieldDef[]; + validate: (v: TemplateValues) => ValidationIssue[]; + draft: (v: TemplateValues) => string; + onchain: (v: TemplateValues) => OnchainPlan; +}; + +// ────────────────────────────────────────────────────────────────────────── +// Helpers + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const; + +function bpsTotal(parties: Party[]): number { + return parties.reduce((acc, p) => acc + (Number.isFinite(p.shareBps) ? p.shareBps : 0), 0); +} + +function pct(bps: number): string { + return `${(bps / 100).toFixed(bps % 100 === 0 ? 0 : 2)}%`; +} + +function todayISO(): string { + return new Date().toISOString().slice(0, 10); +} + +function partyLine(p: Party): string { + const addr = p.address ? p.address : '[address pending]'; + return `${p.name || '[unnamed party]'} (${p.role}) — ${addr} — ${pct(p.shareBps)}`; +} + +function isAddressLike(v: string): v is Address { + return /^0x[a-fA-F0-9]{40}$/.test(v); +} + +function validateParties(parties: Party[], requireCapTable: boolean): ValidationIssue[] { + const issues: ValidationIssue[] = []; + if (parties.length === 0) { + issues.push({ message: 'Add at least one party.' }); + return issues; + } + parties.forEach((p, i) => { + if (!p.name) issues.push({ field: `parties.${i}.name`, message: `Party #${i + 1}: name required.` }); + if (!p.address || !isAddressLike(p.address)) { + issues.push({ field: `parties.${i}.address`, message: `Party #${i + 1}: needs a valid 0x address.` }); + } + if (!Number.isFinite(p.shareBps) || p.shareBps < 0 || p.shareBps > 10000) { + issues.push({ field: `parties.${i}.shareBps`, message: `Party #${i + 1}: share must be 0-10000 bps.` }); + } + }); + if (requireCapTable) { + const total = bpsTotal(parties); + if (total !== 10000) { + issues.push({ message: `Shares must total 100% (10000 bps). Currently ${(total / 100).toFixed(2)}%.` }); + } + } + return issues; +} + +function metadataURI(values: Record<string, string | number>, parties: Party[]): string { + // The UI shows this as a placeholder for the IPFS/HTTPS metadata pin. + // SDK/oracle will replace this with a real pin during deploy. + const seed = `${values.isrc || 'NO-ISRC'}:${values.title || 'untitled'}:${parties.length}`; + return `ipfs://muzix/${seed.replace(/[^a-z0-9:_-]/gi, '').toLowerCase()}`; +} + +// ────────────────────────────────────────────────────────────────────────── +// Templates + +const recordingSplit: Template = { + slug: 'recording-split', + name: 'Recording royalty split', + blurb: + 'Mint a Muzix catalog token for a recording and lock the cap table on-chain. Artist + producer + label + publisher style.', + category: 'Recording', + partiesAreCapTable: true, + allowedRoles: ['Artist', 'Featured Artist', 'Producer', 'Label', 'Publisher', 'Other'], + defaultParties: [ + { name: '', address: '', role: 'Artist', shareBps: 6000 }, + { name: '', address: '', role: 'Producer', shareBps: 2000 }, + { name: '', address: '', role: 'Label', shareBps: 1500 }, + { name: '', address: '', role: 'Publisher', shareBps: 500 }, + ], + fields: [ + { key: 'title', label: 'Track title', kind: 'text', required: true, placeholder: 'Slow Light, Falling Brass' }, + { key: 'artist', label: 'Artist (display)', kind: 'text', required: true, placeholder: 'Lavender Cassette' }, + { key: 'isrc', label: 'ISRC', kind: 'isrc', required: true, placeholder: 'USRC17607839' }, + { key: 'releaseDate', label: 'Release date', kind: 'date', defaultValue: todayISO() }, + { + key: 'territory', + label: 'Territory', + kind: 'select', + options: ['Worldwide', 'North America', 'Europe', 'Asia-Pacific', 'Custom (see notes)'], + defaultValue: 'Worldwide', + }, + { key: 'notes', label: 'Special terms / notes', kind: 'multiline' }, + ], + validate: (v) => validateParties(v.parties, true), + draft: (v) => { + const f = v.fields; + return [ + `# Recording Royalty Agreement`, + ``, + `**Title:** ${f.title || '[title]'}`, + `**Performing artist:** ${f.artist || '[artist]'}`, + `**ISRC:** ${f.isrc || '[ISRC]'}`, + `**Release date:** ${f.releaseDate || todayISO()}`, + `**Territory:** ${f.territory || 'Worldwide'}`, + ``, + `## 1. Parties & cap table`, + `The parties below agree to the proportional split of net recording revenue, enforced on-chain by the MuzixCatalog royalty-split mechanism (basis points, 10000 = 100%):`, + ``, + ...v.parties.map((p) => `- ${partyLine(p)}`), + ``, + `## 2. Revenue settlement`, + `Streaming platforms and licensees deposit revenue into the token's on-chain balance via \`depositRevenue(tokenId)\`. Each party withdraws their pro-rata share via \`claimStreamingRevenue(tokenId)\`. No party shall be entitled to a distribution exceeding their share above.`, + ``, + `## 3. Provenance`, + `The parties attest that this recording is human-authored unless an AI-provenance record is subsequently attached via the MuzixAIProvenance registry by the catalog token owner.`, + ``, + `## 4. Governing protocol`, + `Settlement is governed by the MuzixCatalog contract on chain ${'${chainId}'}. The signed token URI is the canonical metadata reference.`, + ``, + f.notes ? `## 5. Notes\n${f.notes}` : ``, + ] + .filter(Boolean) + .join('\n'); + }, + onchain: (v) => { + const tokenURI = metadataURI(v.fields, v.parties); + const isrc = String(v.fields.isrc || ''); + const artistName = String(v.fields.artist || ''); + const recipients = v.parties.map((p) => (p.address || ZERO_ADDRESS) as Address); + const shares = v.parties.map((p) => p.shareBps); + return { + mode: 'live', + notes: [ + 'Step 1 mints the catalog token to your connected wallet.', + 'Step 2 is sent from the same wallet; the token owner is the only address allowed to set the split.', + ], + calls: [ + { + contract: 'MuzixCatalog', + fn: 'mintMusic', + args: [tokenURI, { isrc, artist: artistName }], + description: `mintMusic(tokenURI, {isrc:"${isrc}", artist:"${artistName}"})`, + }, + { + contract: 'MuzixCatalog', + fn: 'setRoyaltySplit', + args: ['__LAST_MINTED_TOKEN_ID__', recipients, shares], + description: `setRoyaltySplit(<newTokenId>, ${recipients.length} recipients, shares totaling ${pct(bpsTotal(v.parties))})`, + }, + ], + }; + }, +}; + +const featuredAdd: Template = { + slug: 'featured-add', + name: 'Add a featured artist', + blurb: + 'Re-split an existing Muzix catalog token to include a featured artist or new collaborator. Token owner only.', + category: 'Recording', + partiesAreCapTable: true, + allowedRoles: ['Artist', 'Featured Artist', 'Producer', 'Label', 'Publisher', 'Other'], + defaultParties: [ + { name: '', address: '', role: 'Artist', shareBps: 5500 }, + { name: '', address: '', role: 'Featured Artist', shareBps: 1500 }, + { name: '', address: '', role: 'Producer', shareBps: 2000 }, + { name: '', address: '', role: 'Label', shareBps: 1000 }, + ], + fields: [ + { + key: 'tokenId', + label: 'Existing Muzix tokenId', + kind: 'tokenId', + required: true, + placeholder: '1', + help: 'The token you already minted with the Recording split template.', + }, + { + key: 'reason', + label: 'Reason for re-split', + kind: 'multiline', + placeholder: 'Adding a featured artist on the remix release; consideration agreed off-platform.', + }, + ], + validate: (v) => { + const issues = validateParties(v.parties, true); + const tid = String(v.fields.tokenId || ''); + if (!/^\d+$/.test(tid)) issues.push({ field: 'tokenId', message: 'tokenId must be a non-negative integer.' }); + return issues; + }, + draft: (v) => { + const f = v.fields; + return [ + `# Re-Split Amendment — Featured Artist Addition`, + ``, + `**Catalog tokenId:** ${f.tokenId || '[tokenId]'}`, + `**Effective date:** ${todayISO()}`, + ``, + `## 1. Updated cap table`, + `The current token owner amends the on-chain royalty split to:`, + ``, + ...v.parties.map((p) => `- ${partyLine(p)}`), + ``, + `## 2. Accrued balance`, + `Any revenue deposited prior to this amendment has already accrued under the previous split and remains claimable under the prior terms. New deposits are split under the new cap table.`, + ``, + f.reason ? `## 3. Reason\n${f.reason}` : ``, + ] + .filter(Boolean) + .join('\n'); + }, + onchain: (v) => { + const tokenId = BigInt(String(v.fields.tokenId || '0')); + const recipients = v.parties.map((p) => (p.address || ZERO_ADDRESS) as Address); + const shares = v.parties.map((p) => p.shareBps); + return { + mode: 'live', + notes: [ + 'Only the current token owner can call setRoyaltySplit — connect with the owner wallet.', + ], + calls: [ + { + contract: 'MuzixCatalog', + fn: 'setRoyaltySplit', + args: [tokenId, recipients, shares], + description: `setRoyaltySplit(tokenId=${tokenId}, ${recipients.length} recipients, totaling ${pct(bpsTotal(v.parties))})`, + }, + ], + }; + }, +}; + +const aiTrainingLicense: Template = { + slug: 'ai-training-license', + name: 'AI training license', + blurb: + 'Opt a recording into AI training under recorded terms. Binds the catalog token to AI-model token contracts via MuzixAIProvenance.', + category: 'AI', + partiesAreCapTable: false, + allowedRoles: ['Artist', 'AI Model Owner', 'Licensee', 'Other'], + defaultParties: [ + { name: '', address: '', role: 'Artist', shareBps: 0 }, + { name: '', address: '', role: 'AI Model Owner', shareBps: 0 }, + ], + fields: [ + { key: 'tokenId', label: 'Muzix tokenId being licensed', kind: 'tokenId', required: true }, + { + key: 'humanOnly', + label: 'Human-only attestation?', + kind: 'select', + options: ['No — AI was involved', 'Yes — fully human'], + defaultValue: 'No — AI was involved', + help: 'A "human-only" record forbids attaching AI model tokens. Use this to certify a track is AI-free.', + }, + { + key: 'aiModelTokens', + label: 'AI model token contracts (one per line)', + kind: 'uriList', + placeholder: '0xabc...\n0xdef...', + help: 'Addresses of ERC-721-AI (or compatible) model contracts that contributed.', + }, + { + key: 'ipLineageURIs', + label: 'IP lineage URIs (one per line)', + kind: 'uriList', + placeholder: 'ipfs://bafy.../model-card.json', + help: 'Pointers to training-set manifests, model cards, or licensing receipts.', + }, + { + key: 'revenueShareBps', + label: 'Model-owner revenue share (bps)', + kind: 'integer', + defaultValue: 0, + help: 'Informational only at this layer — the cap table itself lives on MuzixCatalog and is set separately.', + }, + { key: 'termMonths', label: 'License term (months)', kind: 'integer', defaultValue: 12 }, + { key: 'notes', label: 'Notes / restrictions', kind: 'multiline' }, + ], + validate: (v) => { + const issues: ValidationIssue[] = []; + const tid = String(v.fields.tokenId || ''); + if (!/^\d+$/.test(tid)) issues.push({ field: 'tokenId', message: 'tokenId must be a non-negative integer.' }); + const humanOnly = String(v.fields.humanOnly || '').startsWith('Yes'); + const models = String(v.fields.aiModelTokens || '') + .split(/\r?\n/) + .map((s) => s.trim()) + .filter(Boolean); + if (humanOnly && models.length > 0) { + issues.push({ field: 'aiModelTokens', message: 'Human-only attestation cannot list AI model tokens.' }); + } + models.forEach((m, i) => { + if (!isAddressLike(m)) { + issues.push({ field: 'aiModelTokens', message: `Model #${i + 1} is not a valid 0x address.` }); + } + }); + if (models.length > 16) issues.push({ field: 'aiModelTokens', message: 'Max 16 model tokens (contract limit).' }); + return issues; + }, + draft: (v) => { + const f = v.fields; + const humanOnly = String(f.humanOnly || '').startsWith('Yes'); + const models = String(f.aiModelTokens || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean); + const uris = String(f.ipLineageURIs || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean); + return [ + `# AI ${humanOnly ? 'Provenance Attestation' : 'Training License'}`, + ``, + `**Catalog tokenId:** ${f.tokenId || '[tokenId]'}`, + `**Effective date:** ${todayISO()}`, + `**Term:** ${f.termMonths || 12} months`, + ``, + humanOnly + ? `## 1. Attestation\nThe parties attest that no AI model contributed to the master recording referenced above. The Muzix AI-provenance record will be set with \`humanOnly = true\`.` + : `## 1. Grant\nThe rights holder grants the listed AI model owners a non-exclusive license to use the recording referenced above for the purpose of model training and inference, subject to the lineage references below.`, + ``, + `## 2. AI model contracts`, + models.length ? models.map((m, i) => `- Model ${i + 1}: ${m}`).join('\n') : '_(none)_', + ``, + `## 3. IP lineage`, + uris.length ? uris.map((u, i) => `- ${i + 1}. ${u}`).join('\n') : '_(none)_', + ``, + `## 4. Revenue share`, + `Model-owner share: ${pct(Number(f.revenueShareBps) || 0)} (informational; settled via MuzixCatalog cap-table updates if/when applicable).`, + ``, + f.notes ? `## 5. Restrictions\n${f.notes}` : ``, + ] + .filter(Boolean) + .join('\n'); + }, + onchain: (v) => { + const tokenId = BigInt(String(v.fields.tokenId || '0')); + const humanOnly = String(v.fields.humanOnly || '').startsWith('Yes'); + const models = String(v.fields.aiModelTokens || '') + .split(/\r?\n/) + .map((s) => s.trim()) + .filter(Boolean) as Address[]; + const uris = String(v.fields.ipLineageURIs || '') + .split(/\r?\n/) + .map((s) => s.trim()) + .filter(Boolean); + // provenanceHash = keccak256(abi.encode(humanOnly, models, uris)) — UI shows a placeholder; the SDK computes the real hash at submit time. + const provenanceHash = ('0x' + '00'.repeat(32)) as Hex; + return { + mode: 'live', + notes: [ + 'The provenanceHash is computed at submit time via MuzixAIProvenance.computeProvenanceHash and replaced before sending.', + 'Caller must be the current owner of the catalog tokenId.', + ], + calls: [ + { + contract: 'MuzixAIProvenance', + fn: 'setProvenance', + args: ['__CATALOG_ADDR__', tokenId, humanOnly, models, uris, provenanceHash], + description: `setProvenance(tokenId=${tokenId}, humanOnly=${humanOnly}, ${models.length} models, ${uris.length} lineage URIs)`, + }, + ], + }; + }, +}; + +const syncLicense: Template = { + slug: 'sync-license', + name: 'Sync license (film/TV/ad)', + blurb: + 'One-shot synchronization license for placement. Terms live in the metadata JSON; on-chain footprint is the metadata hash + revenue split.', + category: 'Licensing', + partiesAreCapTable: true, + allowedRoles: ['Artist', 'Publisher', 'Label', 'Licensee', 'Other'], + defaultParties: [ + { name: '', address: '', role: 'Artist', shareBps: 5000 }, + { name: '', address: '', role: 'Publisher', shareBps: 3000 }, + { name: '', address: '', role: 'Label', shareBps: 2000 }, + ], + fields: [ + { key: 'title', label: 'Track title', kind: 'text', required: true }, + { key: 'isrc', label: 'ISRC', kind: 'isrc', required: true }, + { key: 'licensee', label: 'Licensee (display)', kind: 'text', required: true, placeholder: 'Acme Pictures, LLC' }, + { + key: 'usage', + label: 'Usage', + kind: 'select', + options: ['Feature film', 'TV series', 'Advertisement', 'Trailer', 'Video game', 'Other'], + defaultValue: 'Feature film', + }, + { key: 'territory', label: 'Territory', kind: 'select', options: ['Worldwide', 'North America', 'Europe', 'Asia-Pacific'], defaultValue: 'Worldwide' }, + { key: 'termMonths', label: 'Term (months)', kind: 'integer', defaultValue: 60 }, + { key: 'feeMusd', label: 'Up-front fee (MUSD)', kind: 'integer', defaultValue: 5000 }, + { key: 'exclusivity', label: 'Exclusivity', kind: 'select', options: ['Non-exclusive', 'Exclusive within usage'], defaultValue: 'Non-exclusive' }, + { key: 'project', label: 'Project / production name', kind: 'text' }, + { key: 'restrictions', label: 'Restrictions', kind: 'multiline' }, + ], + validate: (v) => validateParties(v.parties, true), + draft: (v) => { + const f = v.fields; + return [ + `# Synchronization License Agreement`, + ``, + `**Track:** ${f.title || '[title]'} (ISRC ${f.isrc || '[ISRC]'})`, + `**Licensee:** ${f.licensee || '[licensee]'}`, + `**Project:** ${f.project || '[project]'}`, + `**Usage:** ${f.usage}`, + `**Territory:** ${f.territory}`, + `**Term:** ${f.termMonths || 60} months from ${todayISO()}`, + `**Exclusivity:** ${f.exclusivity}`, + `**Up-front fee:** ${f.feeMusd || 0} MUSD`, + ``, + `## 1. Grant`, + `Licensor grants Licensee a ${String(f.exclusivity).toLowerCase()} synchronization right to use the recording above in the ${String(f.usage).toLowerCase()} identified, within the territory and term stated.`, + ``, + `## 2. Consideration & split`, + `The up-front fee is paid into the MuzixCatalog token balance and distributed to the parties below per the on-chain royalty split (basis points, 10000 = 100%):`, + ``, + ...v.parties.map((p) => `- ${partyLine(p)}`), + ``, + `## 3. Off-chain enforcement`, + `Usage scope and restrictions are off-chain obligations of the Licensee. The keccak256 hash of this JSON metadata is recorded as the canonical agreement reference on the catalog token.`, + ``, + f.restrictions ? `## 4. Restrictions\n${f.restrictions}` : ``, + ] + .filter(Boolean) + .join('\n'); + }, + onchain: (v) => { + const tokenURI = metadataURI(v.fields, v.parties); + const isrc = String(v.fields.isrc || ''); + const artistName = String(v.fields.title || ''); // sync deals are referenced by track, not artist + const recipients = v.parties.map((p) => (p.address || ZERO_ADDRESS) as Address); + const shares = v.parties.map((p) => p.shareBps); + return { + mode: 'hybrid', + notes: [ + 'Sync windows and usage scope are off-chain — they live in the metadata JSON pinned to tokenURI.', + 'On-chain we mint a catalog token holding the agreement hash and the revenue split. Settlement of the up-front fee is a separate deposit transaction.', + ], + calls: [ + { + contract: 'MuzixCatalog', + fn: 'mintMusic', + args: [tokenURI, { isrc, artist: artistName }], + description: `mintMusic(tokenURI=${tokenURI}, {isrc:"${isrc}"}) — token represents the sync agreement`, + }, + { + contract: 'MuzixCatalog', + fn: 'setRoyaltySplit', + args: ['__LAST_MINTED_TOKEN_ID__', recipients, shares], + description: `setRoyaltySplit on the sync token (${recipients.length} recipients, totaling ${pct(bpsTotal(v.parties))})`, + }, + ], + }; + }, +}; + +export const TEMPLATES: Template[] = [ + recordingSplit, + featuredAdd, + aiTrainingLicense, + syncLicense, +]; + +export function getTemplate(slug: string): Template | undefined { + return TEMPLATES.find((t) => t.slug === slug); +} + +export function bpsTotalOf(parties: Party[]): number { + return bpsTotal(parties); +} + +export function defaultValues(t: Template): TemplateValues { + const fields: Record<string, string | number> = {}; + for (const f of t.fields) { + if (f.defaultValue !== undefined) fields[f.key] = f.defaultValue; + else fields[f.key] = ''; + } + return { parties: t.defaultParties.map((p) => ({ ...p })), fields }; +} diff --git a/web/lib/contracts.ts b/web/lib/contracts.ts new file mode 100644 index 00000000..cc9a8a2c --- /dev/null +++ b/web/lib/contracts.ts @@ -0,0 +1,112 @@ +/** + * On-chain bindings for the Muzix contract builder. + * + * Addresses default to the local devnet (chainId 1338). When the canonical + * deploy ships, override via NEXT_PUBLIC_MUZIX_CATALOG / NEXT_PUBLIC_MUZIX_AI_PROVENANCE. + * + * The ABI fragments below are the minimal slice the builder needs — they + * are copied verbatim from `muzix/src/MuzixCatalog.sol` and + * `muzix/src/MuzixAIProvenance.sol`. Keep them in sync if signatures change. + */ + +import type { Abi, Address } from 'viem'; + +export const MUZIX_CHAIN_ID = Number(process.env.NEXT_PUBLIC_MUZIX_CHAIN_ID ?? 1338); + +export const MUZIX_CATALOG_ADDRESS: Address = + (process.env.NEXT_PUBLIC_MUZIX_CATALOG as Address | undefined) ?? + ('0x0000000000000000000000000000000000000000' as Address); + +export const MUZIX_AI_PROVENANCE_ADDRESS: Address = + (process.env.NEXT_PUBLIC_MUZIX_AI_PROVENANCE as Address | undefined) ?? + ('0x0000000000000000000000000000000000000000' as Address); + +export const MUZIX_CATALOG_ABI = [ + { + type: 'function', + name: 'mintMusic', + stateMutability: 'nonpayable', + inputs: [ + { name: 'tokenURI', type: 'string' }, + { + name: 'metadata', + type: 'tuple', + components: [ + { name: 'isrc', type: 'string' }, + { name: 'artist', type: 'string' }, + ], + }, + ], + outputs: [{ name: '', type: 'uint256' }], + }, + { + type: 'function', + name: 'setRoyaltySplit', + stateMutability: 'nonpayable', + inputs: [ + { name: 'tokenId', type: 'uint256' }, + { name: 'recipients', type: 'address[]' }, + { name: 'shares', type: 'uint16[]' }, + ], + outputs: [], + }, + { + type: 'function', + name: 'ownerOf', + stateMutability: 'view', + inputs: [{ name: 'tokenId', type: 'uint256' }], + outputs: [{ name: '', type: 'address' }], + }, + { + type: 'event', + name: 'Transfer', + inputs: [ + { indexed: true, name: 'from', type: 'address' }, + { indexed: true, name: 'to', type: 'address' }, + { indexed: true, name: 'tokenId', type: 'uint256' }, + ], + }, +] as const satisfies Abi; + +export const MUZIX_AI_PROVENANCE_ABI = [ + { + type: 'function', + name: 'setProvenance', + stateMutability: 'nonpayable', + inputs: [ + { name: 'catalog', type: 'address' }, + { name: 'tokenId', type: 'uint256' }, + { name: 'humanOnly', type: 'bool' }, + { name: 'aiModelTokens', type: 'address[]' }, + { name: 'ipLineageURIs', type: 'string[]' }, + { name: 'provenanceHash', type: 'bytes32' }, + ], + outputs: [], + }, + { + type: 'function', + name: 'computeProvenanceHash', + stateMutability: 'pure', + inputs: [ + { name: 'humanOnly', type: 'bool' }, + { name: 'aiModelTokens', type: 'address[]' }, + { name: 'ipLineageURIs', type: 'string[]' }, + ], + outputs: [{ name: '', type: 'bytes32' }], + }, +] as const satisfies Abi; + +export type MuzixContractName = 'MuzixCatalog' | 'MuzixAIProvenance'; + +export function addressFor(name: MuzixContractName): Address { + return name === 'MuzixCatalog' ? MUZIX_CATALOG_ADDRESS : MUZIX_AI_PROVENANCE_ADDRESS; +} + +export function abiFor(name: MuzixContractName): Abi { + return name === 'MuzixCatalog' ? (MUZIX_CATALOG_ABI as unknown as Abi) : (MUZIX_AI_PROVENANCE_ABI as unknown as Abi); +} + +export function isDeployed(name: MuzixContractName): boolean { + const a = addressFor(name); + return !!a && a !== '0x0000000000000000000000000000000000000000'; +} From 3191f630041e0ce2cf54e43e4a53ee1a274dedaa Mon Sep 17 00:00:00 2001 From: Abhishek Krishna <invokerkrishna@gmail.com> Date: Sun, 24 May 2026 17:53:47 +0530 Subject: [PATCH 3/4] fix(web): TS narrowing in ContractBuilder + decodeEventLog cast --- web/components/ContractBuilder.tsx | 19 +++++++++++-------- web/components/DeployPanel.tsx | 2 +- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/web/components/ContractBuilder.tsx b/web/components/ContractBuilder.tsx index 9e4ef739..0127007a 100644 --- a/web/components/ContractBuilder.tsx +++ b/web/components/ContractBuilder.tsx @@ -26,11 +26,14 @@ export function ContractBuilder({ slug }: { slug: string }) { ); if (!template) return null; + // Capture as a non-nullable local so closures (addParty, etc.) don't trip + // on TS narrowing limitations across nested function scopes. + const t = template; - const issues = template.validate(values); + const issues = t.validate(values); const bpsSum = bpsTotalOf(values.parties); - const plan = template.onchain(values); - const draftText = template.draft(values); + const plan = t.onchain(values); + const draftText = t.draft(values); function patchField(key: string, v: string | number) { setValues((cur) => ({ ...cur, fields: { ...cur.fields, [key]: v } })); @@ -49,7 +52,7 @@ export function ContractBuilder({ slug }: { slug: string }) { ...cur, parties: [ ...cur.parties, - { name: '', address: '', role: template.allowedRoles[0] ?? 'Other', shareBps: 0 }, + { name: '', address: '', role: t.allowedRoles[0] ?? 'Other', shareBps: 0 }, ], })); } @@ -78,16 +81,16 @@ export function ContractBuilder({ slug }: { slug: string }) { <div className="space-y-3"> <PartiesEditor parties={values.parties} - allowedRoles={template.allowedRoles} + allowedRoles={t.allowedRoles} onChange={patchParty} onRemove={removeParty} - capTable={template.partiesAreCapTable} + capTable={t.partiesAreCapTable} /> <div className="flex flex-wrap items-center justify-between gap-3"> <button onClick={addParty} className="btn"> + add party </button> - {template.partiesAreCapTable && ( + {t.partiesAreCapTable && ( <div className="flex items-center gap-3"> <button onClick={autoBalance} className="btn"> auto-balance to 100% @@ -102,7 +105,7 @@ export function ContractBuilder({ slug }: { slug: string }) { {/* Fields */} <Card label="02 · terms"> <div className="grid gap-4 sm:grid-cols-2"> - {template.fields.map((f) => ( + {t.fields.map((f) => ( <FieldRow key={f.key} def={f} diff --git a/web/components/DeployPanel.tsx b/web/components/DeployPanel.tsx index e8706956..ef23f627 100644 --- a/web/components/DeployPanel.tsx +++ b/web/components/DeployPanel.tsx @@ -301,7 +301,7 @@ function extractMintedTokenId(receipt: TransactionReceipt, mintedTo: Address): b topics: log.topics, }); if (decoded.eventName === 'Transfer') { - const args = decoded.args as { from: Address; to: Address; tokenId: bigint }; + const args = decoded.args as unknown as { from: Address; to: Address; tokenId: bigint }; if (args.from.toLowerCase() === '0x0000000000000000000000000000000000000000' && args.to.toLowerCase() === mintedTo.toLowerCase()) { return args.tokenId; } From ca39ee115cafc3cf88d019dfc382f0d4713d8569 Mon Sep 17 00:00:00 2001 From: Abhishek Krishna <invokerkrishna@gmail.com> Date: Sun, 24 May 2026 17:55:12 +0530 Subject: [PATCH 4/4] =?UTF-8?q?fix(web):=20resolveCall=20preview=20mode=20?= =?UTF-8?q?=E2=80=94=20fall=20back=20to=20placeholders=20for=20SSR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The static prerender of /contracts/[slug] renders the encoded-calldata preview, which calls resolveCall before any tokenId exists. Throwing broke the build. Preview path now substitutes 0n for the placeholder tokenId; the live deploy loop still throws if it ever hits the same state at runtime. --- web/components/DeployPanel.tsx | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/web/components/DeployPanel.tsx b/web/components/DeployPanel.tsx index ef23f627..154398ae 100644 --- a/web/components/DeployPanel.tsx +++ b/web/components/DeployPanel.tsx @@ -110,7 +110,7 @@ export function DeployPanel({ const call = calls[i]; setStatus({ kind: 'sending', step: i + 1, total: calls.length }); - const resolved = resolveCall(call, { mintedTokenId, values, catalogAddress }); + const resolved = resolveCall(call, { mintedTokenId, values, catalogAddress, forPreview: false }); const hash = await wallet.writeContract({ address: addressFor(resolved.contract), @@ -141,7 +141,7 @@ export function DeployPanel({ function copyCalldata() { const blob = calls .map((c, i) => { - const resolved = resolveCall(c, { mintedTokenId: undefined, values, catalogAddress }); + const resolved = resolveCall(c, { mintedTokenId: undefined, values, catalogAddress, forPreview: true }); const calldata = encodeFunctionData({ abi: abiFor(resolved.contract), functionName: resolved.fn, @@ -203,7 +203,7 @@ export function DeployPanel({ <pre className="mt-3 max-h-72 overflow-auto whitespace-pre-wrap break-all font-mono text-[10px] leading-relaxed text-ink-300"> {calls .map((c, i) => { - const resolved = resolveCall(c, { mintedTokenId: undefined, values, catalogAddress }); + const resolved = resolveCall(c, { mintedTokenId: undefined, values, catalogAddress, forPreview: true }); const calldata = encodeFunctionData({ abi: abiFor(resolved.contract), functionName: resolved.fn, @@ -258,14 +258,21 @@ function StatusLine({ status }: { status: Status }) { function resolveCall( call: OnchainCall, - ctx: { mintedTokenId: bigint | undefined; values: TemplateValues; catalogAddress: Address }, + ctx: { + mintedTokenId: bigint | undefined; + values: TemplateValues; + catalogAddress: Address; + /** When true, unresolved placeholders fall back to safe defaults so the + * caller can encode a preview. Used by the calldata preview + copy + * buttons; the live deploy loop passes `false` and surfaces the error. */ + forPreview: boolean; + }, ): OnchainCall { const args = call.args.map((a) => { if (a === '__LAST_MINTED_TOKEN_ID__') { - if (ctx.mintedTokenId === undefined) { - throw new Error('Cannot resolve token id before mint — make sure mintMusic runs first.'); - } - return ctx.mintedTokenId; + if (ctx.mintedTokenId !== undefined) return ctx.mintedTokenId; + if (ctx.forPreview) return 0n; + throw new Error('Cannot resolve token id before mint — make sure mintMusic runs first.'); } if (a === '__CATALOG_ADDR__') return ctx.catalogAddress; return a;