ClipChain Remix
Mint provenance tokens for remixed clips to credit original and derivative creators fairly.
Soroban NFT provenance· onchain authorship
Section · Onchain
full primer →The primitive.
Videographers mint each video remixing as an NFT-style entry in a Soroban contract on the Stellar Testnet, pointing at an IPFS CID — a public, verifiable authorship badge for every piece.
Why this primitiveSoroban NFT NFT minting verifies remix lineage and enforces creator ownership onchain.
Kernel
a Soroban token contract that mints a creator-owned entry pointing at an IPFS CID and records the author's Stellar address in contract state
Drives the UI as
a 'mint to claim authorship' button that returns the token ID, owner address, and Stellar Expert link
Required keys.
PRIVY_APP_ID
Privy app ID (public). Provisions the embedded Stellar wallet on email / Google sign-in.
open ↗PRIVY_APP_SECRET
Privy app secret (server-only). Verifies the user token and raw-signs the tx hash with the embedded Ed25519 key.
open ↗Add these in your Lovable project under Settings → Secrets before pasting the prompt below.
Appendix · Mega-prompt
The build prompt.
budget · 1 message
Paste into a fresh Lovable project. Make sure all six secrets above are set first. read the build strategy →
Build "ClipChain Remix" in ONE Lovable message. Single-page demo on Stellar.
CONCEPT
Mint provenance tokens for remixed clips to credit original and derivative creators fairly.
Discipline: Videography & Film (video remixing).
Onchain primitive: Soroban NFT provenance. Why this primitive: Soroban NFT NFT minting verifies remix lineage and enforces creator ownership onchain.
5-CREDIT BUDGET (HARD LIMIT):
- ONE single-page app. No router, no Lovable Cloud, no database, no server auth.
- ONE Soroban contract in Rust, <=120 lines, deployed to Stellar Testnet.
- Privy embedded wallets (email + Google sign-in) are the auth + signing layer. Privy provisions a Stellar Ed25519 wallet in-browser for each user — no extension, no QR code, no WalletConnect.
- Pinata/IPFS only if the idea genuinely needs to store a file or metadata.
- At most ONE AI call per user action (use Lovable AI Gateway with LOVABLE_API_KEY if AI is part of the idea).
- Skip tests, skip CI, skip docs pages. Ship the demo, nothing else.
STACK
- TanStack Start on Cloudflare workerd, bun, Vite 7. Server code runs in a Worker: `process.env.*` is undefined at module scope — read secrets inside `createServerFn` handler bodies only.
- Data + tx flow: client sends { privyToken, ...payload } to a `createServerFn`. Handler verifies the token, derives the user's embedded Stellar wallet, builds + prepares a Soroban tx, asks Privy to raw-sign the 32-byte tx hash, submits via Soroban RPC, polls, returns { hash }. Client renders the Stellar Expert link. Never accept a client-supplied `author` address.
--- CLIENT (browser only) ---
- `bun add @privy-io/react-auth`. Mount PrivyProvider EXCLUSIVELY from a lazy client-only chunk. Do NOT import @privy-io/react-auth from any route file, from __root.tsx, or from any module that SSR reaches — its transitive @solana/web3.js pulls rpc-websockets, which lacks the workerd export condition and hard-crashes the build with `"." is not exported under the conditions ["workerd", ...] from rpc-websockets`.
// src/routes/index.tsx
import { lazy } from "react";
import { ClientOnly } from "@tanstack/react-router";
const Demo = lazy(() => import("@/components/clipchain_remix-demo"));
export const Route = createFileRoute("/")({
component: () => (
<ClientOnly fallback={<div className="h-64 animate-pulse bg-card" />}>
<Demo />
</ClientOnly>
),
});
- Vite stub for `rpc-websockets` — REQUIRED even with the lazy import, because Vite still resolves during SSR analysis. Add to vite.config.ts:
import path from "node:path";
import type { Plugin } from "vite";
const SSR_STUB = path.resolve(__dirname, "src/lib/empty-ssr-stub.ts");
function stubBrowserOnlyPackages(): Plugin {
return {
name: "stub-browser-only-packages",
enforce: "pre",
resolveId(id) {
if (id === "rpc-websockets" || id.startsWith("rpc-websockets/")) return SSR_STUB;
return null;
},
};
}
// pass in defineConfig({ vite: { plugins: [stubBrowserOnlyPackages()] } })
Then `src/lib/empty-ssr-stub.ts` is literally: `export {};`
Do NOT use ssr.external / resolve.external — the Lovable tanstack config blocks externals for the Worker environment.
- The demo component (`src/components/clipchain_remix-demo.tsx`) is the ONLY place that imports @privy-io/react-auth. Shape:
import { PrivyProvider, usePrivy, useLogin } from "@privy-io/react-auth";
import { useServerFn } from "@tanstack/react-start";
import { getPrivyConfig } from "@/lib/privy-config.functions";
// Fetch the Privy app ID at runtime (never VITE_), then mount PrivyProvider:
<PrivyProvider appId={appId} config={{ loginMethods: ["email","google"], appearance: { theme: "dark" } }}>
<DemoInner />
</PrivyProvider>
// Inside DemoInner: const { ready, authenticated, user, getAccessToken } = usePrivy();
// const { login } = useLogin();
// if (!ready) return <Skeleton/>; if (!authenticated) return <button onClick={()=>login()}>Sign in</button>;
// const privyToken = await getAccessToken(); await logMove({ data: { privyToken, cid } });
--- SERVER ---
- `bun add @stellar/stellar-sdk @privy-io/server-auth @privy-io/node`.
- File split (critical — top-level code in *.functions.ts ships to the client bundle; only handler bodies are stripped):
src/lib/stellar.server.ts — sorobanServer(), horizonUrl(), networkPassphrase() using process.env
src/lib/privy.server.ts — verifyPrivyToken, getOrCreateStellarWalletFor, privyRawSignHash
src/lib/clipchain_remix.functions.ts — createServerFn wrappers (dynamic-import everything server-only)
src/lib/privy-config.functions.ts — public createServerFn returning { appId: process.env.PRIVY_APP_ID }
src/data/contract.json — { address, explorer: "https://stellar.expert/explorer/testnet" }
- privy.server.ts — two separate Privy clients (both required, singletons):
import { PrivyClient as PrivyAuthClient } from "@privy-io/server-auth";
import { PrivyClient as PrivyNodeClient } from "@privy-io/node";
// privyAuth().verifyAuthToken(token) -> { userId }
// privyNode().wallets().list({ chain_type: "stellar", external_id }) / .create({...}) / .rawSign(walletId, { params: { hash: "0x"+hex } })
// external_id = did.replace(/[^a-zA-Z0-9_-]/g,"_").slice(0,64)
- Canonical write handler (clipchain_remix.functions.ts):
export const logMove = createServerFn({ method: "POST" })
.inputValidator((i: { privyToken: string; cid: string }) => {
if (typeof i?.privyToken !== "string" || i.privyToken.length < 8) throw new Error("privyToken required");
if (typeof i?.cid !== "string" || !i.cid.length || i.cid.length > 200) throw new Error("cid 1..200");
return i;
})
.handler(async ({ data }) => {
// Dynamic imports keep server-only deps out of the client bundle.
const { Address, Contract, TransactionBuilder, nativeToScVal, BASE_FEE, rpc: StellarRpc } = await import("@stellar/stellar-sdk");
const { sorobanServer, networkPassphrase } = await import("./stellar.server");
const { verifyPrivyToken, getOrCreateStellarWalletFor, privyRawSignHash } = await import("./privy.server");
const contractCfg = (await import("@/data/contract.json")).default;
const userDid = await verifyPrivyToken(data.privyToken);
const wallet = await getOrCreateStellarWalletFor(userDid); // creates on first call
const author = wallet.address;
const server = sorobanServer();
const account = await server.getAccount(author);
const contract = new Contract(contractCfg.address);
const op = contract.call("log", new Address(author).toScVal(), nativeToScVal(data.cid, { type: "string" }));
const built = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: networkPassphrase() })
.addOperation(op).setTimeout(180).build();
// prepareTransaction attaches Soroban footprint + auth. Sign the PREPARED tx hash.
const prepared = await server.prepareTransaction(built);
const hashHex = Buffer.from(prepared.hash()).toString("hex");
const sigBytes = await privyRawSignHash(wallet.id, hashHex); // 64-byte Ed25519 signature
prepared.addSignature(author, Buffer.from(sigBytes).toString("base64"));
const sent = await server.sendTransaction(prepared);
if (sent.status === "ERROR") throw new Error("send ERROR: " + JSON.stringify(sent.errorResult?.toXDR("base64") ?? sent));
const start = Date.now();
let last;
while (Date.now() - start < 45_000) {
last = await server.getTransaction(sent.hash);
if (last.status !== StellarRpc.Api.GetTransactionStatus.NOT_FOUND) break;
await new Promise(r => setTimeout(r, 1500));
}
if (last?.status !== StellarRpc.Api.GetTransactionStatus.SUCCESS) throw new Error("tx " + sent.hash + " ended " + last?.status);
return { hash: sent.hash };
});
- Read-only Horizon (no key needed) for the "is my wallet funded?" check:
const res = await fetch(`${horizonUrl()}/accounts/${address}`);
if (res.status === 404) return { funded: false, xlm: null }; // send user to https://stellarfaucet.lovable.app
const b = await res.json(); const native = b.balances?.find(x => x.asset_type === "native");
return { funded: true, xlm: native?.balance ?? "0" };
- Client middleware: TanStack Start's `functionMiddleware` already forwards headers; you do NOT need Supabase auth middleware here — Privy auth is inside the handler.
--- CONTRACT DEPLOY (sandbox, one-shot) ---
cd contracts-soroban/clipchain_remix && stellar contract build
stellar contract deploy \
--wasm target/wasm32v1-none/release/clipchain_remix.wasm \
--source "$STELLAR_DEPLOYER_SECRET" \
--rpc-url "$SOROBAN_RPC_URL" \
--network-passphrase "Test SDF Network ; September 2015"
Write the returned C... contract id to `src/data/contract.json`. UI links to
`https://stellar.expert/explorer/testnet/contract/<address>` and `/tx/<hash>`.
Note: recent stellar-cli builds emit to `target/wasm32v1-none/`, older ones to `target/wasm32-unknown-unknown/`. Check whichever exists.
FAILURE MODES (know these — they burn a lot of credits):
- Build error `"." is not exported under the conditions ["workerd", ...] from rpc-websockets` -> a route or SSR-reachable module statically imports @privy-io/react-auth. Move it into the lazy client chunk AND confirm the vite stub is registered.
- Runtime `SOROBAN_RPC_URL is not set` -> you read process.env at module scope. Move the read into the handler.
- Runtime `Privy raw_sign returned no signature` -> Stellar is not enabled in the Privy dashboard's Embedded Wallets settings.
- `sendTransaction ERROR: op_bad_auth` -> you signed toXDR/pre-simulation bytes/pre-hashed on client. Sign exactly `prepared.hash()` and base64-encode the returned signature.
- `op_no_source_account` or Horizon 404 for the user's address -> account is unfunded. Direct them to https://stellarfaucet.lovable.app.
- Privy popup blank inside the Lovable preview iframe -> preview URL is not in Privy's Allowed Domains.
- Type errors on SorobanRpc.Server -> the SDK renamed it. Use `import { rpc as StellarRpc } from "@stellar/stellar-sdk"` and `StellarRpc.Server` / `StellarRpc.Api.GetTransactionStatus`.
- Contract call reverts silently -> you skipped `server.prepareTransaction(built)`. That call attaches the Soroban auth footprint the contract's `require_auth()` needs.
VERIFICATION CHECKLIST before shipping:
- [ ] `bun run build:dev` succeeds — no rpc-websockets resolver error.
- [ ] Signing in with a fresh Google account shows a G... address distinct from any pre-existing account.
- [ ] After funding via https://stellarfaucet.lovable.app, the primary action produces a Stellar Expert tx link where the source account matches the user's embedded wallet.
- [ ] `src/data/contract.json` is committed with the live C... address.
- [ ] "ClipChain Remix" footer credit line renders (see CREDIT section).
FORBIDDEN (these will break the build or leak funds):
- No @creit.tech/stellar-wallets-kit, no Lobstr, no WalletConnect / Reown, no QR-based signing.
- No ethers / viem / wagmi / RainbowKit / MetaMask / any EVM wallet library.
- No Hardhat / Foundry / Solidity / OpenZeppelin ERC-721. Contracts are Rust + soroban-sdk.
- No Alchemy Ethereum RPC / Infura / Etherscan / Sepolia / chainId 11155111.
- No VITE_PRIVY_APP_ID, VITE_SOROBAN_RPC_URL, VITE_HORIZON_URL, or VITE_PRIVY_APP_SECRET. RPC + Horizon URLs stay server-only; the Privy app ID is fetched at runtime via a public server function.
- No static import of @privy-io/react-auth from a route file, from __root.tsx, or from any module reachable during SSR. Mount PrivyProvider only from a client-only lazy chunk (see STACK).
- No top-level `process.env.SOROBAN_RPC_URL` / `process.env.PRIVY_APP_SECRET` reads. Workerd injects env per request; module-scope reads return undefined. Read inside the handler.
- Never sign `prepared.toXDR()`, never pre-hash on the client, never sign the pre-simulation build. Always sign the 32-byte `prepared.hash()`.
- Never accept a client-supplied `author` address in a server function. Always derive it from `verifyPrivyToken(privyToken)`; otherwise a caller can spend a foreign funded account.
CONTRACT (contracts-soroban/clipchain_remix/src/lib.rs):
```rust
#![no_std]
use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Symbol, symbol_short};
// ClipChainRemix — ERC-721-style provenance in Soroban for: Mint provenance tokens for remixed clips to credit original and derivative creators fairly.
// Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
const NEXT: Symbol = symbol_short!("NEXT");
#[contracttype]
pub enum Key {
Owner(u32),
Cid(u32),
}
#[contract]
pub struct ClipChainRemix;
#[contractimpl]
impl ClipChainRemix {
/// Mint a new token pointing at an IPFS CID. Returns the new token id.
pub fn mint(env: Env, to: Address, cid: String) -> u32 {
to.require_auth();
let id: u32 = env.storage().instance().get(&NEXT).unwrap_or(0) + 1;
env.storage().persistent().set(&Key::Owner(id), &to);
env.storage().persistent().set(&Key::Cid(id), &cid);
env.storage().instance().set(&NEXT, &id);
id
}
pub fn owner_of(env: Env, id: u32) -> Address {
env.storage().persistent().get(&Key::Owner(id)).unwrap()
}
pub fn cid_of(env: Env, id: u32) -> String {
env.storage().persistent().get(&Key::Cid(id)).unwrap()
}
}
```
USER FLOW
1. Land on page -> "Sign in with Google or email (Privy)" -> Privy provisions a Stellar embedded wallet -> browser shows the G... address with a Copy button and live XLM balance. Unfunded accounts show a "Fund" link to https://stellarfaucet.lovable.app.
2. After the user creates a video remixing artefact, pin the file to IPFS via Pinata, then invoke `mint(to, cid)` on the deployed Soroban contract. The user's Lobstr app pops a push-approval; on success show the token id, the IPFS preview (`https://gateway.pinata.cloud/ipfs/<cid>`), and the Stellar Expert link.
3. Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14"
REQUIRED SECRETS (Lovable -> Project Settings -> Secrets). All server-only. Do NOT add VITE_ copies.
- PRIVY_APP_ID Privy app ID (public, but fetched at runtime by the client via a public server fn — never hardcoded, never VITE_). Dashboard: https://dashboard.privy.io
- PRIVY_APP_SECRET Privy app secret (server-only). Used to verify user access tokens and to raw-sign the tx hash with the embedded Ed25519 key.
- SOROBAN_RPC_URL https://soroban-testnet.stellar.org (public) or a private endpoint.
- HORIZON_URL https://horizon-testnet.stellar.org
- STELLAR_NETWORK_PASSPHRASE "Test SDF Network ; September 2015"
- STELLAR_DEPLOYER_SECRET S... secret key of a funded Testnet account (used ONLY for the one-shot contract deploy from the sandbox — never read at runtime). Fund it: https://stellarfaucet.lovable.app
- PINATA_JWT IPFS uploads (only if app pins media). Docs: https://docs.pinata.cloud/llms-full.txt
Privy dashboard one-time setup (all three are required before the first sign-in works):
1. Login methods -> enable Email AND Google.
2. Embedded wallets -> enable Stellar (OFF by default; without it wallets.create({ chain_type: "stellar" }) returns chain_not_supported).
3. Allowed domains -> add BOTH the Lovable preview URL (id-preview--<uuid>.lovable.app) AND the published URL. Missing the preview URL silently breaks sign-in inside the editor iframe with a CORS failure.
CREDIT (must appear in UI footer AND as a comment header on every deployed contract):
Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
Market sizing.
TAM
$1.1B
editing and remix software market
SAM
$400M
content creators engaging in remix culture
SOM
$25M
top remix-focused video editors
Indicative figures for hackathon pitches — refine with your own research before raising.
Adjacent entries.
shot authentication
FrameLock Provenance
Securely prove original shot ownership for video editors and creators to prevent unauthorized reuse.
color gradingColorGrade Ledger
Track color correction versions with creator-owned tokens to maintain authentic grading history.
scene licensingSceneReveal Rights
Onchain minting proves scene ownership, simplifying licensing and reuse agreements for creators.
previsualizationStoryboard Stamp
Mint storyboards as NFT tokens to secure creative vision and enable easy sharing with teams.