From 288497af61c4edfb038a0fb7fee4be7bffa1dc05 Mon Sep 17 00:00:00 2001 From: Wotori Movako Date: Thu, 7 May 2026 23:41:09 +0300 Subject: [PATCH 01/10] Add Stellar SDK, localnet seeding, and global universe registry --- .gitignore | 2 + Makefile | 52 + README.md | 26 +- app/src/lib/stellar.ts | 37 +- app/src/pages/AssetsPage.tsx | 1 + app/src/pages/UniversePage.tsx | 19 +- programs/solana-stellar/Cargo.toml | 2 +- programs/solana-stellar/src/constants.rs | 2 + programs/solana-stellar/src/contexts.rs | 29 +- programs/solana-stellar/src/events.rs | 1 + programs/solana-stellar/src/handlers/asset.rs | 7 +- .../solana-stellar/src/handlers/universe.rs | 18 + programs/solana-stellar/src/lib.rs | 14 +- programs/solana-stellar/src/state.rs | 43 +- scripts/capture-manifest-previews.js | 252 +++ scripts/deploy-random-models-localnet.js | 673 +++++++ scripts/dump_stellar_universe.mjs | 457 +++++ scripts/serve-metadata.js | 240 +++ sdk/.gitignore | 2 + sdk/idl/solana_stellar.json | 1664 ++++++++++++++++ sdk/idl/solana_stellar.ts | 1718 +++++++++++++++++ sdk/package.json | 52 + sdk/src/client.ts | 68 + sdk/src/filters.ts | 41 + sdk/src/index.ts | 6 + sdk/src/instructions.ts | 424 ++++ sdk/src/licenses.ts | 54 + sdk/src/pda.ts | 75 + sdk/src/utils.ts | 85 + sdk/tsconfig.json | 15 + tests/solana-stellar.ts | 40 + 31 files changed, 6100 insertions(+), 19 deletions(-) create mode 100644 Makefile create mode 100644 scripts/capture-manifest-previews.js create mode 100755 scripts/deploy-random-models-localnet.js create mode 100644 scripts/dump_stellar_universe.mjs create mode 100644 scripts/serve-metadata.js create mode 100644 sdk/.gitignore create mode 100644 sdk/idl/solana_stellar.json create mode 100644 sdk/idl/solana_stellar.ts create mode 100644 sdk/package.json create mode 100644 sdk/src/client.ts create mode 100644 sdk/src/filters.ts create mode 100644 sdk/src/index.ts create mode 100644 sdk/src/instructions.ts create mode 100644 sdk/src/licenses.ts create mode 100644 sdk/src/pda.ts create mode 100644 sdk/src/utils.ts create mode 100644 sdk/tsconfig.json 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..278016b --- /dev/null +++ b/Makefile @@ -0,0 +1,52 @@ +SHELL := /bin/bash + +LOCALNET_URL ?= http://127.0.0.1:8899 +EVERYTHING_DIR ?= $(CURDIR)/univerces/everything +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 + +.PHONY: sdk-build anchor-build sync-sdk-idl build-localnet airdrop-localnet deploy-localnet localnet metadata-server seed-everything-localnet seed-new-everything-localnet seed-new-single-localnet capture-seed-previews deploy-everything-localnet + +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-localnet: sync-sdk-idl + +airdrop-localnet: + solana airdrop 20 --url $(LOCALNET_URL) + +deploy-localnet: build-localnet airdrop-localnet + anchor deploy --provider.cluster localnet + +localnet: + solana-test-validator --ledger test-ledger --bind-address 127.0.0.1 --rpc-port 8899 + +metadata-server: + node scripts/serve-metadata.js --folder "$(EVERYTHING_DIR)" --port "$(METADATA_PORT)" + +seed-everything-localnet: sdk-build + node scripts/deploy-random-models-localnet.js --folder "$(EVERYTHING_DIR)" --count "$(MODEL_COUNT)" --endpoint "$(LOCALNET_URL)" --metadata-base-url "$(METADATA_BASE_URL)" --model-format "$(MODEL_FORMAT)" + +seed-new-everything-localnet: sdk-build + node scripts/deploy-random-models-localnet.js --folder "$(EVERYTHING_DIR)" --count "$(MODEL_COUNT)" --endpoint "$(LOCALNET_URL)" --metadata-base-url "$(METADATA_BASE_URL)" --model-format "$(MODEL_FORMAT)" --new-universe + +seed-new-single-localnet: sdk-build + node scripts/deploy-random-models-localnet.js --folder "$(EVERYTHING_DIR)" --count "1" --endpoint "$(LOCALNET_URL)" --metadata-base-url "$(METADATA_BASE_URL)" --model-format "$(MODEL_FORMAT)" --new-universe + +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: deploy-localnet seed-everything-localnet diff --git a/README.md b/README.md index 0b74b2e..b6919ab 100644 --- a/README.md +++ b/README.md @@ -44,5 +44,29 @@ 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 +``` + +`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/src/lib/stellar.ts b/app/src/lib/stellar.ts index e70530b..68b3d53 100644 --- a/app/src/lib/stellar.ts +++ b/app/src/lib/stellar.ts @@ -18,19 +18,27 @@ 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 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)}`; + return `https://explorer.solana.com/tx/${signature}?cluster=custom&customUrl=${encodeURIComponent( + endpoint + )}`; } return `https://explorer.solana.com/tx/${signature}?cluster=${cluster}`; } @@ -61,42 +69,53 @@ 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/pages/AssetsPage.tsx b/app/src/pages/AssetsPage.tsx index 96a6ae0..0602ff9 100644 --- a/app/src/pages/AssetsPage.tsx +++ b/app/src/pages/AssetsPage.tsx @@ -34,6 +34,7 @@ export function AssetsPage() { new anchor.BN(Number(assetIndex || "0")), enumValue(kind) as any, enumValue(subtype) as any, + enumValue("unknown") as any, metadataHash, previewHash, ) diff --git a/app/src/pages/UniversePage.tsx b/app/src/pages/UniversePage.tsx index af95b6d..b6bcdc6 100644 --- a/app/src/pages/UniversePage.tsx +++ b/app/src/pages/UniversePage.tsx @@ -3,7 +3,13 @@ import * as anchor from "@coral-xyz/anchor"; import { ensureClient, logSignature, useAppState } from "../App"; import { Field, Panel } from "../components/Panel"; -import { deriveUniverse, enumValue, systemProgram } from "../lib/stellar"; +import { + deriveRegistry, + deriveUniverse, + deriveUniverseIndex, + enumValue, + systemProgram, +} from "../lib/stellar"; export function UniversePage() { const state = useAppState(); @@ -23,6 +29,15 @@ 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), @@ -32,7 +47,9 @@ export function UniversePage() { open, ) .accountsStrict({ + registry, universe, + universeLookup, owner: state.walletPublicKey!, systemProgram, }) 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..a7b71da 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>, 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..ba5f5bf 100644 --- a/programs/solana-stellar/src/handlers/asset.rs +++ b/programs/solana-stellar/src/handlers/asset.rs @@ -6,7 +6,7 @@ use crate::{ }, error::StellarError, events::{AssetCreated, AssetParentAdded, AssetStatusChanged}, - state::{AssetKind, AssetStatus, AssetSubtype, UniverseStatus}, + state::{AssetKind, AssetStatus, AssetSubtype, LicenseKind, UniverseStatus}, utils::{validate_hash, validate_optional_hash}, }; @@ -15,6 +15,7 @@ pub fn create_asset( asset_index: u64, kind: AssetKind, subtype: AssetSubtype, + license_kind: LicenseKind, metadata_hash: String, preview_hash: String, ) -> Result<()> { @@ -48,6 +49,7 @@ 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.metadata_hash = metadata_hash; asset.preview_hash = preview_hash; @@ -73,6 +75,7 @@ pub fn create_asset( pub fn update_asset_metadata( ctx: Context, + license_kind: LicenseKind, metadata_hash: String, preview_hash: String, ) -> Result<()> { @@ -87,6 +90,7 @@ pub fn update_asset_metadata( asset.metadata_hash = metadata_hash; asset.preview_hash = preview_hash; + asset.license_kind = license_kind; asset.updated_at = Clock::get()?.unix_timestamp; Ok(()) @@ -121,6 +125,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/universe.rs b/programs/solana-stellar/src/handlers/universe.rs index a9515bb..7973e09 100644 --- a/programs/solana-stellar/src/handlers/universe.rs +++ b/programs/solana-stellar/src/handlers/universe.rs @@ -2,6 +2,7 @@ use anchor_lang::prelude::*; use crate::{ contexts::{CloseUniverse, CreateUniverse, UpdateUniverse}, + error::StellarError, events::{UniverseCreated, UniverseUpdated}, state::{AssetKind, CollaborationPolicy, UniverseStatus}, utils::validate_hash, @@ -18,12 +19,16 @@ pub fn create_universe( 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; @@ -35,10 +40,23 @@ pub fn create_universe( 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(()) diff --git a/programs/solana-stellar/src/lib.rs b/programs/solana-stellar/src/lib.rs index 89657c7..41f8349 100644 --- a/programs/solana-stellar/src/lib.rs +++ b/programs/solana-stellar/src/lib.rs @@ -54,18 +54,28 @@ pub mod solana_stellar { asset_index: u64, kind: AssetKind, subtype: AssetSubtype, + license_kind: LicenseKind, metadata_hash: String, preview_hash: String, ) -> 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, + ) } pub fn update_asset_metadata( ctx: Context, + license_kind: LicenseKind, metadata_hash: String, preview_hash: String, ) -> Result<()> { - handlers::update_asset_metadata(ctx, metadata_hash, preview_hash) + handlers::update_asset_metadata(ctx, license_kind, metadata_hash, preview_hash) } pub fn add_asset_parent(ctx: Context) -> Result<()> { diff --git a/programs/solana-stellar/src/state.rs b/programs/solana-stellar/src/state.rs index 1437df2..9ad4028 100644 --- a/programs/solana-stellar/src/state.rs +++ b/programs/solana-stellar/src/state.rs @@ -2,10 +2,34 @@ 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, @@ -19,7 +43,8 @@ pub struct Universe { } 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 + 1 + (4 + MAX_HASH_LEN) + 8 + 8; } #[account] @@ -31,6 +56,7 @@ pub struct Asset { pub bump: u8, pub kind: AssetKind, pub subtype: AssetSubtype, + pub license_kind: LicenseKind, pub status: AssetStatus, pub metadata_hash: String, pub preview_hash: String, @@ -41,7 +67,7 @@ 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; + 32 + 8 + 32 + 32 + 1 + 1 + 1 + 1 + 1 + (4 + MAX_HASH_LEN) + (4 + MAX_HASH_LEN) + 8 + 8 + 2; } #[account] @@ -143,6 +169,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..9d2bd88 --- /dev/null +++ b/scripts/capture-manifest-previews.js @@ -0,0 +1,252 @@ +#!/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..d76dc45 --- /dev/null +++ b/scripts/deploy-random-models-localnet.js @@ -0,0 +1,673 @@ +#!/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"), + collaborationPolicy: enumValue("custom"), + 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/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..adf460c --- /dev/null +++ b/sdk/idl/solana_stellar.json @@ -0,0 +1,1664 @@ +{ + "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": "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": "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": "collaboration_policy", + "type": { + "defined": { + "name": "CollaborationPolicy" + } + } + }, + { + "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": "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": "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" + }, + { + "name": "collaboration_policy", + "type": { + "defined": { + "name": "CollaborationPolicy" + } + } + } + ] + } + ], + "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": "UniverseNotActive", + "msg": "Universe is not active." + }, + { + "code": 6003, + "name": "UniverseNotEmpty", + "msg": "Universe still has live assets or releases." + }, + { + "code": 6004, + "name": "InvalidHash", + "msg": "Invalid metadata or content hash." + }, + { + "code": 6005, + "name": "InvalidAssetIndex", + "msg": "Invalid asset index." + }, + { + "code": 6006, + "name": "InvalidReleaseIndex", + "msg": "Invalid release index." + }, + { + "code": 6007, + "name": "AssetLocked", + "msg": "Asset is locked for this operation." + }, + { + "code": 6008, + "name": "InvalidAssetStatus", + "msg": "Invalid asset status for this operation." + }, + { + "code": 6009, + "name": "UniverseMismatch", + "msg": "Universe mismatch." + }, + { + "code": 6010, + "name": "AssetMismatch", + "msg": "Asset mismatch." + }, + { + "code": 6011, + "name": "ReleaseMismatch", + "msg": "Release mismatch." + }, + { + "code": 6012, + "name": "InvalidLineageLink", + "msg": "Invalid lineage link." + }, + { + "code": 6013, + "name": "InvalidLineageProof", + "msg": "Invalid lineage proof." + }, + { + "code": 6014, + "name": "InvalidContributorCount", + "msg": "Invalid contributor count." + }, + { + "code": 6015, + "name": "ReleaseLocked", + "msg": "Release is locked for this operation." + }, + { + "code": 6016, + "name": "ReleaseNotFinalized", + "msg": "Release is not finalized." + }, + { + "code": 6017, + "name": "InvalidShareBps", + "msg": "Invalid contributor share basis points." + }, + { + "code": 6018, + "name": "InvalidDistributionModel", + "msg": "Invalid release distribution model for this operation." + }, + { + "code": 6019, + "name": "InvalidRevenueAmount", + "msg": "Invalid revenue amount." + }, + { + "code": 6020, + "name": "NoRevenueToClaim", + "msg": "No revenue available to claim." + }, + { + "code": 6021, + "name": "InsufficientVaultBalance", + "msg": "Release vault balance is insufficient." + }, + { + "code": 6022, + "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": "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": "collaboration_policy", + "type": { + "defined": { + "name": "CollaborationPolicy" + } + } + }, + { + "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..3f86612 --- /dev/null +++ b/sdk/idl/solana_stellar.ts @@ -0,0 +1,1718 @@ +/** + * 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: "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: "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: "collaborationPolicy"; + type: { + defined: { + name: "collaborationPolicy"; + }; + }; + }, + { + 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: "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: "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"; + }, + { + name: "collaborationPolicy"; + type: { + defined: { + name: "collaborationPolicy"; + }; + }; + } + ]; + } + ]; + 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: "universeNotActive"; + msg: "Universe is not active."; + }, + { + code: 6003; + name: "universeNotEmpty"; + msg: "Universe still has live assets or releases."; + }, + { + code: 6004; + name: "invalidHash"; + msg: "Invalid metadata or content hash."; + }, + { + code: 6005; + name: "invalidAssetIndex"; + msg: "Invalid asset index."; + }, + { + code: 6006; + name: "invalidReleaseIndex"; + msg: "Invalid release index."; + }, + { + code: 6007; + name: "assetLocked"; + msg: "Asset is locked for this operation."; + }, + { + code: 6008; + name: "invalidAssetStatus"; + msg: "Invalid asset status for this operation."; + }, + { + code: 6009; + name: "universeMismatch"; + msg: "Universe mismatch."; + }, + { + code: 6010; + name: "assetMismatch"; + msg: "Asset mismatch."; + }, + { + code: 6011; + name: "releaseMismatch"; + msg: "Release mismatch."; + }, + { + code: 6012; + name: "invalidLineageLink"; + msg: "Invalid lineage link."; + }, + { + code: 6013; + name: "invalidLineageProof"; + msg: "Invalid lineage proof."; + }, + { + code: 6014; + name: "invalidContributorCount"; + msg: "Invalid contributor count."; + }, + { + code: 6015; + name: "releaseLocked"; + msg: "Release is locked for this operation."; + }, + { + code: 6016; + name: "releaseNotFinalized"; + msg: "Release is not finalized."; + }, + { + code: 6017; + name: "invalidShareBps"; + msg: "Invalid contributor share basis points."; + }, + { + code: 6018; + name: "invalidDistributionModel"; + msg: "Invalid release distribution model for this operation."; + }, + { + code: 6019; + name: "invalidRevenueAmount"; + msg: "Invalid revenue amount."; + }, + { + code: 6020; + name: "noRevenueToClaim"; + msg: "No revenue available to claim."; + }, + { + code: 6021; + name: "insufficientVaultBalance"; + msg: "Release vault balance is insufficient."; + }, + { + code: 6022; + 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: "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: "collaborationPolicy"; + type: { + defined: { + name: "collaborationPolicy"; + }; + }; + }, + { + 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..d390056 --- /dev/null +++ b/sdk/src/instructions.ts @@ -0,0 +1,424 @@ +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>; + +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; + collaborationPolicy: 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.collaborationPolicy as any, + args.open + ) + .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; + collaborationPolicy: EnumArg; + } +) { + const signature = await client.program.methods + .updateUniverse( + args.metadataHash, + args.open, + args.collaborationPolicy as any + ) + .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; + } +) { + 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 + ) + .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; + } +) { + const signature = await client.program.methods + .updateAssetMetadata( + args.licenseKind as any, + args.metadataHash, + args.previewHash + ) + .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 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 }; +} 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..f6f91b6 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)], @@ -81,6 +93,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); @@ -98,7 +112,9 @@ describe("solana-stellar", () => { true ) .accountsStrict({ + registry, universe, + universeLookup, owner: owner.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }) @@ -109,6 +125,7 @@ describe("solana-stellar", () => { new anchor.BN(0), { image: {} } as any, { concept: {} } as any, + { ccBy4: {} } as any, "QmConceptMetadataHash", "QmConceptPreviewHash" ) @@ -139,6 +156,7 @@ describe("solana-stellar", () => { new anchor.BN(1), { model3D: {} } as any, { final: {} } as any, + { unknown: {} } as any, "QmModelMetadataHash", "QmModelPreviewHash" ) @@ -246,6 +264,9 @@ 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 +275,13 @@ 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: {} }); @@ -268,6 +294,8 @@ describe("solana-stellar", () => { it("finalizes equal lineage shares across merged branches", 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); @@ -290,7 +318,9 @@ describe("solana-stellar", () => { true ) .accountsStrict({ + registry, universe, + universeLookup, owner: owner.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }) @@ -301,6 +331,7 @@ describe("solana-stellar", () => { new anchor.BN(0), { image: {} } as any, { concept: {} } as any, + { ccBy4: {} } as any, "QmBaseMetadataHash", "QmBasePreviewHash" ) @@ -325,6 +356,7 @@ describe("solana-stellar", () => { new anchor.BN(1), { model3D: {} } as any, { texture: {} } as any, + { ccBy4: {} } as any, "QmUvMetadataHash", "QmUvPreviewHash" ) @@ -362,6 +394,7 @@ describe("solana-stellar", () => { new anchor.BN(2), { animation: {} } as any, { motion: {} } as any, + { ccBy4: {} } as any, "QmAnimMetadataHash", "QmAnimPreviewHash" ) @@ -406,6 +439,7 @@ describe("solana-stellar", () => { new anchor.BN(3), { model3D: {} } as any, { final: {} } as any, + { ccBy4: {} } as any, "QmFinalMetadataHash", "QmFinalPreviewHash" ) @@ -543,12 +577,18 @@ 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: {}, From 3460ab86876b03c91e05aecf7b8fe4c6c23064c5 Mon Sep 17 00:00:00 2001 From: Wotori Movako Date: Mon, 11 May 2026 00:23:57 +0300 Subject: [PATCH 02/10] Add localnet workflows and lock collaboration policy --- Makefile | 173 ++++++++++++++++-- programs/solana-stellar/src/error.rs | 2 + .../solana-stellar/src/handlers/universe.rs | 5 +- programs/solana-stellar/src/state.rs | 3 + sdk/idl/solana_stellar.json | 16 +- sdk/idl/solana_stellar.ts | 16 +- tests/solana-stellar.ts | 62 +++++++ 7 files changed, 256 insertions(+), 21 deletions(-) diff --git a/Makefile b/Makefile index 278016b..e7502f4 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,113 @@ SHELL := /bin/bash +CLUSTER ?= localnet +ALLOW_MAINNET ?= 0 +ALLOW_REMOTE_SEED ?= 0 +WALLET ?= $(HOME)/.config/solana/id.json + 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 +AIRDROP_SOL ?= 20 + EVERYTHING_DIR ?= $(CURDIR)/univerces/everything 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 +SEED_SCRIPT ?= scripts/deploy-random-models-localnet.js +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 metadata-server app-dev \ + seed-random-models seed-new-random-models seed-new-single \ + seed-everything-localnet seed-new-everything-localnet seed-new-single-localnet \ + setup-localnet setup-localnet-single capture-seed-previews deploy-everything-localnet + +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" \ + " make metadata-server Serve EVERYTHING_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" \ + "" \ + "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" \ + "" \ + "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" -.PHONY: sdk-build anchor-build sync-sdk-idl build-localnet airdrop-localnet deploy-localnet localnet metadata-server seed-everything-localnet seed-new-everything-localnet seed-new-single-localnet capture-seed-previews deploy-everything-localnet +print-config: + @printf "CLUSTER=%s\nANCHOR_CLUSTER=%s\nRPC_URL=%s\nWALLET=%s\nEVERYTHING_DIR=%s\nMODEL_COUNT=%s\nMODEL_FORMAT=%s\nMETADATA_BASE_URL=%s\n" \ + "$(CLUSTER)" "$(ANCHOR_CLUSTER)" "$(RPC_URL)" "$(WALLET)" "$(EVERYTHING_DIR)" "$(MODEL_COUNT)" "$(MODEL_FORMAT)" "$(METADATA_BASE_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 random-model 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 @@ -23,30 +122,76 @@ sync-sdk-idl: anchor-build node_modules/.bin/prettier --write sdk/idl/solana_stellar.json sdk/idl/solana_stellar.ts yarn --cwd sdk build -build-localnet: sync-sdk-idl +build: sync-sdk-idl + +build-localnet: CLUSTER = localnet +build-localnet: build + +build-devnet: CLUSTER = devnet +build-devnet: build -airdrop-localnet: - solana airdrop 20 --url $(LOCALNET_URL) +build-mainnet: CLUSTER = mainnet +build-mainnet: build -deploy-localnet: build-localnet airdrop-localnet - anchor deploy --provider.cluster localnet +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 test-ledger --bind-address 127.0.0.1 --rpc-port 8899 + solana-test-validator --ledger "$(LOCALNET_LEDGER)" --bind-address "$(LOCALNET_BIND_ADDRESS)" --rpc-port "$(LOCALNET_RPC_PORT)" metadata-server: node scripts/serve-metadata.js --folder "$(EVERYTHING_DIR)" --port "$(METADATA_PORT)" -seed-everything-localnet: sdk-build - node scripts/deploy-random-models-localnet.js --folder "$(EVERYTHING_DIR)" --count "$(MODEL_COUNT)" --endpoint "$(LOCALNET_URL)" --metadata-base-url "$(METADATA_BASE_URL)" --model-format "$(MODEL_FORMAT)" +app-dev: + 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-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-new-everything-localnet: sdk-build - node scripts/deploy-random-models-localnet.js --folder "$(EVERYTHING_DIR)" --count "$(MODEL_COUNT)" --endpoint "$(LOCALNET_URL)" --metadata-base-url "$(METADATA_BASE_URL)" --model-format "$(MODEL_FORMAT)" --new-universe +setup-localnet: + $(MAKE) CLUSTER=localnet check-rpc + $(MAKE) CLUSTER=localnet deploy-localnet + $(MAKE) CLUSTER=localnet seed-new-random-models -seed-new-single-localnet: sdk-build - node scripts/deploy-random-models-localnet.js --folder "$(EVERYTHING_DIR)" --count "1" --endpoint "$(LOCALNET_URL)" --metadata-base-url "$(METADATA_BASE_URL)" --model-format "$(MODEL_FORMAT)" --new-universe +setup-localnet-single: + $(MAKE) CLUSTER=localnet check-rpc + $(MAKE) CLUSTER=localnet deploy-localnet + $(MAKE) CLUSTER=localnet seed-new-single 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: deploy-localnet seed-everything-localnet +deploy-everything-localnet: + $(MAKE) CLUSTER=localnet deploy-localnet + $(MAKE) CLUSTER=localnet seed-everything-localnet diff --git a/programs/solana-stellar/src/error.rs b/programs/solana-stellar/src/error.rs index 5dea8d2..c3b4453 100644 --- a/programs/solana-stellar/src/error.rs +++ b/programs/solana-stellar/src/error.rs @@ -40,6 +40,8 @@ pub enum StellarError { InvalidShareBps, #[msg("Invalid release distribution model for this operation.")] InvalidDistributionModel, + #[msg("Collaboration policy is immutable after universe creation.")] + ImmutableCollaborationPolicy, #[msg("Invalid revenue amount.")] InvalidRevenueAmount, #[msg("No revenue available to claim.")] diff --git a/programs/solana-stellar/src/handlers/universe.rs b/programs/solana-stellar/src/handlers/universe.rs index 7973e09..16f8bbc 100644 --- a/programs/solana-stellar/src/handlers/universe.rs +++ b/programs/solana-stellar/src/handlers/universe.rs @@ -71,9 +71,12 @@ pub fn update_universe( validate_hash(&metadata_hash)?; let universe = &mut ctx.accounts.universe; + require!( + universe.collaboration_policy == collaboration_policy, + StellarError::ImmutableCollaborationPolicy + ); 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/state.rs b/programs/solana-stellar/src/state.rs index 9ad4028..ae0c9b1 100644 --- a/programs/solana-stellar/src/state.rs +++ b/programs/solana-stellar/src/state.rs @@ -36,6 +36,9 @@ pub struct Universe { pub open: bool, pub status: UniverseStatus, pub project_type: AssetKind, + /// Revenue distribution policy used for releases in this universe. + /// It is immutable after universe creation so admins cannot alter the + /// economic deal that contributors relied on when joining. pub collaboration_policy: CollaborationPolicy, pub metadata_hash: String, pub created_at: i64, diff --git a/sdk/idl/solana_stellar.json b/sdk/idl/solana_stellar.json index adf460c..9ad5bf0 100644 --- a/sdk/idl/solana_stellar.json +++ b/sdk/idl/solana_stellar.json @@ -870,21 +870,26 @@ }, { "code": 6019, + "name": "ImmutableCollaborationPolicy", + "msg": "Collaboration policy is immutable after universe creation." + }, + { + "code": 6020, "name": "InvalidRevenueAmount", "msg": "Invalid revenue amount." }, { - "code": 6020, + "code": 6021, "name": "NoRevenueToClaim", "msg": "No revenue available to claim." }, { - "code": 6021, + "code": 6022, "name": "InsufficientVaultBalance", "msg": "Release vault balance is insufficient." }, { - "code": 6022, + "code": 6023, "name": "NumericalOverflow", "msg": "Numerical overflow occurred." } @@ -1557,6 +1562,11 @@ }, { "name": "collaboration_policy", + "docs": [ + "Revenue distribution policy used for releases in this universe.", + "It is immutable after universe creation so admins cannot alter the", + "economic deal that contributors relied on when joining." + ], "type": { "defined": { "name": "CollaborationPolicy" diff --git a/sdk/idl/solana_stellar.ts b/sdk/idl/solana_stellar.ts index 3f86612..6d14d3e 100644 --- a/sdk/idl/solana_stellar.ts +++ b/sdk/idl/solana_stellar.ts @@ -924,21 +924,26 @@ export type SolanaStellar = { }, { code: 6019; + name: "immutableCollaborationPolicy"; + msg: "Collaboration policy is immutable after universe creation."; + }, + { + code: 6020; name: "invalidRevenueAmount"; msg: "Invalid revenue amount."; }, { - code: 6020; + code: 6021; name: "noRevenueToClaim"; msg: "No revenue available to claim."; }, { - code: 6021; + code: 6022; name: "insufficientVaultBalance"; msg: "Release vault balance is insufficient."; }, { - code: 6022; + code: 6023; name: "numericalOverflow"; msg: "Numerical overflow occurred."; } @@ -1611,6 +1616,11 @@ export type SolanaStellar = { }, { name: "collaborationPolicy"; + docs: [ + "Revenue distribution policy used for releases in this universe.", + "It is immutable after universe creation so admins cannot alter the", + "economic deal that contributors relied on when joining." + ]; type: { defined: { name: "collaborationPolicy"; diff --git a/tests/solana-stellar.ts b/tests/solana-stellar.ts index f6f91b6..2e71b67 100644 --- a/tests/solana-stellar.ts +++ b/tests/solana-stellar.ts @@ -598,4 +598,66 @@ describe("solana-stellar", () => { 3333, 3333, 3334, ]); }); + + it("keeps universe collaboration policy immutable after creation", 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); + + await program.methods + .createUniverse( + new anchor.BN(ownerIndex), + "QmImmutablePolicyMetadata", + { model3D: {} } as any, + { equal: {} } as any, + true + ) + .accountsStrict({ + registry, + universe, + universeLookup, + owner: owner.publicKey, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .rpc(); + + await program.methods + .updateUniverse( + "QmImmutablePolicyMetadata2", + false, + { equal: {} } as any + ) + .accountsStrict({ + universe, + owner: owner.publicKey, + }) + .rpc(); + + try { + await program.methods + .updateUniverse( + "QmImmutablePolicyMetadata3", + true, + { custom: {} } as any + ) + .accountsStrict({ + universe, + owner: owner.publicKey, + }) + .rpc(); + expect.fail("Expected 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); + expect(fetchedUniverse.collaborationPolicy).to.deep.equal({ equal: {} }); + }); }); From ff64455c2f237eb9868207c10e7c294b6847d9d3 Mon Sep 17 00:00:00 2001 From: Wotori Movako Date: Mon, 11 May 2026 00:49:32 +0300 Subject: [PATCH 03/10] Add weighted lineage release distribution --- .../solana-stellar/src/handlers/release.rs | 267 ++++++++++++++- programs/solana-stellar/src/lib.rs | 8 + sdk/idl/solana_stellar.json | 37 +++ sdk/idl/solana_stellar.ts | 37 +++ sdk/src/instructions.ts | 27 ++ tests/solana-stellar.ts | 310 +++++++++++++++++- 6 files changed, 668 insertions(+), 18 deletions(-) diff --git a/programs/solana-stellar/src/handlers/release.rs b/programs/solana-stellar/src/handlers/release.rs index 859348b..b476e11 100644 --- a/programs/solana-stellar/src/handlers/release.rs +++ b/programs/solana-stellar/src/handlers/release.rs @@ -18,6 +18,13 @@ use crate::{ utils::validate_hash, }; +fn is_auto_lineage_model(policy: CollaborationPolicy) -> bool { + matches!( + policy, + CollaborationPolicy::LineageEqual | CollaborationPolicy::Weighted + ) +} + pub fn create_release( ctx: Context, release_index: u64, @@ -89,7 +96,7 @@ pub fn add_release_share(ctx: Context, bps: u16) -> Result<()> StellarError::ReleaseLocked ); require!( - release.distribution_model != CollaborationPolicy::LineageEqual, + !is_auto_lineage_model(release.distribution_model), StellarError::InvalidDistributionModel ); @@ -128,7 +135,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!( @@ -371,6 +378,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/lib.rs b/programs/solana-stellar/src/lib.rs index 41f8349..86f4c15 100644 --- a/programs/solana-stellar/src/lib.rs +++ b/programs/solana-stellar/src/lib.rs @@ -122,6 +122,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) } diff --git a/sdk/idl/solana_stellar.json b/sdk/idl/solana_stellar.json index 9ad5bf0..1aabf2e 100644 --- a/sdk/idl/solana_stellar.json +++ b/sdk/idl/solana_stellar.json @@ -562,6 +562,43 @@ ], "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], diff --git a/sdk/idl/solana_stellar.ts b/sdk/idl/solana_stellar.ts index 6d14d3e..4aaac20 100644 --- a/sdk/idl/solana_stellar.ts +++ b/sdk/idl/solana_stellar.ts @@ -616,6 +616,43 @@ export type SolanaStellar = { ]; 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]; diff --git a/sdk/src/instructions.ts b/sdk/src/instructions.ts index d390056..54be82a 100644 --- a/sdk/src/instructions.ts +++ b/sdk/src/instructions.ts @@ -358,6 +358,33 @@ export async function finalizeLineageEqualRelease( return { signature }; } +export async function finalizeWeightedRelease( + client: StellarClient, + args: { + universe: PublicKey; + release: PublicKey; + asset: PublicKey; + owner: PublicKey; + assetCount: number; + linkCount: number; + remainingAccounts: AccountMeta[]; + } +) { + const signature = await client.program.methods + .finalizeWeightedRelease(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: { diff --git a/tests/solana-stellar.ts b/tests/solana-stellar.ts index 2e71b67..22bf56c 100644 --- a/tests/solana-stellar.ts +++ b/tests/solana-stellar.ts @@ -265,8 +265,9 @@ describe("solana-stellar", () => { 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 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); @@ -277,7 +278,9 @@ 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.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); @@ -578,8 +581,9 @@ describe("solana-stellar", () => { 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 fetchedUniverseIndex = await program.account.universeIndex.fetch( + universeLookup + ); const shares = await Promise.all( shareAccounts.map((share) => program.account.contributorShare.fetch(share) @@ -587,7 +591,9 @@ describe("solana-stellar", () => { ); expect(fetchedUniverse.globalIndex.toNumber()).to.equal(1); - expect(fetchedUniverseIndex.universe.toBase58()).to.equal(universe.toBase58()); + 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({ @@ -599,6 +605,284 @@ describe("solana-stellar", () => { ]); }); + 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 + ) + .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, + { weighted: {} } 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("keeps universe collaboration policy immutable after creation", async () => { const registry = registryPda(); const registryDataBefore = (await program.account.registry.fetch( @@ -627,11 +911,7 @@ describe("solana-stellar", () => { .rpc(); await program.methods - .updateUniverse( - "QmImmutablePolicyMetadata2", - false, - { equal: {} } as any - ) + .updateUniverse("QmImmutablePolicyMetadata2", false, { equal: {} } as any) .accountsStrict({ universe, owner: owner.publicKey, @@ -640,11 +920,9 @@ describe("solana-stellar", () => { try { await program.methods - .updateUniverse( - "QmImmutablePolicyMetadata3", - true, - { custom: {} } as any - ) + .updateUniverse("QmImmutablePolicyMetadata3", true, { + custom: {}, + } as any) .accountsStrict({ universe, owner: owner.publicKey, From 75e3b56940f2407ba6e61ac834691d68ee7ef456 Mon Sep 17 00:00:00 2001 From: Wotori Movako Date: Mon, 11 May 2026 18:20:45 +0300 Subject: [PATCH 04/10] Allow lineage-equal finalize in release flow --- programs/solana-stellar/src/handlers/release.rs | 9 +++++++-- tests/solana-stellar.ts | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/programs/solana-stellar/src/handlers/release.rs b/programs/solana-stellar/src/handlers/release.rs index b476e11..492dfa2 100644 --- a/programs/solana-stellar/src/handlers/release.rs +++ b/programs/solana-stellar/src/handlers/release.rs @@ -25,6 +25,10 @@ fn is_auto_lineage_model(policy: CollaborationPolicy) -> bool { ) } +fn is_manual_split_model(policy: CollaborationPolicy) -> bool { + matches!(policy, CollaborationPolicy::Custom) +} + pub fn create_release( ctx: Context, release_index: u64, @@ -96,7 +100,7 @@ pub fn add_release_share(ctx: Context, bps: u16) -> Result<()> StellarError::ReleaseLocked ); require!( - !is_auto_lineage_model(release.distribution_model), + is_manual_split_model(release.distribution_model), StellarError::InvalidDistributionModel ); @@ -178,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); diff --git a/tests/solana-stellar.ts b/tests/solana-stellar.ts index 22bf56c..1f0498b 100644 --- a/tests/solana-stellar.ts +++ b/tests/solana-stellar.ts @@ -295,7 +295,7 @@ 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); @@ -317,7 +317,7 @@ describe("solana-stellar", () => { new anchor.BN(1), "QmUniverseMetadataHash2", { model3D: {} } as any, - { lineageEqual: {} } as any, + { equal: {} } as any, true ) .accountsStrict({ From 551eb15eaba2e7e252a714360187892dcd1ab605 Mon Sep 17 00:00:00 2001 From: Wotori Movako Date: Tue, 12 May 2026 02:11:33 +0300 Subject: [PATCH 05/10] Add claim-on-behalf revenue payout and coverage test --- app/index.html | 1 + app/src/main.tsx | 1 - app/src/pages/RevenuePage.tsx | 17 +- programs/solana-stellar/src/contexts.rs | 31 +++ .../solana-stellar/src/handlers/revenue.rs | 40 +++- programs/solana-stellar/src/lib.rs | 4 + sdk/idl/solana_stellar.json | 56 +++++ sdk/idl/solana_stellar.ts | 68 ++++++ sdk/src/instructions.ts | 24 +++ tests/solana-stellar.ts | 194 ++++++++++++++++++ 10 files changed, 426 insertions(+), 10 deletions(-) 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/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/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/programs/solana-stellar/src/contexts.rs b/programs/solana-stellar/src/contexts.rs index a7b71da..362e24f 100644 --- a/programs/solana-stellar/src/contexts.rs +++ b/programs/solana-stellar/src/contexts.rs @@ -328,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/handlers/revenue.rs b/programs/solana-stellar/src/handlers/revenue.rs index d4ece2a..3778f75 100644 --- a/programs/solana-stellar/src/handlers/revenue.rs +++ b/programs/solana-stellar/src/handlers/revenue.rs @@ -3,9 +3,10 @@ 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}, }; pub fn deposit_revenue(ctx: Context, amount: u64) -> Result<()> { @@ -39,23 +40,51 @@ 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 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(); require!( vault_info.lamports() >= claimable, StellarError::InsufficientVaultBalance @@ -65,7 +94,8 @@ pub fn claim_revenue(ctx: Context) -> Result<()> { .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 +104,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/lib.rs b/programs/solana-stellar/src/lib.rs index 86f4c15..42b8197 100644 --- a/programs/solana-stellar/src/lib.rs +++ b/programs/solana-stellar/src/lib.rs @@ -141,4 +141,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/sdk/idl/solana_stellar.json b/sdk/idl/solana_stellar.json index 1aabf2e..a20855c 100644 --- a/sdk/idl/solana_stellar.json +++ b/sdk/idl/solana_stellar.json @@ -175,6 +175,62 @@ ], "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], diff --git a/sdk/idl/solana_stellar.ts b/sdk/idl/solana_stellar.ts index 4aaac20..655bc4b 100644 --- a/sdk/idl/solana_stellar.ts +++ b/sdk/idl/solana_stellar.ts @@ -193,6 +193,74 @@ export type SolanaStellar = { ]; 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]; diff --git a/sdk/src/instructions.ts b/sdk/src/instructions.ts index 54be82a..a3f24d6 100644 --- a/sdk/src/instructions.ts +++ b/sdk/src/instructions.ts @@ -449,3 +449,27 @@ export async function claimRevenue( 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/tests/solana-stellar.ts b/tests/solana-stellar.ts index 1f0498b..b932d2a 100644 --- a/tests/solana-stellar.ts +++ b/tests/solana-stellar.ts @@ -883,6 +883,200 @@ describe("solana-stellar", () => { ).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, + { custom: {} } 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" + ) + .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); + + 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 universe collaboration policy immutable after creation", async () => { const registry = registryPda(); const registryDataBefore = (await program.account.registry.fetch( From 99ca7a076752a0ea81a3804d3539f1e518f64ca8 Mon Sep 17 00:00:00 2001 From: Wotori Movako Date: Tue, 12 May 2026 02:44:45 +0300 Subject: [PATCH 06/10] Protect vault rent reserve during revenue claims --- programs/solana-stellar/src/error.rs | 2 ++ programs/solana-stellar/src/handlers/revenue.rs | 13 +++++++++++-- tests/solana-stellar.ts | 6 ++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/programs/solana-stellar/src/error.rs b/programs/solana-stellar/src/error.rs index c3b4453..88af1d9 100644 --- a/programs/solana-stellar/src/error.rs +++ b/programs/solana-stellar/src/error.rs @@ -44,6 +44,8 @@ pub enum StellarError { 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/handlers/revenue.rs b/programs/solana-stellar/src/handlers/revenue.rs index 3778f75..8aaef58 100644 --- a/programs/solana-stellar/src/handlers/revenue.rs +++ b/programs/solana-stellar/src/handlers/revenue.rs @@ -9,6 +9,8 @@ use crate::{ 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!( @@ -72,6 +74,9 @@ fn process_claim( ) -> 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) @@ -85,9 +90,13 @@ fn process_claim( require!(claimable > 0, StellarError::NoRevenueToClaim); 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 diff --git a/tests/solana-stellar.ts b/tests/solana-stellar.ts index b932d2a..62bf729 100644 --- a/tests/solana-stellar.ts +++ b/tests/solana-stellar.ts @@ -60,6 +60,8 @@ describe("solana-stellar", () => { program.programId )[0]; + const releaseVaultRentExemptBytes = 8 + 32 + 1; + const sharePda = ( release: anchor.web3.PublicKey, contributorPk: anchor.web3.PublicKey @@ -1058,6 +1060,10 @@ describe("solana-stellar", () => { 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 From 46c22f674f6cfe9f21a9b9babecbf7c97262a3fe Mon Sep 17 00:00:00 2001 From: Wotori Movako Date: Tue, 12 May 2026 09:25:14 +0300 Subject: [PATCH 07/10] Fix local create-universe networking from browser UI --- Makefile | 9 ++++- README.md | 10 ++++++ app/src/lib/cluster.tsx | 3 +- app/src/lib/errors.ts | 62 ++++++++++++++++++++++++++++++++++ app/src/pages/UniversePage.tsx | 16 +++++++-- app/vite.config.ts | 8 +++++ 6 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 app/src/lib/errors.ts diff --git a/Makefile b/Makefile index e7502f4..85f2b97 100644 --- a/Makefile +++ b/Makefile @@ -11,8 +11,15 @@ 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 AIRDROP_SOL ?= 20 +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 \ +) + EVERYTHING_DIR ?= $(CURDIR)/univerces/everything MODEL_COUNT ?= 10 MODEL_FORMAT ?= glb @@ -152,7 +159,7 @@ deploy-mainnet: $(MAKE) CLUSTER=mainnet deploy localnet: - solana-test-validator --ledger "$(LOCALNET_LEDGER)" --bind-address "$(LOCALNET_BIND_ADDRESS)" --rpc-port "$(LOCALNET_RPC_PORT)" + solana-test-validator --ledger "$(LOCALNET_LEDGER)" --bind-address "$(LOCALNET_BIND_ADDRESS)" --rpc-port "$(LOCALNET_RPC_PORT)" $(RPC_CORS_FLAG) metadata-server: node scripts/serve-metadata.js --folder "$(EVERYTHING_DIR)" --port "$(METADATA_PORT)" diff --git a/README.md b/README.md index b6919ab..86855ff 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,16 @@ 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 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..43e1135 --- /dev/null +++ b/app/src/lib/errors.ts @@ -0,0 +1,62 @@ +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/pages/UniversePage.tsx b/app/src/pages/UniversePage.tsx index b6bcdc6..e56b0b0 100644 --- a/app/src/pages/UniversePage.tsx +++ b/app/src/pages/UniversePage.tsx @@ -2,6 +2,7 @@ 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 { deriveRegistry, @@ -58,7 +59,11 @@ export function UniversePage() { 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); } @@ -73,7 +78,14 @@ export function UniversePage() { 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); } 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", From b9139e515306042b0431fecffe06c79557257a9b Mon Sep 17 00:00:00 2001 From: Wotori Movako Date: Tue, 12 May 2026 10:21:07 +0300 Subject: [PATCH 08/10] Add direct explorer links for created universe --- app/src/lib/stellar.ts | 30 ++++++++++++++++++++++++++++-- app/src/pages/UniversePage.tsx | 22 +++++++++++++++++++--- app/src/styles.css | 16 ++++++++++++++++ 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/app/src/lib/stellar.ts b/app/src/lib/stellar.ts index 68b3d53..f2df6d8 100644 --- a/app/src/lib/stellar.ts +++ b/app/src/lib/stellar.ts @@ -34,15 +34,41 @@ export function createClient( } 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")) { + if (resolvedEndpoint.includes("127.0.0.1") || resolvedEndpoint.includes("localhost")) { return `https://explorer.solana.com/tx/${signature}?cluster=custom&customUrl=${encodeURIComponent( - endpoint + 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); } diff --git a/app/src/pages/UniversePage.tsx b/app/src/pages/UniversePage.tsx index e56b0b0..2399878 100644 --- a/app/src/pages/UniversePage.tsx +++ b/app/src/pages/UniversePage.tsx @@ -9,6 +9,8 @@ import { deriveUniverse, deriveUniverseIndex, enumValue, + accountExplorerUrl, + solscanAccountUrl, systemProgram, } from "../lib/stellar"; @@ -119,10 +121,24 @@ export function UniversePage() { {universe ? ( -
- Derived universe PDA - {universe.toBase58()} +
+ 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; From 951d44015649ccc2a7f85c08b4cb36d3e4dc99f1 Mon Sep 17 00:00:00 2001 From: Wotori Movako Date: Wed, 13 May 2026 22:11:21 +0300 Subject: [PATCH 09/10] Add Wotori localnet seeding workflow Expand localnet Make targets for Metaplex cloning, Wotori metadata serving and seeding, and local avatar program deployment. Improve demo app asset logs with explorer links, sync SDK IDL error codes, and add a finalizeWeightedRelease ABI fallback. --- Makefile | 101 +- app/src/components/LogPanel.tsx | 30 +- app/src/pages/AssetsPage.tsx | 153 ++- scripts/deploy-wotori-universe-localnet.js | 1323 ++++++++++++++++++++ sdk/idl/solana_stellar.json | 9 +- sdk/idl/solana_stellar.ts | 9 +- sdk/src/instructions.ts | 15 +- 7 files changed, 1595 insertions(+), 45 deletions(-) create mode 100755 scripts/deploy-wotori-universe-localnet.js diff --git a/Makefile b/Makefile index 85f2b97..b3b4137 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ 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 @@ -12,23 +13,49 @@ 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 \ + 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) @@ -51,10 +78,11 @@ 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 metadata-server app-dev \ - seed-random-models seed-new-random-models seed-new-single \ - seed-everything-localnet seed-new-everything-localnet seed-new-single-localnet \ - setup-localnet setup-localnet-single capture-seed-previews deploy-everything-localnet + 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" \ @@ -68,27 +96,36 @@ help: " make airdrop CLUSTER=localnet Airdrop AIRDROP_SOL to WALLET" \ "" \ "Local testing:" \ - " make localnet Run solana-test-validator" \ + " 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" + " 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\nMODEL_COUNT=%s\nMODEL_FORMAT=%s\nMETADATA_BASE_URL=%s\n" \ - "$(CLUSTER)" "$(ANCHOR_CLUSTER)" "$(RPC_URL)" "$(WALLET)" "$(EVERYTHING_DIR)" "$(MODEL_COUNT)" "$(MODEL_FORMAT)" "$(METADATA_BASE_URL)" + @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 \ @@ -104,7 +141,7 @@ check-airdrop: check-seed-cluster: @if [[ "$(ANCHOR_CLUSTER)" != "localnet" && "$(ALLOW_REMOTE_SEED)" != "1" ]]; then \ - echo "The random-model seeder is intended for localnet and stores METADATA_BASE_URL=$(METADATA_BASE_URL)."; \ + 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 @@ -159,12 +196,20 @@ 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) + 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 @@ -176,6 +221,12 @@ 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 @@ -186,6 +237,12 @@ 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 @@ -196,9 +253,27 @@ setup-localnet-single: $(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/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/pages/AssetsPage.tsx b/app/src/pages/AssetsPage.tsx index 0602ff9..abaefc7 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(); @@ -17,12 +25,18 @@ export function AssetsPage() { 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); @@ -36,7 +50,7 @@ export function AssetsPage() { enumValue(subtype) as any, enumValue("unknown") as any, metadataHash, - previewHash, + previewHash ) .accountsStrict({ universe, @@ -48,9 +62,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 { @@ -74,7 +109,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)); @@ -122,49 +160,114 @@ 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/scripts/deploy-wotori-universe-localnet.js b/scripts/deploy-wotori-universe-localnet.js new file mode 100755 index 0000000..685c9c1 --- /dev/null +++ b/scripts/deploy-wotori-universe-localnet.js @@ -0,0 +1,1323 @@ +#!/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"), + collaborationPolicy: enumValue("custom"), + 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/sdk/idl/solana_stellar.json b/sdk/idl/solana_stellar.json index a20855c..ce2c0b7 100644 --- a/sdk/idl/solana_stellar.json +++ b/sdk/idl/solana_stellar.json @@ -973,16 +973,21 @@ }, { "code": 6021, + "name": "InsufficientVaultBalanceForClaim", + "msg": "Release vault balance is below required reserve for claims." + }, + { + "code": 6022, "name": "NoRevenueToClaim", "msg": "No revenue available to claim." }, { - "code": 6022, + "code": 6023, "name": "InsufficientVaultBalance", "msg": "Release vault balance is insufficient." }, { - "code": 6023, + "code": 6024, "name": "NumericalOverflow", "msg": "Numerical overflow occurred." } diff --git a/sdk/idl/solana_stellar.ts b/sdk/idl/solana_stellar.ts index 655bc4b..14e021a 100644 --- a/sdk/idl/solana_stellar.ts +++ b/sdk/idl/solana_stellar.ts @@ -1039,16 +1039,21 @@ export type SolanaStellar = { }, { code: 6021; + name: "insufficientVaultBalanceForClaim"; + msg: "Release vault balance is below required reserve for claims."; + }, + { + code: 6022; name: "noRevenueToClaim"; msg: "No revenue available to claim."; }, { - code: 6022; + code: 6023; name: "insufficientVaultBalance"; msg: "Release vault balance is insufficient."; }, { - code: 6023; + code: 6024; name: "numericalOverflow"; msg: "Numerical overflow occurred."; } diff --git a/sdk/src/instructions.ts b/sdk/src/instructions.ts index a3f24d6..c9f268a 100644 --- a/sdk/src/instructions.ts +++ b/sdk/src/instructions.ts @@ -370,8 +370,19 @@ export async function finalizeWeightedRelease( remainingAccounts: AccountMeta[]; } ) { - const signature = await client.program.methods - .finalizeWeightedRelease(args.assetCount, args.linkCount) + 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, From 86c104c6cd292e338e15432d3db4ebfb4771f975 Mon Sep 17 00:00:00 2001 From: Wotori Movako Date: Sun, 24 May 2026 15:04:35 +0300 Subject: [PATCH 10/10] feat(stellar): move collaboration policy to assets Keep universe creation policy/open state on universes, move release collaboration policy to assets, regenerate IDL/SDK, and update protocol tests. --- app/src/lib/errors.ts | 26 +++- app/src/lib/stellar.ts | 24 +++- app/src/pages/AssetsPage.tsx | 24 +++- app/src/pages/UniversePage.tsx | 84 ++++++++---- programs/solana-stellar/src/error.rs | 4 +- programs/solana-stellar/src/handlers/asset.rs | 19 ++- .../solana-stellar/src/handlers/release.rs | 2 +- .../solana-stellar/src/handlers/universe.rs | 9 +- programs/solana-stellar/src/lib.rs | 28 ++-- programs/solana-stellar/src/state.rs | 28 ++-- scripts/capture-manifest-previews.js | 38 ++++-- scripts/deploy-random-models-localnet.js | 10 +- scripts/deploy-wotori-universe-localnet.js | 1 - sdk/idl/solana_stellar.json | 119 +++++++++-------- sdk/idl/solana_stellar.ts | 119 +++++++++-------- sdk/src/instructions.ts | 35 +++-- tests/solana-stellar.ts | 122 +++++++++++++----- 17 files changed, 462 insertions(+), 230 deletions(-) diff --git a/app/src/lib/errors.ts b/app/src/lib/errors.ts index 43e1135..96d3dfb 100644 --- a/app/src/lib/errors.ts +++ b/app/src/lib/errors.ts @@ -13,16 +13,30 @@ function collectHints(error: unknown, message: string) { 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."); + 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("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."); + 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); diff --git a/app/src/lib/stellar.ts b/app/src/lib/stellar.ts index f2df6d8..10020b8 100644 --- a/app/src/lib/stellar.ts +++ b/app/src/lib/stellar.ts @@ -36,7 +36,10 @@ export function createClient( export function explorerUrl(signature: string, endpoint: string) { const resolvedEndpoint = endpointForExplorer(endpoint); const cluster = endpoint.includes("devnet") ? "devnet" : "custom"; - if (resolvedEndpoint.includes("127.0.0.1") || resolvedEndpoint.includes("localhost")) { + if ( + resolvedEndpoint.includes("127.0.0.1") || + resolvedEndpoint.includes("localhost") + ) { return `https://explorer.solana.com/tx/${signature}?cluster=custom&customUrl=${encodeURIComponent( resolvedEndpoint )}`; @@ -47,7 +50,10 @@ export function explorerUrl(signature: string, endpoint: string) { 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")) { + if ( + resolvedEndpoint.includes("127.0.0.1") || + resolvedEndpoint.includes("localhost") + ) { return `https://explorer.solana.com/address/${address}?cluster=custom&customUrl=${encodeURIComponent( resolvedEndpoint )}`; @@ -56,9 +62,12 @@ export function accountExplorerUrl(address: string, endpoint: string) { } 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; + 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}`; } @@ -100,7 +109,10 @@ export function deriveUniverse(owner: PublicKey, index: number) { } export function deriveRegistry() { - return PublicKey.findProgramAddressSync([Buffer.from("registry")], PROGRAM_ID)[0]; + return PublicKey.findProgramAddressSync( + [Buffer.from("registry")], + PROGRAM_ID + )[0]; } export function deriveUniverseIndex(globalIndex: number) { diff --git a/app/src/pages/AssetsPage.tsx b/app/src/pages/AssetsPage.tsx index abaefc7..3db77db 100644 --- a/app/src/pages/AssetsPage.tsx +++ b/app/src/pages/AssetsPage.tsx @@ -22,6 +22,8 @@ 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( @@ -50,7 +52,9 @@ export function AssetsPage() { enumValue(subtype) as any, enumValue("unknown") as any, metadataHash, - previewHash + previewHash, + open, + enumValue(collaborationPolicy) as any ) .accountsStrict({ universe, @@ -238,6 +242,24 @@ export function AssetsPage() { onChange={(event) => setPreviewHash(event.target.value)} />
+ + + +
diff --git a/app/src/pages/UniversePage.tsx b/app/src/pages/UniversePage.tsx index 2399878..08f8e6a 100644 --- a/app/src/pages/UniversePage.tsx +++ b/app/src/pages/UniversePage.tsx @@ -33,7 +33,9 @@ export function UniversePage() { setLoading(true); try { const registry = deriveRegistry(); - const registryAccount = await client.provider.connection.getAccountInfo(registry); + const registryAccount = await client.provider.connection.getAccountInfo( + registry + ); const registryData = registryAccount ? await client.program.account.registry.fetch(registry) : null; @@ -46,8 +48,7 @@ export function UniversePage() { new anchor.BN(universeIndex), metadataHash, enumValue("Model3d") as any, - enumValue("Custom") as any, - open, + open ) .accountsStrict({ registry, @@ -58,13 +59,19 @@ export function UniversePage() { }) .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", - formatRpcError(error, "Could not create universe with current RPC endpoint.") + formatRpcError( + error, + "Could not create universe with current RPC endpoint." + ) ); } finally { setLoading(false); @@ -77,8 +84,15 @@ 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", @@ -100,45 +114,67 @@ export function UniversePage() { >
- setIndex(event.target.value)} inputMode="numeric" /> + setIndex(event.target.value)} + inputMode="numeric" + /> - setMetadataHash(event.target.value)} /> + setMetadataHash(event.target.value)} + />
- -
{universe ? ( -
- Derived universe PDA - {universe.toBase58()} -
- - Open in Solana Explorer - - {solscanAccountUrl(universe.toBase58(), state.endpoint) ? ( +
+ Derived universe PDA + {universe.toBase58()} +
- Open in Solscan + Open in Solana Explorer - ) : null} + {solscanAccountUrl(universe.toBase58(), state.endpoint) ? ( + + Open in Solscan + + ) : null} +
-
) : null} ); diff --git a/programs/solana-stellar/src/error.rs b/programs/solana-stellar/src/error.rs index 88af1d9..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,7 +42,7 @@ pub enum StellarError { InvalidShareBps, #[msg("Invalid release distribution model for this operation.")] InvalidDistributionModel, - #[msg("Collaboration policy is immutable after universe creation.")] + #[msg("Collaboration policy is immutable after asset creation.")] ImmutableCollaborationPolicy, #[msg("Invalid revenue amount.")] InvalidRevenueAmount, diff --git a/programs/solana-stellar/src/handlers/asset.rs b/programs/solana-stellar/src/handlers/asset.rs index ba5f5bf..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, LicenseKind, UniverseStatus}, + state::{ + AssetKind, AssetStatus, AssetSubtype, CollaborationPolicy, LicenseKind, UniverseStatus, + }, utils::{validate_hash, validate_optional_hash}, }; @@ -18,6 +20,8 @@ pub fn create_asset( license_kind: LicenseKind, metadata_hash: String, preview_hash: String, + open: bool, + collaboration_policy: CollaborationPolicy, ) -> Result<()> { validate_hash(&metadata_hash)?; validate_optional_hash(&preview_hash)?; @@ -51,6 +55,8 @@ pub fn create_asset( 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; @@ -78,6 +84,8 @@ pub fn update_asset_metadata( license_kind: LicenseKind, metadata_hash: String, preview_hash: String, + open: bool, + collaboration_policy: CollaborationPolicy, ) -> Result<()> { validate_hash(&metadata_hash)?; validate_optional_hash(&preview_hash)?; @@ -87,10 +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(()) @@ -113,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(); diff --git a/programs/solana-stellar/src/handlers/release.rs b/programs/solana-stellar/src/handlers/release.rs index 492dfa2..cd7df7d 100644 --- a/programs/solana-stellar/src/handlers/release.rs +++ b/programs/solana-stellar/src/handlers/release.rs @@ -61,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; diff --git a/programs/solana-stellar/src/handlers/universe.rs b/programs/solana-stellar/src/handlers/universe.rs index 16f8bbc..dc491ac 100644 --- a/programs/solana-stellar/src/handlers/universe.rs +++ b/programs/solana-stellar/src/handlers/universe.rs @@ -4,7 +4,7 @@ use crate::{ contexts::{CloseUniverse, CreateUniverse, UpdateUniverse}, error::StellarError, events::{UniverseCreated, UniverseUpdated}, - state::{AssetKind, CollaborationPolicy, UniverseStatus}, + state::{AssetKind, UniverseStatus}, utils::validate_hash, }; @@ -13,7 +13,6 @@ pub fn create_universe( universe_index: u64, metadata_hash: String, project_type: AssetKind, - collaboration_policy: CollaborationPolicy, open: bool, ) -> Result<()> { validate_hash(&metadata_hash)?; @@ -35,7 +34,6 @@ pub fn create_universe( 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; @@ -66,15 +64,10 @@ 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; - require!( - universe.collaboration_policy == collaboration_policy, - StellarError::ImmutableCollaborationPolicy - ); universe.metadata_hash = metadata_hash; universe.open = open; universe.updated_at = Clock::get()?.unix_timestamp; diff --git a/programs/solana-stellar/src/lib.rs b/programs/solana-stellar/src/lib.rs index 42b8197..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<()> { @@ -57,6 +48,8 @@ pub mod solana_stellar { license_kind: LicenseKind, metadata_hash: String, preview_hash: String, + open: bool, + collaboration_policy: CollaborationPolicy, ) -> Result<()> { handlers::create_asset( ctx, @@ -66,6 +59,8 @@ pub mod solana_stellar { license_kind, metadata_hash, preview_hash, + open, + collaboration_policy, ) } @@ -74,8 +69,17 @@ pub mod solana_stellar { license_kind: LicenseKind, metadata_hash: String, preview_hash: String, + open: bool, + collaboration_policy: CollaborationPolicy, ) -> Result<()> { - handlers::update_asset_metadata(ctx, license_kind, 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<()> { diff --git a/programs/solana-stellar/src/state.rs b/programs/solana-stellar/src/state.rs index ae0c9b1..bc6bbb6 100644 --- a/programs/solana-stellar/src/state.rs +++ b/programs/solana-stellar/src/state.rs @@ -36,18 +36,13 @@ pub struct Universe { pub open: bool, pub status: UniverseStatus, pub project_type: AssetKind, - /// Revenue distribution policy used for releases in this universe. - /// It is immutable after universe creation so admins cannot alter the - /// economic deal that contributors relied on when joining. - 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 + 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] @@ -61,6 +56,9 @@ pub struct Asset { 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, @@ -69,8 +67,22 @@ pub struct Asset { } impl Asset { - pub const INIT_SPACE: usize = - 32 + 8 + 32 + 32 + 1 + 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] diff --git a/scripts/capture-manifest-previews.js b/scripts/capture-manifest-previews.js index 9d2bd88..84ff6f8 100644 --- a/scripts/capture-manifest-previews.js +++ b/scripts/capture-manifest-previews.js @@ -4,7 +4,11 @@ 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 { + createClient, + enumValue, + updateAssetMetadata, +} = require("../sdk/dist/src"); const DEFAULT_FOLDER = path.resolve(__dirname, "../univerces/everything"); const DEFAULT_ENDPOINT = "http://127.0.0.1:8899"; @@ -104,7 +108,9 @@ function previewUrl(previewFile, folder, metadataBaseUrl) { } function loadKeypair(filePath) { - const secretKey = Uint8Array.from(JSON.parse(fs.readFileSync(filePath, "utf8"))); + const secretKey = Uint8Array.from( + JSON.parse(fs.readFileSync(filePath, "utf8")) + ); return Keypair.fromSecretKey(secretKey); } @@ -139,10 +145,14 @@ async function main() { 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", - }) + ? createClient( + new Connection(args.endpoint, "confirmed"), + new anchor.Wallet(owner), + { + commitment: "processed", + preflightCommitment: "processed", + } + ) : null; const { chromium } = requirePlaywright(); @@ -221,13 +231,15 @@ async function main() { metadataHash: asset.metadataHash, previewHash: urlForMetadata, }); - asset.modelAssetPreviewUpdateSignature = await updatePreviewHashOnChain({ - client, - owner, - assetAddress: asset.modelAssetAddress, - metadataHash: asset.modelAssetMetadataHash, - 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) { diff --git a/scripts/deploy-random-models-localnet.js b/scripts/deploy-random-models-localnet.js index d76dc45..4a0bc59 100755 --- a/scripts/deploy-random-models-localnet.js +++ b/scripts/deploy-random-models-localnet.js @@ -34,11 +34,13 @@ const EVERYTHING_LIBRARY_ATTRIBUTION = { 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)", + 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.", + 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"; @@ -188,7 +190,8 @@ function findPreviewFile(folder, title) { .readdirSync(previewsDir) .find((file) => new RegExp(`-${escapedTitle}\\.png$`).test(file)); - if (previewFile) return path.join(SERVICE_DIR_NAME, "previews", previewFile); + if (previewFile) + return path.join(SERVICE_DIR_NAME, "previews", previewFile); } return null; @@ -282,7 +285,6 @@ async function createFreshUniverse({ universeIndex, metadataHash: universeMetadataHash, projectType: enumValue("model3D"), - collaborationPolicy: enumValue("custom"), open: true, }); await waitForAccount(client.connection, universe, "universe"); diff --git a/scripts/deploy-wotori-universe-localnet.js b/scripts/deploy-wotori-universe-localnet.js index 685c9c1..3a8d3fb 100755 --- a/scripts/deploy-wotori-universe-localnet.js +++ b/scripts/deploy-wotori-universe-localnet.js @@ -605,7 +605,6 @@ async function createFreshUniverse({ universeIndex, metadataHash: universeMetadataHash, projectType: enumValue("metadata"), - collaborationPolicy: enumValue("custom"), open: plan.universe.open !== false, }); await waitForAccount(client.connection, universe, "universe"); diff --git a/sdk/idl/solana_stellar.json b/sdk/idl/solana_stellar.json index ce2c0b7..883a718 100644 --- a/sdk/idl/solana_stellar.json +++ b/sdk/idl/solana_stellar.json @@ -344,6 +344,18 @@ { "name": "preview_hash", "type": "string" + }, + { + "name": "open", + "type": "bool" + }, + { + "name": "collaboration_policy", + "type": { + "defined": { + "name": "CollaborationPolicy" + } + } } ] }, @@ -501,14 +513,6 @@ } } }, - { - "name": "collaboration_policy", - "type": { - "defined": { - "name": "CollaborationPolicy" - } - } - }, { "name": "open", "type": "bool" @@ -744,6 +748,18 @@ { "name": "preview_hash", "type": "string" + }, + { + "name": "open", + "type": "bool" + }, + { + "name": "collaboration_policy", + "type": { + "defined": { + "name": "CollaborationPolicy" + } + } } ] }, @@ -769,14 +785,6 @@ { "name": "open", "type": "bool" - }, - { - "name": "collaboration_policy", - "type": { - "defined": { - "name": "CollaborationPolicy" - } - } } ] } @@ -878,116 +886,121 @@ }, { "code": 6002, + "name": "AssetClosed", + "msg": "Asset is closed to public collaboration." + }, + { + "code": 6003, "name": "UniverseNotActive", "msg": "Universe is not active." }, { - "code": 6003, + "code": 6004, "name": "UniverseNotEmpty", "msg": "Universe still has live assets or releases." }, { - "code": 6004, + "code": 6005, "name": "InvalidHash", "msg": "Invalid metadata or content hash." }, { - "code": 6005, + "code": 6006, "name": "InvalidAssetIndex", "msg": "Invalid asset index." }, { - "code": 6006, + "code": 6007, "name": "InvalidReleaseIndex", "msg": "Invalid release index." }, { - "code": 6007, + "code": 6008, "name": "AssetLocked", "msg": "Asset is locked for this operation." }, { - "code": 6008, + "code": 6009, "name": "InvalidAssetStatus", "msg": "Invalid asset status for this operation." }, { - "code": 6009, + "code": 6010, "name": "UniverseMismatch", "msg": "Universe mismatch." }, { - "code": 6010, + "code": 6011, "name": "AssetMismatch", "msg": "Asset mismatch." }, { - "code": 6011, + "code": 6012, "name": "ReleaseMismatch", "msg": "Release mismatch." }, { - "code": 6012, + "code": 6013, "name": "InvalidLineageLink", "msg": "Invalid lineage link." }, { - "code": 6013, + "code": 6014, "name": "InvalidLineageProof", "msg": "Invalid lineage proof." }, { - "code": 6014, + "code": 6015, "name": "InvalidContributorCount", "msg": "Invalid contributor count." }, { - "code": 6015, + "code": 6016, "name": "ReleaseLocked", "msg": "Release is locked for this operation." }, { - "code": 6016, + "code": 6017, "name": "ReleaseNotFinalized", "msg": "Release is not finalized." }, { - "code": 6017, + "code": 6018, "name": "InvalidShareBps", "msg": "Invalid contributor share basis points." }, { - "code": 6018, + "code": 6019, "name": "InvalidDistributionModel", "msg": "Invalid release distribution model for this operation." }, { - "code": 6019, + "code": 6020, "name": "ImmutableCollaborationPolicy", - "msg": "Collaboration policy is immutable after universe creation." + "msg": "Collaboration policy is immutable after asset creation." }, { - "code": 6020, + "code": 6021, "name": "InvalidRevenueAmount", "msg": "Invalid revenue amount." }, { - "code": 6021, + "code": 6022, "name": "InsufficientVaultBalanceForClaim", "msg": "Release vault balance is below required reserve for claims." }, { - "code": 6022, + "code": 6023, "name": "NoRevenueToClaim", "msg": "No revenue available to claim." }, { - "code": 6023, + "code": 6024, "name": "InsufficientVaultBalance", "msg": "Release vault balance is insufficient." }, { - "code": 6024, + "code": 6025, "name": "NumericalOverflow", "msg": "Numerical overflow occurred." } @@ -1050,6 +1063,21 @@ } } }, + { + "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" @@ -1658,19 +1686,6 @@ } } }, - { - "name": "collaboration_policy", - "docs": [ - "Revenue distribution policy used for releases in this universe.", - "It is immutable after universe creation so admins cannot alter the", - "economic deal that contributors relied on when joining." - ], - "type": { - "defined": { - "name": "CollaborationPolicy" - } - } - }, { "name": "metadata_hash", "type": "string" diff --git a/sdk/idl/solana_stellar.ts b/sdk/idl/solana_stellar.ts index 14e021a..b5eb650 100644 --- a/sdk/idl/solana_stellar.ts +++ b/sdk/idl/solana_stellar.ts @@ -374,6 +374,18 @@ export type SolanaStellar = { { name: "previewHash"; type: "string"; + }, + { + name: "open"; + type: "bool"; + }, + { + name: "collaborationPolicy"; + type: { + defined: { + name: "collaborationPolicy"; + }; + }; } ]; }, @@ -555,14 +567,6 @@ export type SolanaStellar = { }; }; }, - { - name: "collaborationPolicy"; - type: { - defined: { - name: "collaborationPolicy"; - }; - }; - }, { name: "open"; type: "bool"; @@ -810,6 +814,18 @@ export type SolanaStellar = { { name: "previewHash"; type: "string"; + }, + { + name: "open"; + type: "bool"; + }, + { + name: "collaborationPolicy"; + type: { + defined: { + name: "collaborationPolicy"; + }; + }; } ]; }, @@ -835,14 +851,6 @@ export type SolanaStellar = { { name: "open"; type: "bool"; - }, - { - name: "collaborationPolicy"; - type: { - defined: { - name: "collaborationPolicy"; - }; - }; } ]; } @@ -944,116 +952,121 @@ export type SolanaStellar = { }, { code: 6002; + name: "assetClosed"; + msg: "Asset is closed to public collaboration."; + }, + { + code: 6003; name: "universeNotActive"; msg: "Universe is not active."; }, { - code: 6003; + code: 6004; name: "universeNotEmpty"; msg: "Universe still has live assets or releases."; }, { - code: 6004; + code: 6005; name: "invalidHash"; msg: "Invalid metadata or content hash."; }, { - code: 6005; + code: 6006; name: "invalidAssetIndex"; msg: "Invalid asset index."; }, { - code: 6006; + code: 6007; name: "invalidReleaseIndex"; msg: "Invalid release index."; }, { - code: 6007; + code: 6008; name: "assetLocked"; msg: "Asset is locked for this operation."; }, { - code: 6008; + code: 6009; name: "invalidAssetStatus"; msg: "Invalid asset status for this operation."; }, { - code: 6009; + code: 6010; name: "universeMismatch"; msg: "Universe mismatch."; }, { - code: 6010; + code: 6011; name: "assetMismatch"; msg: "Asset mismatch."; }, { - code: 6011; + code: 6012; name: "releaseMismatch"; msg: "Release mismatch."; }, { - code: 6012; + code: 6013; name: "invalidLineageLink"; msg: "Invalid lineage link."; }, { - code: 6013; + code: 6014; name: "invalidLineageProof"; msg: "Invalid lineage proof."; }, { - code: 6014; + code: 6015; name: "invalidContributorCount"; msg: "Invalid contributor count."; }, { - code: 6015; + code: 6016; name: "releaseLocked"; msg: "Release is locked for this operation."; }, { - code: 6016; + code: 6017; name: "releaseNotFinalized"; msg: "Release is not finalized."; }, { - code: 6017; + code: 6018; name: "invalidShareBps"; msg: "Invalid contributor share basis points."; }, { - code: 6018; + code: 6019; name: "invalidDistributionModel"; msg: "Invalid release distribution model for this operation."; }, { - code: 6019; + code: 6020; name: "immutableCollaborationPolicy"; - msg: "Collaboration policy is immutable after universe creation."; + msg: "Collaboration policy is immutable after asset creation."; }, { - code: 6020; + code: 6021; name: "invalidRevenueAmount"; msg: "Invalid revenue amount."; }, { - code: 6021; + code: 6022; name: "insufficientVaultBalanceForClaim"; msg: "Release vault balance is below required reserve for claims."; }, { - code: 6022; + code: 6023; name: "noRevenueToClaim"; msg: "No revenue available to claim."; }, { - code: 6023; + code: 6024; name: "insufficientVaultBalance"; msg: "Release vault balance is insufficient."; }, { - code: 6024; + code: 6025; name: "numericalOverflow"; msg: "Numerical overflow occurred."; } @@ -1116,6 +1129,21 @@ export type SolanaStellar = { }; }; }, + { + 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"; @@ -1724,19 +1752,6 @@ export type SolanaStellar = { }; }; }, - { - name: "collaborationPolicy"; - docs: [ - "Revenue distribution policy used for releases in this universe.", - "It is immutable after universe creation so admins cannot alter the", - "economic deal that contributors relied on when joining." - ]; - type: { - defined: { - name: "collaborationPolicy"; - }; - }; - }, { name: "metadataHash"; type: "string"; diff --git a/sdk/src/instructions.ts b/sdk/src/instructions.ts index c9f268a..3843e1f 100644 --- a/sdk/src/instructions.ts +++ b/sdk/src/instructions.ts @@ -16,6 +16,7 @@ import { import { toNumber } from "./utils"; type EnumArg = Record>; +const defaultCollaborationPolicy = (): EnumArg => ({ custom: {} }); export async function nextUniverseIndex( client: StellarClient, @@ -39,8 +40,7 @@ export async function createUniverse( universeIndex: number; metadataHash: string; projectType: EnumArg; - collaborationPolicy: EnumArg; - open: boolean; + open?: boolean; } ) { const registry = deriveRegistry(); @@ -56,8 +56,7 @@ export async function createUniverse( new anchor.BN(args.universeIndex), args.metadataHash, args.projectType as any, - args.collaborationPolicy as any, - args.open + args.open ?? true ) .accountsStrict({ registry, @@ -77,15 +76,17 @@ export async function updateUniverse( universe: PublicKey; owner: PublicKey; metadataHash: string; - open: boolean; - collaborationPolicy: EnumArg; + 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, - args.collaborationPolicy as any + args.open ?? Boolean(current?.open ?? true) ) .accountsStrict({ universe: args.universe, @@ -122,6 +123,8 @@ export async function createAsset( licenseKind: EnumArg; metadataHash: string; previewHash: string; + open?: boolean; + collaborationPolicy?: EnumArg; } ) { const asset = deriveAsset(args.universe, args.assetIndex); @@ -132,7 +135,9 @@ export async function createAsset( args.subtype as any, args.licenseKind as any, args.metadataHash, - args.previewHash + args.previewHash, + args.open ?? true, + (args.collaborationPolicy ?? defaultCollaborationPolicy()) as any ) .accountsStrict({ universe: args.universe, @@ -153,13 +158,23 @@ export async function updateAssetMetadata( 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.previewHash, + args.open ?? Boolean(current?.open ?? true), + (args.collaborationPolicy ?? + current?.collaborationPolicy ?? + defaultCollaborationPolicy()) as any ) .accountsStrict({ asset: args.asset, creator: args.creator }) .rpc(); diff --git a/tests/solana-stellar.ts b/tests/solana-stellar.ts index 62bf729..65a5492 100644 --- a/tests/solana-stellar.ts +++ b/tests/solana-stellar.ts @@ -110,7 +110,6 @@ describe("solana-stellar", () => { new anchor.BN(0), "QmUniverseMetadataHash", { model3D: {} } as any, - { custom: {} } as any, true ) .accountsStrict({ @@ -129,7 +128,9 @@ describe("solana-stellar", () => { { concept: {} } as any, { ccBy4: {} } as any, "QmConceptMetadataHash", - "QmConceptPreviewHash" + "QmConceptPreviewHash", + true, + { custom: {} } as any ) .accountsStrict({ universe, @@ -160,7 +161,9 @@ describe("solana-stellar", () => { { final: {} } as any, { unknown: {} } as any, "QmModelMetadataHash", - "QmModelPreviewHash" + "QmModelPreviewHash", + true, + { custom: {} } as any ) .accountsStrict({ universe, @@ -319,7 +322,6 @@ describe("solana-stellar", () => { new anchor.BN(1), "QmUniverseMetadataHash2", { model3D: {} } as any, - { equal: {} } as any, true ) .accountsStrict({ @@ -338,7 +340,9 @@ describe("solana-stellar", () => { { concept: {} } as any, { ccBy4: {} } as any, "QmBaseMetadataHash", - "QmBasePreviewHash" + "QmBasePreviewHash", + true, + { equal: {} } as any ) .accountsStrict({ universe, @@ -363,7 +367,9 @@ describe("solana-stellar", () => { { texture: {} } as any, { ccBy4: {} } as any, "QmUvMetadataHash", - "QmUvPreviewHash" + "QmUvPreviewHash", + true, + { equal: {} } as any ) .accountsStrict({ universe, @@ -401,7 +407,9 @@ describe("solana-stellar", () => { { motion: {} } as any, { ccBy4: {} } as any, "QmAnimMetadataHash", - "QmAnimPreviewHash" + "QmAnimPreviewHash", + true, + { equal: {} } as any ) .accountsStrict({ universe, @@ -446,7 +454,9 @@ describe("solana-stellar", () => { { final: {} } as any, { ccBy4: {} } as any, "QmFinalMetadataHash", - "QmFinalPreviewHash" + "QmFinalPreviewHash", + true, + { equal: {} } as any ) .accountsStrict({ universe, @@ -645,7 +655,9 @@ describe("solana-stellar", () => { subtype, { ccBy4: {} } as any, metadataHash, - previewHash + previewHash, + true, + { weighted: {} } as any ) .accountsStrict({ universe, @@ -699,7 +711,6 @@ describe("solana-stellar", () => { new anchor.BN(2), "QmWeightedUniverseMetadata", { model3D: {} } as any, - { weighted: {} } as any, true ) .accountsStrict({ @@ -905,7 +916,6 @@ describe("solana-stellar", () => { new anchor.BN(3), "QmUniverseRevenueMetadataHash", { model3D: {} } as any, - { custom: {} } as any, true ) .accountsStrict({ @@ -924,7 +934,9 @@ describe("solana-stellar", () => { { concept: {} } as any, { ccBy4: {} } as any, "QmRevenueMetadataHash", - "QmRevenuePreviewHash" + "QmRevenuePreviewHash", + true, + { custom: {} } as any ) .accountsStrict({ universe, @@ -1000,7 +1012,9 @@ describe("solana-stellar", () => { }) .rpc(); - const vaultBalanceBeforeDeposit = await provider.connection.getBalance(vault); + const vaultBalanceBeforeDeposit = await provider.connection.getBalance( + vault + ); await program.methods .depositRevenue(new anchor.BN(1_000_000)) @@ -1043,13 +1057,19 @@ describe("solana-stellar", () => { }) .rpc(); - const fetchedOwnerShare = await program.account.contributorShare.fetch(ownerShare); + const fetchedOwnerShare = await program.account.contributorShare.fetch( + ownerShare + ); const fetchedContributorShare = await program.account.contributorShare.fetch(contributorShare); - const fetchedBranchShare = await program.account.contributorShare.fetch(branchShare); + 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(fetchedContributorShare.claimedLamports.toNumber()).to.equal( + 333_300 + ); expect(fetchedBranchShare.claimedLamports.toNumber()).to.equal(333_400); const fetchedRelease = await program.account.release.fetch(release); @@ -1060,9 +1080,10 @@ describe("solana-stellar", () => { const vaultBalanceAfterClaims = await provider.connection.getBalance(vault); expect(vaultBalanceAfterClaims).to.equal(vaultBalanceBeforeDeposit); - const vaultRentReserve = await provider.connection.getMinimumBalanceForRentExemption( - releaseVaultRentExemptBytes - ); + const vaultRentReserve = + await provider.connection.getMinimumBalanceForRentExemption( + releaseVaultRentExemptBytes + ); expect(vaultBalanceAfterClaims).to.equal(vaultRentReserve); try { @@ -1083,7 +1104,7 @@ describe("solana-stellar", () => { } }); - it("keeps universe collaboration policy immutable after creation", async () => { + it("keeps collaboration settings on assets instead of universes", async () => { const registry = registryPda(); const registryDataBefore = (await program.account.registry.fetch( registry @@ -1092,13 +1113,13 @@ describe("solana-stellar", () => { const ownerIndex = globalIndex; const universe = universePda(ownerIndex); const universeLookup = universeIndexPda(globalIndex); + const asset = assetPda(universe, 0); await program.methods .createUniverse( new anchor.BN(ownerIndex), - "QmImmutablePolicyMetadata", + "QmAssetPolicyUniverseMetadata", { model3D: {} } as any, - { equal: {} } as any, true ) .accountsStrict({ @@ -1111,24 +1132,61 @@ describe("solana-stellar", () => { .rpc(); await program.methods - .updateUniverse("QmImmutablePolicyMetadata2", false, { equal: {} } as any) + .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 - .updateUniverse("QmImmutablePolicyMetadata3", true, { - custom: {}, - } as any) + .updateAssetMetadata( + { ccBy4: {} } as any, + "QmAssetPolicyMetadata3", + "QmAssetPolicyPreview3", + true, + { custom: {} } as any + ) .accountsStrict({ - universe, - owner: owner.publicKey, + asset, + creator: owner.publicKey, }) .rpc(); - expect.fail("Expected collaboration policy change to be rejected"); + expect.fail("Expected asset collaboration policy change to be rejected"); } catch (error: any) { expect(error.error?.errorCode?.code).to.equal( "ImmutableCollaborationPolicy" @@ -1136,6 +1194,10 @@ describe("solana-stellar", () => { } const fetchedUniverse = await program.account.universe.fetch(universe); - expect(fetchedUniverse.collaborationPolicy).to.deep.equal({ equal: {} }); + 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: {} }); }); });