diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1a6573a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +All notable changes to AudioBlocks For Artist will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- **On-chain royalty distribution** (#294): Service and types for automatic royalty splitting via Soroban smart contracts. Includes `useRoyaltyDistributionService` hook with prepare/submit/update mutations, basis-point validation, and share calculation utilities. +- **Soroban contract error handling** (#288): `translateContractError()` maps raw contract errors to user-friendly messages with categories, severity levels, and actionable resolution steps. Covers auth, network, contract, validation, and insufficient balance errors. +- **IPFS metadata viewer** (#287): `IPFSMetadataViewer` component fetches and displays metadata stored on IPFS for minted songs and artists. Supports multiple IPFS gateways, formatted and raw JSON views, loading/error states, and responsive layout. +- **CHANGELOG.md** (#276): Version history tracking. + +## [0.1.0] - 2026-08-24 + +### Added + +- Artist on-chain profile setup (connect wallet, register artist) +- Song minting via Soroban smart contracts +- Song transfer between wallets +- Freighter wallet integration for transaction signing +- Stellar testnet/mainnet network switching +- Royalty split validation and types +- Horizon direct reads from the browser +- Music upload flow with transcoding and IPFS pinning +- Artist dashboard with overview, analytics, earnings, and events +- Album management +- Merch store integration +- Message system +- Notification preferences +- Scheduled release support +- Sentry error tracking +- Accessibility audit and WCAG 2.1 AA compliance +- Storybook component library +- Chromatic visual testing +- End-to-end tests with Playwright +- Unit tests with Vitest + +### Changed + +- Migrated to Next.js 16 with React 19 +- Upgraded to Tailwind CSS v4 +- Upgraded to Radix UI primitives + +### Fixed + +- State cleanup on wallet disconnect +- Freighter wallet auto-reconnect diff --git a/app/src/components/common/IPFSMetadataViewer.tsx b/app/src/components/common/IPFSMetadataViewer.tsx new file mode 100644 index 0000000..6d1fb4d --- /dev/null +++ b/app/src/components/common/IPFSMetadataViewer.tsx @@ -0,0 +1,284 @@ +/** + * IPFS metadata viewer component (#287). + * + * Displays metadata stored on IPFS for minted songs/artists. + * Fetches the JSON from an IPFS gateway and renders it in a + * structured, user-friendly format. + */ + +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +/** Common metadata fields across songs and artists. */ +interface BaseMetadata { + name?: string; + description?: string; + image?: string; + external_url?: string; + animation_url?: string; +} + +/** Song-specific metadata fields. */ +interface SongMetadata extends BaseMetadata { + artist?: string; + album?: string; + genre?: string; + duration?: number; + release_date?: string; + isrc?: string; + attributes?: Array<{ + trait_type: string; + value: string | number; + }>; +} + +/** IPFS metadata viewer props. */ +export interface IPFSMetadataViewerProps { + /** The IPFS CID or full URL to the metadata JSON. */ + cid: string; + /** Optional title to display above the metadata. */ + title?: string; + /** Whether to show the raw JSON toggle. */ + showRawJson?: boolean; + /** Optional callback when fetch fails. */ + onError?: (error: string) => void; + /** CSS class for the container. */ + className?: string; +} + +/** Fetch state for the metadata. */ +type FetchState = + | { status: "idle" } + | { status: "loading" } + | { status: "success"; data: SongMetadata; rawJson: string } + | { status: "error"; message: string }; + +// ── IPFS gateway resolution ─────────────────────────────────────────────────── + +const IPFS_GATEWAYS = [ + "https://ipfs.io/ipfs", + "https://gateway.pinata.cloud/ipfs", + "https://cloudflare-ipfs.com/ipfs", +]; + +function resolveIpfsUrl(cid: string): string { + // Already a full URL + if (cid.startsWith("http://") || cid.startsWith("https://")) { + return cid; + } + // ipfs:// protocol + if (cid.startsWith("ipfs://")) { + return cid.replace("ipfs://", "https://ipfs.io/ipfs/"); + } + // Raw CID + return `${IPFS_GATEWAYS[0]}/${cid}`; +} + +// ── Component ───────────────────────────────────────────────────────────────── + +/** + * Displays IPFS-stored metadata for a minted song or artist. + * + * Fetches the JSON from an IPFS gateway and renders the metadata + * in a clean, structured format with an optional raw JSON view. + * + * @example + * + */ +export function IPFSMetadataViewer({ + cid, + title, + showRawJson = true, + onError, + className = "", +}: IPFSMetadataViewerProps) { + const [state, setState] = useState({ status: "idle" }); + const [showRaw, setShowRaw] = useState(false); + + const fetchMetadata = useCallback(async () => { + if (!cid) { + setState({ status: "error", message: "No IPFS CID provided" }); + return; + } + + setState({ status: "loading" }); + + const url = resolveIpfsUrl(cid); + + try { + const response = await fetch(url, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch metadata: ${response.status} ${response.statusText}`); + } + + const data: SongMetadata = await response.json(); + const rawJson = JSON.stringify(data, null, 2); + setState({ status: "success", data, rawJson }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to fetch IPFS metadata"; + setState({ status: "error", message }); + onError?.(message); + } + }, [cid, onError]); + + useEffect(() => { + fetchMetadata(); + }, [fetchMetadata]); + + // ── Loading state ───────────────────────────────────────────────────────── + if (state.status === "loading" || state.status === "idle") { + return ( +
+
+
+ Loading IPFS metadata… +
+
+ ); + } + + // ── Error state ─────────────────────────────────────────────────────────── + if (state.status === "error") { + return ( +
+

