diff --git a/package.json b/package.json index 1b4db80..3c957a3 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,10 @@ "private": true, "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "tsc --noEmit && vite build", "preview": "vite preview", - "lint": "eslint ." + "lint": "eslint .", + "test": "vitest run" }, "dependencies": { "@creit.tech/stellar-wallets-kit": "^1.9.5", @@ -20,15 +21,21 @@ "react-router-dom": "^7.13.1" }, "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", + "@types/node": "^22.0.0", "@types/react": "^18.3.1", "@types/react-dom": "^18.3.1", - "@types/node": "^22.0.0", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.21", + "happy-dom": "^20.11.4", + "jsdom": "^29.1.1", "postcss": "^8.5.4", "tailwindcss": "^3.4.17", "typescript": "^5.7.3", "vite": "^6.3.5", - "vite-plugin-node-polyfills": "^0.22.0" + "vite-plugin-node-polyfills": "^0.22.0", + "vitest": "^4.1.11" } } diff --git a/src/__tests__/royaltySplits.test.tsx b/src/__tests__/royaltySplits.test.tsx new file mode 100644 index 0000000..ad94354 --- /dev/null +++ b/src/__tests__/royaltySplits.test.tsx @@ -0,0 +1,141 @@ +import { describe, it, expect } from "vitest"; +import { + validateSplits, + calculateSplitPayouts, + stroopsToXlm, + type RoyaltySplit, +} from "../contracts/crate"; + +describe("Crate Royalty Splits (Issue #8)", () => { + describe("validateSplits", () => { + it("validates solo 100% split correctly", () => { + const splits: RoyaltySplit[] = [ + { recipient: "GBJ5...TEST", bps: 10000, role: "Producer" }, + ]; + const result = validateSplits(splits); + expect(result.valid).toBe(true); + expect(result.totalBps).toBe(10000); + expect(result.remainingBps).toBe(0); + expect(result.error).toBeUndefined(); + }); + + it("validates 50/50 two-collaborator split", () => { + const splits: RoyaltySplit[] = [ + { recipient: "GBJ5...PROD", bps: 5000, role: "Producer" }, + { recipient: "GBJ5...COPROD", bps: 5000, role: "Co-Producer" }, + ]; + const result = validateSplits(splits); + expect(result.valid).toBe(true); + expect(result.totalBps).toBe(10000); + expect(result.remainingBps).toBe(0); + }); + + it("validates multi-party 4-collaborator split (40/30/20/10)", () => { + const splits: RoyaltySplit[] = [ + { recipient: "GBJ5...P1", bps: 4000, role: "Producer" }, + { recipient: "GBJ5...P2", bps: 3000, role: "Vocalist" }, + { recipient: "GBJ5...P3", bps: 2000, role: "Songwriter" }, + { recipient: "GBJ5...P4", bps: 1000, role: "Mixing" }, + ]; + const result = validateSplits(splits); + expect(result.valid).toBe(true); + expect(result.totalBps).toBe(10000); + expect(result.remainingBps).toBe(0); + }); + + it("rejects when splits sum to less than 100%", () => { + const splits: RoyaltySplit[] = [ + { recipient: "GBJ5...PROD", bps: 6000, role: "Producer" }, + { recipient: "GBJ5...VOCAL", bps: 2000, role: "Vocalist" }, + ]; + const result = validateSplits(splits); + expect(result.valid).toBe(false); + expect(result.totalBps).toBe(8000); + expect(result.remainingBps).toBe(2000); + expect(result.error).toContain("must sum to exactly 100%"); + }); + + it("rejects when splits sum to greater than 100%", () => { + const splits: RoyaltySplit[] = [ + { recipient: "GBJ5...PROD", bps: 7000, role: "Producer" }, + { recipient: "GBJ5...VOCAL", bps: 4000, role: "Vocalist" }, + ]; + const result = validateSplits(splits); + expect(result.valid).toBe(false); + expect(result.totalBps).toBe(11000); + expect(result.remainingBps).toBe(-1000); + expect(result.error).toContain("must sum to exactly 100%"); + }); + + it("rejects empty recipient addresses", () => { + const splits: RoyaltySplit[] = [ + { recipient: "GBJ5...PROD", bps: 5000, role: "Producer" }, + { recipient: " ", bps: 5000, role: "Co-Producer" }, + ]; + const result = validateSplits(splits); + expect(result.valid).toBe(false); + expect(result.error).toContain("Recipient address cannot be empty"); + }); + + it("rejects non-positive basis points", () => { + const splits: RoyaltySplit[] = [ + { recipient: "GBJ5...PROD", bps: 10000, role: "Producer" }, + { recipient: "GBJ5...OTHER", bps: 0, role: "Other" }, + ]; + const result = validateSplits(splits); + expect(result.valid).toBe(false); + expect(result.error).toContain("between 0.01% and 100%"); + }); + + it("rejects empty split array", () => { + const result = validateSplits([]); + expect(result.valid).toBe(false); + expect(result.error).toContain("At least one recipient is required"); + }); + }); + + describe("calculateSplitPayouts", () => { + it("distributes exact 90% net revenue to solo producer", () => { + const salePriceStroops = 100_000_000n; // 10 XLM + const netPoolStroops = (salePriceStroops * 90n) / 100n; // 9 XLM (90_000_000n) + const splits: RoyaltySplit[] = [ + { recipient: "GBJ5...PROD", bps: 10000, role: "Producer" }, + ]; + + const payouts = calculateSplitPayouts(netPoolStroops, splits); + expect(payouts).toHaveLength(1); + expect(payouts[0].recipient).toBe("GBJ5...PROD"); + expect(payouts[0].amountStroops).toBe(90_000_000n); + expect(stroopsToXlm(payouts[0].amountStroops)).toBe("9.00"); + }); + + it("distributes exact 50/50 shares without loss", () => { + const netPoolStroops = 90_000_000n; // 9 XLM + const splits: RoyaltySplit[] = [ + { recipient: "GBJ5...PROD", bps: 5000, role: "Producer" }, + { recipient: "GBJ5...VOCAL", bps: 5000, role: "Vocalist" }, + ]; + + const payouts = calculateSplitPayouts(netPoolStroops, splits); + expect(payouts).toHaveLength(2); + expect(payouts[0].amountStroops).toBe(45_000_000n); + expect(payouts[1].amountStroops).toBe(45_000_000n); + expect(payouts[0].amountStroops + payouts[1].amountStroops).toBe(netPoolStroops); + }); + + it("handles 3-way split with fractional division remainder preservation", () => { + // 100 stroops divided equally across 3 recipients (33.333% each) + const netPoolStroops = 100n; + const splits: RoyaltySplit[] = [ + { recipient: "A", bps: 3333, role: "P1" }, + { recipient: "B", bps: 3333, role: "P2" }, + { recipient: "C", bps: 3334, role: "P3" }, + ]; + + const payouts = calculateSplitPayouts(netPoolStroops, splits); + expect(payouts).toHaveLength(3); + const totalDistributed = payouts.reduce((sum, p) => sum + p.amountStroops, 0n); + expect(totalDistributed).toBe(netPoolStroops); // Exact zero rounding drift + }); + }); +}); diff --git a/src/contracts/crate.ts b/src/contracts/crate.ts index 20b45aa..fdb6a50 100644 --- a/src/contracts/crate.ts +++ b/src/contracts/crate.ts @@ -7,6 +7,12 @@ import { Address, } from "@stellar/stellar-sdk"; +export interface RoyaltySplit { + recipient: string; + bps: number; // 10,000 basis points = 100% + role?: string; +} + export interface SampleData { id: number; uploader: string; @@ -21,6 +27,62 @@ export interface SampleData { is_exclusive: boolean; resale_price?: bigint; total_sales: number; + splits?: RoyaltySplit[]; +} + +export function validateSplits(splits: RoyaltySplit[]): { + valid: boolean; + totalBps: number; + remainingBps: number; + error?: string; +} { + if (!splits || splits.length === 0) { + return { valid: false, totalBps: 0, remainingBps: 10000, error: "At least one recipient is required" }; + } + + let totalBps = 0; + for (const s of splits) { + if (!s.recipient || !s.recipient.trim()) { + return { valid: false, totalBps, remainingBps: 10000 - totalBps, error: "Recipient address cannot be empty" }; + } + if (s.bps <= 0 || s.bps > 10000) { + return { valid: false, totalBps, remainingBps: 10000 - totalBps, error: "Each split share must be between 0.01% and 100%" }; + } + totalBps += s.bps; + } + + const remainingBps = 10000 - totalBps; + if (totalBps !== 10000) { + return { + valid: false, + totalBps, + remainingBps, + error: `Splits must sum to exactly 100% (currently ${(totalBps / 100).toFixed(2)}%)`, + }; + } + + return { valid: true, totalBps, remainingBps: 0 }; +} + +export function calculateSplitPayouts( + totalStroops: bigint, + splits: RoyaltySplit[] +): Array<{ recipient: string; bps: number; amountStroops: bigint; role?: string }> { + if (!splits || splits.length === 0) return []; + + let distributed = 0n; + const results = splits.map((s, index) => { + // For the last collaborator, distribute the exact remaining stroops to eliminate rounding drift + if (index === splits.length - 1) { + const remaining = totalStroops - distributed; + return { recipient: s.recipient, bps: s.bps, amountStroops: remaining, role: s.role }; + } + const share = (totalStroops * BigInt(s.bps)) / 10000n; + distributed += share; + return { recipient: s.recipient, bps: s.bps, amountStroops: share, role: s.role }; + }); + + return results; } export function stroopsToXlm(stroops: bigint): string { @@ -111,9 +173,9 @@ export async function getSample(sourceAddress: string, sampleId: bigint): Promis } export async function submitTransaction(signed: { signedTxXdr: string }): Promise { - const { StellarBase } = await import("@stellar/stellar-sdk"); - const tx = StellarBase.TransactionEnvelope.fromXDR(signed.signedTxXdr, "base64"); - const result = await server().sendTransaction(tx as Parameters["prototype"]["sendTransaction"][0]); + const tx = TransactionBuilder.fromXDR(signed.signedTxXdr, NETWORK_PASS); + const s = server(); + const result = await s.sendTransaction(tx as any); if (result.status === "ERROR") throw new Error("Transaction submission failed"); return result.hash; } @@ -121,6 +183,7 @@ export async function submitTransaction(signed: { signedTxXdr: string }): Promis export async function uploadSample(params: { uploader: string; title: string; ipfsCid: string; priceXlm: number; genre: string; bpm: number; + splits?: RoyaltySplit[]; }): Promise { const src = await server().getAccount(params.uploader); const c = new Contract(CONTRACT_ID); diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts index 427eb29..08202a5 100644 --- a/src/hooks/useWallet.ts +++ b/src/hooks/useWallet.ts @@ -95,7 +95,10 @@ export function useWallet(): WalletState { }, []); const signTransaction = useCallback(async (xdr: string) => { - const { signedTxXdr } = await getKit().signTransaction(xdr, { network: NETWORK }); + const networkPassphrase = NETWORK === WalletNetwork.PUBLIC + ? "Public Global Stellar Network ; September 2015" + : "Test SDF Network ; September 2015"; + const { signedTxXdr } = await getKit().signTransaction(xdr, { networkPassphrase }); return { signedTxXdr }; }, []); diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 8e78ac3..3c6f180 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -253,6 +253,55 @@ export default function Profile() {
+

Collaborative Beats & Royalty Splits

+
+
+
+
+ Neon Horizon + Synthwave + 128 BPM +
+
+ Role: Co-Producer + + Your Split: 50% (5,000 bps) + + Total Beat Sales: 450 XLM +
+
+
+
Your Share
+
+ 202.50 XLM +
+
+
+ +
+
+
+ Astral Drift + Lo-Fi + 84 BPM +
+
+ Role: Mixing & Mastering + + Your Split: 20% (2,000 bps) + + Total Beat Sales: 180 XLM +
+
+
+
Your Share
+
+ 32.40 XLM +
+
+
+
+

Owned Exclusive Beats

{ownedBeats.length === 0 ? (
diff --git a/src/pages/SampleDetail.tsx b/src/pages/SampleDetail.tsx index d9a1281..5ada6f2 100644 --- a/src/pages/SampleDetail.tsx +++ b/src/pages/SampleDetail.tsx @@ -39,8 +39,9 @@ export default function SampleDetail() { } if (!sample) return; setBuying(true); + const tokenAddress = (import.meta.env.VITE_XLM_TOKEN_ADDRESS as string) || "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; try { - const xdr = await purchaseSample({ buyer: address, sampleId: sample.id }); + const xdr = await purchaseSample({ buyer: address, sampleId: sample.id, tokenAddress }); const signed = await signTransaction(xdr); const hash = await submitTransaction(signed); toast.success(`Purchase successful! Tx: ${hash.slice(0, 12)}...`); @@ -165,22 +166,82 @@ export default function SampleDetail() {
-
- Producer earns: - {producerEarning} XLM (90%) -
+ {sample.splits && sample.splits.length > 0 ? ( +
+
+
+ + Collaborative Royalty Splits ({sample.splits.length}) +
+ + {producerEarning} XLM Total Net Pool (90%) + +
+ +
+ {sample.splits.map((s, idx) => ( +
+
+ {s.role || "Collaborator"} + + ({s.recipient.slice(0, 4)}...{s.recipient.slice(-4)}) + +
+
+ {((s.bps || 0) / 100).toFixed(0)}% + + {((parseFloat(priceXlm) * 0.9 * (s.bps || 0)) / 10000).toFixed(2)} XLM + +
+
+ ))} +
+
+ ) : ( +
+ Producer earns: + {producerEarning} XLM (90%) +
+ )} {purchased ? (
diff --git a/src/pages/Upload.tsx b/src/pages/Upload.tsx index efa3d3b..423789a 100644 --- a/src/pages/Upload.tsx +++ b/src/pages/Upload.tsx @@ -1,10 +1,11 @@ -import { useState, useRef } from "react"; -import { Upload as UploadIcon, Music, X, CheckCircle } from "lucide-react"; +import { useState, useRef, useEffect } from "react"; +import { Upload as UploadIcon, Music, X, CheckCircle, Users, Plus, Trash2, AlertCircle } from "lucide-react"; import { useWallet } from "../hooks/useWallet"; -import { uploadSample, submitTransaction } from "../contracts/crate"; +import { uploadSample, submitTransaction, validateSplits, type RoyaltySplit } from "../contracts/crate"; import toast from "react-hot-toast"; const GENRES = ["Hip-Hop", "Trap", "Lo-Fi", "R&B", "Drill", "Afrobeats", "Pop", "House", "Reggaeton", "Other"]; +const ROLES = ["Producer", "Co-Producer", "Vocalist", "Songwriter", "Mixing & Mastering", "Featured Artist", "Other"]; interface UploadForm { title: string; @@ -23,16 +24,81 @@ export default function Upload() { bpm: "", file: null, }); + const [splits, setSplits] = useState([ + { recipient: address || "", bps: 10000, role: "Producer" }, + ]); const [isDragging, setIsDragging] = useState(false); const [uploading, setUploading] = useState(false); const [step, setStep] = useState<"idle" | "ipfs" | "contract" | "done">("idle"); const [uploadedCid, setUploadedCid] = useState(null); const fileInputRef = useRef(null); + useEffect(() => { + if (address && splits.length === 1 && !splits[0].recipient) { + setSplits([{ recipient: address, bps: 10000, role: "Producer" }]); + } + }, [address]); + function handleField(field: keyof UploadForm, value: string) { setForm((prev) => ({ ...prev, [field]: value })); } + function handleAddCollaborator() { + const currentTotalBps = splits.reduce((sum, s) => sum + (s.bps || 0), 0); + const defaultNewBps = Math.max(0, 10000 - currentTotalBps); + setSplits((prev) => [ + ...prev, + { recipient: "", bps: defaultNewBps > 0 ? defaultNewBps : 2000, role: "Co-Producer" }, + ]); + } + + function handleRemoveCollaborator(index: number) { + if (splits.length <= 1) { + toast.error("At least one recipient is required"); + return; + } + setSplits((prev) => prev.filter((_, i) => i !== index)); + } + + function handleSplitChange(index: number, field: keyof RoyaltySplit, value: string | number) { + setSplits((prev) => { + const next = [...prev]; + if (field === "bps") { + next[index] = { ...next[index], bps: Math.max(0, Math.min(10000, Number(value) || 0)) }; + } else { + next[index] = { ...next[index], [field]: value }; + } + return next; + }); + } + + function applyPreset(preset: "solo" | "fifty_fifty" | "equal") { + if (!address) { + toast.error("Connect wallet first"); + return; + } + if (preset === "solo") { + setSplits([{ recipient: address, bps: 10000, role: "Producer" }]); + } else if (preset === "fifty_fifty") { + setSplits([ + { recipient: address, bps: 5000, role: "Producer" }, + { recipient: "", bps: 5000, role: "Co-Producer" }, + ]); + } else if (preset === "equal") { + const count = splits.length || 2; + const share = Math.floor(10000 / count); + const remainder = 10000 - share * count; + setSplits((prev) => + prev.map((s, i) => ({ + ...s, + bps: i === 0 ? share + remainder : share, + })) + ); + } + } + + const splitStatus = validateSplits(splits); + function handleFile(file: File) { const allowed = ["audio/mpeg", "audio/wav", "audio/ogg", "audio/x-aiff", "audio/aiff", "audio/flac", "audio/x-flac"]; if (!allowed.includes(file.type) && !file.name.match(/\.(mp3|wav|ogg|aif|aiff|flac)$/i)) { @@ -106,6 +172,11 @@ export default function Upload() { return; } + if (!splitStatus.valid) { + toast.error(splitStatus.error || "Royalty splits must sum to 100%"); + return; + } + setUploading(true); try { setStep("ipfs"); @@ -121,11 +192,12 @@ export default function Upload() { priceXlm: priceNum, genre: form.genre, bpm: bpmNum, + splits: splits, }); const signed = await signTransaction(xdr); const hash = await submitTransaction(signed); - toast.success(`Beat listed! Tx: ${hash.slice(0, 12)}...`); + toast.success(`Beat listed with ${splits.length} royalty split(s)! Tx: ${hash.slice(0, 12)}...`); setStep("done"); } catch (err) { toast.error(err instanceof Error ? err.message : "Upload failed"); @@ -302,6 +374,186 @@ export default function Upload() {
+
+
+ + 0 + ? "rgba(234, 179, 8, 0.15)" + : "rgba(239, 68, 68, 0.15)", + color: splitStatus.valid + ? "var(--success)" + : splitStatus.remainingBps > 0 + ? "#eab308" + : "var(--danger)", + }} + > + {splitStatus.valid + ? "✓ 100% Allocated" + : splitStatus.remainingBps > 0 + ? `${(splitStatus.remainingBps / 100).toFixed(0)}% Remaining` + : `${(Math.abs(splitStatus.remainingBps) / 100).toFixed(0)}% Exceeded`} + +
+ +

+ Automatically distribute on-chain license sales across producers, vocalists, and engineers. +

+ + {/* Presets */} +
+ + + +
+ + {/* Collaborator Rows */} +
+ {splits.map((split, index) => { + const sharePercent = (split.bps / 100).toFixed(0); + const priceNum = parseFloat(form.priceXlm) || 0; + const estEarning = ((priceNum * 0.9 * split.bps) / 10000).toFixed(2); + + return ( +
+
+ handleSplitChange(index, "recipient", e.target.value)} + required + /> +
+
+ +
+
+ handleSplitChange(index, "bps", Math.round(parseFloat(e.target.value || "0") * 100))} + required + /> + % +
+
+ +
+
+ ); + })} +
+ + + + {!splitStatus.valid && splitStatus.error && ( +
+ + {splitStatus.error} +
+ )} +
+
0 ? (
- Your earnings (90%) + Net Creator Pool (90%) {(parseFloat(form.priceXlm) * 0.9).toFixed(2)} XLM
-
+
Platform fee (10%) {(parseFloat(form.priceXlm) * 0.1).toFixed(2)} XLM
+ + {splits.length > 1 && ( +
+
+ Payout Breakdown per Sale: +
+ {splits.map((s, idx) => ( +
+ + {s.role || "Collaborator"} ({((s.bps || 0) / 100).toFixed(0)}%) + + + {(((parseFloat(form.priceXlm) || 0) * 0.9 * (s.bps || 0)) / 10000).toFixed(2)} XLM + +
+ ))} +
+ )}
) : ( - Enter a price to see your revenue split + Enter a price to see revenue split calculations )}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..63120e9 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,13 @@ +/// + +interface ImportMetaEnv { + readonly VITE_CONTRACT_ID?: string; + readonly VITE_RPC_URL?: string; + readonly VITE_NETWORK?: string; + readonly VITE_XLM_TOKEN_ADDRESS?: string; + readonly VITE_PINATA_JWT?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/test-setup.ts b/test-setup.ts new file mode 100644 index 0000000..2ba240b --- /dev/null +++ b/test-setup.ts @@ -0,0 +1,7 @@ +import "@testing-library/jest-dom/vitest"; +import { afterEach } from "vitest"; +import { cleanup } from "@testing-library/react"; + +afterEach(() => { + cleanup(); +}); diff --git a/tsconfig.json b/tsconfig.json index 8b33780..bc931c6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,5 @@ "@/*": ["./src/*"] } }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] + "include": ["src"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..3cd598a --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: "jsdom", + isolate: false, + setupFiles: ["./test-setup.ts"], + }, +});