Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
141 changes: 141 additions & 0 deletions src/__tests__/royaltySplits.test.tsx
Original file line number Diff line number Diff line change
@@ -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
});
});
});
69 changes: 66 additions & 3 deletions src/contracts/crate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -111,16 +173,17 @@ export async function getSample(sourceAddress: string, sampleId: bigint): Promis
}

export async function submitTransaction(signed: { signedTxXdr: string }): Promise<string> {
const { StellarBase } = await import("@stellar/stellar-sdk");
const tx = StellarBase.TransactionEnvelope.fromXDR(signed.signedTxXdr, "base64");
const result = await server().sendTransaction(tx as Parameters<typeof server>["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;
}

export async function uploadSample(params: {
uploader: string; title: string; ipfsCid: string;
priceXlm: number; genre: string; bpm: number;
splits?: RoyaltySplit[];
}): Promise<string> {
const src = await server().getAccount(params.uploader);
const c = new Contract(CONTRACT_ID);
Expand Down
5 changes: 4 additions & 1 deletion src/hooks/useWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}, []);

Expand Down
49 changes: 49 additions & 0 deletions src/pages/Profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,55 @@ export default function Profile() {
</div>

<div style={{ marginTop: "40px", maxWidth: "800px" }}>
<h2 style={{ fontSize: "20px", fontWeight: 700, marginBottom: "16px" }}>Collaborative Beats & Royalty Splits</h2>
<div style={{ display: "grid", gap: "12px", marginBottom: "32px" }}>
<div className="card" style={{ padding: "18px", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
<span style={{ fontWeight: 600, fontSize: "15px" }}>Neon Horizon</span>
<span className="badge badge-yellow" style={{ fontSize: "11px" }}>Synthwave</span>
<span style={{ fontSize: "12px", color: "var(--text-muted)" }}>128 BPM</span>
</div>
<div style={{ fontSize: "13px", color: "var(--text-secondary)", display: "flex", gap: "12px", alignItems: "center" }}>
<span>Role: <strong style={{ color: "var(--text-primary)" }}>Co-Producer</strong></span>
<span>•</span>
<span>Your Split: <strong style={{ color: "var(--accent)" }}>50%</strong> (5,000 bps)</span>
<span>•</span>
<span>Total Beat Sales: <strong style={{ color: "var(--success)" }}>450 XLM</strong></span>
</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: "11px", color: "var(--text-muted)", marginBottom: 2 }}>Your Share</div>
<div style={{ fontSize: "16px", fontWeight: 700, color: "var(--success)", fontFamily: "var(--font-mono)" }}>
202.50 XLM
</div>
</div>
</div>

<div className="card" style={{ padding: "18px", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
<span style={{ fontWeight: 600, fontSize: "15px" }}>Astral Drift</span>
<span className="badge badge-yellow" style={{ fontSize: "11px" }}>Lo-Fi</span>
<span style={{ fontSize: "12px", color: "var(--text-muted)" }}>84 BPM</span>
</div>
<div style={{ fontSize: "13px", color: "var(--text-secondary)", display: "flex", gap: "12px", alignItems: "center" }}>
<span>Role: <strong style={{ color: "var(--text-primary)" }}>Mixing & Mastering</strong></span>
<span>•</span>
<span>Your Split: <strong style={{ color: "var(--accent)" }}>20%</strong> (2,000 bps)</span>
<span>•</span>
<span>Total Beat Sales: <strong style={{ color: "var(--success)" }}>180 XLM</strong></span>
</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: "11px", color: "var(--text-muted)", marginBottom: 2 }}>Your Share</div>
<div style={{ fontSize: "16px", fontWeight: 700, color: "var(--success)", fontFamily: "var(--font-mono)" }}>
32.40 XLM
</div>
</div>
</div>
</div>

<h2 style={{ fontSize: "20px", fontWeight: 700, marginBottom: "16px" }}>Owned Exclusive Beats</h2>
{ownedBeats.length === 0 ? (
<div className="card" style={{ padding: "32px", textAlign: "center", color: "var(--text-muted)" }}>
Expand Down
Loading