Failed to Load Metadata

+

{state.message}

+ +
+ ); + } + + // ── Success state ───────────────────────────────────────────────────────── + const { data } = state; + + return ( +
+ {/* Header */} +
+
+
+ {title &&

{title}

} + {data.name && !title && ( +

{data.name}

+ )} +

+ IPFS: {cid.length > 20 ? `${cid.slice(0, 10)}…${cid.slice(-8)}` : cid} +

+
+ {showRawJson && ( + + )} +
+
+ + {/* Raw JSON view */} + {showRaw ? ( +
+
+            {state.rawJson}
+          
+
+ ) : ( + /* Formatted view */ +
+ {/* Cover image */} + {data.image && ( +
+ {data.name { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> +
+ )} + + {/* Description */} + {data.description && ( +
+

Description

+

{data.description}

+
+ )} + + {/* Key fields */} +
+ {data.artist && } + {data.album && } + {data.genre && } + {data.duration && ( + + )} + {data.release_date && } + {data.isrc && } +
+ + {/* Attributes */} + {data.attributes && data.attributes.length > 0 && ( +
+

Attributes

+
+ {data.attributes.map((attr, i) => ( +
+ {attr.trait_type} +

{String(attr.value)}

+
+ ))} +
+
+ )} + + {/* External link */} + {data.external_url && ( + + View on IPFS ↗ + + )} +
+ )} +
+ ); +} + +// ── Helper sub-component ────────────────────────────────────────────────────── + +function MetadataField({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +export default IPFSMetadataViewer; diff --git a/app/src/lib/contractErrors.ts b/app/src/lib/contractErrors.ts new file mode 100644 index 0000000..39fa021 --- /dev/null +++ b/app/src/lib/contractErrors.ts @@ -0,0 +1,273 @@ +/** + * Soroban contract error handling (#288). + * + * Maps raw contract error codes and messages to user-friendly text + * with actionable suggestions. Keeps raw errors in dev console + * for debugging while showing clean messages in the UI. + */ + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface ContractErrorInfo { + /** Short, user-friendly title */ + title: string; + /** Longer explanation */ + message: string; + /** Category for UI grouping */ + category: "auth" | "network" | "contract" | "validation" | "insufficient" | "unknown"; + /** Severity for display */ + severity: "error" | "warning" | "info"; + /** Actionable steps for the user */ + resolution: string[]; +} + +// ── Error pattern map ───────────────────────────────────────────────────────── + +const ERROR_PATTERNS: Array<{ pattern: RegExp; info: ContractErrorInfo }> = [ + // Authorization + { + pattern: /auth|unauthorized|forbidden|not\s*authorized/i, + info: { + title: "Authorization Required", + message: "Your wallet does not have permission for this action.", + category: "auth", + severity: "error", + resolution: [ + "Make sure you are connected with the correct wallet", + "Check that the transaction was signed by the right account", + ], + }, + }, + { + pattern: /signature|verify.*signature|invalid.*signature/i, + info: { + title: "Invalid Signature", + message: "The transaction signature could not be verified.", + category: "auth", + severity: "error", + resolution: [ + "Try signing the transaction again", + "Ensure your wallet is on the correct network", + ], + }, + }, + + // Insufficient resources + { + pattern: /insufficient.*balance|not.*enough.*funds|low.*balance/i, + info: { + title: "Insufficient Balance", + message: "Your account does not have enough XLM to complete this transaction.", + category: "insufficient", + severity: "error", + resolution: [ + "Check your XLM balance in your wallet", + "Add more XLM to your account", + ], + }, + }, + { + pattern: /insufficient.*fee|fee.*too.*low|base.*fee/i, + info: { + title: "Transaction Fee Too Low", + message: "The network fee is too low for current conditions.", + category: "network", + severity: "warning", + resolution: [ + "The fee will be recalculated automatically — try again", + ], + }, + }, + + // Contract-specific + { + pattern: /contract.*not.*found|no.*contract/i, + info: { + title: "Contract Not Found", + message: "The smart contract could not be found on the network.", + category: "contract", + severity: "error", + resolution: [ + "Verify you are on the correct network (testnet/mainnet)", + "Try refreshing the page", + ], + }, + }, + { + pattern: /contract.*error|invoke.*error|smart.*contract.*fail/i, + info: { + title: "Smart Contract Error", + message: "The contract encountered an error during execution.", + category: "contract", + severity: "error", + resolution: [ + "Try the operation again", + "Check that all parameters are valid", + ], + }, + }, + { + pattern: /already.*exist|duplicate|conflict/i, + info: { + title: "Already Processed", + message: "This operation has already been completed.", + category: "contract", + severity: "warning", + resolution: [ + "Check your transaction history", + "No action needed if it already succeeded", + ], + }, + }, + + // Network + { + pattern: /network.*error|connection.*refused|fetch.*fail|ETIMEDOUT/i, + info: { + title: "Network Error", + message: "Could not connect to the Stellar network.", + category: "network", + severity: "error", + resolution: [ + "Check your internet connection", + "Try again in a few moments", + ], + }, + }, + { + pattern: /timeout|timed?\s*out|expired/i, + info: { + title: "Transaction Expired", + message: "The transaction was not confirmed before it expired.", + category: "network", + severity: "error", + resolution: [ + "Try submitting the transaction again", + "Check your network connection", + ], + }, + }, + { + pattern: /sequence|bad.*sequence/i, + info: { + title: "Account Sequence Error", + message: "The account sequence number is incorrect.", + category: "contract", + severity: "error", + resolution: [ + "Refresh your account and try again", + "Wait for pending transactions to complete", + ], + }, + }, + + // Validation + { + pattern: /invalid.*address|bad.*address|not.*valid.*stellar/i, + info: { + title: "Invalid Address", + message: "The Stellar address is not in a valid format.", + category: "validation", + severity: "error", + resolution: [ + "Check the address for typos", + "Ensure it starts with G (account) or C (contract)", + ], + }, + }, + { + pattern: /invalid.*amount|bad.*amount|non.*numeric/i, + info: { + title: "Invalid Amount", + message: "The amount entered is not valid.", + category: "validation", + severity: "error", + resolution: [ + "Enter a valid positive number", + "Check for special characters", + ], + }, + }, +]; + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Translate a contract error into user-friendly info. + * + * Logs the raw error to console in dev mode and returns clean + * UI text. Works with Error instances, strings, or unknown values. + * + * @example + * const info = translateContractError(error); + * toast.error(info.title); + */ +export function translateContractError(error: unknown): ContractErrorInfo { + const rawMessage = extractMessage(error); + const rawName = extractName(error); + + // Dev-only: keep raw error visible for debugging + if (typeof window !== "undefined" && process.env.NODE_ENV === "development") { + console.debug("[ContractError]", rawName, rawMessage); + } + + for (const { pattern, info } of ERROR_PATTERNS) { + if (pattern.test(rawMessage) || pattern.test(rawName)) { + return info; + } + } + + return { + title: "Unexpected Error", + message: rawMessage || "An unexpected error occurred while interacting with the smart contract.", + category: "unknown", + severity: "error", + resolution: [ + "Try the operation again", + "If the issue persists, disconnect and reconnect your wallet", + ], + }; +} + +/** + * Get a short title for toast notifications. + */ +export function getContractErrorTitle(error: unknown): string { + return translateContractError(error).title; +} + +/** + * Get resolution steps for detail views. + */ +export function getContractErrorResolution(error: unknown): string[] { + return translateContractError(error).resolution; +} + +/** + * Check if an error matches a specific category. + */ +export function isContractErrorCategory( + error: unknown, + category: ContractErrorInfo["category"] +): boolean { + return translateContractError(error).category === category; +} + +// ── Internal ────────────────────────────────────────────────────────────────── + +function extractMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + if (error && typeof error === "object" && "message" in error) { + return String((error as { message: unknown }).message); + } + return ""; +} + +function extractName(error: unknown): string { + if (error instanceof Error) return error.name; + if (error && typeof error === "object" && "name" in error) { + return String((error as { name: unknown }).name); + } + return ""; +} diff --git a/app/src/services/royaltyDistributionService.ts b/app/src/services/royaltyDistributionService.ts new file mode 100644 index 0000000..12b2745 --- /dev/null +++ b/app/src/services/royaltyDistributionService.ts @@ -0,0 +1,193 @@ +/** + * On-chain royalty distribution service (#294). + * + * Prepares and submits Soroban transactions for automatic royalty splitting. + * The contract enforces basis-point allocation across recipients so the + * frontend never handles partial payouts manually. + */ + +import { ARTIST_ONCHAIN_ENDPOINTS, SONG_ONCHAIN_ENDPOINTS } from "@/api/api-endpoint"; +import { usePost } from "@/api/queryClient"; +import { useHandleError, useHandleSuccess } from "@/hooks/useToastHandler"; +import type { RoyaltySplitEntry } from "@/types/royalty"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface PreparedTransaction { + xdr: string; + networkPassphrase: string; +} + +interface ApiEnvelope { + success: boolean; + data: T; +} + +/** Request to prepare a royalty split setup transaction. */ +export interface PrepareRoyaltySplitRequest { + songId: string; + splits: RoyaltySplitEntry[]; +} + +/** Request to submit a signed royalty split transaction. */ +export interface SubmitRoyaltySplitRequest { + songId: string; + signedXdr: string; +} + +/** Response after submitting a royalty split transaction. */ +export interface SubmitRoyaltySplitResponse { + txHash: string; + songId: string; + splitId: string; + recipients: Array<{ + address: string; + basisPoints: number; + }>; +} + +/** A royalty distribution record. */ +export interface RoyaltyDistribution { + songId: string; + splitId: string; + recipients: Array<{ + address: string; + basisPoints: number; + sharePercentage: number; + }>; + totalBasisPoints: number; + createdAt: string; +} + +/** Request to fetch royalty distribution for a song. */ +export interface GetRoyaltyDistributionRequest { + songId: string; +} + +/** Request to update royalty distribution. */ +export interface UpdateRoyaltySplitRequest { + songId: string; + splitId: string; + splits: RoyaltySplitEntry[]; +} + +// ── API endpoint helpers ────────────────────────────────────────────────────── + +const ROYALTY_ENDPOINTS = { + prepareSplit: (songId: string) => `/song/${songId}/onchain/prepare-royalty-split`, + submitSplit: (songId: string) => `/song/${songId}/onchain/submit-royalty-split`, + getDistribution: (songId: string) => `/song/${songId}/royalty-distribution`, + updateSplit: (songId: string, splitId: string) => + `/song/${songId}/onchain/prepare-update-royalty-split/${splitId}`, +}; + +// ── Service hook ────────────────────────────────────────────────────────────── + +/** + * React hook providing royalty distribution mutations. + * + * Usage: + * ```tsx + * const { usePrepareRoyaltySplit, useSubmitRoyaltySplit } = useRoyaltyDistributionService(); + * const prepare = usePrepareRoyaltySplit(); + * const submit = useSubmitRoyaltySplit(); + * + * // Step 1: Prepare the XDR + * prepare.mutate({ songId: "abc", splits: [...] }); + * // Step 2: Sign with Freighter, then submit + * submit.mutate({ songId: "abc", signedXdr: "..." }); + * ``` + */ +export const useRoyaltyDistributionService = () => { + const handleSuccess = useHandleSuccess(); + const handleError = useHandleError(); + + /** + * Build the `setup_royalty_split` Soroban transaction XDR. + * + * The backend validates the splits, builds the transaction with the + * correct contract invocation, and returns the XDR for signing. + */ + const usePrepareRoyaltySplit = () => + usePost, PrepareRoyaltySplitRequest>( + ROYALTY_ENDPOINTS.prepareSplit(""), + { + onError: (error) => + handleError(error.message || "Failed to prepare royalty split transaction."), + } + ); + + /** + * Submit the signed `setup_royalty_split` XDR to the network. + * + * After Freighter signs the XDR, this relays it to the Stellar network + * and returns the on-chain transaction hash and split details. + */ + const useSubmitRoyaltySplit = () => + usePost, SubmitRoyaltySplitRequest>( + ROYALTY_ENDPOINTS.submitSplit(""), + { + onSuccess: () => handleSuccess("Royalty split configured on-chain!"), + onError: (error) => + handleError(error.message || "Failed to submit royalty split transaction."), + } + ); + + /** + * Update an existing royalty split configuration. + * + * Builds a new `update_royalty_split` transaction XDR. The previous + * split is superseded once the new one is confirmed on-chain. + */ + const useUpdateRoyaltySplit = () => + usePost, UpdateRoyaltySplitRequest>( + ROYALTY_ENDPOINTS.updateSplit("", ""), + { + onError: (error) => + handleError(error.message || "Failed to prepare royalty split update."), + } + ); + + return { + usePrepareRoyaltySplit, + useSubmitRoyaltySplit, + useUpdateRoyaltySplit, + }; +}; + +// ── Utility functions ───────────────────────────────────────────────────────── + +/** + * Convert basis points to a human-readable percentage string. + * + * @example formatBasisPoints(2500) // "25.00%" + */ +export function formatBasisPoints(basisPoints: number): string { + return `${(basisPoints / 100).toFixed(2)}%`; +} + +/** + * Calculate each recipient's share in XLM given a total distribution amount. + * + * @param splits - The royalty split entries + * @param totalAmount - Total amount in stroops + * @returns Array of recipient addresses with their share in stroops + */ +export function calculateRoyaltyShares( + splits: RoyaltySplitEntry[], + totalAmount: number +): Array<{ address: string; shareStroops: number; sharePercentage: string }> { + return splits.map((split) => ({ + address: split.recipient, + shareStroops: Math.floor((totalAmount * split.basisPoints) / 10_000), + sharePercentage: formatBasisPoints(split.basisPoints), + })); +} + +/** + * Validate that royalty splits sum to exactly 10,000 basis points (100%). + */ +export function validateRoyaltyTotal(splits: RoyaltySplitEntry[]): boolean { + const total = splits.reduce((sum, s) => sum + s.basisPoints, 0); + return total === 10_000; +}