diff --git a/.gitignore b/.gitignore index 2e0446b..a204dbe 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ target node_modules test-ledger .yarn +univerces/ +dumps/ \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b3b4137 --- /dev/null +++ b/Makefile @@ -0,0 +1,279 @@ +SHELL := /bin/bash + +CLUSTER ?= localnet +ALLOW_MAINNET ?= 0 +ALLOW_REMOTE_SEED ?= 0 +WALLET ?= $(HOME)/.config/solana/id.json +SOLANA_AVATARS_DIR ?= $(CURDIR)/../solana-avatars + +LOCALNET_URL ?= http://127.0.0.1:8899 +DEVNET_URL ?= https://api.devnet.solana.com +MAINNET_URL ?= https://api.mainnet-beta.solana.com +LOCALNET_LEDGER ?= test-ledger +LOCALNET_BIND_ADDRESS ?= 127.0.0.1 +LOCALNET_RPC_PORT ?= 8899 +LOCALNET_RPC_CORS ?= all +LOCALNET_CLONE_METAPLEX ?= 1 +LOCALNET_RESET ?= 0 +LOCALNET_EXTRA_ARGS ?= +AIRDROP_SOL ?= 20 +METAPLEX_TOKEN_METADATA_PROGRAM ?= metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s +METAPLEX_CLONE_URL ?= $(DEVNET_URL) + +RPC_CORS_FLAG := $(shell \ + if solana-test-validator --help 2>/dev/null | rg -- "--rpc-cors" >/dev/null; then \ + echo "--rpc-cors \"$(LOCALNET_RPC_CORS)\""; \ + fi \ +) + +METAPLEX_METADATA_FLAG := $(shell \ + if [ "$(LOCALNET_CLONE_METAPLEX)" = "1" ]; then \ + if solana-test-validator --help 2>/dev/null | grep -q -- "--clone-upgradeable-program"; then \ + echo "--clone-upgradeable-program \"$(METAPLEX_TOKEN_METADATA_PROGRAM)\" --url \"$(METAPLEX_CLONE_URL)\""; \ + else \ + echo "--clone \"$(METAPLEX_TOKEN_METADATA_PROGRAM)\" --url \"$(METAPLEX_CLONE_URL)\""; \ + fi; \ + fi \ +) + +LOCALNET_RESET_FLAG := $(shell \ + if [ "$(LOCALNET_RESET)" = "1" ]; then \ + echo "--reset"; \ + fi \ +) + +EVERYTHING_DIR ?= $(CURDIR)/univerces/everything +WOTORI_DIR ?= $(CURDIR)/univerces/wotori +MODEL_COUNT ?= 10 +MODEL_FORMAT ?= glb +METADATA_PORT ?= 8787 +METADATA_BASE_URL ?= http://127.0.0.1:$(METADATA_PORT) +EKZA_STELLAR_URL ?= http://localhost:53328 +APP_PORT ?= 53328 +REACT_APP_IPFS_UPLOAD_MODE ?= ipfs +REACT_APP_IPFS_UPLOAD_API ?= http://127.0.0.1:5001/api/v0/add +SEED_SCRIPT ?= scripts/deploy-random-models-localnet.js +WOTORI_SEED_SCRIPT ?= scripts/deploy-wotori-universe-localnet.js +SEED_FLAGS ?= +WOTORI_SEED_FLAGS ?= + +ifeq ($(CLUSTER),localnet) +DEFAULT_RPC_URL := $(LOCALNET_URL) +ANCHOR_CLUSTER := localnet +else ifeq ($(CLUSTER),devnet) +DEFAULT_RPC_URL := $(DEVNET_URL) +ANCHOR_CLUSTER := devnet +else ifeq ($(CLUSTER),mainnet) +DEFAULT_RPC_URL := $(MAINNET_URL) +ANCHOR_CLUSTER := mainnet-beta +else ifeq ($(CLUSTER),mainnet-beta) +DEFAULT_RPC_URL := $(MAINNET_URL) +ANCHOR_CLUSTER := mainnet-beta +else +$(error CLUSTER must be localnet, devnet, mainnet, or mainnet-beta) +endif + +RPC_URL ?= $(DEFAULT_RPC_URL) + +.PHONY: help print-config check-mainnet check-airdrop check-seed-cluster check-rpc \ + sdk-build anchor-build sync-sdk-idl build build-localnet build-devnet build-mainnet \ + airdrop airdrop-localnet deploy deploy-localnet deploy-devnet deploy-mainnet \ + localnet localnet-metaplex metadata-server metadata-server-wotori app-dev \ + seed-random-models seed-new-random-models seed-new-single seed-wotori seed-new-wotori \ + seed-everything-localnet seed-new-everything-localnet seed-new-single-localnet seed-wotori-localnet seed-new-wotori-localnet \ + setup-localnet setup-localnet-single capture-seed-previews deploy-everything-localnet \ + deploy-wotori-localnet deploy-local-avatar-programs + +help: + @printf "%s\n" \ + "Solana Stellar Make targets" \ + "" \ + "Core:" \ + " make build Build Anchor program, sync IDL, build SDK" \ + " make deploy CLUSTER=localnet Deploy to CLUSTER via RPC_URL" \ + " make deploy CLUSTER=devnet Deploy to devnet" \ + " make deploy CLUSTER=mainnet ALLOW_MAINNET=1" \ + " make airdrop CLUSTER=localnet Airdrop AIRDROP_SOL to WALLET" \ + "" \ + "Local testing:" \ + " make localnet Run solana-test-validator with Metaplex Token Metadata cloned" \ + " make localnet-metaplex Run localnet on a separate Metaplex-ready ledger" \ + " make metadata-server Serve EVERYTHING_DIR metadata/assets" \ + " make metadata-server-wotori Serve WOTORI_DIR metadata/assets" \ + " make app-dev Run the React console on APP_PORT" \ + " make setup-localnet MODEL_COUNT=10 Deploy + seed a fresh localnet universe" \ + " make setup-localnet-single Deploy + seed one model-backed project" \ + " make deploy-wotori-localnet Deploy program + seed a fresh Wotori Studio universe" \ + " make deploy-local-avatar-programs Deploy solana-avatars + avatar minter to localnet (requires sibling ../solana-avatars repo)" \ + "" \ + "Seeder:" \ + " make seed-random-models Append random models to manifest universe" \ + " make seed-new-random-models Create a fresh universe, then seed models" \ + " make seed-new-single Fresh universe with one model-backed project" \ + " make seed-wotori-localnet Map Wotori Studio dump into Solana Stellar" \ + " make seed-new-wotori-localnet Force a fresh Wotori Studio universe" \ + "" \ + "Variables:" \ + " CLUSTER=localnet|devnet|mainnet Default: $(CLUSTER)" \ + " RPC_URL= Default for cluster: $(DEFAULT_RPC_URL)" \ + " WALLET= Default: $(WALLET)" \ + " MODEL_COUNT=10 MODEL_FORMAT=glb Seeder controls" \ + " METADATA_BASE_URL=http://... Seeder metadata URL" \ + " LOCALNET_CLONE_METAPLEX=0 Disable local Metaplex Token Metadata clone" \ + " LOCALNET_RESET=1 Pass --reset to solana-test-validator" \ + " LOCALNET_EXTRA_ARGS='...' Extra solana-test-validator args" + +print-config: + @printf "CLUSTER=%s\nANCHOR_CLUSTER=%s\nRPC_URL=%s\nWALLET=%s\nEVERYTHING_DIR=%s\nWOTORI_DIR=%s\nMODEL_COUNT=%s\nMODEL_FORMAT=%s\nMETADATA_BASE_URL=%s\nLOCALNET_CLONE_METAPLEX=%s\nLOCALNET_RESET=%s\nMETAPLEX_TOKEN_METADATA_PROGRAM=%s\nMETAPLEX_CLONE_URL=%s\n" \ + "$(CLUSTER)" "$(ANCHOR_CLUSTER)" "$(RPC_URL)" "$(WALLET)" "$(EVERYTHING_DIR)" "$(WOTORI_DIR)" "$(MODEL_COUNT)" "$(MODEL_FORMAT)" "$(METADATA_BASE_URL)" "$(LOCALNET_CLONE_METAPLEX)" "$(LOCALNET_RESET)" "$(METAPLEX_TOKEN_METADATA_PROGRAM)" "$(METAPLEX_CLONE_URL)" + +check-mainnet: + @if [[ "$(ANCHOR_CLUSTER)" == "mainnet-beta" && "$(ALLOW_MAINNET)" != "1" ]]; then \ + echo "Refusing mainnet deploy. Re-run with CLUSTER=mainnet ALLOW_MAINNET=1 after checking WALLET and RPC_URL."; \ + exit 1; \ + fi + +check-airdrop: + @if [[ "$(ANCHOR_CLUSTER)" == "mainnet-beta" ]]; then \ + echo "Airdrop is not available on mainnet."; \ + exit 1; \ + fi + +check-seed-cluster: + @if [[ "$(ANCHOR_CLUSTER)" != "localnet" && "$(ALLOW_REMOTE_SEED)" != "1" ]]; then \ + echo "The seeder is intended for localnet and stores METADATA_BASE_URL=$(METADATA_BASE_URL)."; \ + echo "Use CLUSTER=localnet, or set ALLOW_REMOTE_SEED=1 with a reachable METADATA_BASE_URL."; \ + exit 1; \ + fi + @if [[ "$(ANCHOR_CLUSTER)" == "mainnet-beta" && "$(ALLOW_MAINNET)" != "1" ]]; then \ + echo "Refusing to run the seeder on mainnet. Re-run with CLUSTER=mainnet ALLOW_MAINNET=1 ALLOW_REMOTE_SEED=1 only if this is intentional."; \ + exit 1; \ + fi + +check-rpc: + solana cluster-version --url "$(RPC_URL)" + +sdk-build: + yarn --cwd sdk build + +anchor-build: + anchor build + +sync-sdk-idl: anchor-build + mkdir -p sdk/idl + cp target/idl/solana_stellar.json sdk/idl/solana_stellar.json + cp target/types/solana_stellar.ts sdk/idl/solana_stellar.ts + node_modules/.bin/prettier --write sdk/idl/solana_stellar.json sdk/idl/solana_stellar.ts + yarn --cwd sdk build + +build: sync-sdk-idl + +build-localnet: CLUSTER = localnet +build-localnet: build + +build-devnet: CLUSTER = devnet +build-devnet: build + +build-mainnet: CLUSTER = mainnet +build-mainnet: build + +airdrop: check-airdrop + solana --keypair "$(WALLET)" airdrop "$(AIRDROP_SOL)" --url "$(RPC_URL)" + +airdrop-localnet: CLUSTER = localnet +airdrop-localnet: airdrop + +deploy: check-mainnet build + anchor deploy --provider.cluster "$(RPC_URL)" --provider.wallet "$(WALLET)" + +deploy-localnet: + $(MAKE) CLUSTER=localnet build airdrop deploy + +deploy-devnet: + $(MAKE) CLUSTER=devnet deploy + +deploy-mainnet: + $(MAKE) CLUSTER=mainnet deploy + +localnet: + solana-test-validator --ledger "$(LOCALNET_LEDGER)" --bind-address "$(LOCALNET_BIND_ADDRESS)" --rpc-port "$(LOCALNET_RPC_PORT)" $(RPC_CORS_FLAG) $(METAPLEX_METADATA_FLAG) $(LOCALNET_RESET_FLAG) $(LOCALNET_EXTRA_ARGS) + +localnet-metaplex: LOCALNET_LEDGER = test-ledger-metaplex +localnet-metaplex: localnet + +metadata-server: + node scripts/serve-metadata.js --folder "$(EVERYTHING_DIR)" --port "$(METADATA_PORT)" + +metadata-server-wotori: + node scripts/serve-metadata.js --folder "$(WOTORI_DIR)" --port "$(METADATA_PORT)" + +app-dev: + REACT_APP_IPFS_UPLOAD_MODE="$(REACT_APP_IPFS_UPLOAD_MODE)" \ + REACT_APP_IPFS_UPLOAD_API="$(REACT_APP_IPFS_UPLOAD_API)" \ + npm run dev --prefix app -- --port "$(APP_PORT)" + +seed-random-models: check-seed-cluster sdk-build + node "$(SEED_SCRIPT)" --folder "$(EVERYTHING_DIR)" --count "$(MODEL_COUNT)" --endpoint "$(RPC_URL)" --metadata-base-url "$(METADATA_BASE_URL)" --model-format "$(MODEL_FORMAT)" $(SEED_FLAGS) + +seed-new-random-models: SEED_FLAGS += --new-universe +seed-new-random-models: seed-random-models + +seed-new-single: MODEL_COUNT = 1 +seed-new-single: seed-new-random-models + +seed-wotori: check-seed-cluster sdk-build + node "$(WOTORI_SEED_SCRIPT)" --folder "$(WOTORI_DIR)" --endpoint "$(RPC_URL)" --metadata-base-url "$(METADATA_BASE_URL)" $(WOTORI_SEED_FLAGS) + +seed-new-wotori: WOTORI_SEED_FLAGS += --new-universe +seed-new-wotori: seed-wotori + +seed-everything-localnet: CLUSTER = localnet +seed-everything-localnet: seed-random-models + +seed-new-everything-localnet: CLUSTER = localnet +seed-new-everything-localnet: seed-new-random-models + +seed-new-single-localnet: CLUSTER = localnet +seed-new-single-localnet: MODEL_COUNT = 1 +seed-new-single-localnet: seed-new-random-models + +seed-wotori-localnet: CLUSTER = localnet +seed-wotori-localnet: seed-wotori + +seed-new-wotori-localnet: CLUSTER = localnet +seed-new-wotori-localnet: seed-new-wotori + +setup-localnet: + $(MAKE) CLUSTER=localnet check-rpc + $(MAKE) CLUSTER=localnet deploy-localnet + $(MAKE) CLUSTER=localnet seed-new-random-models + +setup-localnet-single: + $(MAKE) CLUSTER=localnet check-rpc + $(MAKE) CLUSTER=localnet deploy-localnet + $(MAKE) CLUSTER=localnet seed-new-single + +deploy-local-avatar-programs: + @if [[ "$(ANCHOR_CLUSTER)" != "localnet" ]]; then \ + echo "deploy-local-avatar-programs is intended for localnet only (set CLUSTER=localnet)."; \ + exit 1; \ + fi + @if [[ ! -d "$(SOLANA_AVATARS_DIR)" ]]; then \ + echo "Missing Solana Avatars repo at $(SOLANA_AVATARS_DIR)"; \ + exit 1; \ + fi + cd "$(SOLANA_AVATARS_DIR)" && \ + anchor build && \ + anchor deploy --program-name minter --program-keypair target-deploy-keypair-minter.json --provider.cluster "$(RPC_URL)" --provider.wallet "$(WALLET)" && \ + anchor deploy --program-name avatars --program-keypair target-deploy-keypair.json --provider.cluster "$(RPC_URL)" --provider.wallet "$(WALLET)" + +capture-seed-previews: + node scripts/capture-manifest-previews.js --folder "$(EVERYTHING_DIR)" --app-url "$(EKZA_STELLAR_URL)" --metadata-base-url "$(METADATA_BASE_URL)" + +deploy-everything-localnet: + $(MAKE) CLUSTER=localnet deploy-localnet + $(MAKE) CLUSTER=localnet seed-everything-localnet + +deploy-wotori-localnet: + $(MAKE) CLUSTER=localnet deploy-localnet + $(MAKE) CLUSTER=localnet seed-new-wotori-localnet diff --git a/README.md b/README.md index 0b74b2e..86855ff 100644 --- a/README.md +++ b/README.md @@ -44,5 +44,39 @@ this protocol. ## Development ```sh +yarn --cwd sdk build anchor test -``` \ No newline at end of file +``` + +Localnet universe seeding: + +```sh +make metadata-server +make seed-new-single-localnet +``` + +If your browser app shows `NetworkError when attempting to fetch resource` or RPC +fetch errors during local testing, make sure the UI uses the local Vite proxy: + +```sh +make localnet +``` + +The app points localnet RPC through `http://localhost:/rpc` and the +Vite dev server proxies this path to `127.0.0.1:8899`, so RPC calls stay same-origin. + +`seed-new-single-localnet` creates a fresh universe and guarantees at least one +top-level project plus one child 3D model asset inside it. Seeding uses `.glb` +files by default so each model is a single portable artifact with embedded +textures. Use +`make seed-new-everything-localnet MODEL_COUNT=10` for a fresh universe with +more model-backed projects, or `make seed-everything-localnet MODEL_COUNT=10` +to append to the current manifest universe. For local OBJ loader diagnostics +only, pass `MODEL_FORMAT=obj` or `MODEL_FORMAT=all`. + +## TypeScript SDK + +The local `solana-stellar-sdk` package in `sdk/` is the frontend-facing source +of truth for the Solana Stellar IDL, typed Anchor client, PDA helpers, account +filters, and low-level instruction helpers. Downstream apps can consume it with +a local file dependency such as `file:../solana-stellar/sdk`. diff --git a/app/index.html b/app/index.html index 043e3bb..1a5943c 100644 --- a/app/index.html +++ b/app/index.html @@ -4,6 +4,7 @@ Solana Stellar Test Console +
diff --git a/app/src/components/LogPanel.tsx b/app/src/components/LogPanel.tsx index 0499fa2..3e9c633 100644 --- a/app/src/components/LogPanel.tsx +++ b/app/src/components/LogPanel.tsx @@ -1,5 +1,22 @@ import type { LogEntry } from "../lib/types"; +const LOG_URL_RE = /(https?:\/\/[^\s]+)/g; + +function renderDetail(detail: string) { + const parts = detail.split(LOG_URL_RE); + return parts.map((part, index) => { + if (part.match(LOG_URL_RE)) { + return ( + + {part} + + ); + } + + return part; + }); +} + export function LogPanel({ logs }: { logs: LogEntry[] }) { return (
@@ -17,7 +34,18 @@ export function LogPanel({ logs }: { logs: LogEntry[] }) { {log.message} - {log.detail ? {log.detail} : null} + {log.detail ? ( + + {log.detail.includes("\n") + ? renderDetail(log.detail).map((part, index) => ( + + {index ?
: null} + {part} +
+ )) + : renderDetail(log.detail)} +
+ ) : null} )) )} diff --git a/app/src/lib/cluster.tsx b/app/src/lib/cluster.tsx index 7f4ed62..dfc305a 100644 --- a/app/src/lib/cluster.tsx +++ b/app/src/lib/cluster.tsx @@ -11,7 +11,8 @@ type ClusterState = { setCustomEndpoint: (endpoint: string) => void; }; -const LOCALNET = "http://127.0.0.1:8899"; +const LOCALNET = + typeof window === "undefined" ? "/rpc" : `${window.location.origin}/rpc`; const DEFAULT_CUSTOM = clusterApiUrl("devnet"); const ClusterContext = createContext(null); diff --git a/app/src/lib/errors.ts b/app/src/lib/errors.ts new file mode 100644 index 0000000..96d3dfb --- /dev/null +++ b/app/src/lib/errors.ts @@ -0,0 +1,76 @@ +export function formatRpcError(error: unknown, fallback: string) { + const message = extractErrorMessage(error); + const hints = collectHints(error, message); + if (!hints.length) return message || fallback; + return `${message}\n\nHints:\n${hints.join("\n")}`; +} + +function collectHints(error: unknown, message: string) { + const errorText = String(message).toLowerCase(); + const hints: string[] = []; + + if (errorText.includes("cors") || errorText.includes("cross-origin")) { + hints.push( + "Browser calls to local RPC should go through Vite proxy (`/rpc`) to avoid CORS." + ); + hints.push( + "Start app with `npm run dev -- --port ` and localnet on 127.0.0.1:8899." + ); + } + + if ( + errorText.includes("networkerror") && + errorText.includes("fetch resource") + ) { + hints.push( + "Network request to RPC failed. Check that the RPC is running and reachable." + ); + hints.push( + "Use the same local endpoint as UI is using and confirm the test validator is up." + ); + } + + if ( + errorText.includes("disconnected port") || + errorText.includes("service worker") + ) { + hints.push( + "Phantom service worker is temporarily unavailable. Re-open Phantom and reconnect the wallet." + ); + } + + const logs = extractErrorLogs(error); + if (logs?.length) { + hints.push(`Program logs available: ${logs}`); + } + + return hints; +} + +function extractErrorLogs(error: unknown): string | null { + if (error && typeof error === "object") { + const maybeAny = error as Record; + if (typeof maybeAny.logs === "string") return String(maybeAny.logs); + if (Array.isArray(maybeAny.logs)) { + return maybeAny.logs.map((line) => String(line)).join("\n"); + } + const maybeCause = maybeAny.cause as Record | undefined; + if (maybeCause?.logs) { + if (Array.isArray(maybeCause.logs)) { + return maybeCause.logs.map((line) => String(line)).join("\n"); + } + if (typeof maybeCause.logs === "string") return String(maybeCause.logs); + } + } + return null; +} + +function extractErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message || error.toString(); + if (typeof error === "string") return error; + try { + return JSON.stringify(error); + } catch { + return "Unknown error"; + } +} diff --git a/app/src/lib/stellar.ts b/app/src/lib/stellar.ts index e70530b..10020b8 100644 --- a/app/src/lib/stellar.ts +++ b/app/src/lib/stellar.ts @@ -18,23 +18,66 @@ export type TxResult = { explorerUrl: string; }; -export function createClient(connection: Connection, wallet: AnchorWallet): StellarClient { +export function createClient( + connection: Connection, + wallet: AnchorWallet +): StellarClient { const provider = new anchor.AnchorProvider(connection, wallet, { commitment: "confirmed", preflightCommitment: "confirmed", }); - const program = new anchor.Program(idl as anchor.Idl, provider) as unknown as anchor.Program; + const program = new anchor.Program( + idl as anchor.Idl, + provider + ) as unknown as anchor.Program; return { provider, program }; } export function explorerUrl(signature: string, endpoint: string) { + const resolvedEndpoint = endpointForExplorer(endpoint); const cluster = endpoint.includes("devnet") ? "devnet" : "custom"; - if (endpoint.includes("127.0.0.1") || endpoint.includes("localhost")) { - return `https://explorer.solana.com/tx/${signature}?cluster=custom&customUrl=${encodeURIComponent(endpoint)}`; + if ( + resolvedEndpoint.includes("127.0.0.1") || + resolvedEndpoint.includes("localhost") + ) { + return `https://explorer.solana.com/tx/${signature}?cluster=custom&customUrl=${encodeURIComponent( + resolvedEndpoint + )}`; } return `https://explorer.solana.com/tx/${signature}?cluster=${cluster}`; } +export function accountExplorerUrl(address: string, endpoint: string) { + const resolvedEndpoint = endpointForExplorer(endpoint); + const cluster = endpoint.includes("devnet") ? "devnet" : "custom"; + if ( + resolvedEndpoint.includes("127.0.0.1") || + resolvedEndpoint.includes("localhost") + ) { + return `https://explorer.solana.com/address/${address}?cluster=custom&customUrl=${encodeURIComponent( + resolvedEndpoint + )}`; + } + return `https://explorer.solana.com/address/${address}?cluster=${cluster}`; +} + +export function solscanAccountUrl(address: string, endpoint: string) { + if (endpoint.includes("devnet")) + return `https://solscan.io/account/${address}?cluster=devnet`; + if (endpoint.includes("testnet")) + return `https://solscan.io/account/${address}?cluster=testnet`; + if (endpoint.includes("localhost") || endpoint.includes("127.0.0.1")) + return null; + return `https://solscan.io/account/${address}`; +} + +function endpointForExplorer(endpoint: string) { + if (!endpoint || !endpoint.includes("://")) { + return "http://127.0.0.1:8899"; + } + return endpoint; +} + export function toLeBytes(value: number) { return new anchor.BN(value).toArrayLike(Buffer, "le", 8); } @@ -61,42 +104,56 @@ export function lamportsFromSol(value: string) { export function deriveUniverse(owner: PublicKey, index: number) { return PublicKey.findProgramAddressSync( [Buffer.from("universe"), owner.toBuffer(), toLeBytes(index)], - PROGRAM_ID, + PROGRAM_ID + )[0]; +} + +export function deriveRegistry() { + return PublicKey.findProgramAddressSync( + [Buffer.from("registry")], + PROGRAM_ID + )[0]; +} + +export function deriveUniverseIndex(globalIndex: number) { + return PublicKey.findProgramAddressSync( + [Buffer.from("universe_index"), toLeBytes(globalIndex)], + PROGRAM_ID )[0]; } export function deriveAsset(universe: PublicKey, index: number) { return PublicKey.findProgramAddressSync( [Buffer.from("asset"), universe.toBuffer(), toLeBytes(index)], - PROGRAM_ID, + PROGRAM_ID )[0]; } export function deriveAssetParent(child: PublicKey, parent: PublicKey) { return PublicKey.findProgramAddressSync( [Buffer.from("link"), child.toBuffer(), parent.toBuffer()], - PROGRAM_ID, + PROGRAM_ID )[0]; } export function deriveRelease(universe: PublicKey, index: number) { return PublicKey.findProgramAddressSync( [Buffer.from("release"), universe.toBuffer(), toLeBytes(index)], - PROGRAM_ID, + PROGRAM_ID )[0]; } export function deriveVault(release: PublicKey) { return PublicKey.findProgramAddressSync( [Buffer.from("release_vault"), release.toBuffer()], - PROGRAM_ID, + PROGRAM_ID )[0]; } export function deriveShare(release: PublicKey, contributor: PublicKey) { return PublicKey.findProgramAddressSync( [Buffer.from("share"), release.toBuffer(), contributor.toBuffer()], - PROGRAM_ID, + PROGRAM_ID )[0]; } diff --git a/app/src/main.tsx b/app/src/main.tsx index c00a81c..908f7ab 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -4,7 +4,6 @@ import ReactDOM from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import "@solana/wallet-adapter-react-ui/styles.css"; -import "./styles.css"; import { App } from "./App"; import { ClusterProvider } from "./lib/cluster"; diff --git a/app/src/pages/AssetsPage.tsx b/app/src/pages/AssetsPage.tsx index 96a6ae0..3db77db 100644 --- a/app/src/pages/AssetsPage.tsx +++ b/app/src/pages/AssetsPage.tsx @@ -4,7 +4,15 @@ import { PublicKey } from "@solana/web3.js"; import { ensureClient, logSignature, useAppState } from "../App"; import { Field, Panel } from "../components/Panel"; -import { deriveAsset, deriveAssetParent, enumValue, systemProgram } from "../lib/stellar"; +import { + accountExplorerUrl, + deriveAsset, + deriveAssetParent, + enumValue, + explorerUrl, + solscanAccountUrl, + systemProgram, +} from "../lib/stellar"; export function AssetsPage() { const state = useAppState(); @@ -14,15 +22,23 @@ export function AssetsPage() { const [previewHash, setPreviewHash] = useState("QmAssetPreviewHash"); const [kind, setKind] = useState("Image"); const [subtype, setSubtype] = useState("Concept"); + const [open, setOpen] = useState(true); + const [collaborationPolicy, setCollaborationPolicy] = useState("Custom"); const [loading, setLoading] = useState(false); const universe = useMemo( - () => (state.addresses.universe ? new PublicKey(state.addresses.universe) : null), - [state.addresses.universe], + () => + state.addresses.universe ? new PublicKey(state.addresses.universe) : null, + [state.addresses.universe] ); - const asset = universe ? deriveAsset(universe, Number(assetIndex || "0")) : null; - const parentAsset = universe ? deriveAsset(universe, Number(parentIndex || "0")) : null; - const link = asset && parentAsset ? deriveAssetParent(asset, parentAsset) : null; + const asset = universe + ? deriveAsset(universe, Number(assetIndex || "0")) + : null; + const parentAsset = universe + ? deriveAsset(universe, Number(parentIndex || "0")) + : null; + const link = + asset && parentAsset ? deriveAssetParent(asset, parentAsset) : null; async function createAsset() { const client = ensureClient(state); @@ -34,8 +50,11 @@ export function AssetsPage() { new anchor.BN(Number(assetIndex || "0")), enumValue(kind) as any, enumValue(subtype) as any, + enumValue("unknown") as any, metadataHash, previewHash, + open, + enumValue(collaborationPolicy) as any ) .accountsStrict({ universe, @@ -47,9 +66,30 @@ export function AssetsPage() { state.setAddresses((current) => ({ ...current, - [Number(assetIndex) === 0 ? "parentAsset" : "childAsset"]: asset.toBase58(), + [Number(assetIndex) === 0 ? "parentAsset" : "childAsset"]: + asset.toBase58(), })); - logSignature(state, "Asset created", signature); + const solanaExplorer = accountExplorerUrl( + asset.toBase58(), + state.endpoint + ); + const solscanExplorer = solscanAccountUrl( + asset.toBase58(), + state.endpoint + ); + const links = [ + `Solana Explorer: ${solanaExplorer}`, + ...(solscanExplorer ? [`Solscan: ${solscanExplorer}`] : []), + ].join("\n"); + + state.addLog( + "success", + "Asset was successfully created!", + `Asset: ${asset.toBase58()}\n${links}\nTransaction: ${explorerUrl( + signature, + state.endpoint + )}` + ); } catch (error) { state.addLog("error", "Create asset failed", String(error)); } finally { @@ -73,7 +113,10 @@ export function AssetsPage() { }) .rpc(); - state.setAddresses((current) => ({ ...current, parentLink: link.toBase58() })); + state.setAddresses((current) => ({ + ...current, + parentLink: link.toBase58(), + })); logSignature(state, "Asset parent linked", signature); } catch (error) { state.addLog("error", "Add parent failed", String(error)); @@ -121,49 +164,132 @@ export function AssetsPage() { if (!client || !asset) return; try { const account = await client.program.account.asset.fetch(asset); - state.addLog("success", "Asset fetched", JSON.stringify(account, null, 2)); + state.addLog( + "success", + "Asset fetched", + JSON.stringify(account, null, 2) + ); } catch (error) { state.addLog("error", "Fetch asset failed", String(error)); } } return ( - +
- setAssetIndex(event.target.value)} /> + setAssetIndex(event.target.value)} + /> - setParentIndex(event.target.value)} /> + setParentIndex(event.target.value)} + /> - setKind(event.target.value)} + > + {[ + "Image", + "Model3d", + "Animation", + "Audio", + "Script", + "Metadata", + "Other", + ].map((item) => ( ))} - setSubtype(event.target.value)} + > + {[ + "Concept", + "Sketch", + "Texture", + "Mesh", + "Rig", + "Motion", + "Preview", + "Final", + "Other", + ].map((item) => ( ))} - setMetadataHash(event.target.value)} /> + setMetadataHash(event.target.value)} + /> - setPreviewHash(event.target.value)} /> + setPreviewHash(event.target.value)} + /> + + + +
- - - - - + + + + +
{asset ? ( diff --git a/app/src/pages/RevenuePage.tsx b/app/src/pages/RevenuePage.tsx index fcc2cf8..0f383be 100644 --- a/app/src/pages/RevenuePage.tsx +++ b/app/src/pages/RevenuePage.tsx @@ -43,16 +43,25 @@ export function RevenuePage() { async function claimRevenue() { const client = ensureClient(state); - if (!client || !release || !vault || !share) return; + if (!client || !release || !vault || !share || !contributorKey) return; setLoading(true); try { + const releaseData = await client.program.account.release.fetch(release); + const authority = releaseData.authority; + const wallet = state.walletPublicKey; + if (!wallet) throw new Error("Wallet not connected"); + if (!authority.equals(wallet) && !contributorKey.equals(wallet)) { + throw new Error("Wallet is not allowed to claim for this beneficiary"); + } + const signature = await client.program.methods - .claimRevenue() + .claimRevenueFor() .accountsStrict({ release, vault, share, - contributor: state.walletPublicKey!, + beneficiary: contributorKey, + authority: wallet, }) .rpc(); logSignature(state, "Revenue claimed", signature); @@ -80,7 +89,7 @@ export function RevenuePage() { setAmountSol(event.target.value)} /> - + setContributor(event.target.value)} /> diff --git a/app/src/pages/UniversePage.tsx b/app/src/pages/UniversePage.tsx index af95b6d..08f8e6a 100644 --- a/app/src/pages/UniversePage.tsx +++ b/app/src/pages/UniversePage.tsx @@ -2,8 +2,17 @@ import { useState } from "react"; import * as anchor from "@coral-xyz/anchor"; import { ensureClient, logSignature, useAppState } from "../App"; +import { formatRpcError } from "../lib/errors"; import { Field, Panel } from "../components/Panel"; -import { deriveUniverse, enumValue, systemProgram } from "../lib/stellar"; +import { + deriveRegistry, + deriveUniverse, + deriveUniverseIndex, + enumValue, + accountExplorerUrl, + solscanAccountUrl, + systemProgram, +} from "../lib/stellar"; export function UniversePage() { const state = useAppState(); @@ -23,25 +32,47 @@ export function UniversePage() { if (!client || !universe) return; setLoading(true); try { + const registry = deriveRegistry(); + const registryAccount = await client.provider.connection.getAccountInfo( + registry + ); + const registryData = registryAccount + ? await client.program.account.registry.fetch(registry) + : null; + const globalIndex = registryAccount + ? Number((registryData?.universeCount as any)?.toNumber?.() ?? 0) + : 0; + const universeLookup = deriveUniverseIndex(globalIndex); const signature = await client.program.methods .createUniverse( new anchor.BN(universeIndex), metadataHash, enumValue("Model3d") as any, - enumValue("Custom") as any, - open, + open ) .accountsStrict({ + registry, universe, + universeLookup, owner: state.walletPublicKey!, systemProgram, }) .rpc(); - state.setAddresses((current) => ({ ...current, universe: universe.toBase58() })); + state.setAddresses((current) => ({ + ...current, + universe: universe.toBase58(), + })); logSignature(state, "Universe created", signature); } catch (error) { - state.addLog("error", "Create universe failed", String(error)); + state.addLog( + "error", + "Create universe failed", + formatRpcError( + error, + "Could not create universe with current RPC endpoint." + ) + ); } finally { setLoading(false); } @@ -53,10 +84,24 @@ export function UniversePage() { setLoading(true); try { const account = await client.program.account.universe.fetch(universe); - state.setAddresses((current) => ({ ...current, universe: universe.toBase58() })); - state.addLog("success", "Universe fetched", JSON.stringify(account, null, 2)); + state.setAddresses((current) => ({ + ...current, + universe: universe.toBase58(), + })); + state.addLog( + "success", + "Universe fetched", + JSON.stringify(account, null, 2) + ); } catch (error) { - state.addLog("error", "Fetch universe failed", String(error)); + state.addLog( + "error", + "Fetch universe failed", + formatRpcError( + error, + "Could not fetch universe account with current RPC endpoint." + ) + ); } finally { setLoading(false); } @@ -69,22 +114,40 @@ export function UniversePage() { >
- setIndex(event.target.value)} inputMode="numeric" /> + setIndex(event.target.value)} + inputMode="numeric" + /> - setMetadataHash(event.target.value)} /> + setMetadataHash(event.target.value)} + />
- -
@@ -93,6 +156,24 @@ export function UniversePage() {
Derived universe PDA {universe.toBase58()} +
+ + Open in Solana Explorer + + {solscanAccountUrl(universe.toBase58(), state.endpoint) ? ( + + Open in Solscan + + ) : null} +
) : null}
diff --git a/app/src/styles.css b/app/src/styles.css index baa62c7..7bf3c1d 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -255,6 +255,22 @@ code { background: rgba(255, 255, 255, 0.05); } +.links { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.links a { + color: #dbeafe; + text-decoration: underline; +} + +.links a:hover { + text-decoration: none; + color: #c4b5fd; +} + .address-list div { display: grid; gap: 4px; diff --git a/app/vite.config.ts b/app/vite.config.ts index 484174c..988fbd9 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -7,6 +7,14 @@ export default defineConfig({ fs: { allow: [searchForWorkspaceRoot(process.cwd())], }, + proxy: { + "/rpc": { + target: "http://127.0.0.1:8899", + changeOrigin: true, + rewrite: (path) => path.replace(/^\/rpc/, ""), + ws: true, + }, + }, }, define: { global: "globalThis", diff --git a/programs/solana-stellar/Cargo.toml b/programs/solana-stellar/Cargo.toml index ab5255b..b81de83 100644 --- a/programs/solana-stellar/Cargo.toml +++ b/programs/solana-stellar/Cargo.toml @@ -21,7 +21,7 @@ custom-panic = [] [dependencies] -anchor-lang = "0.32.1" +anchor-lang = { version = "0.32.1", features = ["init-if-needed"] } [lints.rust] diff --git a/programs/solana-stellar/src/constants.rs b/programs/solana-stellar/src/constants.rs index a786a36..49c796a 100644 --- a/programs/solana-stellar/src/constants.rs +++ b/programs/solana-stellar/src/constants.rs @@ -1,4 +1,6 @@ +pub const REGISTRY_SEED: &[u8] = b"registry"; pub const UNIVERSE_SEED: &[u8] = b"universe"; +pub const UNIVERSE_INDEX_SEED: &[u8] = b"universe_index"; pub const ASSET_SEED: &[u8] = b"asset"; pub const LINK_SEED: &[u8] = b"link"; pub const RELEASE_SEED: &[u8] = b"release"; diff --git a/programs/solana-stellar/src/contexts.rs b/programs/solana-stellar/src/contexts.rs index b394d30..362e24f 100644 --- a/programs/solana-stellar/src/contexts.rs +++ b/programs/solana-stellar/src/contexts.rs @@ -1,14 +1,28 @@ use anchor_lang::prelude::*; use crate::{ - constants::{ASSET_SEED, LINK_SEED, RELEASE_SEED, SHARE_SEED, UNIVERSE_SEED, VAULT_SEED}, + constants::{ + ASSET_SEED, LINK_SEED, REGISTRY_SEED, RELEASE_SEED, SHARE_SEED, UNIVERSE_INDEX_SEED, + UNIVERSE_SEED, VAULT_SEED, + }, error::StellarError, - state::{Asset, AssetParent, AssetStatus, ContributorShare, Release, ReleaseVault, Universe}, + state::{ + Asset, AssetParent, AssetStatus, ContributorShare, Registry, Release, ReleaseVault, + Universe, UniverseIndex, + }, }; #[derive(Accounts)] #[instruction(universe_index: u64)] pub struct CreateUniverse<'info> { + #[account( + init_if_needed, + payer = owner, + space = 8 + Registry::INIT_SPACE, + seeds = [REGISTRY_SEED], + bump + )] + pub registry: Account<'info, Registry>, #[account( init, payer = owner, @@ -21,6 +35,17 @@ pub struct CreateUniverse<'info> { bump )] pub universe: Account<'info, Universe>, + #[account( + init, + payer = owner, + space = 8 + UniverseIndex::INIT_SPACE, + seeds = [ + UNIVERSE_INDEX_SEED, + ®istry.universe_count.to_le_bytes() + ], + bump + )] + pub universe_lookup: Account<'info, UniverseIndex>, #[account(mut)] pub owner: Signer<'info>, pub system_program: Program<'info, System>, @@ -303,3 +328,34 @@ pub struct ClaimRevenue<'info> { #[account(mut)] pub contributor: Signer<'info>, } + +#[derive(Accounts)] +pub struct ClaimRevenueFor<'info> { + pub release: Account<'info, Release>, + #[account( + mut, + seeds = [ + VAULT_SEED, + release.key().as_ref() + ], + bump = vault.bump, + constraint = vault.release == release.key() @ StellarError::ReleaseMismatch + )] + pub vault: Account<'info, ReleaseVault>, + #[account( + mut, + seeds = [ + SHARE_SEED, + release.key().as_ref(), + beneficiary.key().as_ref() + ], + bump = share.bump, + constraint = share.release == release.key() @ StellarError::ReleaseMismatch, + constraint = share.contributor == beneficiary.key() @ StellarError::Unauthorized + )] + pub share: Account<'info, ContributorShare>, + /// CHECK: The beneficiary can be any destination wallet that will receive revenue. + #[account(mut)] + pub beneficiary: AccountInfo<'info>, + pub authority: Signer<'info>, +} diff --git a/programs/solana-stellar/src/error.rs b/programs/solana-stellar/src/error.rs index 5dea8d2..038da08 100644 --- a/programs/solana-stellar/src/error.rs +++ b/programs/solana-stellar/src/error.rs @@ -6,6 +6,8 @@ pub enum StellarError { Unauthorized, #[msg("Universe is closed to public collaboration.")] UniverseClosed, + #[msg("Asset is closed to public collaboration.")] + AssetClosed, #[msg("Universe is not active.")] UniverseNotActive, #[msg("Universe still has live assets or releases.")] @@ -40,8 +42,12 @@ pub enum StellarError { InvalidShareBps, #[msg("Invalid release distribution model for this operation.")] InvalidDistributionModel, + #[msg("Collaboration policy is immutable after asset creation.")] + ImmutableCollaborationPolicy, #[msg("Invalid revenue amount.")] InvalidRevenueAmount, + #[msg("Release vault balance is below required reserve for claims.")] + InsufficientVaultBalanceForClaim, #[msg("No revenue available to claim.")] NoRevenueToClaim, #[msg("Release vault balance is insufficient.")] diff --git a/programs/solana-stellar/src/events.rs b/programs/solana-stellar/src/events.rs index 6d664b0..7bba0ef 100644 --- a/programs/solana-stellar/src/events.rs +++ b/programs/solana-stellar/src/events.rs @@ -7,6 +7,7 @@ pub struct UniverseCreated { pub universe: Pubkey, pub owner: Pubkey, pub index: u64, + pub global_index: u64, } #[event] diff --git a/programs/solana-stellar/src/handlers/asset.rs b/programs/solana-stellar/src/handlers/asset.rs index 1e8027b..9198725 100644 --- a/programs/solana-stellar/src/handlers/asset.rs +++ b/programs/solana-stellar/src/handlers/asset.rs @@ -6,7 +6,9 @@ use crate::{ }, error::StellarError, events::{AssetCreated, AssetParentAdded, AssetStatusChanged}, - state::{AssetKind, AssetStatus, AssetSubtype, UniverseStatus}, + state::{ + AssetKind, AssetStatus, AssetSubtype, CollaborationPolicy, LicenseKind, UniverseStatus, + }, utils::{validate_hash, validate_optional_hash}, }; @@ -15,8 +17,11 @@ pub fn create_asset( asset_index: u64, kind: AssetKind, subtype: AssetSubtype, + license_kind: LicenseKind, metadata_hash: String, preview_hash: String, + open: bool, + collaboration_policy: CollaborationPolicy, ) -> Result<()> { validate_hash(&metadata_hash)?; validate_optional_hash(&preview_hash)?; @@ -48,7 +53,10 @@ pub fn create_asset( asset.bump = ctx.bumps.asset; asset.kind = kind; asset.subtype = subtype; + asset.license_kind = license_kind; asset.status = AssetStatus::Draft; + asset.open = open; + asset.collaboration_policy = collaboration_policy; asset.metadata_hash = metadata_hash; asset.preview_hash = preview_hash; asset.created_at = now; @@ -73,8 +81,11 @@ pub fn create_asset( pub fn update_asset_metadata( ctx: Context, + license_kind: LicenseKind, metadata_hash: String, preview_hash: String, + open: bool, + collaboration_policy: CollaborationPolicy, ) -> Result<()> { validate_hash(&metadata_hash)?; validate_optional_hash(&preview_hash)?; @@ -84,9 +95,15 @@ pub fn update_asset_metadata( asset.status == AssetStatus::Draft, StellarError::AssetLocked ); + require!( + asset.collaboration_policy == collaboration_policy, + StellarError::ImmutableCollaborationPolicy + ); asset.metadata_hash = metadata_hash; asset.preview_hash = preview_hash; + asset.license_kind = license_kind; + asset.open = open; asset.updated_at = Clock::get()?.unix_timestamp; Ok(()) @@ -109,6 +126,10 @@ pub fn add_asset_parent(ctx: Context) -> Result<()> { child.key() != parent.key(), StellarError::InvalidLineageLink ); + require!( + parent.open || parent.creator == ctx.accounts.creator.key(), + StellarError::AssetClosed + ); let child_key = child.key(); let parent_key = parent.key(); @@ -121,6 +142,7 @@ pub fn add_asset_parent(ctx: Context) -> Result<()> { .parent_count .checked_add(1) .ok_or(StellarError::NumericalOverflow)?; + child.license_kind = parent.license_kind; child.updated_at = Clock::get()?.unix_timestamp; emit!(AssetParentAdded { diff --git a/programs/solana-stellar/src/handlers/release.rs b/programs/solana-stellar/src/handlers/release.rs index 859348b..cd7df7d 100644 --- a/programs/solana-stellar/src/handlers/release.rs +++ b/programs/solana-stellar/src/handlers/release.rs @@ -18,6 +18,17 @@ use crate::{ utils::validate_hash, }; +fn is_auto_lineage_model(policy: CollaborationPolicy) -> bool { + matches!( + policy, + CollaborationPolicy::LineageEqual | CollaborationPolicy::Weighted + ) +} + +fn is_manual_split_model(policy: CollaborationPolicy) -> bool { + matches!(policy, CollaborationPolicy::Custom) +} + pub fn create_release( ctx: Context, release_index: u64, @@ -50,7 +61,7 @@ pub fn create_release( release.index = release_index; release.authority = ctx.accounts.owner.key(); release.status = ReleaseStatus::Draft; - release.distribution_model = universe.collaboration_policy; + release.distribution_model = asset.collaboration_policy; release.metadata_hash = metadata_hash; release.total_share_bps = 0; release.total_deposited_lamports = 0; @@ -89,7 +100,7 @@ pub fn add_release_share(ctx: Context, bps: u16) -> Result<()> StellarError::ReleaseLocked ); require!( - release.distribution_model != CollaborationPolicy::LineageEqual, + is_manual_split_model(release.distribution_model), StellarError::InvalidDistributionModel ); @@ -128,7 +139,7 @@ pub fn finalize_release(ctx: Context) -> Result<()> { StellarError::ReleaseLocked ); require!( - release.distribution_model != CollaborationPolicy::LineageEqual, + !is_auto_lineage_model(release.distribution_model), StellarError::InvalidDistributionModel ); require!( @@ -171,7 +182,8 @@ pub fn finalize_lineage_equal_release<'info>( StellarError::ReleaseLocked ); require!( - release.distribution_model == CollaborationPolicy::LineageEqual, + release.distribution_model == CollaborationPolicy::Equal + || release.distribution_model == CollaborationPolicy::LineageEqual, StellarError::InvalidDistributionModel ); require!(release.total_share_bps == 0, StellarError::InvalidShareBps); @@ -371,6 +383,262 @@ pub fn finalize_lineage_equal_release<'info>( Ok(()) } +pub fn finalize_weighted_release<'info>( + ctx: Context<'_, '_, 'info, 'info, FinalizeLineageEqualRelease<'info>>, + asset_count: u16, + link_count: u16, +) -> Result<()> { + let release = &mut ctx.accounts.release; + let asset = &mut ctx.accounts.asset; + + require!( + release.status == ReleaseStatus::Draft, + StellarError::ReleaseLocked + ); + require!( + release.distribution_model == CollaborationPolicy::Weighted, + StellarError::InvalidDistributionModel + ); + require!(release.total_share_bps == 0, StellarError::InvalidShareBps); + require!( + asset.status == AssetStatus::Approved, + StellarError::InvalidAssetStatus + ); + require!(asset_count > 0, StellarError::InvalidLineageProof); + + let remaining = ctx.remaining_accounts; + let asset_count = asset_count as usize; + let link_count = link_count as usize; + require!( + remaining.len() >= asset_count + link_count, + StellarError::InvalidLineageProof + ); + + let universe_key = ctx.accounts.universe.key(); + let release_key = release.key(); + let final_asset_key = asset.key(); + + let mut lineage_assets: Vec<(Pubkey, Pubkey, u16)> = Vec::with_capacity(asset_count); + for account_info in remaining.iter().take(asset_count) { + let account = Account::::try_from(account_info)?; + require!( + account.universe == universe_key, + StellarError::UniverseMismatch + ); + require!( + account.status == AssetStatus::Approved + || account.status == AssetStatus::Finalized + || account.status == AssetStatus::Minted, + StellarError::InvalidAssetStatus + ); + require!( + !lineage_assets + .iter() + .any(|(asset_key, _, _)| *asset_key == account.key()), + StellarError::InvalidLineageProof + ); + lineage_assets.push((account.key(), account.creator, account.parent_count)); + } + + require!( + lineage_assets + .iter() + .any(|(asset_key, _, _)| *asset_key == final_asset_key), + StellarError::InvalidLineageProof + ); + + let asset_keys: Vec = lineage_assets + .iter() + .map(|(asset_key, _, _)| *asset_key) + .collect(); + let mut lineage_links: Vec<(Pubkey, Pubkey)> = Vec::with_capacity(link_count); + for account_info in remaining.iter().skip(asset_count).take(link_count) { + let account = Account::::try_from(account_info)?; + require!( + asset_keys.contains(&account.child_asset) && asset_keys.contains(&account.parent_asset), + StellarError::InvalidLineageLink + ); + lineage_links.push((account.child_asset, account.parent_asset)); + } + + let mut reachable = vec![final_asset_key]; + let mut changed = true; + while changed { + changed = false; + for (child_asset, parent_asset) in &lineage_links { + if reachable.contains(child_asset) && !reachable.contains(parent_asset) { + reachable.push(*parent_asset); + changed = true; + } + } + } + + require!( + asset_keys + .iter() + .all(|asset_key| reachable.contains(asset_key)), + StellarError::InvalidLineageProof + ); + for (asset_key, _, parent_count) in &lineage_assets { + let provided_parent_count = lineage_links + .iter() + .filter(|(child_asset, _)| child_asset == asset_key) + .count(); + require!( + provided_parent_count == *parent_count as usize, + StellarError::InvalidLineageProof + ); + } + + let mut contribution_counts: Vec<(Pubkey, u16)> = Vec::new(); + for (_, creator, _) in &lineage_assets { + if let Some((_, count)) = contribution_counts + .iter_mut() + .find(|(contributor, _)| contributor == creator) + { + *count = count + .checked_add(1) + .ok_or(StellarError::NumericalOverflow)?; + } else { + contribution_counts.push((*creator, 1)); + } + } + + contribution_counts.sort_by_key(|(contributor, _)| *contributor); + let contributors: Vec = contribution_counts + .iter() + .map(|(contributor, _)| *contributor) + .collect(); + let total_contributions: u32 = contribution_counts + .iter() + .map(|(_, count)| *count as u32) + .sum(); + require!( + !contributors.is_empty() + && contributors.len() <= BPS_DENOMINATOR as usize + && total_contributions > 0 + && total_contributions <= BPS_DENOMINATOR as u32, + StellarError::InvalidContributorCount + ); + + let share_accounts_start = asset_count + link_count; + require!( + remaining.len() == share_accounts_start + contributors.len(), + StellarError::InvalidContributorCount + ); + + let mut allocations: Vec = Vec::with_capacity(contribution_counts.len()); + let mut allocated_bps: u16 = 0; + let mut remainders: Vec<(usize, u32, Pubkey)> = Vec::with_capacity(contribution_counts.len()); + for (idx, (contributor, count)) in contribution_counts.iter().enumerate() { + let weighted_bps = (*count as u32) + .checked_mul(BPS_DENOMINATOR as u32) + .ok_or(StellarError::NumericalOverflow)?; + let bps = (weighted_bps / total_contributions) as u16; + allocated_bps = allocated_bps + .checked_add(bps) + .ok_or(StellarError::NumericalOverflow)?; + allocations.push(bps); + remainders.push((idx, weighted_bps % total_contributions, *contributor)); + } + + let remainder_bps = BPS_DENOMINATOR + .checked_sub(allocated_bps) + .ok_or(StellarError::NumericalOverflow)?; + remainders.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2))); + for (idx, _, _) in remainders.into_iter().take(remainder_bps as usize) { + allocations[idx] = allocations[idx] + .checked_add(1) + .ok_or(StellarError::NumericalOverflow)?; + } + + let rent = Rent::get()?; + let share_space = 8 + ContributorShare::INIT_SPACE; + let share_lamports = rent.minimum_balance(share_space); + + for (idx, contributor) in contributors.iter().enumerate() { + let share_info = &remaining[share_accounts_start + idx]; + let (expected_share, bump) = Pubkey::find_program_address( + &[SHARE_SEED, release_key.as_ref(), contributor.as_ref()], + ctx.program_id, + ); + require_keys_eq!( + share_info.key(), + expected_share, + StellarError::InvalidLineageProof + ); + require!( + share_info.lamports() == 0, + StellarError::InvalidLineageProof + ); + + let signer_seeds: &[&[u8]] = &[ + SHARE_SEED, + release_key.as_ref(), + contributor.as_ref(), + &[bump], + ]; + let create_ix = system_instruction::create_account( + &ctx.accounts.owner.key(), + &expected_share, + share_lamports, + share_space as u64, + ctx.program_id, + ); + invoke_signed( + &create_ix, + &[ + ctx.accounts.owner.to_account_info(), + share_info.clone(), + ctx.accounts.system_program.to_account_info(), + ], + &[signer_seeds], + )?; + + let bps = allocations[idx]; + let share = ContributorShare { + release: release_key, + contributor: *contributor, + bps, + claimed_lamports: 0, + bump, + }; + let mut data = share_info.try_borrow_mut_data()?; + let mut data_slice: &mut [u8] = &mut data; + share.try_serialize(&mut data_slice)?; + + emit!(ReleaseShareAdded { + release: release_key, + contributor: *contributor, + bps, + }); + } + + let now = Clock::get()?.unix_timestamp; + release.status = ReleaseStatus::Finalized; + release.distribution_model = CollaborationPolicy::Weighted; + release.total_share_bps = BPS_DENOMINATOR; + release.finalized_at = now; + asset.status = AssetStatus::Finalized; + asset.updated_at = now; + + emit!(ReleaseDistributionModelSet { + release: release_key, + distribution_model: CollaborationPolicy::Weighted, + contributor_count: contributors.len() as u16, + }); + emit!(ReleaseStatusChanged { + release: release_key, + status: ReleaseStatus::Finalized, + }); + emit!(AssetStatusChanged { + asset: asset.key(), + status: AssetStatus::Finalized, + }); + + Ok(()) +} + pub fn link_avatar_data(ctx: Context, avatar_data: Pubkey) -> Result<()> { let release = &mut ctx.accounts.release; require!( diff --git a/programs/solana-stellar/src/handlers/revenue.rs b/programs/solana-stellar/src/handlers/revenue.rs index d4ece2a..8aaef58 100644 --- a/programs/solana-stellar/src/handlers/revenue.rs +++ b/programs/solana-stellar/src/handlers/revenue.rs @@ -3,11 +3,14 @@ use anchor_lang::system_program::{transfer, Transfer}; use crate::{ constants::BPS_DENOMINATOR, - contexts::{ClaimRevenue, DepositRevenue}, + contexts::{ClaimRevenue, ClaimRevenueFor, DepositRevenue}, error::StellarError, events::{RevenueClaimed, RevenueDeposited}, + state::{ContributorShare, Release, ReleaseVault}, }; +const RELEASE_VAULT_ACCOUNT_SPACE: usize = 8 + ReleaseVault::INIT_SPACE; + pub fn deposit_revenue(ctx: Context, amount: u64) -> Result<()> { require!(amount > 0, StellarError::InvalidRevenueAmount); require!( @@ -39,33 +42,69 @@ pub fn deposit_revenue(ctx: Context, amount: u64) -> Result<()> } pub fn claim_revenue(ctx: Context) -> Result<()> { + let release = &ctx.accounts.release; + let vault = &ctx.accounts.vault; + let share = &mut ctx.accounts.share; + let contributor = ctx.accounts.contributor.to_account_info(); + + process_claim(release, vault, share, &contributor) +} + +pub fn claim_revenue_for(ctx: Context) -> Result<()> { let release = &ctx.accounts.release; require!(release.accepts_revenue(), StellarError::ReleaseNotFinalized); + let authority = ctx.accounts.authority.key(); + require!( + authority == release.authority || authority == ctx.accounts.share.contributor, + StellarError::Unauthorized + ); + let vault = &ctx.accounts.vault; let share = &mut ctx.accounts.share; + let beneficiary = ctx.accounts.beneficiary.to_account_info(); + + process_claim(release, vault, share, &beneficiary) +} + +fn process_claim( + release: &Account, + vault: &Account, + share: &mut Account, + recipient: &AccountInfo, +) -> Result<()> { + require!(release.accepts_revenue(), StellarError::ReleaseNotFinalized); + + let rent = Rent::get()?; + let vault_reserve = rent.minimum_balance(RELEASE_VAULT_ACCOUNT_SPACE); + let entitled = release .total_deposited_lamports .checked_mul(share.bps as u64) .ok_or(StellarError::NumericalOverflow)? .checked_div(BPS_DENOMINATOR as u64) .ok_or(StellarError::NumericalOverflow)?; + let claimable = entitled .checked_sub(share.claimed_lamports) .ok_or(StellarError::NumericalOverflow)?; require!(claimable > 0, StellarError::NoRevenueToClaim); - let vault_info = ctx.accounts.vault.to_account_info(); - let contributor_info = ctx.accounts.contributor.to_account_info(); + let vault_info = vault.to_account_info(); + let vault_lamports = vault_info.lamports(); + let available_for_claim = vault_lamports + .checked_sub(vault_reserve) + .ok_or(StellarError::InsufficientVaultBalanceForClaim)?; require!( - vault_info.lamports() >= claimable, - StellarError::InsufficientVaultBalance + available_for_claim >= claimable, + StellarError::InsufficientVaultBalanceForClaim ); **vault_info.try_borrow_mut_lamports()? = vault_info .lamports() .checked_sub(claimable) .ok_or(StellarError::InsufficientVaultBalance)?; - **contributor_info.try_borrow_mut_lamports()? = contributor_info + let recipient_info = recipient.to_account_info(); + **recipient_info.try_borrow_mut_lamports()? = recipient_info .lamports() .checked_add(claimable) .ok_or(StellarError::NumericalOverflow)?; @@ -74,7 +113,7 @@ pub fn claim_revenue(ctx: Context) -> Result<()> { emit!(RevenueClaimed { release: release.key(), - contributor: ctx.accounts.contributor.key(), + contributor: share.contributor, amount: claimable, total_claimed: entitled, }); diff --git a/programs/solana-stellar/src/handlers/universe.rs b/programs/solana-stellar/src/handlers/universe.rs index a9515bb..dc491ac 100644 --- a/programs/solana-stellar/src/handlers/universe.rs +++ b/programs/solana-stellar/src/handlers/universe.rs @@ -2,8 +2,9 @@ use anchor_lang::prelude::*; use crate::{ contexts::{CloseUniverse, CreateUniverse, UpdateUniverse}, + error::StellarError, events::{UniverseCreated, UniverseUpdated}, - state::{AssetKind, CollaborationPolicy, UniverseStatus}, + state::{AssetKind, UniverseStatus}, utils::validate_hash, }; @@ -12,33 +13,48 @@ pub fn create_universe( universe_index: u64, metadata_hash: String, project_type: AssetKind, - collaboration_policy: CollaborationPolicy, open: bool, ) -> Result<()> { validate_hash(&metadata_hash)?; let now = Clock::get()?.unix_timestamp; + let registry = &mut ctx.accounts.registry; + let global_index = registry.universe_count; let universe_key = ctx.accounts.universe.key(); let owner_key = ctx.accounts.owner.key(); let universe = &mut ctx.accounts.universe; + let universe_lookup = &mut ctx.accounts.universe_lookup; universe.owner = owner_key; universe.index = universe_index; + universe.global_index = global_index; universe.bump = ctx.bumps.universe; universe.asset_count = 0; universe.release_count = 0; universe.open = open; universe.status = UniverseStatus::Active; universe.project_type = project_type; - universe.collaboration_policy = collaboration_policy; universe.metadata_hash = metadata_hash; universe.created_at = now; universe.updated_at = now; + registry.bump = ctx.bumps.registry; + registry.universe_count = registry + .universe_count + .checked_add(1) + .ok_or(StellarError::NumericalOverflow)?; + + universe_lookup.global_index = global_index; + universe_lookup.universe = universe_key; + universe_lookup.owner = owner_key; + universe_lookup.owner_index = universe_index; + universe_lookup.bump = ctx.bumps.universe_lookup; + emit!(UniverseCreated { universe: universe_key, owner: owner_key, index: universe_index, + global_index, }); Ok(()) @@ -48,14 +64,12 @@ pub fn update_universe( ctx: Context, metadata_hash: String, open: bool, - collaboration_policy: CollaborationPolicy, ) -> Result<()> { validate_hash(&metadata_hash)?; let universe = &mut ctx.accounts.universe; universe.metadata_hash = metadata_hash; universe.open = open; - universe.collaboration_policy = collaboration_policy; universe.updated_at = Clock::get()?.unix_timestamp; emit!(UniverseUpdated { diff --git a/programs/solana-stellar/src/lib.rs b/programs/solana-stellar/src/lib.rs index 89657c7..11624bd 100644 --- a/programs/solana-stellar/src/lib.rs +++ b/programs/solana-stellar/src/lib.rs @@ -23,26 +23,17 @@ pub mod solana_stellar { universe_index: u64, metadata_hash: String, project_type: AssetKind, - collaboration_policy: CollaborationPolicy, open: bool, ) -> Result<()> { - handlers::create_universe( - ctx, - universe_index, - metadata_hash, - project_type, - collaboration_policy, - open, - ) + handlers::create_universe(ctx, universe_index, metadata_hash, project_type, open) } pub fn update_universe( ctx: Context, metadata_hash: String, open: bool, - collaboration_policy: CollaborationPolicy, ) -> Result<()> { - handlers::update_universe(ctx, metadata_hash, open, collaboration_policy) + handlers::update_universe(ctx, metadata_hash, open) } pub fn close_universe(ctx: Context) -> Result<()> { @@ -54,18 +45,41 @@ pub mod solana_stellar { asset_index: u64, kind: AssetKind, subtype: AssetSubtype, + license_kind: LicenseKind, metadata_hash: String, preview_hash: String, + open: bool, + collaboration_policy: CollaborationPolicy, ) -> Result<()> { - handlers::create_asset(ctx, asset_index, kind, subtype, metadata_hash, preview_hash) + handlers::create_asset( + ctx, + asset_index, + kind, + subtype, + license_kind, + metadata_hash, + preview_hash, + open, + collaboration_policy, + ) } pub fn update_asset_metadata( ctx: Context, + license_kind: LicenseKind, metadata_hash: String, preview_hash: String, + open: bool, + collaboration_policy: CollaborationPolicy, ) -> Result<()> { - handlers::update_asset_metadata(ctx, metadata_hash, preview_hash) + handlers::update_asset_metadata( + ctx, + license_kind, + metadata_hash, + preview_hash, + open, + collaboration_policy, + ) } pub fn add_asset_parent(ctx: Context) -> Result<()> { @@ -112,6 +126,14 @@ pub mod solana_stellar { handlers::finalize_lineage_equal_release(ctx, asset_count, link_count) } + pub fn finalize_weighted_release<'info>( + ctx: Context<'_, '_, 'info, 'info, FinalizeLineageEqualRelease<'info>>, + asset_count: u16, + link_count: u16, + ) -> Result<()> { + handlers::finalize_weighted_release(ctx, asset_count, link_count) + } + pub fn link_avatar_data(ctx: Context, avatar_data: Pubkey) -> Result<()> { handlers::link_avatar_data(ctx, avatar_data) } @@ -123,4 +145,8 @@ pub mod solana_stellar { pub fn claim_revenue(ctx: Context) -> Result<()> { handlers::claim_revenue(ctx) } + + pub fn claim_revenue_for(ctx: Context) -> Result<()> { + handlers::claim_revenue_for(ctx) + } } diff --git a/programs/solana-stellar/src/state.rs b/programs/solana-stellar/src/state.rs index 1437df2..bc6bbb6 100644 --- a/programs/solana-stellar/src/state.rs +++ b/programs/solana-stellar/src/state.rs @@ -2,24 +2,47 @@ use anchor_lang::prelude::*; use crate::constants::MAX_HASH_LEN; +#[account] +pub struct Registry { + pub universe_count: u64, + pub bump: u8, +} + +impl Registry { + pub const INIT_SPACE: usize = 8 + 1; +} + +#[account] +pub struct UniverseIndex { + pub global_index: u64, + pub universe: Pubkey, + pub owner: Pubkey, + pub owner_index: u64, + pub bump: u8, +} + +impl UniverseIndex { + pub const INIT_SPACE: usize = 8 + 32 + 32 + 8 + 1; +} + #[account] pub struct Universe { pub owner: Pubkey, pub index: u64, + pub global_index: u64, pub bump: u8, pub asset_count: u64, pub release_count: u64, pub open: bool, pub status: UniverseStatus, pub project_type: AssetKind, - pub collaboration_policy: CollaborationPolicy, pub metadata_hash: String, pub created_at: i64, pub updated_at: i64, } impl Universe { - pub const INIT_SPACE: usize = 32 + 8 + 1 + 8 + 8 + 1 + 1 + 1 + 1 + (4 + MAX_HASH_LEN) + 8 + 8; + pub const INIT_SPACE: usize = 32 + 8 + 8 + 1 + 8 + 8 + 1 + 1 + 1 + (4 + MAX_HASH_LEN) + 8 + 8; } #[account] @@ -31,7 +54,11 @@ pub struct Asset { pub bump: u8, pub kind: AssetKind, pub subtype: AssetSubtype, + pub license_kind: LicenseKind, pub status: AssetStatus, + pub open: bool, + /// Revenue distribution policy used when this asset is finalized as a release. + pub collaboration_policy: CollaborationPolicy, pub metadata_hash: String, pub preview_hash: String, pub created_at: i64, @@ -40,8 +67,22 @@ pub struct Asset { } impl Asset { - pub const INIT_SPACE: usize = - 32 + 8 + 32 + 32 + 1 + 1 + 1 + 1 + (4 + MAX_HASH_LEN) + (4 + MAX_HASH_LEN) + 8 + 8 + 2; + pub const INIT_SPACE: usize = 32 + + 8 + + 32 + + 32 + + 1 + + 1 + + 1 + + 1 + + 1 + + 1 + + 1 + + (4 + MAX_HASH_LEN) + + (4 + MAX_HASH_LEN) + + 8 + + 8 + + 2; } #[account] @@ -143,6 +184,19 @@ pub enum AssetSubtype { Other, } +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq)] +pub enum LicenseKind { + Unknown, + AllRightsReserved, + Cc0, + CcBy4, + CcBySa4, + CcByNc4, + CcByNcSa4, + Mit, + Custom, +} + #[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq)] pub enum AssetStatus { Draft, diff --git a/scripts/capture-manifest-previews.js b/scripts/capture-manifest-previews.js new file mode 100644 index 0000000..84ff6f8 --- /dev/null +++ b/scripts/capture-manifest-previews.js @@ -0,0 +1,264 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const path = require("node:path"); +const anchor = require("@coral-xyz/anchor"); +const { Connection, Keypair } = require("@solana/web3.js"); +const { + createClient, + enumValue, + updateAssetMetadata, +} = require("../sdk/dist/src"); + +const DEFAULT_FOLDER = path.resolve(__dirname, "../univerces/everything"); +const DEFAULT_ENDPOINT = "http://127.0.0.1:8899"; + +function parseArgs(argv) { + const args = { + folder: DEFAULT_FOLDER, + appUrl: "http://localhost:53328", + endpoint: DEFAULT_ENDPOINT, + metadataBaseUrl: "http://127.0.0.1:8787", + limit: "all", + updateChainPreview: false, + }; + + for (let index = 2; index < argv.length; index += 1) { + const arg = argv[index]; + const next = argv[index + 1]; + if (arg === "--folder" && next) { + args.folder = path.resolve(next); + index += 1; + } else if (arg === "--app-url" && next) { + args.appUrl = next.replace(/\/+$/, ""); + index += 1; + } else if (arg === "--endpoint" && next) { + args.endpoint = next; + index += 1; + } else if (arg === "--metadata-base-url" && next) { + args.metadataBaseUrl = next.replace(/\/+$/, ""); + index += 1; + } else if (arg === "--limit" && next) { + args.limit = next === "all" ? "all" : Number(next); + index += 1; + } else if (arg === "--update-chain-preview") { + args.updateChainPreview = true; + } else if (arg === "--help" || arg === "-h") { + console.log( + "Usage: node scripts/capture-manifest-previews.js [--folder path] [--app-url http://localhost:53328] [--endpoint http://127.0.0.1:8899] [--metadata-base-url http://127.0.0.1:8787] [--limit all|3] [--update-chain-preview]" + ); + process.exit(0); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + if ( + args.limit !== "all" && + (!Number.isInteger(args.limit) || args.limit < 1) + ) { + throw new Error("--limit must be a positive integer or all"); + } + + return args; +} + +function requirePlaywright() { + const candidates = [ + "playwright", + "/tmp/ekza-pw/node_modules/playwright", + process.env.PLAYWRIGHT_PATH, + ].filter(Boolean); + + for (const candidate of candidates) { + try { + return require(candidate); + } catch { + // Try the next candidate. + } + } + + throw new Error( + "Playwright is not available. Run: mkdir -p /tmp/ekza-pw && cd /tmp/ekza-pw && npm init -y && npm install playwright && npx playwright install chromium" + ); +} + +function loadJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function writeJson(filePath, data) { + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); +} + +function localMetadataFile(folder, metadataFileOrUrl) { + if (!metadataFileOrUrl) return null; + const relativePath = metadataFileOrUrl.startsWith("http") + ? new URL(metadataFileOrUrl).pathname.replace(/^\/+/, "") + : metadataFileOrUrl; + return path.join(folder, relativePath); +} + +function previewUrl(previewFile, folder, metadataBaseUrl) { + const relativePath = path + .relative(folder, previewFile) + .split(path.sep) + .join("/"); + return `${metadataBaseUrl}/${relativePath}`; +} + +function loadKeypair(filePath) { + const secretKey = Uint8Array.from( + JSON.parse(fs.readFileSync(filePath, "utf8")) + ); + return Keypair.fromSecretKey(secretKey); +} + +async function updatePreviewHashOnChain({ + client, + owner, + assetAddress, + metadataHash, + previewHash, +}) { + if (!assetAddress || !metadataHash || !previewHash) return null; + + const { signature } = await updateAssetMetadata(client, { + asset: new anchor.web3.PublicKey(assetAddress), + creator: owner.publicKey, + licenseKind: enumValue("ccBy4"), + metadataHash, + previewHash, + }); + + return signature; +} + +async function main() { + const args = parseArgs(process.argv); + const manifestPath = path.join(args.folder, "_", "deployment-manifest.json"); + const manifest = loadJson(manifestPath); + const previewDir = path.join(args.folder, "_", "previews"); + fs.mkdirSync(previewDir, { recursive: true }); + + const keypairPath = path.join(args.folder, manifest.ownerKeypair); + const owner = fs.existsSync(keypairPath) ? loadKeypair(keypairPath) : null; + const client = + owner && args.updateChainPreview + ? createClient( + new Connection(args.endpoint, "confirmed"), + new anchor.Wallet(owner), + { + commitment: "processed", + preflightCommitment: "processed", + } + ) + : null; + + const { chromium } = requirePlaywright(); + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ + viewport: { width: 1280, height: 900 }, + }); + await page.addInitScript(() => localStorage.clear()); + + const selectedAssets = manifest.assets + .filter((asset) => asset.modelAssetAddress && asset.modelAssetIndex != null) + .slice(0, args.limit === "all" ? undefined : args.limit); + const captured = []; + const failed = []; + + for (const asset of selectedAssets) { + try { + const modelFile = path.join(args.folder, asset.sourceFile); + if (!fs.existsSync(modelFile)) { + console.warn(`Skipping missing model file: ${modelFile}`); + failed.push({ title: asset.title, reason: "missing model file" }); + continue; + } + + console.log(`Capturing preview for ${asset.title}: ${asset.sourceFile}`); + await page.goto(`${args.appUrl}/create`, { + waitUntil: "domcontentloaded", + timeout: 30_000, + }); + await page.getByRole("button", { name: "Asset" }).click(); + await page.setInputFiles('input[type="file"]', modelFile); + const canvas = page.locator("canvas"); + await canvas.waitFor({ timeout: 60_000 }); + await canvas.scrollIntoViewIfNeeded(); + await page.waitForTimeout(2_500); + + const safeTitle = String(asset.title || asset.modelAssetAddress) + .replace(/[^\w.-]+/g, "-") + .slice(0, 60); + const fileName = `${asset.modelAssetIndex}-${safeTitle}.png`; + const filePath = path.join(previewDir, fileName); + await canvas.screenshot({ path: filePath }); + const urlForMetadata = previewUrl( + filePath, + args.folder, + args.metadataBaseUrl + ); + + const projectMetadataFile = localMetadataFile( + args.folder, + asset.metadataFile || asset.metadataHash + ); + const modelMetadataFile = localMetadataFile( + args.folder, + asset.modelAssetMetadataFile || asset.modelAssetMetadataHash + ); + if (projectMetadataFile && fs.existsSync(projectMetadataFile)) { + const metadata = loadJson(projectMetadataFile); + metadata.ipfs_img_hash = urlForMetadata; + metadata.preview_ipfs_hash = urlForMetadata; + writeJson(projectMetadataFile, metadata); + } + if (modelMetadataFile && fs.existsSync(modelMetadataFile)) { + const metadata = loadJson(modelMetadataFile); + metadata.preview_ipfs_hash = urlForMetadata; + writeJson(modelMetadataFile, metadata); + } + + asset.previewFile = path.relative(args.folder, filePath); + asset.previewUrl = urlForMetadata; + if (client && owner) { + asset.previewUpdateSignature = await updatePreviewHashOnChain({ + client, + owner, + assetAddress: asset.address, + metadataHash: asset.metadataHash, + previewHash: urlForMetadata, + }); + asset.modelAssetPreviewUpdateSignature = await updatePreviewHashOnChain( + { + client, + owner, + assetAddress: asset.modelAssetAddress, + metadataHash: asset.modelAssetMetadataHash, + previewHash: urlForMetadata, + } + ); + } + captured.push({ title: asset.title, url: urlForMetadata }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.warn(`Failed to capture preview for ${asset.title}: ${reason}`); + failed.push({ title: asset.title, sourceFile: asset.sourceFile, reason }); + } + } + + manifest.updatedAt = new Date().toISOString(); + writeJson(manifestPath, manifest); + await browser.close(); + + console.log( + JSON.stringify({ manifest: manifestPath, captured, failed }, null, 2) + ); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/scripts/deploy-random-models-localnet.js b/scripts/deploy-random-models-localnet.js new file mode 100755 index 0000000..4a0bc59 --- /dev/null +++ b/scripts/deploy-random-models-localnet.js @@ -0,0 +1,675 @@ +#!/usr/bin/env node + +const anchor = require("@coral-xyz/anchor"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const { + addAssetParent, + approveAsset, + createAsset, + createClient, + createUniverse, + enumValue, + nextUniverseIndex, + PROGRAM_ID, + submitAsset, +} = require("../sdk/dist/src"); +const { Connection, Keypair, LAMPORTS_PER_SOL } = require("@solana/web3.js"); + +const DEFAULT_FOLDER = path.resolve(__dirname, "../univerces/everything"); +const DEFAULT_ENDPOINT = "http://127.0.0.1:8899"; +const MODEL_FORMATS = new Map([ + ["glb", new Set([".glb"])], + ["obj", new Set([".obj"])], + ["all", new Set([".glb", ".obj"])], +]); +const SERVICE_DIR_NAME = "_"; +const EVERYTHING_LIBRARY_ATTRIBUTION = { + title: "Everything Library - ANIMALS 0.2", + source: "Everything Library - ANIMALS 0.2", + author: "David OReilly", + copyright: "Everything Library © David OReilly", + sourceUrl: "http://davidoreilly.com/library", + license: "CC BY 4.0", + licenseUrl: "https://creativecommons.org/licenses/by/4.0/", + libraryLicenseUrl: "http://davidoreilly.com/library", + creativeLicense: + "Creative Commons Attribution 4.0 International License (CC BY 4.0)", + softwareLicense: "MIT License", + releasedAt: "2020-06-21", + modified: false, + modificationNote: + "No model geometry, texture, rig, or animation changes were made by this seeding script.", + note: "Original assets from the Everything Library ANIMALS pack. Attribution and license notice should be preserved in copies and derivatives.", +}; +const EVERYTHING_LIBRARY_LICENSE_KIND = "ccBy4"; +const EVERYTHING_LIBRARY_LICENSE_LABEL = "CC BY 4.0"; +const EVERYTHING_LIBRARY_RIGHTS_NOTICE = + "This 3D model is from Everything Library © David OReilly and is licensed under CC BY 4.0. The NFT/mint does not grant exclusive copyright ownership of the original model. The platform fee is charged only for minting/service infrastructure."; +const EVERYTHING_LIBRARY_DESCRIPTION = + "3D animal model from Everything Library - ANIMALS 0.2 by David OReilly. Everything Library © David OReilly. Licensed under CC BY 4.0. Source: http://davidoreilly.com/library. License: https://creativecommons.org/licenses/by/4.0/. Modified: no model geometry, texture, rig, or animation changes were made by this seeding script."; + +function parseArgs(argv) { + const args = { + folder: DEFAULT_FOLDER, + count: 10, + endpoint: DEFAULT_ENDPOINT, + metadataBaseUrl: "http://127.0.0.1:8787", + modelFormat: "glb", + newUniverse: false, + dryRun: false, + }; + + for (let index = 2; index < argv.length; index += 1) { + const arg = argv[index]; + const next = argv[index + 1]; + if (arg === "--folder" && next) { + args.folder = path.resolve(next); + index += 1; + } else if (arg === "--count" && next) { + args.count = next === "all" ? "all" : Number(next); + index += 1; + } else if (arg === "--endpoint" && next) { + args.endpoint = next; + index += 1; + } else if (arg === "--metadata-base-url" && next) { + args.metadataBaseUrl = next.replace(/\/+$/, ""); + index += 1; + } else if (arg === "--model-format" && next) { + args.modelFormat = next.toLowerCase(); + index += 1; + } else if (arg === "--new-universe") { + args.newUniverse = true; + } else if (arg === "--dry-run") { + args.dryRun = true; + } else if (arg === "--help" || arg === "-h") { + printHelpAndExit(); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + if ( + args.count !== "all" && + (!Number.isInteger(args.count) || args.count < 0) + ) { + throw new Error("--count must be a non-negative integer or all"); + } + if (!MODEL_FORMATS.has(args.modelFormat)) { + throw new Error("--model-format must be one of: glb, obj, all"); + } + + return args; +} + +function printHelpAndExit() { + console.log(`Usage: + node scripts/deploy-random-models-localnet.js [--folder path] [--count 0|10|all] [--endpoint http://127.0.0.1:8899] [--metadata-base-url http://127.0.0.1:8787] [--model-format glb|obj|all] [--new-universe] + +Creates or reuses a universe owner keypair under /_ and deploys random +.glb models into the existing manifest universe by default. Use +--model-format obj or --model-format all only for local loader diagnostics. Use +--new-universe to force a fresh universe.`); + process.exit(0); +} + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +function loadOrCreateKeypair(keypairPath) { + if (fs.existsSync(keypairPath)) { + const secretKey = Uint8Array.from( + JSON.parse(fs.readFileSync(keypairPath, "utf8")) + ); + return { keypair: Keypair.fromSecretKey(secretKey), created: false }; + } + + const keypair = Keypair.generate(); + fs.writeFileSync( + keypairPath, + JSON.stringify(Array.from(keypair.secretKey), null, 2) + ); + fs.chmodSync(keypairPath, 0o600); + return { keypair, created: true }; +} + +function listModelFiles(folder, modelFormat) { + const modelExtensions = MODEL_FORMATS.get(modelFormat); + return fs + .readdirSync(folder, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => path.join(folder, entry.name)) + .filter((filePath) => + modelExtensions.has(path.extname(filePath).toLowerCase()) + ) + .sort((a, b) => a.localeCompare(b)); +} + +function shuffle(values) { + return values + .map((value) => ({ value, order: crypto.randomInt(0, 2 ** 31 - 1) })) + .sort((a, b) => a.order - b.order) + .map(({ value }) => value); +} + +function assetTitle(filePath) { + return path.basename(filePath, path.extname(filePath)); +} + +function shortHash(value) { + return crypto.createHash("sha256").update(value).digest("hex").slice(0, 24); +} + +function metadataPointer(metadataFile, metadataBaseUrl) { + return `${metadataBaseUrl}/${SERVICE_DIR_NAME}/metadata/${path.basename( + metadataFile + )}`; +} + +function modelPointer(relativePath, metadataBaseUrl) { + return `${metadataBaseUrl}/${relativePath + .split(path.sep) + .map(encodeURIComponent) + .join("/")}`; +} + +function assetFilePointer(relativePath, metadataBaseUrl) { + return `${metadataBaseUrl}/${relativePath + .split(path.sep) + .map(encodeURIComponent) + .join("/")}`; +} + +function findPreviewFile(folder, title) { + const previewsDir = path.join(folder, SERVICE_DIR_NAME, "previews"); + if (fs.existsSync(previewsDir)) { + const escapedTitle = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const previewFile = fs + .readdirSync(previewsDir) + .find((file) => new RegExp(`-${escapedTitle}\\.png$`).test(file)); + + if (previewFile) + return path.join(SERVICE_DIR_NAME, "previews", previewFile); + } + + return null; +} + +async function waitForAccount(connection, publicKey, label) { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const account = await connection.getAccountInfo(publicKey, "confirmed"); + if (account) return account; + await new Promise((resolve) => setTimeout(resolve, 150)); + } + + throw new Error( + `Timed out waiting for ${label} account ${publicKey.toBase58()}` + ); +} + +async function confirmAirdrop(connection, publicKey, sol) { + const before = await connection.getBalance(publicKey); + if (before >= sol * LAMPORTS_PER_SOL) { + return { requested: false, balanceLamports: before }; + } + + const signature = await connection.requestAirdrop( + publicKey, + sol * LAMPORTS_PER_SOL + ); + const latest = await connection.getLatestBlockhash(); + await connection.confirmTransaction({ signature, ...latest }, "confirmed"); + const after = await connection.getBalance(publicKey); + return { requested: true, signature, balanceLamports: after }; +} + +async function assertProgramDeployed(connection) { + const account = await connection.getAccountInfo(PROGRAM_ID); + if (!account) { + throw new Error( + `Solana Stellar program ${PROGRAM_ID.toBase58()} is not deployed on the selected localnet. Run make deploy-localnet first.` + ); + } +} + +function loadManifest(manifestPath) { + if (!fs.existsSync(manifestPath)) return null; + return JSON.parse(fs.readFileSync(manifestPath, "utf8")); +} + +async function createFreshUniverse({ + args, + client, + metadataDir, + owner, + selectedCount, +}) { + const universeIndex = await nextUniverseIndex(client, owner.publicKey); + const universeMetadataFile = path.join( + metadataDir, + `universe-${universeIndex}-${shortHash( + `${Date.now()}:${owner.publicKey.toBase58()}` + )}.json` + ); + const universeMetadata = { + type: "universe", + name: "Everything Localnet", + title: "Everything Localnet", + description: + "Localnet universe seeded from Everything Library - ANIMALS 0.2, a collection of 3D animal models by David OReilly. Creative assets are licensed under Creative Commons Attribution 4.0 International; software/application materials are licensed under MIT. Source and license: http://davidoreilly.com/library.", + attribution: EVERYTHING_LIBRARY_ATTRIBUTION, + rightsNotice: EVERYTHING_LIBRARY_RIGHTS_NOTICE, + sourceFolder: path.relative(process.cwd(), args.folder), + modelFormat: args.modelFormat, + modelCount: selectedCount, + createdAt: new Date().toISOString(), + }; + fs.writeFileSync( + universeMetadataFile, + JSON.stringify(universeMetadata, null, 2) + ); + + const universeMetadataHash = metadataPointer( + universeMetadataFile, + args.metadataBaseUrl + ); + const { + universe, + globalIndex, + signature: universeSignature, + } = await createUniverse(client, { + owner: owner.publicKey, + universeIndex, + metadataHash: universeMetadataHash, + projectType: enumValue("model3D"), + open: true, + }); + await waitForAccount(client.connection, universe, "universe"); + + return { + universe: universe.toBase58(), + universeIndex, + universeGlobalIndex: globalIndex, + universeMetadataFile: path.relative(args.folder, universeMetadataFile), + universeMetadataHash, + universeSignature, + assets: [], + }; +} + +async function createApprovedAsset({ + client, + universe, + owner, + assetIndex, + kind, + subtype, + licenseKind = enumValue(EVERYTHING_LIBRARY_LICENSE_KIND), + metadataHash, + previewHash = "", +}) { + const { asset, signature: createSignature } = await createAsset(client, { + universe, + creator: owner.publicKey, + assetIndex, + kind, + subtype, + licenseKind, + metadataHash, + previewHash, + }); + await waitForAccount(client.connection, asset, "asset"); + const { signature: submitSignature } = await submitAsset(client, { + asset, + creator: owner.publicKey, + }); + const { signature: approveSignature } = await approveAsset(client, { + universe, + asset, + owner: owner.publicKey, + }); + + return { asset, createSignature, submitSignature, approveSignature }; +} + +async function createModelAssetForProject({ + args, + client, + metadataDir, + owner, + universe, + projectAsset, + assetIndex, + sourceFile, + title, + previewHash, +}) { + const fileExtension = path.extname(sourceFile).toLowerCase(); + const metadataFile = path.join( + metadataDir, + `asset-${assetIndex}-model-${shortHash(`${sourceFile}:${Date.now()}`)}.json` + ); + const metadata = { + type: "asset", + title: `${title} 3D Model`, + description: `${title} 3D model. ${EVERYTHING_LIBRARY_DESCRIPTION}`, + attribution: EVERYTHING_LIBRARY_ATTRIBUTION, + rightsNotice: EVERYTHING_LIBRARY_RIGHTS_NOTICE, + license_inherited: true, + inherited_from_asset: projectAsset.toBase58(), + medium_type: "3d", + medium_sub_type: "model", + source_id: 0, + ipfs_hash: modelPointer(sourceFile, args.metadataBaseUrl), + preview_ipfs_hash: previewHash || "", + sourceFile, + model_source_file: sourceFile, + fileExtension, + createdAt: new Date().toISOString(), + }; + fs.writeFileSync(metadataFile, JSON.stringify(metadata, null, 2)); + + const metadataHash = metadataPointer(metadataFile, args.metadataBaseUrl); + console.log( + `Creating model asset ${assetIndex} for ${projectAsset.toBase58()} from ${sourceFile}` + ); + const { asset, signature: createSignature } = await createAsset(client, { + universe, + creator: owner.publicKey, + assetIndex, + kind: enumValue("model3D"), + subtype: enumValue("mesh"), + licenseKind: enumValue("unknown"), + metadataHash, + previewHash: previewHash || "", + }); + await waitForAccount(client.connection, asset, "model asset"); + const { assetParent, signature: parentSignature } = await addAssetParent( + client, + { + childAsset: asset, + parentAsset: projectAsset, + creator: owner.publicKey, + } + ); + await waitForAccount(client.connection, assetParent, "asset parent"); + const { signature: submitSignature } = await submitAsset(client, { + asset, + creator: owner.publicKey, + }); + const { signature: approveSignature } = await approveAsset(client, { + universe, + asset, + owner: owner.publicKey, + }); + + return { + index: assetIndex, + address: asset.toBase58(), + metadataFile: path.relative(args.folder, metadataFile), + metadataHash, + parentLink: assetParent.toBase58(), + parentSignature, + createSignature, + submitSignature, + approveSignature, + }; +} + +async function main() { + const args = parseArgs(process.argv); + if (!fs.existsSync(args.folder)) { + throw new Error(`Folder does not exist: ${args.folder}`); + } + + const serviceDir = path.join(args.folder, SERVICE_DIR_NAME); + const metadataDir = path.join(serviceDir, "metadata"); + const keypairPath = path.join(serviceDir, "universe-owner-keypair.json"); + const manifestPath = path.join(serviceDir, "deployment-manifest.json"); + const previousManifest = args.newUniverse ? null : loadManifest(manifestPath); + const creatingNewUniverse = args.newUniverse || !previousManifest; + if (creatingNewUniverse && args.count !== "all" && args.count < 1) { + throw new Error( + "Creating a new universe requires --count >= 1 so it is not left empty." + ); + } + + const files = listModelFiles(args.folder, args.modelFormat); + const deployedSources = new Set( + previousManifest?.assets?.map((asset) => asset.sourceFile) || [] + ); + const availableFiles = files.filter( + (filePath) => !deployedSources.has(path.relative(args.folder, filePath)) + ); + const availableFilesWithViewportPreviews = availableFiles.filter((filePath) => + findPreviewFile(args.folder, assetTitle(filePath)) + ); + const selectableFiles = + availableFilesWithViewportPreviews.length >= + (args.count === "all" ? availableFiles.length : args.count) + ? availableFilesWithViewportPreviews + : availableFiles; + const selectedFiles = + args.count === "all" + ? selectableFiles + : shuffle(selectableFiles).slice(0, args.count); + if (selectedFiles.length === 0 && creatingNewUniverse) { + throw new Error( + `No undeployed ${args.modelFormat} model files are available` + ); + } + if (args.count !== "all" && selectedFiles.length < args.count) { + throw new Error( + `Need ${args.count} undeployed model files, found ${selectedFiles.length} in ${args.folder}` + ); + } + + if (args.dryRun) { + console.log( + JSON.stringify( + { + folder: args.folder, + endpoint: args.endpoint, + metadataBaseUrl: args.metadataBaseUrl, + modelFormat: args.modelFormat, + keypairPath, + keypairExists: fs.existsSync(keypairPath), + existingUniverse: previousManifest?.universe || null, + newUniverse: creatingNewUniverse, + alreadyDeployed: deployedSources.size, + projectsToCreate: selectedFiles.length, + modelAssetsToCreate: selectedFiles.length, + selectedFiles, + }, + null, + 2 + ) + ); + return; + } + + ensureDir(metadataDir); + const { keypair: owner, created } = loadOrCreateKeypair(keypairPath); + const connection = new Connection(args.endpoint, "confirmed"); + await assertProgramDeployed(connection); + const airdrop = await confirmAirdrop(connection, owner.publicKey, 10); + const wallet = new anchor.Wallet(owner); + const client = createClient(connection, wallet, { + commitment: "processed", + preflightCommitment: "processed", + }); + const universeState = + previousManifest || + (await createFreshUniverse({ + args, + client, + metadataDir, + owner, + selectedCount: selectedFiles.length, + })); + const universe = new anchor.web3.PublicKey(universeState.universe); + const universeAccount = await client.program.account.universe.fetch(universe); + let nextAssetIndex = universeAccount.assetCount.toNumber(); + + const assets = []; + const existingAssets = universeState.assets || []; + for (const existingAsset of existingAssets) { + if (existingAsset.modelAssetAddress || !existingAsset.sourceFile) continue; + + const projectAsset = new anchor.web3.PublicKey(existingAsset.address); + const title = existingAsset.title || assetTitle(existingAsset.sourceFile); + const previewFile = findPreviewFile(args.folder, title); + const previewHash = previewFile + ? assetFilePointer(previewFile, args.metadataBaseUrl) + : existingAsset.previewUrl || ""; + const modelAsset = await createModelAssetForProject({ + args, + client, + metadataDir, + owner, + universe, + projectAsset, + assetIndex: nextAssetIndex, + sourceFile: existingAsset.sourceFile, + title, + previewHash, + }); + nextAssetIndex += 1; + + existingAsset.modelAssetIndex = modelAsset.index; + existingAsset.modelAssetAddress = modelAsset.address; + existingAsset.modelAssetMetadataFile = modelAsset.metadataFile; + existingAsset.modelAssetMetadataHash = modelAsset.metadataHash; + existingAsset.modelAssetParentLink = modelAsset.parentLink; + existingAsset.modelAssetParentSignature = modelAsset.parentSignature; + existingAsset.modelAssetCreateSignature = modelAsset.createSignature; + existingAsset.modelAssetSubmitSignature = modelAsset.submitSignature; + existingAsset.modelAssetApproveSignature = modelAsset.approveSignature; + } + + for (let index = 0; index < selectedFiles.length; index += 1) { + const filePath = selectedFiles[index]; + const projectIndex = nextAssetIndex; + const relativePath = path.relative(args.folder, filePath); + const title = assetTitle(filePath); + const previewFile = findPreviewFile(args.folder, title); + const previewHash = previewFile + ? assetFilePointer(previewFile, args.metadataBaseUrl) + : ""; + const metadataFile = path.join( + metadataDir, + `asset-${projectIndex}-project-${shortHash( + `${relativePath}:${Date.now()}` + )}.json` + ); + const metadata = { + type: "project", + open: true, + title, + description: `${title} character project. ${EVERYTHING_LIBRARY_DESCRIPTION}`, + attribution: EVERYTHING_LIBRARY_ATTRIBUTION, + rightsNotice: EVERYTHING_LIBRARY_RIGHTS_NOTICE, + license_kind: EVERYTHING_LIBRARY_LICENSE_KIND, + license_label: EVERYTHING_LIBRARY_LICENSE_LABEL, + project_type: "creature", + sourceFile: relativePath, + model_source_file: relativePath, + ipfs_img_hash: previewHash, + preview_ipfs_hash: previewHash, + fileExtension: path.extname(filePath).toLowerCase(), + modelFormat: args.modelFormat, + createdAt: new Date().toISOString(), + }; + fs.writeFileSync(metadataFile, JSON.stringify(metadata, null, 2)); + + const metadataHash = metadataPointer(metadataFile, args.metadataBaseUrl); + const projectAsset = await createApprovedAsset({ + client, + universe, + owner, + assetIndex: projectIndex, + kind: enumValue("model3D"), + subtype: enumValue("preview"), + licenseKind: enumValue(EVERYTHING_LIBRARY_LICENSE_KIND), + metadataHash, + previewHash, + }); + + nextAssetIndex += 1; + const modelAsset = await createModelAssetForProject({ + args, + client, + metadataDir, + owner, + universe, + projectAsset: projectAsset.asset, + assetIndex: nextAssetIndex, + sourceFile: relativePath, + title, + previewHash, + }); + nextAssetIndex += 1; + + assets.push({ + index: projectIndex, + address: projectAsset.asset.toBase58(), + title, + sourceFile: relativePath, + metadataFile: path.relative(args.folder, metadataFile), + metadataHash, + previewFile, + previewUrl: previewHash, + createSignature: projectAsset.createSignature, + submitSignature: projectAsset.submitSignature, + approveSignature: projectAsset.approveSignature, + modelAssetIndex: modelAsset.index, + modelAssetAddress: modelAsset.address, + modelAssetMetadataFile: modelAsset.metadataFile, + modelAssetMetadataHash: modelAsset.metadataHash, + modelAssetParentLink: modelAsset.parentLink, + modelAssetParentSignature: modelAsset.parentSignature, + modelAssetCreateSignature: modelAsset.createSignature, + modelAssetSubmitSignature: modelAsset.submitSignature, + modelAssetApproveSignature: modelAsset.approveSignature, + }); + } + + const manifest = { + endpoint: args.endpoint, + programId: PROGRAM_ID.toBase58(), + modelFormat: args.modelFormat, + owner: owner.publicKey.toBase58(), + ownerKeypair: path.relative(args.folder, keypairPath), + ownerKeypairCreated: created, + ownerAirdrop: airdrop, + universe: universeState.universe, + universeIndex: universeState.universeIndex, + universeGlobalIndex: universeState.universeGlobalIndex, + universeMetadataFile: universeState.universeMetadataFile, + universeMetadataHash: universeState.universeMetadataHash, + universeSignature: universeState.universeSignature, + assets: [...existingAssets, ...assets], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + + console.log(JSON.stringify(manifest, null, 2)); + console.log( + `\nSeeded universe ${manifest.universe}: ${assets.length} project(s), ${ + assets.filter((asset) => asset.modelAssetAddress).length + } model asset(s).` + ); + console.log(`\nDeployment manifest: ${manifestPath}`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + if (error?.logs) { + console.error(error.logs.join("\n")); + } + process.exit(1); +}); diff --git a/scripts/deploy-wotori-universe-localnet.js b/scripts/deploy-wotori-universe-localnet.js new file mode 100755 index 0000000..3a8d3fb --- /dev/null +++ b/scripts/deploy-wotori-universe-localnet.js @@ -0,0 +1,1322 @@ +#!/usr/bin/env node + +const anchor = require("@coral-xyz/anchor"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const { + addAssetParent, + approveAsset, + createAsset, + createClient, + createUniverse, + deriveAssetParent, + enumValue, + nextUniverseIndex, + PROGRAM_ID, + submitAsset, +} = require("../sdk/dist/src"); +const { Connection, Keypair, LAMPORTS_PER_SOL } = require("@solana/web3.js"); + +const DEFAULT_FOLDER = path.resolve(__dirname, "../univerces/wotori"); +const DEFAULT_ENDPOINT = "http://127.0.0.1:8899"; +const SERVICE_DIR_NAME = "_"; +const MAX_ON_CHAIN_POINTER_LEN = 96; +const WOTORI_LICENSE_KIND = "unknown"; +const WOTORI_RIGHTS_NOTICE = + "Original rights and licensing were not specified in the scraped Wotori Studio Stellar universe dump. Verify rights before public minting, resale, or derivative distribution."; + +function parseArgs(argv) { + const args = { + folder: DEFAULT_FOLDER, + dumpDir: null, + endpoint: DEFAULT_ENDPOINT, + metadataBaseUrl: "http://127.0.0.1:8787", + newUniverse: false, + dryRun: false, + airdropSol: 10, + }; + + for (let index = 2; index < argv.length; index += 1) { + const arg = argv[index]; + const next = argv[index + 1]; + if (arg === "--folder" && next) { + args.folder = path.resolve(next); + index += 1; + } else if (arg === "--dump-dir" && next) { + args.dumpDir = path.resolve(next); + index += 1; + } else if (arg === "--endpoint" && next) { + args.endpoint = next; + index += 1; + } else if (arg === "--metadata-base-url" && next) { + args.metadataBaseUrl = next.replace(/\/+$/, ""); + index += 1; + } else if (arg === "--airdrop-sol" && next) { + args.airdropSol = Number(next); + index += 1; + } else if (arg === "--skip-airdrop") { + args.airdropSol = 0; + } else if (arg === "--new-universe") { + args.newUniverse = true; + } else if (arg === "--dry-run") { + args.dryRun = true; + } else if (arg === "--help" || arg === "-h") { + printHelpAndExit(); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!Number.isFinite(args.airdropSol) || args.airdropSol < 0) { + throw new Error("--airdrop-sol must be a non-negative number"); + } + + return args; +} + +function printHelpAndExit() { + console.log(`Usage: + node scripts/deploy-wotori-universe-localnet.js [--folder path] [--dump-dir path] [--endpoint http://127.0.0.1:8899] [--metadata-base-url http://127.0.0.1:8787] [--new-universe] [--dry-run] + +Creates or reuses a Wotori Studio universe owner keypair under /_ +and maps the scraped Archway Stellar universe dump into Solana Stellar: +one on-chain entity asset per dumped project, plus all dumped media assets +linked under that entity and linked to their legacy source asset when present.`); + process.exit(0); +} + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function writeJson(file, value) { + ensureDir(path.dirname(file)); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function loadOrCreateKeypair(keypairPath) { + if (fs.existsSync(keypairPath)) { + const secretKey = Uint8Array.from( + JSON.parse(fs.readFileSync(keypairPath, "utf8")) + ); + return { keypair: Keypair.fromSecretKey(secretKey), created: false }; + } + + const keypair = Keypair.generate(); + writeJson(keypairPath, Array.from(keypair.secretKey)); + fs.chmodSync(keypairPath, 0o600); + return { keypair, created: true }; +} + +function shortHash(value, length = 16) { + return crypto + .createHash("sha256") + .update(value) + .digest("hex") + .slice(0, length); +} + +function fileHash(file) { + return crypto + .createHash("sha256") + .update(fs.readFileSync(file)) + .digest("hex"); +} + +function safeSlug(value, fallback = "item") { + const slug = String(value || fallback) + .normalize("NFKD") + .replace(/[^\w.-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 48); + return slug || fallback; +} + +function urlForRelativePath(relativePath, metadataBaseUrl) { + return `${metadataBaseUrl}/${relativePath + .split(path.sep) + .map(encodeURIComponent) + .join("/")}`; +} + +function pointerForFile(folder, file, metadataBaseUrl) { + return urlForRelativePath(path.relative(folder, file), metadataBaseUrl); +} + +function assertOnChainPointer(value, label) { + if (value.length > MAX_ON_CHAIN_POINTER_LEN) { + throw new Error( + `${label} is ${value.length} chars, max ${MAX_ON_CHAIN_POINTER_LEN}: ${value}` + ); + } +} + +function loadManifest(manifestPath) { + if (!fs.existsSync(manifestPath)) return null; + return readJson(manifestPath); +} + +function discoverDumpDir(folder, explicitDumpDir) { + if (explicitDumpDir) { + const manifestPath = path.join(explicitDumpDir, "manifest.json"); + if (!fs.existsSync(manifestPath)) { + throw new Error(`Dump manifest does not exist: ${manifestPath}`); + } + return explicitDumpDir; + } + + const dumpsDir = path.join(folder, "dumps"); + const candidates = fs.existsSync(dumpsDir) + ? fs + .readdirSync(dumpsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(dumpsDir, entry.name)) + .filter((dir) => fs.existsSync(path.join(dir, "manifest.json"))) + .sort((a, b) => a.localeCompare(b)) + : []; + + if (!candidates.length) { + throw new Error( + `No dump directory with manifest.json found under ${dumpsDir}. Pass --dump-dir.` + ); + } + + return candidates[candidates.length - 1]; +} + +function loadProjectDirs(dumpDir) { + const projectsRoot = path.join(dumpDir, "projects"); + const byAddress = new Map(); + if (!fs.existsSync(projectsRoot)) return byAddress; + + for (const entry of fs.readdirSync(projectsRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const dir = path.join(projectsRoot, entry.name); + const projectJson = path.join(dir, "project.json"); + if (!fs.existsSync(projectJson)) continue; + const project = readJson(projectJson); + if (project.address) byAddress.set(project.address, dir); + } + + return byAddress; +} + +function findFirstFile(dir, predicate) { + if (!dir || !fs.existsSync(dir)) return null; + return ( + fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => path.join(dir, entry.name)) + .find(predicate) || null + ); +} + +function findProjectCover(projectDir) { + return findFirstFile(path.join(projectDir, "media"), (file) => + path.basename(file).startsWith("project-cover.") + ); +} + +function findAssetDir(projectDir, legacyAsset) { + const assetsRoot = path.join(projectDir, "assets"); + if (!fs.existsSync(assetsRoot)) return null; + + const idPrefix = `${String(legacyAsset.id).padStart(3, "0")}-`; + for (const entry of fs.readdirSync(assetsRoot, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith(idPrefix)) continue; + return path.join(assetsRoot, entry.name); + } + + return null; +} + +function findAssetMedia(assetDir) { + const mediaDir = path.join(assetDir || "", "media"); + const mediaFile = findFirstFile( + mediaDir, + (file) => !path.basename(file).startsWith("asset-preview-") + ); + const previewFile = findFirstFile(mediaDir, (file) => + path.basename(file).startsWith("asset-preview-") + ); + return { mediaFile, previewFile }; +} + +function normalizeDescription(value, fallback) { + const trimmed = String(value || "").trim(); + if (!trimmed || trimmed === "undefined" || trimmed === "null") { + return fallback; + } + return trimmed; +} + +function formatLegacyDate(value) { + if (!value) return null; + const n = Number(value); + if (!Number.isFinite(n)) return String(value); + const ms = String(Math.trunc(n)).length === 10 ? n * 1000 : n; + return new Date(ms).toISOString(); +} + +function mediumLabel(asset) { + return [asset.medium_type, asset.medium_sub_type].filter(Boolean).join(" "); +} + +function assetTitle(project, asset) { + const title = project.info?.title || project.address || "Entity"; + const medium = mediumLabel(asset) || "asset"; + return `${title} ${String(asset.id).padStart(3, "0")} ${medium}`; +} + +function fallbackAssetDescription(project, asset) { + const title = project.info?.title || project.address || "Entity"; + const medium = mediumLabel(asset) || "creative"; + return `${title} ${medium} asset migrated from the Wotori Studio Archway Stellar universe dump.`; +} + +function assetKind(mediumType) { + const normalized = String(mediumType || "").toLowerCase(); + if (normalized === "img" || normalized === "image") return "image"; + if ( + normalized === "3d" || + normalized === "model" || + normalized === "model3d" + ) { + return "model3D"; + } + if (normalized === "animation") return "animation"; + if (normalized === "audio") return "audio"; + if (normalized === "script") return "script"; + if (normalized === "text" || normalized === "metadata") return "metadata"; + return "other"; +} + +function assetSubtype(mediumSubType) { + const normalized = String(mediumSubType || "").toLowerCase(); + if (normalized === "concept") return "concept"; + if (normalized === "sketch") return "sketch"; + if (normalized === "texture") return "texture"; + if (normalized === "model" || normalized === "mesh") return "mesh"; + if (normalized === "rig") return "rig"; + if (normalized === "motion" || normalized === "animation") return "motion"; + if (normalized === "preview") return "preview"; + if (normalized === "final") return "final"; + return "other"; +} + +function buildMigrationPlan({ dump, dumpDir, folder }) { + const projectDirs = loadProjectDirs(dumpDir); + const projects = (dump.projects || []).map((project, index) => { + const projectDir = projectDirs.get(project.address) || null; + const coverFile = projectDir ? findProjectCover(projectDir) : null; + const assets = (project.assets || []).map((asset) => { + const assetDir = projectDir ? findAssetDir(projectDir, asset) : null; + const { mediaFile, previewFile } = findAssetMedia(assetDir); + return { + legacyAsset: asset, + assetDir, + mediaFile, + previewFile, + mediaRelativePath: mediaFile ? path.relative(folder, mediaFile) : null, + previewRelativePath: previewFile + ? path.relative(folder, previewFile) + : null, + }; + }); + + return { + order: index + 1, + project, + projectDir, + coverFile, + coverRelativePath: coverFile ? path.relative(folder, coverFile) : null, + assets, + }; + }); + + const sourceLinks = []; + for (const projectPlan of projects) { + const ids = new Set( + projectPlan.assets.map(({ legacyAsset }) => Number(legacyAsset.id)) + ); + for (const { legacyAsset } of projectPlan.assets) { + const sourceId = Number(legacyAsset.source_id || 0); + if ( + sourceId > 0 && + ids.has(sourceId) && + sourceId !== Number(legacyAsset.id) + ) { + sourceLinks.push({ + projectAddress: projectPlan.project.address, + childLegacyAssetId: Number(legacyAsset.id), + parentLegacyAssetId: sourceId, + }); + } + } + } + + return { + universe: dump.universe || {}, + sourceUniverseAddress: dump.universeAddress, + projectCount: projects.length, + assetCount: projects.reduce( + (count, project) => count + project.assets.length, + 0 + ), + entityParentLinkCount: projects.reduce( + (count, project) => count + project.assets.length, + 0 + ), + sourceParentLinkCount: sourceLinks.length, + projects, + }; +} + +function copyOrLink(source, destination) { + ensureDir(path.dirname(destination)); + if (fs.existsSync(destination)) return; + try { + fs.linkSync(source, destination); + } catch { + fs.copyFileSync(source, destination); + } +} + +function prepareMedia({ args, serviceDir, sourceFile, prefix }) { + if (!sourceFile) return null; + const sha256 = fileHash(sourceFile); + const extension = path.extname(sourceFile).toLowerCase() || ".bin"; + const filename = `${safeSlug(prefix)}-${sha256.slice(0, 16)}${extension}`; + const destination = path.join(serviceDir, "media", filename); + copyOrLink(sourceFile, destination); + const url = pointerForFile(args.folder, destination, args.metadataBaseUrl); + assertOnChainPointer(url, "media pointer"); + const stats = fs.statSync(sourceFile); + return { + file: path.relative(args.folder, destination), + sourceFile: path.relative(args.folder, sourceFile), + url, + sha256, + bytes: stats.size, + extension, + }; +} + +function metadataPointer(args, metadataFile) { + const pointer = pointerForFile( + args.folder, + metadataFile, + args.metadataBaseUrl + ); + assertOnChainPointer(pointer, "metadata pointer"); + return pointer; +} + +function buildUniverseMetadata({ args, dump, plan }) { + const metadata = { + type: "universe", + title: plan.universe.name || "Wotori Studio", + name: plan.universe.name || "Wotori Studio", + description: + plan.universe.description || + "Wotori Studio universe migrated from the original Archway Stellar contract.", + source: "archway-stellar-dump", + sourceUniverseAddress: plan.sourceUniverseAddress, + sourceRpc: dump.rpc, + scrapedAt: dump.scrapedAt, + open: plan.universe.open, + originalUniverse: plan.universe, + projectCount: plan.projectCount, + assetCount: plan.assetCount, + rightsNotice: WOTORI_RIGHTS_NOTICE, + migration: { + script: path.basename(__filename), + folder: path.relative(process.cwd(), args.folder), + createdAt: new Date().toISOString(), + }, + }; + + return metadata; +} + +function buildEntityMetadata({ + projectPlan, + projectCover, + sourceUniverseAddress, +}) { + const { project, order } = projectPlan; + const info = project.info || {}; + const title = info.title || project.address || `Entity ${order}`; + return { + type: "entity", + title, + name: title, + description: normalizeDescription( + info.description, + `${title} entity migrated from the Wotori Studio Archway Stellar universe dump.` + ), + project_type: info.project_type || "entity", + open: info.open, + source: "archway-stellar-dump", + sourceUniverseAddress, + sourceProjectAddress: project.address, + legacyOwner: project.owner, + legacyProject: info, + cover: projectCover + ? { + originalIpfsHash: info.img_ipfs_hash || "", + ipfsHash: projectCover.url, + localUrl: projectCover.url, + localFile: projectCover.file, + sourceFile: projectCover.sourceFile, + sha256: projectCover.sha256, + bytes: projectCover.bytes, + } + : null, + ipfs_img_hash: projectCover?.url || info.img_ipfs_hash || "", + preview_ipfs_hash: projectCover?.url || "", + original_ipfs_img_hash: info.img_ipfs_hash || "", + assetCount: project.assets?.length || 0, + rightsNotice: WOTORI_RIGHTS_NOTICE, + createdAt: formatLegacyDate(planTimestamp(info)) || null, + migratedAt: new Date().toISOString(), + }; + + function planTimestamp(projectInfo) { + return projectInfo.timestamp || projectInfo.date_time_utc || null; + } +} + +function buildAssetMetadata({ + assetPlan, + media, + preview, + project, + sourceUniverseAddress, +}) { + const asset = assetPlan.legacyAsset; + const title = assetTitle(project, asset); + const description = normalizeDescription( + asset.description, + fallbackAssetDescription(project, asset) + ); + return { + type: "asset", + title, + description, + entityTitle: project.info?.title || "", + source: "archway-stellar-dump", + sourceUniverseAddress, + sourceProjectAddress: project.address, + legacyOwner: project.owner, + legacyAssetId: asset.id, + source_id: asset.source_id, + medium_type: asset.medium_type || "", + medium_sub_type: asset.medium_sub_type || "", + ipfs_hash: media?.url || asset.ipfs_hash || "", + ipfs_img_hash: + assetKind(asset.medium_type) === "image" ? media?.url || "" : "", + preview_ipfs_hash: + preview?.url || + (assetKind(asset.medium_type) === "image" ? media?.url : "") || + "", + original_ipfs_hash: asset.ipfs_hash || "", + original_preview_ipfs_hash: asset.preview_ipfs_hash || "", + local_media_url: media?.url || "", + local_preview_url: preview?.url || "", + media, + preview, + license_kind: WOTORI_LICENSE_KIND, + rightsNotice: WOTORI_RIGHTS_NOTICE, + createdAt: formatLegacyDate(asset.date_time_utc), + migratedAt: new Date().toISOString(), + }; +} + +async function waitForAccount(connection, publicKey, label) { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const account = await connection.getAccountInfo(publicKey, "confirmed"); + if (account) return account; + await new Promise((resolve) => setTimeout(resolve, 150)); + } + + throw new Error( + `Timed out waiting for ${label} account ${publicKey.toBase58()}` + ); +} + +async function confirmAirdrop(connection, publicKey, sol) { + const before = await connection.getBalance(publicKey); + if (sol <= 0 || before >= sol * LAMPORTS_PER_SOL) { + return { requested: false, balanceLamports: before }; + } + + const signature = await connection.requestAirdrop( + publicKey, + sol * LAMPORTS_PER_SOL + ); + const latest = await connection.getLatestBlockhash(); + await connection.confirmTransaction({ signature, ...latest }, "confirmed"); + const after = await connection.getBalance(publicKey); + return { requested: true, signature, balanceLamports: after }; +} + +async function assertProgramDeployed(connection) { + const account = await connection.getAccountInfo(PROGRAM_ID); + if (!account) { + throw new Error( + `Solana Stellar program ${PROGRAM_ID.toBase58()} is not deployed on the selected localnet. Run make deploy-localnet first.` + ); + } +} + +async function createFreshUniverse({ + args, + client, + dump, + metadataDir, + owner, + plan, +}) { + const universeIndex = await nextUniverseIndex(client, owner.publicKey); + const metadataFile = path.join( + metadataDir, + `universe-${universeIndex}-${shortHash( + plan.sourceUniverseAddress || "wotori" + )}.json` + ); + writeJson(metadataFile, buildUniverseMetadata({ args, dump, plan })); + + const universeMetadataHash = metadataPointer(args, metadataFile); + const { + universe, + globalIndex, + signature: universeSignature, + } = await createUniverse(client, { + owner: owner.publicKey, + universeIndex, + metadataHash: universeMetadataHash, + projectType: enumValue("metadata"), + open: plan.universe.open !== false, + }); + await waitForAccount(client.connection, universe, "universe"); + + return { + universe: universe.toBase58(), + universeIndex, + universeGlobalIndex: globalIndex, + universeMetadataFile: path.relative(args.folder, metadataFile), + universeMetadataHash, + universeSignature, + entities: [], + }; +} + +async function createApprovedAsset({ + client, + universe, + owner, + assetIndex, + kind, + subtype, + metadataHash, + previewHash, + label, +}) { + const { asset, signature: createSignature } = await createAsset(client, { + universe, + creator: owner.publicKey, + assetIndex, + kind, + subtype, + licenseKind: enumValue(WOTORI_LICENSE_KIND), + metadataHash, + previewHash, + }); + await waitForAccount(client.connection, asset, label); + const { signature: submitSignature } = await submitAsset(client, { + asset, + creator: owner.publicKey, + }); + const { signature: approveSignature } = await approveAsset(client, { + universe, + asset, + owner: owner.publicKey, + }); + + return { asset, createSignature, submitSignature, approveSignature }; +} + +async function createDraftAsset({ + client, + universe, + owner, + assetIndex, + kind, + subtype, + metadataHash, + previewHash, + label, +}) { + const { asset, signature: createSignature } = await createAsset(client, { + universe, + creator: owner.publicKey, + assetIndex, + kind, + subtype, + licenseKind: enumValue(WOTORI_LICENSE_KIND), + metadataHash, + previewHash, + }); + await waitForAccount(client.connection, asset, label); + return { asset, createSignature }; +} + +function enumKey(value) { + if (!value || typeof value !== "object") return "unknown"; + return Object.keys(value)[0] || "unknown"; +} + +async function fetchAssetStatus(client, assetAddress) { + const asset = await client.program.account.asset.fetch(assetAddress); + return enumKey(asset.status); +} + +async function requireDraftForLink(client, assetAddress, title) { + const status = await fetchAssetStatus(client, assetAddress); + if (status !== "draft") { + throw new Error( + `Cannot add missing parent links for ${title}: asset is ${status}. Re-run with make seed-new-wotori-localnet for a fresh universe.` + ); + } +} + +async function submitAndApproveAsset({ + client, + universe, + owner, + assetAddress, + manifestAsset, +}) { + const asset = new anchor.web3.PublicKey(assetAddress); + const status = await fetchAssetStatus(client, asset); + if (status === "approved") { + manifestAsset.status = "approved"; + return; + } + + if (status === "draft") { + const { signature: submitSignature } = await submitAsset(client, { + asset, + creator: owner.publicKey, + }); + manifestAsset.submitSignature = submitSignature; + } else if (status !== "submitted") { + throw new Error( + `Cannot approve ${manifestAsset.title}: unexpected asset status ${status}` + ); + } + + const { signature: approveSignature } = await approveAsset(client, { + universe, + asset, + owner: owner.publicKey, + }); + manifestAsset.approveSignature = approveSignature; + manifestAsset.status = "approved"; +} + +async function ensureParentLink({ + client, + childAsset, + parentAsset, + creator, + label, +}) { + const assetParent = deriveAssetParent(childAsset, parentAsset); + const existing = await client.connection.getAccountInfo( + assetParent, + "confirmed" + ); + if (existing) { + return { assetParent, reused: true, signature: null }; + } + + const { signature } = await addAssetParent(client, { + childAsset, + parentAsset, + creator: creator.publicKey, + }); + await waitForAccount(client.connection, assetParent, label); + return { assetParent, reused: false, signature }; +} + +function findManifestEntity(manifest, projectAddress) { + return (manifest.entities || []).find( + (entity) => entity.sourceProjectAddress === projectAddress + ); +} + +function findManifestAsset(entity, legacyAssetId) { + return (entity.assets || []).find( + (asset) => Number(asset.legacyAssetId) === Number(legacyAssetId) + ); +} + +function writeDeploymentManifest(manifestPath, manifest) { + manifest.assets = (manifest.entities || []).flatMap((entity) => { + const entityAsset = { + type: "entity", + index: entity.index, + address: entity.address, + title: entity.title, + sourceProjectAddress: entity.sourceProjectAddress, + projectType: entity.projectType, + metadataFile: entity.metadataFile, + metadataHash: entity.metadataHash, + previewFile: entity.previewFile, + previewUrl: entity.previewUrl, + childAssetCount: entity.assets?.length || 0, + }; + const childAssets = (entity.assets || []).map((asset) => ({ + ...asset, + type: "asset", + entityAddress: entity.address, + entityTitle: entity.title, + sourceProjectAddress: entity.sourceProjectAddress, + })); + return [entityAsset, ...childAssets]; + }); + manifest.assetLinks = (manifest.entities || []).flatMap((entity) => + (entity.assets || []).flatMap((asset) => { + const links = []; + if (asset.entityParentLink) { + links.push({ + type: "entity", + childAsset: asset.address, + parentAsset: entity.address, + parentLink: asset.entityParentLink, + }); + } + if (asset.sourceParentLink) { + const parent = (entity.assets || []).find( + (candidate) => + Number(candidate.legacyAssetId) === Number(asset.sourceId) + ); + links.push({ + type: "source", + childAsset: asset.address, + parentAsset: parent?.address || "", + parentLegacyAssetId: asset.sourceId, + parentLink: asset.sourceParentLink, + }); + } + return links; + }) + ); + manifest.updatedAt = new Date().toISOString(); + writeJson(manifestPath, manifest); +} + +async function deployEntity({ + args, + client, + metadataDir, + owner, + plan, + projectPlan, + serviceDir, + universe, + nextAssetIndex, +}) { + const { project, order } = projectPlan; + const title = project.info?.title || project.address || `Entity ${order}`; + const cover = prepareMedia({ + args, + serviceDir, + sourceFile: projectPlan.coverFile, + prefix: `entity-${String(order).padStart(3, "0")}-${title}-cover`, + }); + const metadataFile = path.join( + metadataDir, + `entity-${String(order).padStart(3, "0")}-${shortHash( + project.address + )}.json` + ); + const metadata = buildEntityMetadata({ + projectPlan, + projectCover: cover, + sourceUniverseAddress: plan.sourceUniverseAddress, + }); + writeJson(metadataFile, metadata); + + const metadataHash = metadataPointer(args, metadataFile); + const previewHash = cover?.url || ""; + if (previewHash) assertOnChainPointer(previewHash, "entity preview pointer"); + console.log(`Creating entity ${nextAssetIndex}: ${title}`); + const entityAsset = await createApprovedAsset({ + client, + universe, + owner, + assetIndex: nextAssetIndex, + kind: enumValue("metadata"), + subtype: enumValue("preview"), + metadataHash, + previewHash, + label: "entity asset", + }); + + return { + index: nextAssetIndex, + address: entityAsset.asset.toBase58(), + title, + sourceProjectAddress: project.address, + projectType: project.info?.project_type || "", + metadataFile: path.relative(args.folder, metadataFile), + metadataHash, + previewFile: cover?.file || null, + previewUrl: previewHash, + createSignature: entityAsset.createSignature, + submitSignature: entityAsset.submitSignature, + approveSignature: entityAsset.approveSignature, + assets: [], + }; +} + +function refreshEntityMetadata({ + args, + entity, + plan, + projectPlan, + serviceDir, +}) { + if (!entity.metadataFile) return; + const { project, order } = projectPlan; + const title = project.info?.title || project.address || `Entity ${order}`; + const cover = prepareMedia({ + args, + serviceDir, + sourceFile: projectPlan.coverFile, + prefix: `entity-${String(order).padStart(3, "0")}-${title}-cover`, + }); + const metadataFile = path.join(args.folder, entity.metadataFile); + writeJson( + metadataFile, + buildEntityMetadata({ + projectPlan, + projectCover: cover, + sourceUniverseAddress: plan.sourceUniverseAddress, + }) + ); + entity.previewFile = cover?.file || entity.previewFile || null; + entity.previewUrl = cover?.url || entity.previewUrl || ""; +} + +function prepareLegacyAssetMetadata({ + args, + assetPlan, + metadataDir, + plan, + projectPlan, + serviceDir, + nextAssetIndex, +}) { + const { legacyAsset } = assetPlan; + const media = prepareMedia({ + args, + serviceDir, + sourceFile: assetPlan.mediaFile, + prefix: `asset-${String(projectPlan.order).padStart(3, "0")}-${String( + legacyAsset.id + ).padStart(3, "0")}-${legacyAsset.medium_type || "media"}`, + }); + const preview = prepareMedia({ + args, + serviceDir, + sourceFile: assetPlan.previewFile, + prefix: `asset-${String(projectPlan.order).padStart(3, "0")}-${String( + legacyAsset.id + ).padStart(3, "0")}-preview`, + }); + const metadataFile = path.join( + metadataDir, + `asset-${nextAssetIndex}-${String(legacyAsset.id).padStart( + 3, + "0" + )}-${shortHash(`${projectPlan.project.address}:${legacyAsset.id}`)}.json` + ); + const metadata = buildAssetMetadata({ + assetPlan: { ...assetPlan, media, preview }, + media, + preview, + project: projectPlan.project, + sourceUniverseAddress: plan.sourceUniverseAddress, + }); + writeJson(metadataFile, metadata); + + return { media, preview, metadataFile }; +} + +function refreshLegacyAssetMetadata({ + args, + asset, + assetPlan, + plan, + projectPlan, + serviceDir, +}) { + if (!asset.metadataFile) return; + const metadataDir = path.dirname(path.join(args.folder, asset.metadataFile)); + const { media, preview, metadataFile } = prepareLegacyAssetMetadata({ + args, + assetPlan, + metadataDir, + plan, + projectPlan, + serviceDir, + nextAssetIndex: asset.index, + }); + asset.mediaFile = media?.file || null; + asset.mediaUrl = media?.url || ""; + asset.previewFile = preview?.file || null; + asset.previewUrl = + preview?.url || + (assetKind(assetPlan.legacyAsset.medium_type) === "image" + ? media?.url + : "") || + asset.previewUrl || + ""; + asset.metadataFile = path.relative(args.folder, metadataFile); +} + +async function deployLegacyAssetDraft({ + args, + assetPlan, + client, + metadataDir, + owner, + plan, + projectPlan, + serviceDir, + universe, + nextAssetIndex, +}) { + const { legacyAsset } = assetPlan; + const title = assetTitle(projectPlan.project, legacyAsset); + const { media, preview, metadataFile } = prepareLegacyAssetMetadata({ + args, + assetPlan, + metadataDir, + plan, + projectPlan, + serviceDir, + nextAssetIndex, + }); + + const metadataHash = metadataPointer(args, metadataFile); + const previewHash = + preview?.url || + (assetKind(legacyAsset.medium_type) === "image" ? media?.url : "") || + ""; + if (previewHash) assertOnChainPointer(previewHash, "asset preview pointer"); + console.log(`Creating draft asset ${nextAssetIndex}: ${title}`); + const createdAsset = await createDraftAsset({ + client, + universe, + owner, + assetIndex: nextAssetIndex, + kind: enumValue(assetKind(legacyAsset.medium_type)), + subtype: enumValue(assetSubtype(legacyAsset.medium_sub_type)), + metadataHash, + previewHash, + label: "legacy asset", + }); + + return { + index: nextAssetIndex, + address: createdAsset.asset.toBase58(), + title, + legacyAssetId: legacyAsset.id, + sourceId: legacyAsset.source_id, + mediumType: legacyAsset.medium_type || "", + mediumSubType: legacyAsset.medium_sub_type || "", + metadataFile: path.relative(args.folder, metadataFile), + metadataHash, + mediaFile: media?.file || null, + mediaUrl: media?.url || "", + previewFile: preview?.file || null, + previewUrl: previewHash, + originalIpfsHash: legacyAsset.ipfs_hash || "", + originalPreviewIpfsHash: legacyAsset.preview_ipfs_hash || "", + createSignature: createdAsset.createSignature, + status: "draft", + }; +} + +async function ensureEntityParentLinks({ client, entity, owner }) { + const parentAsset = new anchor.web3.PublicKey(entity.address); + for (const asset of entity.assets || []) { + if (asset.entityParentLink) continue; + const childAsset = new anchor.web3.PublicKey(asset.address); + await requireDraftForLink(client, childAsset, asset.title); + const entityLink = await ensureParentLink({ + client, + childAsset, + parentAsset, + creator: owner, + label: "entity parent link", + }); + asset.entityParentLink = entityLink.assetParent.toBase58(); + asset.entityParentLinkReused = entityLink.reused; + asset.entityParentSignature = entityLink.signature; + } +} + +async function ensureLegacySourceLinks({ client, entity, owner }) { + const byLegacyId = new Map( + (entity.assets || []).map((asset) => [Number(asset.legacyAssetId), asset]) + ); + + for (const asset of entity.assets || []) { + if (asset.sourceParentLink) continue; + const sourceId = Number(asset.sourceId || 0); + const parent = byLegacyId.get(sourceId); + if (!sourceId || !parent || Number(asset.legacyAssetId) === sourceId) { + continue; + } + + const childAsset = new anchor.web3.PublicKey(asset.address); + const parentAsset = new anchor.web3.PublicKey(parent.address); + await requireDraftForLink(client, childAsset, asset.title); + const link = await ensureParentLink({ + client, + childAsset, + parentAsset, + creator: owner, + label: "legacy source parent link", + }); + asset.sourceParentLink = link.assetParent.toBase58(); + asset.sourceParentLinkReused = link.reused; + asset.sourceParentSignature = link.signature; + } +} + +async function approveEntityAssets({ client, entity, owner, universe }) { + for (const asset of entity.assets || []) { + if (asset.status === "approved" && asset.approveSignature) continue; + await submitAndApproveAsset({ + client, + universe, + owner, + assetAddress: asset.address, + manifestAsset: asset, + }); + } +} + +async function main() { + const args = parseArgs(process.argv); + if (!fs.existsSync(args.folder)) { + throw new Error(`Folder does not exist: ${args.folder}`); + } + + const dumpDir = discoverDumpDir(args.folder, args.dumpDir); + const dump = readJson(path.join(dumpDir, "manifest.json")); + const plan = buildMigrationPlan({ dump, dumpDir, folder: args.folder }); + + const serviceDir = path.join(args.folder, SERVICE_DIR_NAME); + const metadataDir = path.join(serviceDir, "metadata"); + const keypairPath = path.join(serviceDir, "universe-owner-keypair.json"); + const manifestPath = path.join(serviceDir, "deployment-manifest.json"); + const previousManifest = args.newUniverse ? null : loadManifest(manifestPath); + + if (args.dryRun) { + console.log( + JSON.stringify( + { + folder: args.folder, + dumpDir, + endpoint: args.endpoint, + metadataBaseUrl: args.metadataBaseUrl, + keypairPath, + keypairExists: fs.existsSync(keypairPath), + existingUniverse: previousManifest?.universe || null, + newUniverse: args.newUniverse || !previousManifest, + universeTitle: plan.universe.name || "Wotori Studio", + sourceUniverseAddress: plan.sourceUniverseAddress, + entitiesToMap: plan.projectCount, + assetsToMap: plan.assetCount, + entityParentLinks: plan.entityParentLinkCount, + legacySourceLinks: plan.sourceParentLinkCount, + entities: plan.projects.map((projectPlan) => ({ + title: + projectPlan.project.info?.title || projectPlan.project.address, + projectType: projectPlan.project.info?.project_type || "", + sourceProjectAddress: projectPlan.project.address, + assets: projectPlan.assets.length, + coverFile: projectPlan.coverRelativePath, + })), + }, + null, + 2 + ) + ); + return; + } + + ensureDir(metadataDir); + ensureDir(path.join(serviceDir, "media")); + + const { keypair: owner, created } = loadOrCreateKeypair(keypairPath); + const connection = new Connection(args.endpoint, "confirmed"); + await assertProgramDeployed(connection); + const airdrop = await confirmAirdrop( + connection, + owner.publicKey, + args.airdropSol + ); + const wallet = new anchor.Wallet(owner); + const client = createClient(connection, wallet, { + commitment: "processed", + preflightCommitment: "processed", + }); + + const manifest = + previousManifest || + (await createFreshUniverse({ + args, + client, + dump, + metadataDir, + owner, + plan, + })); + manifest.endpoint = args.endpoint; + manifest.programId = PROGRAM_ID.toBase58(); + manifest.owner = owner.publicKey.toBase58(); + manifest.ownerKeypair = path.relative(args.folder, keypairPath); + manifest.ownerKeypairCreated = created; + manifest.ownerAirdrop = airdrop; + manifest.source = "archway-stellar-dump"; + manifest.sourceUniverseAddress = plan.sourceUniverseAddress; + manifest.dumpDir = path.relative(args.folder, dumpDir); + manifest.entities = manifest.entities || []; + manifest.createdAt = manifest.createdAt || new Date().toISOString(); + writeDeploymentManifest(manifestPath, manifest); + + const universe = new anchor.web3.PublicKey(manifest.universe); + const universeAccount = await client.program.account.universe.fetch(universe); + let nextAssetIndex = universeAccount.assetCount.toNumber(); + + for (const projectPlan of plan.projects) { + let entity = findManifestEntity(manifest, projectPlan.project.address); + if (!entity) { + entity = await deployEntity({ + args, + client, + metadataDir, + owner, + plan, + projectPlan, + serviceDir, + universe, + nextAssetIndex, + }); + nextAssetIndex += 1; + manifest.entities.push(entity); + writeDeploymentManifest(manifestPath, manifest); + } else { + refreshEntityMetadata({ + args, + entity, + plan, + projectPlan, + serviceDir, + }); + writeDeploymentManifest(manifestPath, manifest); + } + + entity.assets = entity.assets || []; + for (const assetPlan of projectPlan.assets) { + let deployedAsset = findManifestAsset(entity, assetPlan.legacyAsset.id); + if (deployedAsset) { + refreshLegacyAssetMetadata({ + args, + asset: deployedAsset, + assetPlan, + plan, + projectPlan, + serviceDir, + }); + writeDeploymentManifest(manifestPath, manifest); + continue; + } + + deployedAsset = await deployLegacyAssetDraft({ + args, + assetPlan, + client, + metadataDir, + owner, + plan, + projectPlan, + serviceDir, + universe, + nextAssetIndex, + }); + nextAssetIndex += 1; + entity.assets.push(deployedAsset); + writeDeploymentManifest(manifestPath, manifest); + } + + await ensureEntityParentLinks({ client, entity, owner }); + writeDeploymentManifest(manifestPath, manifest); + await ensureLegacySourceLinks({ client, entity, owner }); + writeDeploymentManifest(manifestPath, manifest); + await approveEntityAssets({ client, entity, owner, universe }); + writeDeploymentManifest(manifestPath, manifest); + } + + manifest.summary = { + entities: manifest.entities.length, + assets: manifest.entities.reduce( + (count, entity) => count + (entity.assets?.length || 0), + 0 + ), + entityParentLinks: manifest.entities.reduce( + (count, entity) => + count + + (entity.assets || []).filter((asset) => asset.entityParentLink).length, + 0 + ), + legacySourceLinks: manifest.entities.reduce( + (count, entity) => + count + + (entity.assets || []).filter((asset) => asset.sourceParentLink).length, + 0 + ), + }; + writeDeploymentManifest(manifestPath, manifest); + + console.log(JSON.stringify(manifest, null, 2)); + console.log( + `\nSeeded Wotori universe ${manifest.universe}: ${manifest.summary.entities} entities, ${manifest.summary.assets} assets.` + ); + console.log(`\nDeployment manifest: ${manifestPath}`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + if (error?.logs) { + console.error(error.logs.join("\n")); + } + process.exit(1); +}); diff --git a/scripts/dump_stellar_universe.mjs b/scripts/dump_stellar_universe.mjs new file mode 100644 index 0000000..8c58ad7 --- /dev/null +++ b/scripts/dump_stellar_universe.mjs @@ -0,0 +1,457 @@ +#!/usr/bin/env node +import { createRequire } from "node:module"; +import { mkdir, readFile, writeFile, copyFile, link, stat } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; + +const DEFAULT_RPC = "https://rpc.constantine.archway.io:443"; +const DEFAULT_GATEWAYS = [ + "https://ipfs.io/ipfs/", + "https://cloudflare-ipfs.com/ipfs/", + "https://dweb.link/ipfs/", +]; + +const requireFromHere = createRequire(import.meta.url); + +function loadCosmWasmClient() { + const candidates = [ + () => requireFromHere("@cosmjs/cosmwasm-stargate"), + () => createRequire("/private/tmp/stellar-scrape/package.json")("@cosmjs/cosmwasm-stargate"), + ]; + + for (const load of candidates) { + try { + return load().CosmWasmClient; + } catch { + // Try the next known location. + } + } + + throw new Error( + "Cannot load @cosmjs/cosmwasm-stargate. Install it in this repo or in /private/tmp/stellar-scrape." + ); +} + +function usage() { + console.error( + [ + "Usage:", + " node scripts/dump_stellar_universe.mjs [output-dir]", + "", + "Environment:", + ` STELLAR_RPC=${DEFAULT_RPC}`, + ` IPFS_GATEWAYS=${DEFAULT_GATEWAYS.join(",")}`, + ].join("\n") + ); +} + +function shortAddress(value) { + return value ? `${value.slice(0, 10)}...${value.slice(-6)}` : "unknown"; +} + +function safeName(value, fallback = "untitled") { + const normalized = String(value || fallback) + .trim() + .replace(/[^\w .-]+/g, "_") + .replace(/\s+/g, " ") + .slice(0, 80); + return normalized || fallback; +} + +function jsonString(value) { + return JSON.stringify(value, null, 2); +} + +function formatDate(value) { + if (!value) return ""; + const n = Number(value); + if (!Number.isFinite(n)) return String(value); + const ms = String(Math.trunc(n)).length === 10 ? n * 1000 : n; + return new Date(ms).toISOString(); +} + +function isRealHash(hash) { + return Boolean(hash && typeof hash === "string" && hash !== "undefined" && hash !== "null"); +} + +function contentExtension(contentType, buffer) { + const type = String(contentType || "").split(";")[0].trim().toLowerCase(); + const head = buffer.subarray(0, 16); + const ascii = head.toString("ascii"); + + if (head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return ".jpg"; + if (head[0] === 0x89 && ascii.slice(1, 4) === "PNG") return ".png"; + if (ascii.startsWith("GIF87a") || ascii.startsWith("GIF89a")) return ".gif"; + if (ascii.startsWith("RIFF") && buffer.subarray(8, 12).toString("ascii") === "WEBP") return ".webp"; + if (ascii.startsWith("glTF")) return ".glb"; + if (ascii.startsWith("PK\x03\x04")) return ".zip"; + + const map = new Map([ + ["image/jpeg", ".jpg"], + ["image/png", ".png"], + ["image/webp", ".webp"], + ["image/gif", ".gif"], + ["image/svg+xml", ".svg"], + ["application/json", ".json"], + ["model/gltf-binary", ".glb"], + ["model/gltf+json", ".gltf"], + ["text/plain", ".txt"], + ["text/markdown", ".md"], + ["application/octet-stream", ".bin"], + ]); + return map.get(type) || ".bin"; +} + +function mediaLabel(ref) { + const parts = [ref.role, ref.mediumType, ref.mediumSubType, ref.assetId].filter(Boolean); + return safeName(parts.join("-"), ref.role || "media").toLowerCase(); +} + +async function ensureDir(dir) { + await mkdir(dir, { recursive: true }); +} + +async function writeJson(file, value) { + await ensureDir(path.dirname(file)); + await writeFile(file, `${jsonString(value)}\n`); +} + +async function writeText(file, value) { + await ensureDir(path.dirname(file)); + await writeFile(file, value); +} + +async function querySmart(client, contract, msg) { + return client.queryContractSmart(contract, msg); +} + +async function downloadWithTimeout(url, timeoutMs) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + signal: controller.signal, + headers: { "user-agent": "stellar-universe-dumper/1.0" }, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const arrayBuffer = await response.arrayBuffer(); + return { + url, + contentType: response.headers.get("content-type") || "", + buffer: Buffer.from(arrayBuffer), + }; + } finally { + clearTimeout(timer); + } +} + +async function downloadIpfs(hash, mediaRoot, gateways, timeoutMs) { + const errors = []; + for (const gateway of gateways) { + const url = `${gateway.replace(/\/?$/, "/")}${hash}`; + try { + const result = await downloadWithTimeout(url, timeoutMs); + const ext = contentExtension(result.contentType, result.buffer); + const file = path.join(mediaRoot, `${hash}${ext}`); + await writeFile(file, result.buffer); + const metadata = { + hash, + url: result.url, + contentType: result.contentType, + extension: ext, + bytes: result.buffer.length, + }; + await writeJson(path.join(mediaRoot, `${hash}.meta.json`), metadata); + return { ok: true, ...metadata, file }; + } catch (error) { + errors.push({ gateway, error: error.message }); + } + } + return { ok: false, hash, errors }; +} + +async function copyOrLink(source, destination) { + await ensureDir(path.dirname(destination)); + try { + await link(source, destination); + } catch { + await copyFile(source, destination); + } +} + +function collectMediaRefs(project) { + const refs = []; + const projectTitle = project.info?.title || project.address; + if (isRealHash(project.info?.img_ipfs_hash)) { + refs.push({ + hash: project.info.img_ipfs_hash, + role: "project-cover", + owner: project.owner, + projectAddress: project.address, + projectTitle, + }); + } + + for (const asset of project.assets || []) { + if (isRealHash(asset.ipfs_hash)) { + refs.push({ + hash: asset.ipfs_hash, + role: "asset", + owner: project.owner, + projectAddress: project.address, + projectTitle, + assetId: asset.id, + mediumType: asset.medium_type, + mediumSubType: asset.medium_sub_type, + }); + } + if (isRealHash(asset.preview_ipfs_hash)) { + refs.push({ + hash: asset.preview_ipfs_hash, + role: "asset-preview", + owner: project.owner, + projectAddress: project.address, + projectTitle, + assetId: asset.id, + mediumType: asset.medium_type, + mediumSubType: asset.medium_sub_type, + }); + } + } + return refs; +} + +async function maybeReadText(file) { + try { + const stats = await stat(file); + if (stats.size > 512 * 1024) return null; + const buffer = await readFile(file); + const sample = buffer.subarray(0, Math.min(buffer.length, 256)); + if (sample.includes(0)) return null; + return buffer.toString("utf8"); + } catch { + return null; + } +} + +function projectMarkdown(project, refs) { + const info = project.info || {}; + const lines = [ + `# ${info.title || project.address}`, + "", + `Address: ${project.address}`, + `Owner: ${project.owner}`, + `Type: ${info.project_type || ""}`, + `Open: ${info.open}`, + `Assets: ${(project.assets || []).length}`, + "", + "## Description", + "", + info.description || "No description.", + "", + "## Popup Text", + "", + info.description || "undefined project description", + "", + "## Media", + "", + ...refs.map((ref) => `- ${ref.role}: ${ref.hash}`), + "", + ]; + return `${lines.join("\n")}\n`; +} + +function assetMarkdown(asset, textContent) { + const lines = [ + `# Asset ${asset.id}`, + "", + `Medium type: ${asset.medium_type || ""}`, + `Medium subtype: ${asset.medium_sub_type || ""}`, + `Minter: ${asset.minter || ""}`, + `Title: ${asset.title || ""}`, + `Description: ${asset.description || ""}`, + `IPFS hash: ${asset.ipfs_hash || ""}`, + `Preview IPFS hash: ${asset.preview_ipfs_hash || ""}`, + `Source ID: ${asset.source_id ?? ""}`, + `Created: ${formatDate(asset.date_time_utc)}`, + "", + ]; + if (textContent) { + lines.push("## Downloaded Text", "", textContent, ""); + } + return `${lines.join("\n")}\n`; +} + +function readArgs() { + const [, , universeAddress, outputDir] = process.argv; + if (!universeAddress) { + usage(); + process.exit(2); + } + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + return { + universeAddress, + outputDir: + outputDir || + path.resolve( + "dumps", + `stellar_universe_${universeAddress.slice(0, 12)}_${stamp}` + ), + rpc: process.env.STELLAR_RPC || DEFAULT_RPC, + gateways: (process.env.IPFS_GATEWAYS || DEFAULT_GATEWAYS.join(",")) + .split(",") + .map((value) => value.trim()) + .filter(Boolean), + }; +} + +async function main() { + const { universeAddress, outputDir, rpc, gateways } = readArgs(); + const CosmWasmClient = loadCosmWasmClient(); + const absoluteOutputDir = path.resolve(outputDir); + const mediaRoot = path.join(absoluteOutputDir, "_ipfs"); + + await ensureDir(mediaRoot); + const client = await CosmWasmClient.connect(rpc); + + console.log(`Connected to ${rpc}`); + console.log(`Dumping universe ${universeAddress}`); + + const universe = await querySmart(client, universeAddress, { entity_info: {} }); + const allEntities = await querySmart(client, universeAddress, { all_entities: {} }); + const owners = allEntities.all_entities || {}; + const projectAddresses = Object.values(owners).flat(); + + await writeJson(path.join(absoluteOutputDir, "raw", "universe_entity_info.json"), universe); + await writeJson(path.join(absoluteOutputDir, "raw", "all_entities.json"), allEntities); + + const projects = []; + let index = 0; + for (const [owner, addresses] of Object.entries(owners)) { + for (const address of addresses) { + index += 1; + console.log(`[${index}/${projectAddresses.length}] Query project ${shortAddress(address)}`); + const [info, assetResponse] = await Promise.all([ + querySmart(client, address, { project_info: {} }), + querySmart(client, address, { all_assets: {} }), + ]); + projects.push({ + owner, + address, + info, + assets: Array.isArray(assetResponse.all_assets) ? assetResponse.all_assets : [], + }); + } + } + + const allRefs = projects.flatMap(collectMediaRefs); + const uniqueHashes = [...new Set(allRefs.map((ref) => ref.hash))]; + const mediaByHash = {}; + + console.log(`Downloading ${uniqueHashes.length} unique IPFS files`); + for (let i = 0; i < uniqueHashes.length; i += 1) { + const hash = uniqueHashes[i]; + console.log(`[${i + 1}/${uniqueHashes.length}] IPFS ${hash}`); + mediaByHash[hash] = await downloadIpfs(hash, mediaRoot, gateways, 45000); + } + + for (let i = 0; i < projects.length; i += 1) { + const project = projects[i]; + const projectDir = path.join( + absoluteOutputDir, + "projects", + `${String(i + 1).padStart(3, "0")}-${safeName(project.info?.title)}-${project.address.slice(0, 12)}` + ); + const refs = collectMediaRefs(project); + await ensureDir(projectDir); + await writeJson(path.join(projectDir, "project.json"), project); + await writeText(path.join(projectDir, "description.md"), projectMarkdown(project, refs)); + await writeText(path.join(projectDir, "popup.txt"), `${project.info?.description || "undefined project description"}\n`); + + for (const ref of refs.filter((item) => item.role === "project-cover")) { + const media = mediaByHash[ref.hash]; + if (media?.ok) { + const dest = path.join(projectDir, "media", `project-cover${media.extension}`); + await copyOrLink(media.file, dest); + ref.localPath = path.relative(absoluteOutputDir, dest); + } + } + + for (const asset of project.assets || []) { + const assetDir = path.join( + projectDir, + "assets", + `${String(asset.id).padStart(3, "0")}-${safeName(`${asset.medium_type || "asset"}-${asset.medium_sub_type || ""}`)}` + ); + await ensureDir(assetDir); + await writeJson(path.join(assetDir, "asset.json"), asset); + + let textContent = null; + const assetRefs = refs.filter((ref) => ref.assetId === asset.id); + for (const ref of assetRefs) { + const media = mediaByHash[ref.hash]; + if (!media?.ok) continue; + const dest = path.join(assetDir, "media", `${mediaLabel(ref)}${media.extension}`); + await copyOrLink(media.file, dest); + ref.localPath = path.relative(absoluteOutputDir, dest); + if (asset.medium_type === "text" && ref.role === "asset") { + textContent = await maybeReadText(media.file); + if (textContent) await writeText(path.join(assetDir, "text_content.txt"), textContent); + } + } + await writeText(path.join(assetDir, "description.md"), assetMarkdown(asset, textContent)); + } + } + + const failedMedia = Object.values(mediaByHash).filter((item) => !item.ok); + const manifest = { + scrapedAt: new Date().toISOString(), + rpc, + universeAddress, + universe, + allEntities, + projectCount: projects.length, + assetCount: projects.reduce((sum, project) => sum + project.assets.length, 0), + mediaCount: uniqueHashes.length, + failedMediaCount: failedMedia.length, + mediaByHash, + projects, + }; + + await writeJson(path.join(absoluteOutputDir, "manifest.json"), manifest); + + const summary = [ + `# Stellar Universe Dump`, + "", + `Universe: ${universe.name || universeAddress}`, + `Address: ${universeAddress}`, + `Description: ${universe.description || ""}`, + `Scraped at: ${manifest.scrapedAt}`, + `Projects: ${manifest.projectCount}`, + `Assets: ${manifest.assetCount}`, + `Unique IPFS files: ${manifest.mediaCount}`, + `Failed IPFS downloads: ${manifest.failedMediaCount}`, + "", + "## Projects", + "", + ...projects.map((project, i) => { + const info = project.info || {}; + return `${i + 1}. ${info.title || project.address} (${project.address}) - ${project.assets.length} assets`; + }), + "", + "Raw blockchain responses are in `raw/`. Deduplicated IPFS payloads are in `_ipfs/`.", + "Each project folder contains `project.json`, `description.md`, `popup.txt`, media copies, and per-asset folders.", + "", + ]; + await writeText(path.join(absoluteOutputDir, "README.md"), `${summary.join("\n")}\n`); + + console.log(`Done: ${absoluteOutputDir}`); + if (failedMedia.length) { + console.log(`Warning: ${failedMedia.length} IPFS files failed. See manifest.json.`); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/serve-metadata.js b/scripts/serve-metadata.js new file mode 100644 index 0000000..8d27155 --- /dev/null +++ b/scripts/serve-metadata.js @@ -0,0 +1,240 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const http = require("node:http"); +const path = require("node:path"); + +const DEFAULT_FOLDER = path.resolve(__dirname, "../univerces/everything"); +const MIME_TYPES = { + ".json": "application/json; charset=utf-8", + ".glb": "model/gltf-binary", + ".obj": "text/plain; charset=utf-8", + ".mtl": "text/plain; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".txt": "text/plain; charset=utf-8", +}; + +function parseArgs(argv) { + const args = { + folder: DEFAULT_FOLDER, + port: 8787, + }; + + for (let index = 2; index < argv.length; index += 1) { + const arg = argv[index]; + const next = argv[index + 1]; + if (arg === "--folder" && next) { + args.folder = path.resolve(next); + index += 1; + } else if (arg === "--port" && next) { + args.port = Number(next); + index += 1; + } else if (arg === "--help" || arg === "-h") { + console.log( + "Usage: node scripts/serve-metadata.js [--folder path] [--port 8787]" + ); + process.exit(0); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!Number.isInteger(args.port) || args.port <= 0) { + throw new Error("--port must be a positive integer"); + } + + return args; +} + +function send( + res, + statusCode, + body, + contentType = "text/plain; charset=utf-8" +) { + res.writeHead(statusCode, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS", + "Access-Control-Allow-Headers": "Accept, Content-Type", + "Cache-Control": "no-store", + "Content-Type": contentType, + }); + res.end(body); +} + +function resolveRequestPath(root, requestUrl) { + const url = new URL(requestUrl, "http://127.0.0.1"); + const decodedPath = decodeURIComponent(url.pathname).replace(/^\/+/, ""); + const filePath = path.resolve(root, decodedPath); + if (!filePath.startsWith(root + path.sep) && filePath !== root) { + return null; + } + return filePath; +} + +function collectRequestBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + +function sanitizeFilename(filename) { + const clean = path + .basename(filename || "upload.bin") + .replace(/[^\w.-]+/g, "-"); + return clean || "upload.bin"; +} + +function inferFilename(filename, contentType) { + if (filename && filename !== "blob") return sanitizeFilename(filename); + if (contentType?.includes("application/json")) return "metadata.json"; + if (contentType?.includes("text/plain")) return "asset.txt"; + if (contentType?.includes("model/gltf-binary")) return "asset.glb"; + if (contentType?.includes("image/png")) return "asset.png"; + if (contentType?.includes("image/jpeg")) return "asset.jpg"; + return sanitizeFilename(filename); +} + +async function handleUpload(req, res, root, port) { + const contentType = req.headers["content-type"] || ""; + const boundary = + contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/)?.[1] || + contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/)?.[2]; + if (!boundary) { + send( + res, + 400, + JSON.stringify({ error: "Missing multipart boundary" }), + "application/json; charset=utf-8" + ); + return; + } + + const body = await collectRequestBody(req); + const delimiter = Buffer.from(`--${boundary}`); + const start = body.indexOf(delimiter); + if (start === -1) { + send( + res, + 400, + JSON.stringify({ error: "Malformed multipart body" }), + "application/json; charset=utf-8" + ); + return; + } + + const headerStart = start + delimiter.length + 2; + const headerEnd = body.indexOf(Buffer.from("\r\n\r\n"), headerStart); + if (headerEnd === -1) { + send( + res, + 400, + JSON.stringify({ error: "Malformed multipart headers" }), + "application/json; charset=utf-8" + ); + return; + } + + const headers = body.slice(headerStart, headerEnd).toString("utf8"); + const filename = headers.match(/filename="([^"]*)"/)?.[1] || "upload.bin"; + const fileContentType = + headers.match(/content-type:\s*([^\r\n]+)/i)?.[1] || ""; + const dataStart = headerEnd + 4; + const nextDelimiter = body.indexOf( + Buffer.from(`\r\n--${boundary}`), + dataStart + ); + if (nextDelimiter === -1) { + send( + res, + 400, + JSON.stringify({ error: "Missing multipart terminator" }), + "application/json; charset=utf-8" + ); + return; + } + + const uploadsDir = path.join(root, "_", "uploads"); + fs.mkdirSync(uploadsDir, { recursive: true }); + const safeName = inferFilename(filename, fileContentType); + const storedName = `${Date.now()}-${safeName}`; + const storedPath = path.join(uploadsDir, storedName); + fs.writeFileSync(storedPath, body.slice(dataStart, nextDelimiter)); + + const url = `http://127.0.0.1:${port}/_/uploads/${encodeURIComponent( + storedName + )}`; + send( + res, + 200, + JSON.stringify({ ipfs_hash: url, cid: url, local: true }), + "application/json; charset=utf-8" + ); +} + +function main() { + const args = parseArgs(process.argv); + const root = path.resolve(args.folder); + if (!fs.existsSync(root)) { + throw new Error(`Folder does not exist: ${root}`); + } + + const server = http.createServer(async (req, res) => { + if (req.method === "OPTIONS") { + send(res, 204, ""); + return; + } + + if (req.method === "POST" && (req.url || "").startsWith("/_/upload")) { + try { + await handleUpload(req, res, root, args.port); + } catch (error) { + send( + res, + 500, + JSON.stringify({ + error: error instanceof Error ? error.message : String(error), + }), + "application/json; charset=utf-8" + ); + } + return; + } + + if (req.method !== "GET" && req.method !== "HEAD") { + send(res, 405, "Method not allowed"); + return; + } + + const filePath = resolveRequestPath(root, req.url || "/"); + if (!filePath) { + send(res, 403, "Forbidden"); + return; + } + + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + send(res, 404, "Not found"); + return; + } + + const contentType = + MIME_TYPES[path.extname(filePath).toLowerCase()] || + "application/octet-stream"; + const body = req.method === "HEAD" ? "" : fs.readFileSync(filePath); + send(res, 200, body, contentType); + }); + + server.listen(args.port, "127.0.0.1", () => { + console.log(`Metadata server: http://127.0.0.1:${args.port}`); + console.log(`Serving: ${root}`); + }); +} + +main(); diff --git a/sdk/.gitignore b/sdk/.gitignore new file mode 100644 index 0000000..de4d1f0 --- /dev/null +++ b/sdk/.gitignore @@ -0,0 +1,2 @@ +dist +node_modules diff --git a/sdk/idl/solana_stellar.json b/sdk/idl/solana_stellar.json new file mode 100644 index 0000000..883a718 --- /dev/null +++ b/sdk/idl/solana_stellar.json @@ -0,0 +1,1787 @@ +{ + "address": "3rVXfq7LLSLqbDzvZuSrQoMytwczLj2Q8Hue62rxPZAA", + "metadata": { + "name": "solana_stellar", + "version": "0.1.0", + "spec": "0.1.0", + "description": "Created with Anchor" + }, + "instructions": [ + { + "name": "add_asset_parent", + "discriminator": [104, 56, 24, 76, 97, 101, 94, 143], + "accounts": [ + { + "name": "child_asset", + "writable": true + }, + { + "name": "parent_asset" + }, + { + "name": "creator", + "writable": true, + "signer": true, + "relations": ["child_asset"] + }, + { + "name": "asset_parent", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [108, 105, 110, 107] + }, + { + "kind": "account", + "path": "child_asset" + }, + { + "kind": "account", + "path": "parent_asset" + } + ] + } + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [] + }, + { + "name": "add_release_share", + "discriminator": [130, 134, 41, 170, 213, 143, 97, 137], + "accounts": [ + { + "name": "universe" + }, + { + "name": "release", + "writable": true + }, + { + "name": "share", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [115, 104, 97, 114, 101] + }, + { + "kind": "account", + "path": "release" + }, + { + "kind": "account", + "path": "contributor" + } + ] + } + }, + { + "name": "contributor" + }, + { + "name": "owner", + "writable": true, + "signer": true, + "relations": ["universe"] + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "bps", + "type": "u16" + } + ] + }, + { + "name": "approve_asset", + "discriminator": [127, 15, 21, 247, 23, 22, 189, 238], + "accounts": [ + { + "name": "universe" + }, + { + "name": "asset", + "writable": true + }, + { + "name": "owner", + "signer": true, + "relations": ["universe"] + } + ], + "args": [] + }, + { + "name": "claim_revenue", + "discriminator": [4, 22, 151, 70, 183, 79, 73, 189], + "accounts": [ + { + "name": "release" + }, + { + "name": "vault", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 114, 101, 108, 101, 97, 115, 101, 95, 118, 97, 117, 108, 116 + ] + }, + { + "kind": "account", + "path": "release" + } + ] + } + }, + { + "name": "share", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [115, 104, 97, 114, 101] + }, + { + "kind": "account", + "path": "release" + }, + { + "kind": "account", + "path": "contributor" + } + ] + } + }, + { + "name": "contributor", + "writable": true, + "signer": true + } + ], + "args": [] + }, + { + "name": "claim_revenue_for", + "discriminator": [18, 77, 74, 138, 170, 122, 165, 180], + "accounts": [ + { + "name": "release" + }, + { + "name": "vault", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 114, 101, 108, 101, 97, 115, 101, 95, 118, 97, 117, 108, 116 + ] + }, + { + "kind": "account", + "path": "release" + } + ] + } + }, + { + "name": "share", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [115, 104, 97, 114, 101] + }, + { + "kind": "account", + "path": "release" + }, + { + "kind": "account", + "path": "beneficiary" + } + ] + } + }, + { + "name": "beneficiary", + "writable": true + }, + { + "name": "authority", + "signer": true + } + ], + "args": [] + }, + { + "name": "close_asset", + "discriminator": [39, 124, 90, 146, 16, 82, 77, 253], + "accounts": [ + { + "name": "universe" + }, + { + "name": "asset", + "writable": true + }, + { + "name": "authority", + "signer": true + }, + { + "name": "rent_receiver", + "writable": true + } + ], + "args": [] + }, + { + "name": "close_universe", + "discriminator": [44, 6, 172, 166, 141, 160, 154, 4], + "accounts": [ + { + "name": "universe", + "writable": true + }, + { + "name": "owner", + "writable": true, + "signer": true, + "relations": ["universe"] + } + ], + "args": [] + }, + { + "name": "create_asset", + "discriminator": [28, 42, 120, 51, 7, 38, 156, 136], + "accounts": [ + { + "name": "universe", + "writable": true + }, + { + "name": "asset", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [97, 115, 115, 101, 116] + }, + { + "kind": "account", + "path": "universe" + }, + { + "kind": "arg", + "path": "asset_index" + } + ] + } + }, + { + "name": "creator", + "writable": true, + "signer": true + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "asset_index", + "type": "u64" + }, + { + "name": "kind", + "type": { + "defined": { + "name": "AssetKind" + } + } + }, + { + "name": "subtype", + "type": { + "defined": { + "name": "AssetSubtype" + } + } + }, + { + "name": "license_kind", + "type": { + "defined": { + "name": "LicenseKind" + } + } + }, + { + "name": "metadata_hash", + "type": "string" + }, + { + "name": "preview_hash", + "type": "string" + }, + { + "name": "open", + "type": "bool" + }, + { + "name": "collaboration_policy", + "type": { + "defined": { + "name": "CollaborationPolicy" + } + } + } + ] + }, + { + "name": "create_release", + "discriminator": [76, 2, 12, 43, 107, 154, 171, 200], + "accounts": [ + { + "name": "universe", + "writable": true + }, + { + "name": "asset" + }, + { + "name": "release", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [114, 101, 108, 101, 97, 115, 101] + }, + { + "kind": "account", + "path": "universe" + }, + { + "kind": "arg", + "path": "release_index" + } + ] + } + }, + { + "name": "vault", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 114, 101, 108, 101, 97, 115, 101, 95, 118, 97, 117, 108, 116 + ] + }, + { + "kind": "account", + "path": "release" + } + ] + } + }, + { + "name": "owner", + "writable": true, + "signer": true, + "relations": ["universe"] + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "release_index", + "type": "u64" + }, + { + "name": "metadata_hash", + "type": "string" + } + ] + }, + { + "name": "create_universe", + "discriminator": [68, 252, 105, 236, 109, 225, 120, 113], + "accounts": [ + { + "name": "registry", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [114, 101, 103, 105, 115, 116, 114, 121] + } + ] + } + }, + { + "name": "universe", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [117, 110, 105, 118, 101, 114, 115, 101] + }, + { + "kind": "account", + "path": "owner" + }, + { + "kind": "arg", + "path": "universe_index" + } + ] + } + }, + { + "name": "universe_lookup", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 117, 110, 105, 118, 101, 114, 115, 101, 95, 105, 110, 100, + 101, 120 + ] + }, + { + "kind": "account", + "path": "registry.universe_count", + "account": "Registry" + } + ] + } + }, + { + "name": "owner", + "writable": true, + "signer": true + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "universe_index", + "type": "u64" + }, + { + "name": "metadata_hash", + "type": "string" + }, + { + "name": "project_type", + "type": { + "defined": { + "name": "AssetKind" + } + } + }, + { + "name": "open", + "type": "bool" + } + ] + }, + { + "name": "deposit_revenue", + "discriminator": [224, 212, 82, 100, 60, 240, 220, 29], + "accounts": [ + { + "name": "release", + "writable": true + }, + { + "name": "vault", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 114, 101, 108, 101, 97, 115, 101, 95, 118, 97, 117, 108, 116 + ] + }, + { + "kind": "account", + "path": "release" + } + ] + } + }, + { + "name": "payer", + "writable": true, + "signer": true + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + } + ] + }, + { + "name": "finalize_lineage_equal_release", + "discriminator": [62, 145, 16, 168, 240, 50, 4, 42], + "accounts": [ + { + "name": "universe" + }, + { + "name": "release", + "writable": true + }, + { + "name": "asset", + "writable": true + }, + { + "name": "owner", + "writable": true, + "signer": true, + "relations": ["universe"] + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "asset_count", + "type": "u16" + }, + { + "name": "link_count", + "type": "u16" + } + ] + }, + { + "name": "finalize_release", + "discriminator": [133, 95, 4, 17, 103, 213, 141, 58], + "accounts": [ + { + "name": "universe" + }, + { + "name": "release", + "writable": true + }, + { + "name": "asset", + "writable": true + }, + { + "name": "owner", + "signer": true, + "relations": ["universe"] + } + ], + "args": [] + }, + { + "name": "finalize_weighted_release", + "discriminator": [84, 228, 162, 41, 173, 60, 169, 68], + "accounts": [ + { + "name": "universe" + }, + { + "name": "release", + "writable": true + }, + { + "name": "asset", + "writable": true + }, + { + "name": "owner", + "writable": true, + "signer": true, + "relations": ["universe"] + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "asset_count", + "type": "u16" + }, + { + "name": "link_count", + "type": "u16" + } + ] + }, + { + "name": "link_avatar_data", + "discriminator": [100, 18, 17, 99, 22, 120, 141, 252], + "accounts": [ + { + "name": "universe" + }, + { + "name": "release", + "writable": true + }, + { + "name": "owner", + "signer": true, + "relations": ["universe"] + } + ], + "args": [ + { + "name": "avatar_data", + "type": "pubkey" + } + ] + }, + { + "name": "reject_asset", + "discriminator": [79, 96, 89, 56, 10, 45, 227, 217], + "accounts": [ + { + "name": "universe" + }, + { + "name": "asset", + "writable": true + }, + { + "name": "owner", + "signer": true, + "relations": ["universe"] + } + ], + "args": [] + }, + { + "name": "submit_asset", + "discriminator": [4, 23, 13, 111, 93, 172, 183, 91], + "accounts": [ + { + "name": "asset", + "writable": true + }, + { + "name": "creator", + "signer": true, + "relations": ["asset"] + } + ], + "args": [] + }, + { + "name": "update_asset_metadata", + "discriminator": [217, 98, 205, 153, 242, 4, 41, 76], + "accounts": [ + { + "name": "asset", + "writable": true + }, + { + "name": "creator", + "signer": true, + "relations": ["asset"] + } + ], + "args": [ + { + "name": "license_kind", + "type": { + "defined": { + "name": "LicenseKind" + } + } + }, + { + "name": "metadata_hash", + "type": "string" + }, + { + "name": "preview_hash", + "type": "string" + }, + { + "name": "open", + "type": "bool" + }, + { + "name": "collaboration_policy", + "type": { + "defined": { + "name": "CollaborationPolicy" + } + } + } + ] + }, + { + "name": "update_universe", + "discriminator": [157, 157, 54, 180, 142, 174, 246, 121], + "accounts": [ + { + "name": "universe", + "writable": true + }, + { + "name": "owner", + "signer": true, + "relations": ["universe"] + } + ], + "args": [ + { + "name": "metadata_hash", + "type": "string" + }, + { + "name": "open", + "type": "bool" + } + ] + } + ], + "accounts": [ + { + "name": "Asset", + "discriminator": [234, 180, 241, 252, 139, 224, 160, 8] + }, + { + "name": "AssetParent", + "discriminator": [247, 168, 159, 50, 150, 61, 235, 227] + }, + { + "name": "ContributorShare", + "discriminator": [146, 88, 198, 243, 240, 238, 221, 182] + }, + { + "name": "Registry", + "discriminator": [47, 174, 110, 246, 184, 182, 252, 218] + }, + { + "name": "Release", + "discriminator": [229, 49, 96, 148, 167, 188, 17, 49] + }, + { + "name": "ReleaseVault", + "discriminator": [33, 38, 51, 77, 217, 179, 1, 5] + }, + { + "name": "Universe", + "discriminator": [86, 112, 227, 226, 88, 47, 242, 113] + }, + { + "name": "UniverseIndex", + "discriminator": [160, 143, 49, 208, 138, 104, 75, 45] + } + ], + "events": [ + { + "name": "AssetCreated", + "discriminator": [206, 193, 252, 254, 207, 185, 154, 4] + }, + { + "name": "AssetParentAdded", + "discriminator": [194, 97, 145, 92, 28, 207, 67, 68] + }, + { + "name": "AssetStatusChanged", + "discriminator": [50, 89, 231, 242, 218, 23, 131, 216] + }, + { + "name": "AvatarDataLinked", + "discriminator": [189, 148, 22, 111, 17, 129, 142, 202] + }, + { + "name": "ReleaseCreated", + "discriminator": [86, 95, 64, 109, 171, 247, 137, 65] + }, + { + "name": "ReleaseDistributionModelSet", + "discriminator": [211, 71, 130, 3, 8, 110, 114, 3] + }, + { + "name": "ReleaseShareAdded", + "discriminator": [189, 43, 189, 229, 54, 190, 30, 239] + }, + { + "name": "ReleaseStatusChanged", + "discriminator": [116, 240, 44, 172, 164, 80, 0, 127] + }, + { + "name": "RevenueClaimed", + "discriminator": [5, 254, 104, 87, 133, 137, 45, 116] + }, + { + "name": "RevenueDeposited", + "discriminator": [97, 189, 62, 159, 189, 208, 43, 181] + }, + { + "name": "UniverseCreated", + "discriminator": [244, 82, 63, 148, 26, 10, 53, 67] + }, + { + "name": "UniverseUpdated", + "discriminator": [111, 110, 186, 3, 147, 5, 33, 73] + } + ], + "errors": [ + { + "code": 6000, + "name": "Unauthorized", + "msg": "Unauthorized action." + }, + { + "code": 6001, + "name": "UniverseClosed", + "msg": "Universe is closed to public collaboration." + }, + { + "code": 6002, + "name": "AssetClosed", + "msg": "Asset is closed to public collaboration." + }, + { + "code": 6003, + "name": "UniverseNotActive", + "msg": "Universe is not active." + }, + { + "code": 6004, + "name": "UniverseNotEmpty", + "msg": "Universe still has live assets or releases." + }, + { + "code": 6005, + "name": "InvalidHash", + "msg": "Invalid metadata or content hash." + }, + { + "code": 6006, + "name": "InvalidAssetIndex", + "msg": "Invalid asset index." + }, + { + "code": 6007, + "name": "InvalidReleaseIndex", + "msg": "Invalid release index." + }, + { + "code": 6008, + "name": "AssetLocked", + "msg": "Asset is locked for this operation." + }, + { + "code": 6009, + "name": "InvalidAssetStatus", + "msg": "Invalid asset status for this operation." + }, + { + "code": 6010, + "name": "UniverseMismatch", + "msg": "Universe mismatch." + }, + { + "code": 6011, + "name": "AssetMismatch", + "msg": "Asset mismatch." + }, + { + "code": 6012, + "name": "ReleaseMismatch", + "msg": "Release mismatch." + }, + { + "code": 6013, + "name": "InvalidLineageLink", + "msg": "Invalid lineage link." + }, + { + "code": 6014, + "name": "InvalidLineageProof", + "msg": "Invalid lineage proof." + }, + { + "code": 6015, + "name": "InvalidContributorCount", + "msg": "Invalid contributor count." + }, + { + "code": 6016, + "name": "ReleaseLocked", + "msg": "Release is locked for this operation." + }, + { + "code": 6017, + "name": "ReleaseNotFinalized", + "msg": "Release is not finalized." + }, + { + "code": 6018, + "name": "InvalidShareBps", + "msg": "Invalid contributor share basis points." + }, + { + "code": 6019, + "name": "InvalidDistributionModel", + "msg": "Invalid release distribution model for this operation." + }, + { + "code": 6020, + "name": "ImmutableCollaborationPolicy", + "msg": "Collaboration policy is immutable after asset creation." + }, + { + "code": 6021, + "name": "InvalidRevenueAmount", + "msg": "Invalid revenue amount." + }, + { + "code": 6022, + "name": "InsufficientVaultBalanceForClaim", + "msg": "Release vault balance is below required reserve for claims." + }, + { + "code": 6023, + "name": "NoRevenueToClaim", + "msg": "No revenue available to claim." + }, + { + "code": 6024, + "name": "InsufficientVaultBalance", + "msg": "Release vault balance is insufficient." + }, + { + "code": 6025, + "name": "NumericalOverflow", + "msg": "Numerical overflow occurred." + } + ], + "types": [ + { + "name": "Asset", + "type": { + "kind": "struct", + "fields": [ + { + "name": "universe", + "type": "pubkey" + }, + { + "name": "index", + "type": "u64" + }, + { + "name": "creator", + "type": "pubkey" + }, + { + "name": "rent_payer", + "type": "pubkey" + }, + { + "name": "bump", + "type": "u8" + }, + { + "name": "kind", + "type": { + "defined": { + "name": "AssetKind" + } + } + }, + { + "name": "subtype", + "type": { + "defined": { + "name": "AssetSubtype" + } + } + }, + { + "name": "license_kind", + "type": { + "defined": { + "name": "LicenseKind" + } + } + }, + { + "name": "status", + "type": { + "defined": { + "name": "AssetStatus" + } + } + }, + { + "name": "open", + "type": "bool" + }, + { + "name": "collaboration_policy", + "docs": [ + "Revenue distribution policy used when this asset is finalized as a release." + ], + "type": { + "defined": { + "name": "CollaborationPolicy" + } + } + }, + { + "name": "metadata_hash", + "type": "string" + }, + { + "name": "preview_hash", + "type": "string" + }, + { + "name": "created_at", + "type": "i64" + }, + { + "name": "updated_at", + "type": "i64" + }, + { + "name": "parent_count", + "type": "u16" + } + ] + } + }, + { + "name": "AssetCreated", + "type": { + "kind": "struct", + "fields": [ + { + "name": "universe", + "type": "pubkey" + }, + { + "name": "asset", + "type": "pubkey" + }, + { + "name": "creator", + "type": "pubkey" + }, + { + "name": "index", + "type": "u64" + } + ] + } + }, + { + "name": "AssetKind", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Image" + }, + { + "name": "Model3d" + }, + { + "name": "Animation" + }, + { + "name": "Audio" + }, + { + "name": "Script" + }, + { + "name": "Metadata" + }, + { + "name": "Other" + } + ] + } + }, + { + "name": "AssetParent", + "type": { + "kind": "struct", + "fields": [ + { + "name": "child_asset", + "type": "pubkey" + }, + { + "name": "parent_asset", + "type": "pubkey" + }, + { + "name": "bump", + "type": "u8" + } + ] + } + }, + { + "name": "AssetParentAdded", + "type": { + "kind": "struct", + "fields": [ + { + "name": "child_asset", + "type": "pubkey" + }, + { + "name": "parent_asset", + "type": "pubkey" + } + ] + } + }, + { + "name": "AssetStatus", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Draft" + }, + { + "name": "Submitted" + }, + { + "name": "Approved" + }, + { + "name": "Rejected" + }, + { + "name": "Finalized" + }, + { + "name": "Minted" + }, + { + "name": "Archived" + } + ] + } + }, + { + "name": "AssetStatusChanged", + "type": { + "kind": "struct", + "fields": [ + { + "name": "asset", + "type": "pubkey" + }, + { + "name": "status", + "type": { + "defined": { + "name": "AssetStatus" + } + } + } + ] + } + }, + { + "name": "AssetSubtype", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Concept" + }, + { + "name": "Sketch" + }, + { + "name": "Texture" + }, + { + "name": "Mesh" + }, + { + "name": "Rig" + }, + { + "name": "Motion" + }, + { + "name": "Preview" + }, + { + "name": "Final" + }, + { + "name": "Other" + } + ] + } + }, + { + "name": "AvatarDataLinked", + "type": { + "kind": "struct", + "fields": [ + { + "name": "release", + "type": "pubkey" + }, + { + "name": "avatar_data", + "type": "pubkey" + } + ] + } + }, + { + "name": "CollaborationPolicy", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Equal" + }, + { + "name": "LineageEqual" + }, + { + "name": "Weighted" + }, + { + "name": "Custom" + } + ] + } + }, + { + "name": "ContributorShare", + "type": { + "kind": "struct", + "fields": [ + { + "name": "release", + "type": "pubkey" + }, + { + "name": "contributor", + "type": "pubkey" + }, + { + "name": "bps", + "type": "u16" + }, + { + "name": "claimed_lamports", + "type": "u64" + }, + { + "name": "bump", + "type": "u8" + } + ] + } + }, + { + "name": "LicenseKind", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Unknown" + }, + { + "name": "AllRightsReserved" + }, + { + "name": "Cc0" + }, + { + "name": "CcBy4" + }, + { + "name": "CcBySa4" + }, + { + "name": "CcByNc4" + }, + { + "name": "CcByNcSa4" + }, + { + "name": "Mit" + }, + { + "name": "Custom" + } + ] + } + }, + { + "name": "Registry", + "type": { + "kind": "struct", + "fields": [ + { + "name": "universe_count", + "type": "u64" + }, + { + "name": "bump", + "type": "u8" + } + ] + } + }, + { + "name": "Release", + "type": { + "kind": "struct", + "fields": [ + { + "name": "universe", + "type": "pubkey" + }, + { + "name": "asset", + "type": "pubkey" + }, + { + "name": "vault", + "type": "pubkey" + }, + { + "name": "index", + "type": "u64" + }, + { + "name": "authority", + "type": "pubkey" + }, + { + "name": "status", + "type": { + "defined": { + "name": "ReleaseStatus" + } + } + }, + { + "name": "distribution_model", + "type": { + "defined": { + "name": "CollaborationPolicy" + } + } + }, + { + "name": "metadata_hash", + "type": "string" + }, + { + "name": "total_share_bps", + "type": "u16" + }, + { + "name": "total_deposited_lamports", + "type": "u64" + }, + { + "name": "bump", + "type": "u8" + }, + { + "name": "created_at", + "type": "i64" + }, + { + "name": "finalized_at", + "type": "i64" + }, + { + "name": "linked_avatar_data", + "type": "pubkey" + } + ] + } + }, + { + "name": "ReleaseCreated", + "type": { + "kind": "struct", + "fields": [ + { + "name": "universe", + "type": "pubkey" + }, + { + "name": "release", + "type": "pubkey" + }, + { + "name": "asset", + "type": "pubkey" + }, + { + "name": "vault", + "type": "pubkey" + }, + { + "name": "index", + "type": "u64" + } + ] + } + }, + { + "name": "ReleaseDistributionModelSet", + "type": { + "kind": "struct", + "fields": [ + { + "name": "release", + "type": "pubkey" + }, + { + "name": "distribution_model", + "type": { + "defined": { + "name": "CollaborationPolicy" + } + } + }, + { + "name": "contributor_count", + "type": "u16" + } + ] + } + }, + { + "name": "ReleaseShareAdded", + "type": { + "kind": "struct", + "fields": [ + { + "name": "release", + "type": "pubkey" + }, + { + "name": "contributor", + "type": "pubkey" + }, + { + "name": "bps", + "type": "u16" + } + ] + } + }, + { + "name": "ReleaseStatus", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Draft" + }, + { + "name": "Finalized" + }, + { + "name": "Linked" + }, + { + "name": "Archived" + } + ] + } + }, + { + "name": "ReleaseStatusChanged", + "type": { + "kind": "struct", + "fields": [ + { + "name": "release", + "type": "pubkey" + }, + { + "name": "status", + "type": { + "defined": { + "name": "ReleaseStatus" + } + } + } + ] + } + }, + { + "name": "ReleaseVault", + "type": { + "kind": "struct", + "fields": [ + { + "name": "release", + "type": "pubkey" + }, + { + "name": "bump", + "type": "u8" + } + ] + } + }, + { + "name": "RevenueClaimed", + "type": { + "kind": "struct", + "fields": [ + { + "name": "release", + "type": "pubkey" + }, + { + "name": "contributor", + "type": "pubkey" + }, + { + "name": "amount", + "type": "u64" + }, + { + "name": "total_claimed", + "type": "u64" + } + ] + } + }, + { + "name": "RevenueDeposited", + "type": { + "kind": "struct", + "fields": [ + { + "name": "release", + "type": "pubkey" + }, + { + "name": "vault", + "type": "pubkey" + }, + { + "name": "payer", + "type": "pubkey" + }, + { + "name": "amount", + "type": "u64" + } + ] + } + }, + { + "name": "Universe", + "type": { + "kind": "struct", + "fields": [ + { + "name": "owner", + "type": "pubkey" + }, + { + "name": "index", + "type": "u64" + }, + { + "name": "global_index", + "type": "u64" + }, + { + "name": "bump", + "type": "u8" + }, + { + "name": "asset_count", + "type": "u64" + }, + { + "name": "release_count", + "type": "u64" + }, + { + "name": "open", + "type": "bool" + }, + { + "name": "status", + "type": { + "defined": { + "name": "UniverseStatus" + } + } + }, + { + "name": "project_type", + "type": { + "defined": { + "name": "AssetKind" + } + } + }, + { + "name": "metadata_hash", + "type": "string" + }, + { + "name": "created_at", + "type": "i64" + }, + { + "name": "updated_at", + "type": "i64" + } + ] + } + }, + { + "name": "UniverseCreated", + "type": { + "kind": "struct", + "fields": [ + { + "name": "universe", + "type": "pubkey" + }, + { + "name": "owner", + "type": "pubkey" + }, + { + "name": "index", + "type": "u64" + }, + { + "name": "global_index", + "type": "u64" + } + ] + } + }, + { + "name": "UniverseIndex", + "type": { + "kind": "struct", + "fields": [ + { + "name": "global_index", + "type": "u64" + }, + { + "name": "universe", + "type": "pubkey" + }, + { + "name": "owner", + "type": "pubkey" + }, + { + "name": "owner_index", + "type": "u64" + }, + { + "name": "bump", + "type": "u8" + } + ] + } + }, + { + "name": "UniverseStatus", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Active" + }, + { + "name": "Closed" + } + ] + } + }, + { + "name": "UniverseUpdated", + "type": { + "kind": "struct", + "fields": [ + { + "name": "universe", + "type": "pubkey" + }, + { + "name": "open", + "type": "bool" + } + ] + } + } + ] +} diff --git a/sdk/idl/solana_stellar.ts b/sdk/idl/solana_stellar.ts new file mode 100644 index 0000000..b5eb650 --- /dev/null +++ b/sdk/idl/solana_stellar.ts @@ -0,0 +1,1853 @@ +/** + * Program IDL in camelCase format in order to be used in JS/TS. + * + * Note that this is only a type helper and is not the actual IDL. The original + * IDL can be found at `target/idl/solana_stellar.json`. + */ +export type SolanaStellar = { + address: "3rVXfq7LLSLqbDzvZuSrQoMytwczLj2Q8Hue62rxPZAA"; + metadata: { + name: "solanaStellar"; + version: "0.1.0"; + spec: "0.1.0"; + description: "Created with Anchor"; + }; + instructions: [ + { + name: "addAssetParent"; + discriminator: [104, 56, 24, 76, 97, 101, 94, 143]; + accounts: [ + { + name: "childAsset"; + writable: true; + }, + { + name: "parentAsset"; + }, + { + name: "creator"; + writable: true; + signer: true; + relations: ["childAsset"]; + }, + { + name: "assetParent"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [108, 105, 110, 107]; + }, + { + kind: "account"; + path: "childAsset"; + }, + { + kind: "account"; + path: "parentAsset"; + } + ]; + }; + }, + { + name: "systemProgram"; + address: "11111111111111111111111111111111"; + } + ]; + args: []; + }, + { + name: "addReleaseShare"; + discriminator: [130, 134, 41, 170, 213, 143, 97, 137]; + accounts: [ + { + name: "universe"; + }, + { + name: "release"; + writable: true; + }, + { + name: "share"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [115, 104, 97, 114, 101]; + }, + { + kind: "account"; + path: "release"; + }, + { + kind: "account"; + path: "contributor"; + } + ]; + }; + }, + { + name: "contributor"; + }, + { + name: "owner"; + writable: true; + signer: true; + relations: ["universe"]; + }, + { + name: "systemProgram"; + address: "11111111111111111111111111111111"; + } + ]; + args: [ + { + name: "bps"; + type: "u16"; + } + ]; + }, + { + name: "approveAsset"; + discriminator: [127, 15, 21, 247, 23, 22, 189, 238]; + accounts: [ + { + name: "universe"; + }, + { + name: "asset"; + writable: true; + }, + { + name: "owner"; + signer: true; + relations: ["universe"]; + } + ]; + args: []; + }, + { + name: "claimRevenue"; + discriminator: [4, 22, 151, 70, 183, 79, 73, 189]; + accounts: [ + { + name: "release"; + }, + { + name: "vault"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [ + 114, + 101, + 108, + 101, + 97, + 115, + 101, + 95, + 118, + 97, + 117, + 108, + 116 + ]; + }, + { + kind: "account"; + path: "release"; + } + ]; + }; + }, + { + name: "share"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [115, 104, 97, 114, 101]; + }, + { + kind: "account"; + path: "release"; + }, + { + kind: "account"; + path: "contributor"; + } + ]; + }; + }, + { + name: "contributor"; + writable: true; + signer: true; + } + ]; + args: []; + }, + { + name: "claimRevenueFor"; + discriminator: [18, 77, 74, 138, 170, 122, 165, 180]; + accounts: [ + { + name: "release"; + }, + { + name: "vault"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [ + 114, + 101, + 108, + 101, + 97, + 115, + 101, + 95, + 118, + 97, + 117, + 108, + 116 + ]; + }, + { + kind: "account"; + path: "release"; + } + ]; + }; + }, + { + name: "share"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [115, 104, 97, 114, 101]; + }, + { + kind: "account"; + path: "release"; + }, + { + kind: "account"; + path: "beneficiary"; + } + ]; + }; + }, + { + name: "beneficiary"; + writable: true; + }, + { + name: "authority"; + signer: true; + } + ]; + args: []; + }, + { + name: "closeAsset"; + discriminator: [39, 124, 90, 146, 16, 82, 77, 253]; + accounts: [ + { + name: "universe"; + }, + { + name: "asset"; + writable: true; + }, + { + name: "authority"; + signer: true; + }, + { + name: "rentReceiver"; + writable: true; + } + ]; + args: []; + }, + { + name: "closeUniverse"; + discriminator: [44, 6, 172, 166, 141, 160, 154, 4]; + accounts: [ + { + name: "universe"; + writable: true; + }, + { + name: "owner"; + writable: true; + signer: true; + relations: ["universe"]; + } + ]; + args: []; + }, + { + name: "createAsset"; + discriminator: [28, 42, 120, 51, 7, 38, 156, 136]; + accounts: [ + { + name: "universe"; + writable: true; + }, + { + name: "asset"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [97, 115, 115, 101, 116]; + }, + { + kind: "account"; + path: "universe"; + }, + { + kind: "arg"; + path: "assetIndex"; + } + ]; + }; + }, + { + name: "creator"; + writable: true; + signer: true; + }, + { + name: "systemProgram"; + address: "11111111111111111111111111111111"; + } + ]; + args: [ + { + name: "assetIndex"; + type: "u64"; + }, + { + name: "kind"; + type: { + defined: { + name: "assetKind"; + }; + }; + }, + { + name: "subtype"; + type: { + defined: { + name: "assetSubtype"; + }; + }; + }, + { + name: "licenseKind"; + type: { + defined: { + name: "licenseKind"; + }; + }; + }, + { + name: "metadataHash"; + type: "string"; + }, + { + name: "previewHash"; + type: "string"; + }, + { + name: "open"; + type: "bool"; + }, + { + name: "collaborationPolicy"; + type: { + defined: { + name: "collaborationPolicy"; + }; + }; + } + ]; + }, + { + name: "createRelease"; + discriminator: [76, 2, 12, 43, 107, 154, 171, 200]; + accounts: [ + { + name: "universe"; + writable: true; + }, + { + name: "asset"; + }, + { + name: "release"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [114, 101, 108, 101, 97, 115, 101]; + }, + { + kind: "account"; + path: "universe"; + }, + { + kind: "arg"; + path: "releaseIndex"; + } + ]; + }; + }, + { + name: "vault"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [ + 114, + 101, + 108, + 101, + 97, + 115, + 101, + 95, + 118, + 97, + 117, + 108, + 116 + ]; + }, + { + kind: "account"; + path: "release"; + } + ]; + }; + }, + { + name: "owner"; + writable: true; + signer: true; + relations: ["universe"]; + }, + { + name: "systemProgram"; + address: "11111111111111111111111111111111"; + } + ]; + args: [ + { + name: "releaseIndex"; + type: "u64"; + }, + { + name: "metadataHash"; + type: "string"; + } + ]; + }, + { + name: "createUniverse"; + discriminator: [68, 252, 105, 236, 109, 225, 120, 113]; + accounts: [ + { + name: "registry"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [114, 101, 103, 105, 115, 116, 114, 121]; + } + ]; + }; + }, + { + name: "universe"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [117, 110, 105, 118, 101, 114, 115, 101]; + }, + { + kind: "account"; + path: "owner"; + }, + { + kind: "arg"; + path: "universeIndex"; + } + ]; + }; + }, + { + name: "universeLookup"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [ + 117, + 110, + 105, + 118, + 101, + 114, + 115, + 101, + 95, + 105, + 110, + 100, + 101, + 120 + ]; + }, + { + kind: "account"; + path: "registry.universe_count"; + account: "registry"; + } + ]; + }; + }, + { + name: "owner"; + writable: true; + signer: true; + }, + { + name: "systemProgram"; + address: "11111111111111111111111111111111"; + } + ]; + args: [ + { + name: "universeIndex"; + type: "u64"; + }, + { + name: "metadataHash"; + type: "string"; + }, + { + name: "projectType"; + type: { + defined: { + name: "assetKind"; + }; + }; + }, + { + name: "open"; + type: "bool"; + } + ]; + }, + { + name: "depositRevenue"; + discriminator: [224, 212, 82, 100, 60, 240, 220, 29]; + accounts: [ + { + name: "release"; + writable: true; + }, + { + name: "vault"; + writable: true; + pda: { + seeds: [ + { + kind: "const"; + value: [ + 114, + 101, + 108, + 101, + 97, + 115, + 101, + 95, + 118, + 97, + 117, + 108, + 116 + ]; + }, + { + kind: "account"; + path: "release"; + } + ]; + }; + }, + { + name: "payer"; + writable: true; + signer: true; + }, + { + name: "systemProgram"; + address: "11111111111111111111111111111111"; + } + ]; + args: [ + { + name: "amount"; + type: "u64"; + } + ]; + }, + { + name: "finalizeLineageEqualRelease"; + discriminator: [62, 145, 16, 168, 240, 50, 4, 42]; + accounts: [ + { + name: "universe"; + }, + { + name: "release"; + writable: true; + }, + { + name: "asset"; + writable: true; + }, + { + name: "owner"; + writable: true; + signer: true; + relations: ["universe"]; + }, + { + name: "systemProgram"; + address: "11111111111111111111111111111111"; + } + ]; + args: [ + { + name: "assetCount"; + type: "u16"; + }, + { + name: "linkCount"; + type: "u16"; + } + ]; + }, + { + name: "finalizeRelease"; + discriminator: [133, 95, 4, 17, 103, 213, 141, 58]; + accounts: [ + { + name: "universe"; + }, + { + name: "release"; + writable: true; + }, + { + name: "asset"; + writable: true; + }, + { + name: "owner"; + signer: true; + relations: ["universe"]; + } + ]; + args: []; + }, + { + name: "finalizeWeightedRelease"; + discriminator: [84, 228, 162, 41, 173, 60, 169, 68]; + accounts: [ + { + name: "universe"; + }, + { + name: "release"; + writable: true; + }, + { + name: "asset"; + writable: true; + }, + { + name: "owner"; + writable: true; + signer: true; + relations: ["universe"]; + }, + { + name: "systemProgram"; + address: "11111111111111111111111111111111"; + } + ]; + args: [ + { + name: "assetCount"; + type: "u16"; + }, + { + name: "linkCount"; + type: "u16"; + } + ]; + }, + { + name: "linkAvatarData"; + discriminator: [100, 18, 17, 99, 22, 120, 141, 252]; + accounts: [ + { + name: "universe"; + }, + { + name: "release"; + writable: true; + }, + { + name: "owner"; + signer: true; + relations: ["universe"]; + } + ]; + args: [ + { + name: "avatarData"; + type: "pubkey"; + } + ]; + }, + { + name: "rejectAsset"; + discriminator: [79, 96, 89, 56, 10, 45, 227, 217]; + accounts: [ + { + name: "universe"; + }, + { + name: "asset"; + writable: true; + }, + { + name: "owner"; + signer: true; + relations: ["universe"]; + } + ]; + args: []; + }, + { + name: "submitAsset"; + discriminator: [4, 23, 13, 111, 93, 172, 183, 91]; + accounts: [ + { + name: "asset"; + writable: true; + }, + { + name: "creator"; + signer: true; + relations: ["asset"]; + } + ]; + args: []; + }, + { + name: "updateAssetMetadata"; + discriminator: [217, 98, 205, 153, 242, 4, 41, 76]; + accounts: [ + { + name: "asset"; + writable: true; + }, + { + name: "creator"; + signer: true; + relations: ["asset"]; + } + ]; + args: [ + { + name: "licenseKind"; + type: { + defined: { + name: "licenseKind"; + }; + }; + }, + { + name: "metadataHash"; + type: "string"; + }, + { + name: "previewHash"; + type: "string"; + }, + { + name: "open"; + type: "bool"; + }, + { + name: "collaborationPolicy"; + type: { + defined: { + name: "collaborationPolicy"; + }; + }; + } + ]; + }, + { + name: "updateUniverse"; + discriminator: [157, 157, 54, 180, 142, 174, 246, 121]; + accounts: [ + { + name: "universe"; + writable: true; + }, + { + name: "owner"; + signer: true; + relations: ["universe"]; + } + ]; + args: [ + { + name: "metadataHash"; + type: "string"; + }, + { + name: "open"; + type: "bool"; + } + ]; + } + ]; + accounts: [ + { + name: "asset"; + discriminator: [234, 180, 241, 252, 139, 224, 160, 8]; + }, + { + name: "assetParent"; + discriminator: [247, 168, 159, 50, 150, 61, 235, 227]; + }, + { + name: "contributorShare"; + discriminator: [146, 88, 198, 243, 240, 238, 221, 182]; + }, + { + name: "registry"; + discriminator: [47, 174, 110, 246, 184, 182, 252, 218]; + }, + { + name: "release"; + discriminator: [229, 49, 96, 148, 167, 188, 17, 49]; + }, + { + name: "releaseVault"; + discriminator: [33, 38, 51, 77, 217, 179, 1, 5]; + }, + { + name: "universe"; + discriminator: [86, 112, 227, 226, 88, 47, 242, 113]; + }, + { + name: "universeIndex"; + discriminator: [160, 143, 49, 208, 138, 104, 75, 45]; + } + ]; + events: [ + { + name: "assetCreated"; + discriminator: [206, 193, 252, 254, 207, 185, 154, 4]; + }, + { + name: "assetParentAdded"; + discriminator: [194, 97, 145, 92, 28, 207, 67, 68]; + }, + { + name: "assetStatusChanged"; + discriminator: [50, 89, 231, 242, 218, 23, 131, 216]; + }, + { + name: "avatarDataLinked"; + discriminator: [189, 148, 22, 111, 17, 129, 142, 202]; + }, + { + name: "releaseCreated"; + discriminator: [86, 95, 64, 109, 171, 247, 137, 65]; + }, + { + name: "releaseDistributionModelSet"; + discriminator: [211, 71, 130, 3, 8, 110, 114, 3]; + }, + { + name: "releaseShareAdded"; + discriminator: [189, 43, 189, 229, 54, 190, 30, 239]; + }, + { + name: "releaseStatusChanged"; + discriminator: [116, 240, 44, 172, 164, 80, 0, 127]; + }, + { + name: "revenueClaimed"; + discriminator: [5, 254, 104, 87, 133, 137, 45, 116]; + }, + { + name: "revenueDeposited"; + discriminator: [97, 189, 62, 159, 189, 208, 43, 181]; + }, + { + name: "universeCreated"; + discriminator: [244, 82, 63, 148, 26, 10, 53, 67]; + }, + { + name: "universeUpdated"; + discriminator: [111, 110, 186, 3, 147, 5, 33, 73]; + } + ]; + errors: [ + { + code: 6000; + name: "unauthorized"; + msg: "Unauthorized action."; + }, + { + code: 6001; + name: "universeClosed"; + msg: "Universe is closed to public collaboration."; + }, + { + code: 6002; + name: "assetClosed"; + msg: "Asset is closed to public collaboration."; + }, + { + code: 6003; + name: "universeNotActive"; + msg: "Universe is not active."; + }, + { + code: 6004; + name: "universeNotEmpty"; + msg: "Universe still has live assets or releases."; + }, + { + code: 6005; + name: "invalidHash"; + msg: "Invalid metadata or content hash."; + }, + { + code: 6006; + name: "invalidAssetIndex"; + msg: "Invalid asset index."; + }, + { + code: 6007; + name: "invalidReleaseIndex"; + msg: "Invalid release index."; + }, + { + code: 6008; + name: "assetLocked"; + msg: "Asset is locked for this operation."; + }, + { + code: 6009; + name: "invalidAssetStatus"; + msg: "Invalid asset status for this operation."; + }, + { + code: 6010; + name: "universeMismatch"; + msg: "Universe mismatch."; + }, + { + code: 6011; + name: "assetMismatch"; + msg: "Asset mismatch."; + }, + { + code: 6012; + name: "releaseMismatch"; + msg: "Release mismatch."; + }, + { + code: 6013; + name: "invalidLineageLink"; + msg: "Invalid lineage link."; + }, + { + code: 6014; + name: "invalidLineageProof"; + msg: "Invalid lineage proof."; + }, + { + code: 6015; + name: "invalidContributorCount"; + msg: "Invalid contributor count."; + }, + { + code: 6016; + name: "releaseLocked"; + msg: "Release is locked for this operation."; + }, + { + code: 6017; + name: "releaseNotFinalized"; + msg: "Release is not finalized."; + }, + { + code: 6018; + name: "invalidShareBps"; + msg: "Invalid contributor share basis points."; + }, + { + code: 6019; + name: "invalidDistributionModel"; + msg: "Invalid release distribution model for this operation."; + }, + { + code: 6020; + name: "immutableCollaborationPolicy"; + msg: "Collaboration policy is immutable after asset creation."; + }, + { + code: 6021; + name: "invalidRevenueAmount"; + msg: "Invalid revenue amount."; + }, + { + code: 6022; + name: "insufficientVaultBalanceForClaim"; + msg: "Release vault balance is below required reserve for claims."; + }, + { + code: 6023; + name: "noRevenueToClaim"; + msg: "No revenue available to claim."; + }, + { + code: 6024; + name: "insufficientVaultBalance"; + msg: "Release vault balance is insufficient."; + }, + { + code: 6025; + name: "numericalOverflow"; + msg: "Numerical overflow occurred."; + } + ]; + types: [ + { + name: "asset"; + type: { + kind: "struct"; + fields: [ + { + name: "universe"; + type: "pubkey"; + }, + { + name: "index"; + type: "u64"; + }, + { + name: "creator"; + type: "pubkey"; + }, + { + name: "rentPayer"; + type: "pubkey"; + }, + { + name: "bump"; + type: "u8"; + }, + { + name: "kind"; + type: { + defined: { + name: "assetKind"; + }; + }; + }, + { + name: "subtype"; + type: { + defined: { + name: "assetSubtype"; + }; + }; + }, + { + name: "licenseKind"; + type: { + defined: { + name: "licenseKind"; + }; + }; + }, + { + name: "status"; + type: { + defined: { + name: "assetStatus"; + }; + }; + }, + { + name: "open"; + type: "bool"; + }, + { + name: "collaborationPolicy"; + docs: [ + "Revenue distribution policy used when this asset is finalized as a release." + ]; + type: { + defined: { + name: "collaborationPolicy"; + }; + }; + }, + { + name: "metadataHash"; + type: "string"; + }, + { + name: "previewHash"; + type: "string"; + }, + { + name: "createdAt"; + type: "i64"; + }, + { + name: "updatedAt"; + type: "i64"; + }, + { + name: "parentCount"; + type: "u16"; + } + ]; + }; + }, + { + name: "assetCreated"; + type: { + kind: "struct"; + fields: [ + { + name: "universe"; + type: "pubkey"; + }, + { + name: "asset"; + type: "pubkey"; + }, + { + name: "creator"; + type: "pubkey"; + }, + { + name: "index"; + type: "u64"; + } + ]; + }; + }, + { + name: "assetKind"; + type: { + kind: "enum"; + variants: [ + { + name: "image"; + }, + { + name: "model3d"; + }, + { + name: "animation"; + }, + { + name: "audio"; + }, + { + name: "script"; + }, + { + name: "metadata"; + }, + { + name: "other"; + } + ]; + }; + }, + { + name: "assetParent"; + type: { + kind: "struct"; + fields: [ + { + name: "childAsset"; + type: "pubkey"; + }, + { + name: "parentAsset"; + type: "pubkey"; + }, + { + name: "bump"; + type: "u8"; + } + ]; + }; + }, + { + name: "assetParentAdded"; + type: { + kind: "struct"; + fields: [ + { + name: "childAsset"; + type: "pubkey"; + }, + { + name: "parentAsset"; + type: "pubkey"; + } + ]; + }; + }, + { + name: "assetStatus"; + type: { + kind: "enum"; + variants: [ + { + name: "draft"; + }, + { + name: "submitted"; + }, + { + name: "approved"; + }, + { + name: "rejected"; + }, + { + name: "finalized"; + }, + { + name: "minted"; + }, + { + name: "archived"; + } + ]; + }; + }, + { + name: "assetStatusChanged"; + type: { + kind: "struct"; + fields: [ + { + name: "asset"; + type: "pubkey"; + }, + { + name: "status"; + type: { + defined: { + name: "assetStatus"; + }; + }; + } + ]; + }; + }, + { + name: "assetSubtype"; + type: { + kind: "enum"; + variants: [ + { + name: "concept"; + }, + { + name: "sketch"; + }, + { + name: "texture"; + }, + { + name: "mesh"; + }, + { + name: "rig"; + }, + { + name: "motion"; + }, + { + name: "preview"; + }, + { + name: "final"; + }, + { + name: "other"; + } + ]; + }; + }, + { + name: "avatarDataLinked"; + type: { + kind: "struct"; + fields: [ + { + name: "release"; + type: "pubkey"; + }, + { + name: "avatarData"; + type: "pubkey"; + } + ]; + }; + }, + { + name: "collaborationPolicy"; + type: { + kind: "enum"; + variants: [ + { + name: "equal"; + }, + { + name: "lineageEqual"; + }, + { + name: "weighted"; + }, + { + name: "custom"; + } + ]; + }; + }, + { + name: "contributorShare"; + type: { + kind: "struct"; + fields: [ + { + name: "release"; + type: "pubkey"; + }, + { + name: "contributor"; + type: "pubkey"; + }, + { + name: "bps"; + type: "u16"; + }, + { + name: "claimedLamports"; + type: "u64"; + }, + { + name: "bump"; + type: "u8"; + } + ]; + }; + }, + { + name: "licenseKind"; + type: { + kind: "enum"; + variants: [ + { + name: "unknown"; + }, + { + name: "allRightsReserved"; + }, + { + name: "cc0"; + }, + { + name: "ccBy4"; + }, + { + name: "ccBySa4"; + }, + { + name: "ccByNc4"; + }, + { + name: "ccByNcSa4"; + }, + { + name: "mit"; + }, + { + name: "custom"; + } + ]; + }; + }, + { + name: "registry"; + type: { + kind: "struct"; + fields: [ + { + name: "universeCount"; + type: "u64"; + }, + { + name: "bump"; + type: "u8"; + } + ]; + }; + }, + { + name: "release"; + type: { + kind: "struct"; + fields: [ + { + name: "universe"; + type: "pubkey"; + }, + { + name: "asset"; + type: "pubkey"; + }, + { + name: "vault"; + type: "pubkey"; + }, + { + name: "index"; + type: "u64"; + }, + { + name: "authority"; + type: "pubkey"; + }, + { + name: "status"; + type: { + defined: { + name: "releaseStatus"; + }; + }; + }, + { + name: "distributionModel"; + type: { + defined: { + name: "collaborationPolicy"; + }; + }; + }, + { + name: "metadataHash"; + type: "string"; + }, + { + name: "totalShareBps"; + type: "u16"; + }, + { + name: "totalDepositedLamports"; + type: "u64"; + }, + { + name: "bump"; + type: "u8"; + }, + { + name: "createdAt"; + type: "i64"; + }, + { + name: "finalizedAt"; + type: "i64"; + }, + { + name: "linkedAvatarData"; + type: "pubkey"; + } + ]; + }; + }, + { + name: "releaseCreated"; + type: { + kind: "struct"; + fields: [ + { + name: "universe"; + type: "pubkey"; + }, + { + name: "release"; + type: "pubkey"; + }, + { + name: "asset"; + type: "pubkey"; + }, + { + name: "vault"; + type: "pubkey"; + }, + { + name: "index"; + type: "u64"; + } + ]; + }; + }, + { + name: "releaseDistributionModelSet"; + type: { + kind: "struct"; + fields: [ + { + name: "release"; + type: "pubkey"; + }, + { + name: "distributionModel"; + type: { + defined: { + name: "collaborationPolicy"; + }; + }; + }, + { + name: "contributorCount"; + type: "u16"; + } + ]; + }; + }, + { + name: "releaseShareAdded"; + type: { + kind: "struct"; + fields: [ + { + name: "release"; + type: "pubkey"; + }, + { + name: "contributor"; + type: "pubkey"; + }, + { + name: "bps"; + type: "u16"; + } + ]; + }; + }, + { + name: "releaseStatus"; + type: { + kind: "enum"; + variants: [ + { + name: "draft"; + }, + { + name: "finalized"; + }, + { + name: "linked"; + }, + { + name: "archived"; + } + ]; + }; + }, + { + name: "releaseStatusChanged"; + type: { + kind: "struct"; + fields: [ + { + name: "release"; + type: "pubkey"; + }, + { + name: "status"; + type: { + defined: { + name: "releaseStatus"; + }; + }; + } + ]; + }; + }, + { + name: "releaseVault"; + type: { + kind: "struct"; + fields: [ + { + name: "release"; + type: "pubkey"; + }, + { + name: "bump"; + type: "u8"; + } + ]; + }; + }, + { + name: "revenueClaimed"; + type: { + kind: "struct"; + fields: [ + { + name: "release"; + type: "pubkey"; + }, + { + name: "contributor"; + type: "pubkey"; + }, + { + name: "amount"; + type: "u64"; + }, + { + name: "totalClaimed"; + type: "u64"; + } + ]; + }; + }, + { + name: "revenueDeposited"; + type: { + kind: "struct"; + fields: [ + { + name: "release"; + type: "pubkey"; + }, + { + name: "vault"; + type: "pubkey"; + }, + { + name: "payer"; + type: "pubkey"; + }, + { + name: "amount"; + type: "u64"; + } + ]; + }; + }, + { + name: "universe"; + type: { + kind: "struct"; + fields: [ + { + name: "owner"; + type: "pubkey"; + }, + { + name: "index"; + type: "u64"; + }, + { + name: "globalIndex"; + type: "u64"; + }, + { + name: "bump"; + type: "u8"; + }, + { + name: "assetCount"; + type: "u64"; + }, + { + name: "releaseCount"; + type: "u64"; + }, + { + name: "open"; + type: "bool"; + }, + { + name: "status"; + type: { + defined: { + name: "universeStatus"; + }; + }; + }, + { + name: "projectType"; + type: { + defined: { + name: "assetKind"; + }; + }; + }, + { + name: "metadataHash"; + type: "string"; + }, + { + name: "createdAt"; + type: "i64"; + }, + { + name: "updatedAt"; + type: "i64"; + } + ]; + }; + }, + { + name: "universeCreated"; + type: { + kind: "struct"; + fields: [ + { + name: "universe"; + type: "pubkey"; + }, + { + name: "owner"; + type: "pubkey"; + }, + { + name: "index"; + type: "u64"; + }, + { + name: "globalIndex"; + type: "u64"; + } + ]; + }; + }, + { + name: "universeIndex"; + type: { + kind: "struct"; + fields: [ + { + name: "globalIndex"; + type: "u64"; + }, + { + name: "universe"; + type: "pubkey"; + }, + { + name: "owner"; + type: "pubkey"; + }, + { + name: "ownerIndex"; + type: "u64"; + }, + { + name: "bump"; + type: "u8"; + } + ]; + }; + }, + { + name: "universeStatus"; + type: { + kind: "enum"; + variants: [ + { + name: "active"; + }, + { + name: "closed"; + } + ]; + }; + }, + { + name: "universeUpdated"; + type: { + kind: "struct"; + fields: [ + { + name: "universe"; + type: "pubkey"; + }, + { + name: "open"; + type: "bool"; + } + ]; + }; + } + ]; +}; diff --git a/sdk/package.json b/sdk/package.json new file mode 100644 index 0000000..c47453d --- /dev/null +++ b/sdk/package.json @@ -0,0 +1,52 @@ +{ + "name": "solana-stellar-sdk", + "version": "0.1.0", + "license": "ISC", + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "require": "./dist/src/index.js", + "default": "./dist/src/index.js" + }, + "./client": { + "types": "./dist/src/client.d.ts", + "require": "./dist/src/client.js", + "default": "./dist/src/client.js" + }, + "./filters": { + "types": "./dist/src/filters.d.ts", + "require": "./dist/src/filters.js", + "default": "./dist/src/filters.js" + }, + "./instructions": { + "types": "./dist/src/instructions.d.ts", + "require": "./dist/src/instructions.js", + "default": "./dist/src/instructions.js" + }, + "./pda": { + "types": "./dist/src/pda.d.ts", + "require": "./dist/src/pda.js", + "default": "./dist/src/pda.js" + }, + "./utils": { + "types": "./dist/src/utils.d.ts", + "require": "./dist/src/utils.js", + "default": "./dist/src/utils.js" + } + }, + "scripts": { + "build": "node -e \"process.argv.push('-b'); require('typescript/lib/tsc')\"" + }, + "files": [ + "dist" + ], + "dependencies": { + "@coral-xyz/anchor": "^0.32.1", + "@solana/web3.js": "^1.98.4" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/sdk/src/client.ts b/sdk/src/client.ts new file mode 100644 index 0000000..327ea78 --- /dev/null +++ b/sdk/src/client.ts @@ -0,0 +1,68 @@ +import * as anchor from "@coral-xyz/anchor"; +import { + Connection, + PublicKey, + SystemProgram, + Transaction, + VersionedTransaction, +} from "@solana/web3.js"; + +import idl from "../idl/solana_stellar.json"; +import type { SolanaStellar } from "../idl/solana_stellar"; + +export const IDL = idl as anchor.Idl & { address: string }; +export const PROGRAM_ID = new PublicKey(IDL.address); +export const SYSTEM_PROGRAM_ID = SystemProgram.programId; + +export type StellarProgram = anchor.Program; + +export type StellarClient = { + connection: Connection; + provider: anchor.AnchorProvider; + program: StellarProgram; +}; + +export type CreateClientOptions = anchor.web3.ConfirmOptions; + +export type AnchorWalletLike = { + publicKey: PublicKey; + signTransaction( + tx: T + ): Promise; + signAllTransactions( + txs: T[] + ): Promise; +}; + +export function createProgram(provider: anchor.Provider): StellarProgram { + return new anchor.Program( + IDL as anchor.Idl, + provider + ) as unknown as StellarProgram; +} + +export function createClient( + connection: Connection, + wallet: AnchorWalletLike, + options: CreateClientOptions = { + commitment: "confirmed", + preflightCommitment: "confirmed", + } +): StellarClient { + const provider = new anchor.AnchorProvider( + connection, + wallet as anchor.Wallet, + options + ); + return { + connection, + provider, + program: createProgram(provider), + }; +} + +export function systemProgram() { + return SYSTEM_PROGRAM_ID; +} + +export type { SolanaStellar }; diff --git a/sdk/src/filters.ts b/sdk/src/filters.ts new file mode 100644 index 0000000..56ea148 --- /dev/null +++ b/sdk/src/filters.ts @@ -0,0 +1,41 @@ +import type { PublicKey } from "@solana/web3.js"; + +export type ProgramAccountFilter = { + memcmp: { + offset: number; + bytes: string; + }; +}; + +export const ACCOUNT_DISCRIMINATOR_SIZE = 8; + +export const ASSET_OFFSETS = { + universe: ACCOUNT_DISCRIMINATOR_SIZE, + creator: ACCOUNT_DISCRIMINATOR_SIZE + 32 + 8, +} as const; + +export const ASSET_PARENT_OFFSETS = { + childAsset: ACCOUNT_DISCRIMINATOR_SIZE, + parentAsset: ACCOUNT_DISCRIMINATOR_SIZE + 32, +} as const; + +export const CONTRIBUTOR_SHARE_OFFSETS = { + release: ACCOUNT_DISCRIMINATOR_SIZE, +} as const; + +export const RELEASE_OFFSETS = { + universe: ACCOUNT_DISCRIMINATOR_SIZE, + asset: ACCOUNT_DISCRIMINATOR_SIZE + 32, +} as const; + +export function publicKeyMemcmp( + offset: number, + publicKey: PublicKey +): ProgramAccountFilter { + return { + memcmp: { + offset, + bytes: publicKey.toBase58(), + }, + }; +} diff --git a/sdk/src/index.ts b/sdk/src/index.ts new file mode 100644 index 0000000..12be9e1 --- /dev/null +++ b/sdk/src/index.ts @@ -0,0 +1,6 @@ +export * from "./client"; +export * from "./filters"; +export * from "./instructions"; +export * from "./licenses"; +export * from "./pda"; +export * from "./utils"; diff --git a/sdk/src/instructions.ts b/sdk/src/instructions.ts new file mode 100644 index 0000000..3843e1f --- /dev/null +++ b/sdk/src/instructions.ts @@ -0,0 +1,501 @@ +import * as anchor from "@coral-xyz/anchor"; +import type { AccountMeta, PublicKey } from "@solana/web3.js"; + +import type { StellarClient } from "./client"; +import { systemProgram } from "./client"; +import { + deriveAsset, + deriveAssetParent, + deriveRegistry, + deriveRelease, + deriveShare, + deriveVault, + deriveUniverse, + deriveUniverseIndex, +} from "./pda"; +import { toNumber } from "./utils"; + +type EnumArg = Record>; +const defaultCollaborationPolicy = (): EnumArg => ({ custom: {} }); + +export async function nextUniverseIndex( + client: StellarClient, + owner: PublicKey +) { + const universes = await client.program.account.universe.all(); + const ownerUniverses = universes + .map(({ account }) => account as any) + .filter((universe) => universe.owner?.equals?.(owner)); + + if (!ownerUniverses.length) return 0; + return ( + Math.max(...ownerUniverses.map((universe) => toNumber(universe.index))) + 1 + ); +} + +export async function createUniverse( + client: StellarClient, + args: { + owner: PublicKey; + universeIndex: number; + metadataHash: string; + projectType: EnumArg; + open?: boolean; + } +) { + const registry = deriveRegistry(); + const registryAccount = await client.connection.getAccountInfo(registry); + const registryData = registryAccount + ? ((await client.program.account.registry.fetch(registry)) as any) + : null; + const globalIndex = registryData ? toNumber(registryData.universeCount) : 0; + const universe = deriveUniverse(args.owner, args.universeIndex); + const universeLookup = deriveUniverseIndex(globalIndex); + const signature = await client.program.methods + .createUniverse( + new anchor.BN(args.universeIndex), + args.metadataHash, + args.projectType as any, + args.open ?? true + ) + .accountsStrict({ + registry, + universe, + universeLookup, + owner: args.owner, + systemProgram: systemProgram(), + }) + .rpc(); + + return { universe, universeLookup, globalIndex, signature }; +} + +export async function updateUniverse( + client: StellarClient, + args: { + universe: PublicKey; + owner: PublicKey; + metadataHash: string; + open?: boolean; + } +) { + const current = + args.open === undefined + ? ((await client.program.account.universe.fetch(args.universe)) as any) + : null; + const signature = await client.program.methods + .updateUniverse( + args.metadataHash, + args.open ?? Boolean(current?.open ?? true) + ) + .accountsStrict({ + universe: args.universe, + owner: args.owner, + }) + .rpc(); + + return { signature }; +} + +export async function closeUniverse( + client: StellarClient, + args: { universe: PublicKey; owner: PublicKey } +) { + const signature = await client.program.methods + .closeUniverse() + .accountsStrict({ + universe: args.universe, + owner: args.owner, + }) + .rpc(); + + return { signature }; +} + +export async function createAsset( + client: StellarClient, + args: { + universe: PublicKey; + creator: PublicKey; + assetIndex: number; + kind: EnumArg; + subtype: EnumArg; + licenseKind: EnumArg; + metadataHash: string; + previewHash: string; + open?: boolean; + collaborationPolicy?: EnumArg; + } +) { + const asset = deriveAsset(args.universe, args.assetIndex); + const signature = await client.program.methods + .createAsset( + new anchor.BN(args.assetIndex), + args.kind as any, + args.subtype as any, + args.licenseKind as any, + args.metadataHash, + args.previewHash, + args.open ?? true, + (args.collaborationPolicy ?? defaultCollaborationPolicy()) as any + ) + .accountsStrict({ + universe: args.universe, + asset, + creator: args.creator, + systemProgram: systemProgram(), + }) + .rpc(); + + return { asset, signature }; +} + +export async function updateAssetMetadata( + client: StellarClient, + args: { + asset: PublicKey; + creator: PublicKey; + licenseKind: EnumArg; + metadataHash: string; + previewHash: string; + open?: boolean; + collaborationPolicy?: EnumArg; + } +) { + const current = + args.open === undefined || args.collaborationPolicy === undefined + ? ((await client.program.account.asset.fetch(args.asset)) as any) + : null; + const signature = await client.program.methods + .updateAssetMetadata( + args.licenseKind as any, + args.metadataHash, + args.previewHash, + args.open ?? Boolean(current?.open ?? true), + (args.collaborationPolicy ?? + current?.collaborationPolicy ?? + defaultCollaborationPolicy()) as any + ) + .accountsStrict({ asset: args.asset, creator: args.creator }) + .rpc(); + + return { signature }; +} + +export async function addAssetParent( + client: StellarClient, + args: { + childAsset: PublicKey; + parentAsset: PublicKey; + creator: PublicKey; + } +) { + const assetParent = deriveAssetParent(args.childAsset, args.parentAsset); + const signature = await client.program.methods + .addAssetParent() + .accountsStrict({ + childAsset: args.childAsset, + parentAsset: args.parentAsset, + creator: args.creator, + assetParent, + systemProgram: systemProgram(), + }) + .rpc(); + + return { assetParent, signature }; +} + +export async function submitAsset( + client: StellarClient, + args: { asset: PublicKey; creator: PublicKey } +) { + const signature = await client.program.methods + .submitAsset() + .accountsStrict({ asset: args.asset, creator: args.creator }) + .rpc(); + + return { signature }; +} + +export async function approveAsset( + client: StellarClient, + args: { universe: PublicKey; asset: PublicKey; owner: PublicKey } +) { + const signature = await client.program.methods + .approveAsset() + .accountsStrict({ + universe: args.universe, + asset: args.asset, + owner: args.owner, + }) + .rpc(); + + return { signature }; +} + +export async function rejectAsset( + client: StellarClient, + args: { universe: PublicKey; asset: PublicKey; owner: PublicKey } +) { + const signature = await client.program.methods + .rejectAsset() + .accountsStrict({ + universe: args.universe, + asset: args.asset, + owner: args.owner, + }) + .rpc(); + + return { signature }; +} + +export async function closeAsset( + client: StellarClient, + args: { + universe: PublicKey; + asset: PublicKey; + authority: PublicKey; + rentReceiver: PublicKey; + } +) { + const signature = await client.program.methods + .closeAsset() + .accountsStrict({ + universe: args.universe, + asset: args.asset, + authority: args.authority, + rentReceiver: args.rentReceiver, + }) + .rpc(); + + return { signature }; +} + +export async function createRelease( + client: StellarClient, + args: { + universe: PublicKey; + asset: PublicKey; + owner: PublicKey; + releaseIndex: number; + metadataHash: string; + } +) { + const release = deriveRelease(args.universe, args.releaseIndex); + const vault = deriveVault(release); + const signature = await client.program.methods + .createRelease(new anchor.BN(args.releaseIndex), args.metadataHash) + .accountsStrict({ + universe: args.universe, + asset: args.asset, + release, + vault, + owner: args.owner, + systemProgram: systemProgram(), + }) + .rpc(); + + return { release, vault, signature }; +} + +export async function addReleaseShare( + client: StellarClient, + args: { + universe: PublicKey; + release: PublicKey; + contributor: PublicKey; + owner: PublicKey; + bps: number; + } +) { + const share = deriveShare(args.release, args.contributor); + const signature = await client.program.methods + .addReleaseShare(Number(args.bps)) + .accountsStrict({ + universe: args.universe, + release: args.release, + share, + contributor: args.contributor, + owner: args.owner, + systemProgram: systemProgram(), + }) + .rpc(); + + return { share, signature }; +} + +export async function finalizeRelease( + client: StellarClient, + args: { + universe: PublicKey; + release: PublicKey; + asset: PublicKey; + owner: PublicKey; + } +) { + const signature = await client.program.methods + .finalizeRelease() + .accountsStrict({ + universe: args.universe, + release: args.release, + asset: args.asset, + owner: args.owner, + }) + .rpc(); + + return { signature }; +} + +export async function finalizeLineageEqualRelease( + client: StellarClient, + args: { + universe: PublicKey; + release: PublicKey; + asset: PublicKey; + owner: PublicKey; + assetCount: number; + linkCount: number; + remainingAccounts: AccountMeta[]; + } +) { + const signature = await client.program.methods + .finalizeLineageEqualRelease(args.assetCount, args.linkCount) + .accountsStrict({ + universe: args.universe, + release: args.release, + asset: args.asset, + owner: args.owner, + systemProgram: systemProgram(), + }) + .remainingAccounts(args.remainingAccounts) + .rpc(); + + return { signature }; +} + +export async function finalizeWeightedRelease( + client: StellarClient, + args: { + universe: PublicKey; + release: PublicKey; + asset: PublicKey; + owner: PublicKey; + assetCount: number; + linkCount: number; + remainingAccounts: AccountMeta[]; + } +) { + const finalizeWeightedRelease = + (client.program.methods as Record).finalizeWeightedRelease || + (client.program.methods as Record).finalize_weighted_release; + + if (typeof finalizeWeightedRelease !== "function") { + throw new Error( + "Current Solana Stellar SDK ABI does not expose finalizeWeightedRelease. " + + "Sync the local SDK IDL (`make sync-sdk-idl`) and rebuild dependencies before retrying." + ); + } + + const signature = await finalizeWeightedRelease + .call(client.program.methods, args.assetCount, args.linkCount) + .accountsStrict({ + universe: args.universe, + release: args.release, + asset: args.asset, + owner: args.owner, + systemProgram: systemProgram(), + }) + .remainingAccounts(args.remainingAccounts) + .rpc(); + + return { signature }; +} + +export async function linkAvatarData( + client: StellarClient, + args: { + universe: PublicKey; + release: PublicKey; + owner: PublicKey; + avatarData: PublicKey; + } +) { + const signature = await client.program.methods + .linkAvatarData(args.avatarData) + .accountsStrict({ + universe: args.universe, + release: args.release, + owner: args.owner, + }) + .rpc(); + + return { signature }; +} + +export async function depositRevenue( + client: StellarClient, + args: { + release: PublicKey; + payer: PublicKey; + amountLamports: anchor.BN; + } +) { + const vault = deriveVault(args.release); + const signature = await client.program.methods + .depositRevenue(args.amountLamports) + .accountsStrict({ + release: args.release, + vault, + payer: args.payer, + systemProgram: systemProgram(), + }) + .rpc(); + + return { vault, signature }; +} + +export async function claimRevenue( + client: StellarClient, + args: { + release: PublicKey; + contributor: PublicKey; + } +) { + const vault = deriveVault(args.release); + const share = deriveShare(args.release, args.contributor); + const signature = await client.program.methods + .claimRevenue() + .accountsStrict({ + release: args.release, + vault, + share, + contributor: args.contributor, + }) + .rpc(); + + return { vault, share, signature }; +} + +export async function claimRevenueFor( + client: StellarClient, + args: { + release: PublicKey; + authority: PublicKey; + beneficiary: PublicKey; + } +) { + const vault = deriveVault(args.release); + const share = deriveShare(args.release, args.beneficiary); + const signature = await client.program.methods + .claimRevenueFor() + .accountsStrict({ + release: args.release, + vault, + share, + beneficiary: args.beneficiary, + authority: args.authority, + }) + .rpc(); + + return { vault, share, signature }; +} diff --git a/sdk/src/licenses.ts b/sdk/src/licenses.ts new file mode 100644 index 0000000..78a3d10 --- /dev/null +++ b/sdk/src/licenses.ts @@ -0,0 +1,54 @@ +import { enumKey, enumValue } from "./utils"; + +export const LICENSE_KIND_LABELS = { + unknown: "Unknown", + allRightsReserved: "All rights reserved", + cc0: "CC0", + ccBy4: "CC BY 4.0", + ccBySa4: "CC BY-SA 4.0", + ccByNc4: "CC BY-NC 4.0", + ccByNcSa4: "CC BY-NC-SA 4.0", + mit: "MIT", + custom: "Custom", +} as const; + +export type LicenseKindKey = keyof typeof LICENSE_KIND_LABELS; + +export function licenseKind(value?: string | null) { + const normalized = normalizeLicenseKind(value); + return enumValue(normalized); +} + +export function normalizeLicenseKind(value?: string | null): LicenseKindKey { + const key = String(value || "") + .trim() + .replace(/[\s._-]+/g, "") + .toLowerCase(); + + if (key === "allrightsreserved" || key === "copyright") { + return "allRightsReserved"; + } + if (key === "cc0" || key === "creativecommonszero") return "cc0"; + if (key === "ccby4" || key === "ccby40" || key === "ccby") return "ccBy4"; + if (key === "ccbysa4" || key === "ccbysa40" || key === "ccbysa") { + return "ccBySa4"; + } + if (key === "ccbync4" || key === "ccbync40" || key === "ccbync") { + return "ccByNc4"; + } + if (key === "ccbyncsa4" || key === "ccbyncsa40" || key === "ccbyncsa") { + return "ccByNcSa4"; + } + if (key === "mit") return "mit"; + if (key === "custom") return "custom"; + + return "unknown"; +} + +export function licenseKindKey(value: unknown): LicenseKindKey { + return normalizeLicenseKind(enumKey(value)); +} + +export function licenseKindLabel(value: unknown): string { + return LICENSE_KIND_LABELS[licenseKindKey(value)]; +} diff --git a/sdk/src/pda.ts b/sdk/src/pda.ts new file mode 100644 index 0000000..58db568 --- /dev/null +++ b/sdk/src/pda.ts @@ -0,0 +1,75 @@ +import { PublicKey } from "@solana/web3.js"; + +import { PROGRAM_ID } from "./client"; +import { asciiSeed, toLeBytes } from "./utils"; + +export const STELLAR_SEEDS = { + registry: "registry", + universe: "universe", + universeIndex: "universe_index", + asset: "asset", + link: "link", + release: "release", + releaseVault: "release_vault", + share: "share", +} as const; + +export function deriveRegistry() { + return PublicKey.findProgramAddressSync( + [asciiSeed(STELLAR_SEEDS.registry)], + PROGRAM_ID + )[0]; +} + +export function deriveUniverse(owner: PublicKey, index: number) { + return PublicKey.findProgramAddressSync( + [asciiSeed(STELLAR_SEEDS.universe), owner.toBuffer(), toLeBytes(index)], + PROGRAM_ID + )[0]; +} + +export function deriveUniverseIndex(globalIndex: number) { + return PublicKey.findProgramAddressSync( + [asciiSeed(STELLAR_SEEDS.universeIndex), toLeBytes(globalIndex)], + PROGRAM_ID + )[0]; +} + +export function deriveAsset(universe: PublicKey, index: number) { + return PublicKey.findProgramAddressSync( + [asciiSeed(STELLAR_SEEDS.asset), universe.toBuffer(), toLeBytes(index)], + PROGRAM_ID + )[0]; +} + +export function deriveAssetParent(child: PublicKey, parent: PublicKey) { + return PublicKey.findProgramAddressSync( + [asciiSeed(STELLAR_SEEDS.link), child.toBuffer(), parent.toBuffer()], + PROGRAM_ID + )[0]; +} + +export function deriveRelease(universe: PublicKey, index: number) { + return PublicKey.findProgramAddressSync( + [asciiSeed(STELLAR_SEEDS.release), universe.toBuffer(), toLeBytes(index)], + PROGRAM_ID + )[0]; +} + +export function deriveVault(release: PublicKey) { + return PublicKey.findProgramAddressSync( + [asciiSeed(STELLAR_SEEDS.releaseVault), release.toBuffer()], + PROGRAM_ID + )[0]; +} + +export function deriveShare(release: PublicKey, contributor: PublicKey) { + return PublicKey.findProgramAddressSync( + [ + asciiSeed(STELLAR_SEEDS.share), + release.toBuffer(), + contributor.toBuffer(), + ], + PROGRAM_ID + )[0]; +} diff --git a/sdk/src/utils.ts b/sdk/src/utils.ts new file mode 100644 index 0000000..edf22a3 --- /dev/null +++ b/sdk/src/utils.ts @@ -0,0 +1,85 @@ +import * as anchor from "@coral-xyz/anchor"; +import { PublicKey } from "@solana/web3.js"; + +export type ExplorerCluster = "localnet" | "devnet" | "mainnet"; + +export function asciiSeed(value: string): Uint8Array { + return Uint8Array.from(Array.from(value).map((char) => char.charCodeAt(0))); +} + +export function toLeBytes(value: number | anchor.BN): Uint8Array { + const bn = anchor.BN.isBN(value) ? value : new anchor.BN(value); + return Uint8Array.from(bn.toArray("le", 8)); +} + +export function enumValue(value: T) { + return { [value]: {} } as Record>; +} + +export function safePublicKey(value: string | null | undefined) { + if (!value?.trim()) return null; + try { + return new PublicKey(value.trim()); + } catch { + return null; + } +} + +export function toNumber(value: unknown): number { + if ( + value && + typeof value === "object" && + "toNumber" in value && + typeof value.toNumber === "function" + ) { + return value.toNumber(); + } + + return Number(value ?? 0); +} + +export function publicKeyString(value: unknown): string { + if ( + value && + typeof value === "object" && + "toBase58" in value && + typeof value.toBase58 === "function" + ) { + return value.toBase58(); + } + + return String(value ?? ""); +} + +export function enumKey(value: unknown): string { + if (!value || typeof value !== "object") return "unknown"; + return Object.keys(value)[0] || "unknown"; +} + +export function lamportsFromSol(value: string): anchor.BN { + const parsed = Number(value || "0"); + if (!Number.isFinite(parsed) || parsed <= 0) return new anchor.BN(0); + return new anchor.BN(Math.round(parsed * anchor.web3.LAMPORTS_PER_SOL)); +} + +export function explorerUrl( + signature: string, + endpoint: string, + cluster?: ExplorerCluster +) { + if ( + cluster === "localnet" || + endpoint.includes("127.0.0.1") || + endpoint.includes("localhost") + ) { + return `https://explorer.solana.com/tx/${signature}?cluster=custom&customUrl=${encodeURIComponent( + endpoint + )}`; + } + + if (cluster === "devnet" || endpoint.includes("devnet")) { + return `https://explorer.solana.com/tx/${signature}?cluster=devnet`; + } + + return `https://explorer.solana.com/tx/${signature}`; +} diff --git a/sdk/tsconfig.json b/sdk/tsconfig.json new file mode 100644 index 0000000..89218ed --- /dev/null +++ b/sdk/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "declaration": true, + "declarationDir": "dist", + "outDir": "dist", + "module": "CommonJS", + "target": "ES2020", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "moduleResolution": "node" + }, + "include": ["src", "idl"] +} diff --git a/tests/solana-stellar.ts b/tests/solana-stellar.ts index 0b119c1..65a5492 100644 --- a/tests/solana-stellar.ts +++ b/tests/solana-stellar.ts @@ -15,12 +15,24 @@ describe("solana-stellar", () => { const toLeBytes = (value: number) => new anchor.BN(value).toArrayLike(Buffer, "le", 8); + const registryPda = () => + anchor.web3.PublicKey.findProgramAddressSync( + [Buffer.from("registry")], + program.programId + )[0]; + const universePda = (index: number) => anchor.web3.PublicKey.findProgramAddressSync( [Buffer.from("universe"), owner.publicKey.toBuffer(), toLeBytes(index)], program.programId )[0]; + const universeIndexPda = (globalIndex: number) => + anchor.web3.PublicKey.findProgramAddressSync( + [Buffer.from("universe_index"), toLeBytes(globalIndex)], + program.programId + )[0]; + const assetPda = (universe: anchor.web3.PublicKey, index: number) => anchor.web3.PublicKey.findProgramAddressSync( [Buffer.from("asset"), universe.toBuffer(), toLeBytes(index)], @@ -48,6 +60,8 @@ describe("solana-stellar", () => { program.programId )[0]; + const releaseVaultRentExemptBytes = 8 + 32 + 1; + const sharePda = ( release: anchor.web3.PublicKey, contributorPk: anchor.web3.PublicKey @@ -81,6 +95,8 @@ describe("solana-stellar", () => { it("creates a universe, asset graph, release vault, and revenue split", async () => { const universe = universePda(0); + const registry = registryPda(); + const universeLookup = universeIndexPda(0); const parentAsset = assetPda(universe, 0); const childAsset = assetPda(universe, 1); const parentLink = linkPda(childAsset, parentAsset); @@ -94,11 +110,12 @@ describe("solana-stellar", () => { new anchor.BN(0), "QmUniverseMetadataHash", { model3D: {} } as any, - { custom: {} } as any, true ) .accountsStrict({ + registry, universe, + universeLookup, owner: owner.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }) @@ -109,8 +126,11 @@ describe("solana-stellar", () => { new anchor.BN(0), { image: {} } as any, { concept: {} } as any, + { ccBy4: {} } as any, "QmConceptMetadataHash", - "QmConceptPreviewHash" + "QmConceptPreviewHash", + true, + { custom: {} } as any ) .accountsStrict({ universe, @@ -139,8 +159,11 @@ describe("solana-stellar", () => { new anchor.BN(1), { model3D: {} } as any, { final: {} } as any, + { unknown: {} } as any, "QmModelMetadataHash", - "QmModelPreviewHash" + "QmModelPreviewHash", + true, + { custom: {} } as any ) .accountsStrict({ universe, @@ -246,6 +269,10 @@ describe("solana-stellar", () => { .rpc(); const fetchedUniverse = await program.account.universe.fetch(universe); + const fetchedRegistry = await program.account.registry.fetch(registry); + const fetchedUniverseIndex = await program.account.universeIndex.fetch( + universeLookup + ); const fetchedChild = await program.account.asset.fetch(childAsset); const fetchedLink = await program.account.assetParent.fetch(parentLink); const fetchedRelease = await program.account.release.fetch(release); @@ -254,8 +281,15 @@ describe("solana-stellar", () => { ); expect(fetchedUniverse.assetCount.toNumber()).to.equal(2); + expect(fetchedUniverse.globalIndex.toNumber()).to.equal(0); + expect(fetchedRegistry.universeCount.toNumber()).to.equal(1); + expect(fetchedUniverseIndex.universe.toBase58()).to.equal( + universe.toBase58() + ); + expect(fetchedUniverseIndex.ownerIndex.toNumber()).to.equal(0); expect(fetchedUniverse.releaseCount.toNumber()).to.equal(1); expect(fetchedChild.parentCount).to.equal(1); + expect(fetchedChild.licenseKind).to.deep.equal({ ccBy4: {} }); expect(fetchedChild.status).to.deep.equal({ finalized: {} }); expect(fetchedLink.parentAsset.toBase58()).to.equal(parentAsset.toBase58()); expect(fetchedRelease.status).to.deep.equal({ finalized: {} }); @@ -266,8 +300,10 @@ describe("solana-stellar", () => { expect(fetchedShare.claimedLamports.toNumber()).to.equal(600_000); }); - it("finalizes equal lineage shares across merged branches", async () => { + it("finalizes legacy equal policy as automatic equal lineage shares", async () => { const universe = universePda(1); + const registry = registryPda(); + const universeLookup = universeIndexPda(1); const baseAsset = assetPda(universe, 0); const uvAsset = assetPda(universe, 1); const animationAsset = assetPda(universe, 2); @@ -286,11 +322,12 @@ describe("solana-stellar", () => { new anchor.BN(1), "QmUniverseMetadataHash2", { model3D: {} } as any, - { lineageEqual: {} } as any, true ) .accountsStrict({ + registry, universe, + universeLookup, owner: owner.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }) @@ -301,8 +338,11 @@ describe("solana-stellar", () => { new anchor.BN(0), { image: {} } as any, { concept: {} } as any, + { ccBy4: {} } as any, "QmBaseMetadataHash", - "QmBasePreviewHash" + "QmBasePreviewHash", + true, + { equal: {} } as any ) .accountsStrict({ universe, @@ -325,8 +365,11 @@ describe("solana-stellar", () => { new anchor.BN(1), { model3D: {} } as any, { texture: {} } as any, + { ccBy4: {} } as any, "QmUvMetadataHash", - "QmUvPreviewHash" + "QmUvPreviewHash", + true, + { equal: {} } as any ) .accountsStrict({ universe, @@ -362,8 +405,11 @@ describe("solana-stellar", () => { new anchor.BN(2), { animation: {} } as any, { motion: {} } as any, + { ccBy4: {} } as any, "QmAnimMetadataHash", - "QmAnimPreviewHash" + "QmAnimPreviewHash", + true, + { equal: {} } as any ) .accountsStrict({ universe, @@ -406,8 +452,11 @@ describe("solana-stellar", () => { new anchor.BN(3), { model3D: {} } as any, { final: {} } as any, + { ccBy4: {} } as any, "QmFinalMetadataHash", - "QmFinalPreviewHash" + "QmFinalPreviewHash", + true, + { equal: {} } as any ) .accountsStrict({ universe, @@ -543,12 +592,21 @@ describe("solana-stellar", () => { .rpc(); const fetchedRelease = await program.account.release.fetch(release); + const fetchedUniverse = await program.account.universe.fetch(universe); + const fetchedUniverseIndex = await program.account.universeIndex.fetch( + universeLookup + ); const shares = await Promise.all( shareAccounts.map((share) => program.account.contributorShare.fetch(share) ) ); + expect(fetchedUniverse.globalIndex.toNumber()).to.equal(1); + expect(fetchedUniverseIndex.universe.toBase58()).to.equal( + universe.toBase58() + ); + expect(fetchedUniverseIndex.ownerIndex.toNumber()).to.equal(1); expect(fetchedRelease.status).to.deep.equal({ finalized: {} }); expect(fetchedRelease.distributionModel).to.deep.equal({ lineageEqual: {}, @@ -558,4 +616,588 @@ describe("solana-stellar", () => { 3333, 3333, 3334, ]); }); + + it("finalizes weighted lineage shares by contribution count", async () => { + const universe = universePda(2); + const registry = registryPda(); + const universeLookup = universeIndexPda(2); + const baseAsset = assetPda(universe, 0); + const textureAsset = assetPda(universe, 1); + const rigAsset = assetPda(universe, 2); + const finalAsset = assetPda(universe, 3); + const textureBaseLink = linkPda(textureAsset, baseAsset); + const rigBaseLink = linkPda(rigAsset, baseAsset); + const finalTextureLink = linkPda(finalAsset, textureAsset); + const finalRigLink = linkPda(finalAsset, rigAsset); + const release = releasePda(universe, 0); + const vault = vaultPda(release); + const rogueContributor = anchor.web3.Keypair.generate(); + const rogueShare = sharePda(release, rogueContributor.publicKey); + + const send = (tx: any, signer?: anchor.web3.Keypair) => + signer ? tx.signers([signer]).rpc() : tx.rpc(); + + const createAssetOnly = async ( + assetIndex: number, + asset: anchor.web3.PublicKey, + creatorPk: anchor.web3.PublicKey, + signer: anchor.web3.Keypair | undefined, + kind: any, + subtype: any, + metadataHash: string, + previewHash: string + ) => { + await send( + program.methods + .createAsset( + new anchor.BN(assetIndex), + kind, + subtype, + { ccBy4: {} } as any, + metadataHash, + previewHash, + true, + { weighted: {} } as any + ) + .accountsStrict({ + universe, + asset, + creator: creatorPk, + systemProgram: anchor.web3.SystemProgram.programId, + }), + signer + ); + }; + + const addParent = async ( + childAsset: anchor.web3.PublicKey, + parentAsset: anchor.web3.PublicKey, + assetParent: anchor.web3.PublicKey, + creatorPk: anchor.web3.PublicKey, + signer?: anchor.web3.Keypair + ) => { + await send( + program.methods.addAssetParent().accountsStrict({ + childAsset, + parentAsset, + creator: creatorPk, + assetParent, + systemProgram: anchor.web3.SystemProgram.programId, + }), + signer + ); + }; + + const submitAndApprove = async ( + asset: anchor.web3.PublicKey, + creatorPk: anchor.web3.PublicKey, + signer?: anchor.web3.Keypair + ) => { + await send( + program.methods.submitAsset().accountsStrict({ + asset, + creator: creatorPk, + }), + signer + ); + await program.methods + .approveAsset() + .accountsStrict({ universe, asset, owner: owner.publicKey }) + .rpc(); + }; + + await program.methods + .createUniverse( + new anchor.BN(2), + "QmWeightedUniverseMetadata", + { model3D: {} } as any, + true + ) + .accountsStrict({ + registry, + universe, + universeLookup, + owner: owner.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .rpc(); + + await createAssetOnly( + 0, + baseAsset, + owner.publicKey, + undefined, + { image: {} } as any, + { concept: {} } as any, + "QmWeightedBaseMetadataHash", + "QmWeightedBasePreviewHash" + ); + await submitAndApprove(baseAsset, owner.publicKey); + + await createAssetOnly( + 1, + textureAsset, + contributor.publicKey, + contributor, + { model3D: {} } as any, + { texture: {} } as any, + "QmWeightedTextureMetadataHash", + "QmWeightedTexturePreviewHash" + ); + await addParent( + textureAsset, + baseAsset, + textureBaseLink, + contributor.publicKey, + contributor + ); + await submitAndApprove(textureAsset, contributor.publicKey, contributor); + + await createAssetOnly( + 2, + rigAsset, + branchContributor.publicKey, + branchContributor, + { model3D: {} } as any, + { rig: {} } as any, + "QmWeightedRigMetadataHash", + "QmWeightedRigPreviewHash" + ); + await addParent( + rigAsset, + baseAsset, + rigBaseLink, + branchContributor.publicKey, + branchContributor + ); + await submitAndApprove( + rigAsset, + branchContributor.publicKey, + branchContributor + ); + + await createAssetOnly( + 3, + finalAsset, + owner.publicKey, + undefined, + { model3D: {} } as any, + { final: {} } as any, + "QmWeightedFinalMetadataHash", + "QmWeightedFinalPreviewHash" + ); + await addParent( + finalAsset, + textureAsset, + finalTextureLink, + owner.publicKey + ); + await addParent(finalAsset, rigAsset, finalRigLink, owner.publicKey); + await submitAndApprove(finalAsset, owner.publicKey); + + await program.methods + .createRelease(new anchor.BN(0), "QmWeightedReleaseHash") + .accountsStrict({ + universe, + asset: finalAsset, + release, + vault, + owner: owner.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .rpc(); + + try { + await program.methods + .addReleaseShare(1000) + .accountsStrict({ + universe, + release, + share: rogueShare, + contributor: rogueContributor.publicKey, + owner: owner.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .rpc(); + expect.fail("should reject manual shares on weighted lineage releases"); + } catch (e: any) { + expect(e.message).to.include("InvalidDistributionModel"); + } + + try { + await program.methods + .finalizeRelease() + .accountsStrict({ + universe, + release, + asset: finalAsset, + owner: owner.publicKey, + }) + .rpc(); + expect.fail( + "should reject generic finalize on weighted lineage releases" + ); + } catch (e: any) { + expect(e.message).to.include("InvalidDistributionModel"); + } + + const contributors = [ + owner.publicKey, + contributor.publicKey, + branchContributor.publicKey, + ].sort((a, b) => Buffer.compare(a.toBuffer(), b.toBuffer())); + const shareAccounts = contributors.map((pk) => sharePda(release, pk)); + + await program.methods + .finalizeWeightedRelease(4, 4) + .accountsStrict({ + universe, + release, + asset: finalAsset, + owner: owner.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .remainingAccounts([ + { pubkey: finalAsset, isWritable: false, isSigner: false }, + { pubkey: textureAsset, isWritable: false, isSigner: false }, + { pubkey: rigAsset, isWritable: false, isSigner: false }, + { pubkey: baseAsset, isWritable: false, isSigner: false }, + { pubkey: finalTextureLink, isWritable: false, isSigner: false }, + { pubkey: finalRigLink, isWritable: false, isSigner: false }, + { pubkey: textureBaseLink, isWritable: false, isSigner: false }, + { pubkey: rigBaseLink, isWritable: false, isSigner: false }, + ...shareAccounts.map((pubkey) => ({ + pubkey, + isWritable: true, + isSigner: false, + })), + ]) + .rpc(); + + const fetchedRelease = await program.account.release.fetch(release); + const shares = await Promise.all( + shareAccounts.map((share) => + program.account.contributorShare.fetch(share) + ) + ); + const shareByContributor = new Map( + shares.map((share) => [share.contributor.toBase58(), share.bps]) + ); + + expect(fetchedRelease.status).to.deep.equal({ finalized: {} }); + expect(fetchedRelease.distributionModel).to.deep.equal({ weighted: {} }); + expect(shares.reduce((sum, share) => sum + share.bps, 0)).to.equal(10_000); + expect(shareByContributor.get(owner.publicKey.toBase58())).to.equal(5000); + expect(shareByContributor.get(contributor.publicKey.toBase58())).to.equal( + 2500 + ); + expect( + shareByContributor.get(branchContributor.publicKey.toBase58()) + ).to.equal(2500); + }); + + it("distributes revenue proportionally and supports authority claim-on-behalf", async () => { + const registry = registryPda(); + const registryAccount = await program.account.registry.fetch(registry); + const universeLookup = universeIndexPda( + registryAccount.universeCount.toNumber() + ); + + const universe = universePda(3); + const asset = assetPda(universe, 0); + const release = releasePda(universe, 0); + const vault = vaultPda(release); + const ownerShare = sharePda(release, owner.publicKey); + const contributorShare = sharePda(release, contributor.publicKey); + const branchShare = sharePda(release, branchContributor.publicKey); + + await program.methods + .createUniverse( + new anchor.BN(3), + "QmUniverseRevenueMetadataHash", + { model3D: {} } as any, + true + ) + .accountsStrict({ + registry, + universe, + universeLookup, + owner: owner.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .rpc(); + + await program.methods + .createAsset( + new anchor.BN(0), + { image: {} } as any, + { concept: {} } as any, + { ccBy4: {} } as any, + "QmRevenueMetadataHash", + "QmRevenuePreviewHash", + true, + { custom: {} } as any + ) + .accountsStrict({ + universe, + asset, + creator: owner.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .rpc(); + await program.methods + .submitAsset() + .accountsStrict({ asset, creator: owner.publicKey }) + .rpc(); + await program.methods + .approveAsset() + .accountsStrict({ universe, asset, owner: owner.publicKey }) + .rpc(); + + await program.methods + .createRelease(new anchor.BN(0), "QmReleaseRevenueMetadataHash") + .accountsStrict({ + universe, + asset, + release, + vault, + owner: owner.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .rpc(); + + await program.methods + .addReleaseShare(3333) + .accountsStrict({ + universe, + release, + share: ownerShare, + contributor: owner.publicKey, + owner: owner.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .rpc(); + + await program.methods + .addReleaseShare(3333) + .accountsStrict({ + universe, + release, + share: contributorShare, + contributor: contributor.publicKey, + owner: owner.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .rpc(); + + await program.methods + .addReleaseShare(3334) + .accountsStrict({ + universe, + release, + share: branchShare, + contributor: branchContributor.publicKey, + owner: owner.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .rpc(); + + await program.methods + .finalizeRelease() + .accountsStrict({ + universe, + release, + asset, + owner: owner.publicKey, + }) + .rpc(); + + const vaultBalanceBeforeDeposit = await provider.connection.getBalance( + vault + ); + + await program.methods + .depositRevenue(new anchor.BN(1_000_000)) + .accountsStrict({ + release, + vault, + payer: owner.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .rpc(); + + await program.methods + .claimRevenueFor() + .accountsStrict({ + release, + vault, + share: contributorShare, + beneficiary: contributor.publicKey, + authority: owner.publicKey, + }) + .rpc(); + await program.methods + .claimRevenueFor() + .accountsStrict({ + release, + vault, + share: branchShare, + beneficiary: branchContributor.publicKey, + authority: owner.publicKey, + }) + .rpc(); + await program.methods + .claimRevenueFor() + .accountsStrict({ + release, + vault, + share: ownerShare, + beneficiary: owner.publicKey, + authority: owner.publicKey, + }) + .rpc(); + + const fetchedOwnerShare = await program.account.contributorShare.fetch( + ownerShare + ); + const fetchedContributorShare = + await program.account.contributorShare.fetch(contributorShare); + const fetchedBranchShare = await program.account.contributorShare.fetch( + branchShare + ); + + expect(fetchedOwnerShare.claimedLamports.toNumber()).to.equal(333_300); + expect(fetchedContributorShare.claimedLamports.toNumber()).to.equal( + 333_300 + ); + expect(fetchedBranchShare.claimedLamports.toNumber()).to.equal(333_400); + + const fetchedRelease = await program.account.release.fetch(release); + expect(fetchedRelease.totalShareBps).to.equal(10_000); + expect(fetchedRelease.totalDepositedLamports.toNumber()).to.equal( + 1_000_000 + ); + + const vaultBalanceAfterClaims = await provider.connection.getBalance(vault); + expect(vaultBalanceAfterClaims).to.equal(vaultBalanceBeforeDeposit); + const vaultRentReserve = + await provider.connection.getMinimumBalanceForRentExemption( + releaseVaultRentExemptBytes + ); + expect(vaultBalanceAfterClaims).to.equal(vaultRentReserve); + + try { + await program.methods + .claimRevenueFor() + .accountsStrict({ + release, + vault, + share: ownerShare, + beneficiary: owner.publicKey, + authority: contributor.publicKey, + }) + .signers([contributor]) + .rpc(); + expect.fail("should reject claim on behalf from unauthorized authority"); + } catch (error: any) { + expect(error.message).to.include("Unauthorized"); + } + }); + + it("keeps collaboration settings on assets instead of universes", async () => { + const registry = registryPda(); + const registryDataBefore = (await program.account.registry.fetch( + registry + )) as any; + const globalIndex = registryDataBefore.universeCount.toNumber(); + const ownerIndex = globalIndex; + const universe = universePda(ownerIndex); + const universeLookup = universeIndexPda(globalIndex); + const asset = assetPda(universe, 0); + + await program.methods + .createUniverse( + new anchor.BN(ownerIndex), + "QmAssetPolicyUniverseMetadata", + { model3D: {} } as any, + true + ) + .accountsStrict({ + registry, + universe, + universeLookup, + owner: owner.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .rpc(); + + await program.methods + .createAsset( + new anchor.BN(0), + { model3D: {} } as any, + { concept: {} } as any, + { ccBy4: {} } as any, + "QmAssetPolicyMetadata", + "QmAssetPolicyPreview", + false, + { weighted: {} } as any + ) + .accountsStrict({ + universe, + asset, + creator: owner.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .rpc(); + + await program.methods + .updateUniverse("QmAssetPolicyUniverseMetadata2", false) + .accountsStrict({ + universe, + owner: owner.publicKey, + }) + .rpc(); + + await program.methods + .updateAssetMetadata( + { ccBy4: {} } as any, + "QmAssetPolicyMetadata2", + "QmAssetPolicyPreview2", + true, + { weighted: {} } as any + ) + .accountsStrict({ + asset, + creator: owner.publicKey, + }) + .rpc(); + + try { + await program.methods + .updateAssetMetadata( + { ccBy4: {} } as any, + "QmAssetPolicyMetadata3", + "QmAssetPolicyPreview3", + true, + { custom: {} } as any + ) + .accountsStrict({ + asset, + creator: owner.publicKey, + }) + .rpc(); + expect.fail("Expected asset collaboration policy change to be rejected"); + } catch (error: any) { + expect(error.error?.errorCode?.code).to.equal( + "ImmutableCollaborationPolicy" + ); + } + + const fetchedUniverse = await program.account.universe.fetch(universe); + const fetchedAsset = await program.account.asset.fetch(asset); + expect(fetchedUniverse.open).to.equal(false); + expect((fetchedUniverse as any).collaborationPolicy).to.equal(undefined); + expect(fetchedAsset.open).to.equal(true); + expect(fetchedAsset.collaborationPolicy).to.deep.equal({ weighted: {} }); + }); });