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
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 (
+
+
+
+
+ ← all templates
+
+ /
+ {template.category}
+
+
+ {template.name}
+
+
{template.blurb}
+
+
+
+
+ );
+}
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 (
+
+
+
/ contract builder
+
+ Music contracts,
+
+ drafted visually, deployed on-chain.
+
+
+ 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{' '}
+ MuzixCatalog and{' '}
+ MuzixAIProvenance{' '}
+ calls and submits them from your wallet.
+