Spore Protocol
Create and manage digital objects (DOBs) using the Spore Protocol SDK.
Create permanent, ownable Digital Objects (DOBs) directly on the CKB blockchain. A Spore encodes arbitrary content (images, text, JSON, etc.) into a cell — ownership is enforced by a lock script, and transfers are atomic on-chain operations without any marketplace or indexer dependency.
What you'll get
After this guide you'll be able to:
- Create a Spore — store content (text, image, JSON, …) permanently on-chain
- Transfer and melt Spores — change ownership or destroy and reclaim CKB
- Organize with Clusters — group related Spores into named collections
- Query Spores — iterate owned Spores and filter by cluster
All examples assume you already have a connected signer. See Connect Wallets (browser) or Node.js Backend (server) first.
Key concepts
- Spore — a cell containing arbitrary content + a type script that gives it a unique ID. Content is permanent; ownership is transferable.
- Cluster — an optional collection cell with a name and description. Any Spore can reference a Cluster ID to declare membership.
- Melt — destroy a Spore cell and reclaim its locked CKB capacity. Irreversible.
Installation
npm install @ckb-ccc/sporeimport { createSpore, transferSpore, meltSpore } from "@ckb-ccc/spore";
import { createSporeCluster, transferSporeCluster } from "@ckb-ccc/spore";Create a Spore
createSpore encodes your content into a new Spore cell. You provide a MIME content type and raw content bytes; CCC handles the on-chain data packing:
import { ccc } from "@ckb-ccc/ccc";
import { createSpore } from "@ckb-ccc/spore";
const { tx, id } = await createSpore({
signer,
data: {
contentType: "text/plain",
content: new TextEncoder().encode("Hello, Spore!"),
},
// Optional: send the Spore to a different address
// to: recipientLockScript,
});
// Balance capacity and pay fee
await tx.completeInputsByCapacity(signer);
await tx.completeFeeBy(signer);
const txHash = await signer.sendTransaction(tx);
console.log("Spore ID:", id); // "0x..." — unique type script args, store this!
console.log("Transaction hash:", txHash); // "0x..." — 32-byte tx hashThe returned id is the sporeId — the args field of the Spore's type script. Store it; you will need it for transfers and melts.
Spore inside a Cluster
If you want the Spore to belong to a Cluster, include clusterId in the data and set clusterMode:
const { tx, id } = await createSpore({
signer,
data: {
contentType: "image/png",
content: pngBytes,
clusterId: "0xabc123...", // id returned by createSporeCluster
},
clusterMode: "lockProxy", // or "clusterCell"
});clusterMode | Behaviour |
|---|---|
"lockProxy" | Puts a cell with the Cluster's lock in both inputs and outputs — lower cost |
"clusterCell" | Puts the Cluster cell itself in inputs and outputs — proves ownership directly |
"skip" | Skips cluster logic entirely — use only if you handle it yourself |
Transfer a Spore
Use this when: you want to change ownership of an existing Spore (e.g. sell, gift, or move to a different address).
import { transferSpore } from "@ckb-ccc/spore";
const { script: newOwner } = await ccc.Address.fromString(
recipientAddress,
signer.client,
);
const { tx } = await transferSpore({
signer,
id: sporeId, // "0x..."
to: newOwner,
});
await tx.completeInputsByCapacity(signer);
await tx.completeFeeBy(signer);
const txHash = await signer.sendTransaction(tx);Melt (destroy) a Spore
Use this when: you want to permanently destroy a Spore and reclaim its locked CKB capacity:
import { meltSpore } from "@ckb-ccc/spore";
const { tx } = await meltSpore({
signer,
id: sporeId,
});
await tx.completeFeeBy(signer);
const txHash = await signer.sendTransaction(tx);Melting is irreversible. The Spore's content is removed from the chain and its CKB is released. There is no undo.
Clusters
Clusters are optional named collections. Create a Cluster first, then reference its ID when creating Spores.
Create a Cluster
import { createSporeCluster } from "@ckb-ccc/spore";
const { tx, id: clusterId } = await createSporeCluster({
signer,
data: {
name: "My Collection",
description: "A collection of on-chain art.",
},
});
await tx.completeInputsByCapacity(signer);
await tx.completeFeeBy(signer);
const txHash = await signer.sendTransaction(tx);
console.log("Cluster ID:", clusterId); // "0x..." — pass this as clusterId when creating SporesTransfer a Cluster
import { transferSporeCluster } from "@ckb-ccc/spore";
const { script: newOwner } = await ccc.Address.fromString(
recipientAddress,
signer.client,
);
const { tx } = await transferSporeCluster({
signer,
id: clusterId,
to: newOwner,
});
await tx.completeInputsByCapacity(signer);
await tx.completeFeeBy(signer);
await signer.sendTransaction(tx);Query Spores and Clusters
All query functions return async generators — they stream results lazily so you can process large collections without loading everything into memory.
import { findSporesBySigner, findSporeClusters } from "@ckb-ccc/spore";
// Iterate all Spores owned by the signer
for await (const { spore, sporeData } of findSporesBySigner({ signer })) {
console.log(spore.outPoint, sporeData.contentType);
}
// Filter: only Spores in a specific Cluster
for await (const { sporeData } of findSporesBySigner({
signer,
clusterId: "0xabc123...",
})) {
console.log(sporeData);
}
// Filter: only public Spores (not in any cluster)
for await (const { spore } of findSporesBySigner({
signer,
clusterId: "", // empty string = public spores only
})) {
console.log(spore.outPoint);
}API reference
| Function | Description |
|---|---|
createSpore(params) | Create a new Spore cell |
transferSpore(params) | Transfer a Spore to a new owner |
meltSpore(params) | Destroy a Spore and reclaim CKB |
findSpore(client, id) | Look up a single Spore by ID |
findSpores(params) | Query Spores by lock and/or cluster |
findSporesBySigner(params) | Query Spores owned by a signer |
createSporeCluster(params) | Create a new Cluster cell |
transferSporeCluster(params) | Transfer a Cluster to a new owner |
findCluster(client, id) | Look up a single Cluster by ID |
findSporeClusters(params) | Query Clusters by lock |
findSporeClustersBySigner(params) | Query Clusters owned by a signer |
Troubleshooting
createSpore fails with "not enough capacity"
Spore cells store content on-chain, so they require more CKB capacity than a basic transfer. A text Spore needs ~200+ CKB; an image Spore can need thousands. Make sure the signer has sufficient balance.
Spore ID is lost after creation
The id returned by createSpore is the type script args of the Spore cell. It is deterministic (derived from the first input + output index), but you should store it immediately. You can also recover it via findSporesBySigner.
clusterMode — which one should I use?
"lockProxy"(recommended) — lower cost, proves cluster ownership via a proxy cell with the same lock."clusterCell"— higher cost, puts the actual cluster cell in the transaction. Use this when the cluster's lock is different from the signer's."skip"— only if you are manually handling cluster inputs/outputs yourself.
Spore v3 (upcoming)
The proposed contracts in spore-contract #75 and #76 introduce native Mutants and optional core CoBuild Actions. Existing presets and the default V2 behavior stay unchanged. V3 presets remain empty until reviewed deployments are published; supply custom SporeScriptInfoLike descriptors to use a local candidate.
Optional intent
All five Spore/Cluster builders accept withIntent: false to omit their new Actions. Use it only when every affected script accepts absent Actions. Existing transaction Actions are preserved and reattached after Lock preparation, including when a builder adds inputs without a new Action.
Before displaying metadata, call checkSporeIntent from @ckb-ccc/spore/advanced. Display only its returned verified Spore Actions as verified. Foreign Actions remain untouched in the transaction but are excluded from that result. Always show the actual transaction changes too: an empty result does not certify that no assets move.
The checker recognizes existing Spore/Cluster presets by code hash and hash type. Pass reviewed custom deployments by category through scripts: { Spore: [v3Spore], Cluster: [v3Cluster], ClusterProxy: [proxyInfo], ClusterAgent: [agentInfo] }. These are trust anchors supplied by the application, not claims of deployment provenance. If a builder uses a custom scriptInfoHash, explicitly allow it through scriptInfoHashes; the default is DEFAULT_COBUILD_INFO_HASH.
All eleven Action variants bind their IDs, owners and operation shape to resolved cells. Transfers preserve data; Spore/Cluster retain contract-valid ID suffixes, and Proxy IDs use the first 32 bytes of valid 32/40-byte args. Agent creation resolves the referenced Proxy from code deps or depGroups, checks its approved deployment, ID and Cluster binding. Missing inputs/dependencies, unknown known-family metadata, duplicate cells/Actions/Messages and malformed CoBuild layouts fail closed. The checker scans every witness so a trailing ordinary witness cannot hide a Message. Signature verification, fees and application authorization remain separate wallet responsibilities.
Native rules
A native Mutant stores a compiled RISC-V ELF in immutable Cell data. Use checkNativeMutantCode for bounded ELF header preflight and createMutant with a reviewed data2 deployment to construct publication. The VM still decides executable validity and the rule result.
withMutants and getMutantIds manage comma-separated 32-byte IDs without a 0x prefix. Parsing includes every mutant[] parameter and rejects global duplicate IDs. findMutant resolves either 32-byte args or the 40-byte ID/payment form, validates deployment identity and rejects ambiguous matches. Use injectMutantReferences before mint, transfer and melt, including every declared rule dependency; a payment suffix does not change the referenced ID. checkMutantPayments checks net-capacity thresholds for one Spore type-script group per call. Callers must partition the declared Mutant outpoints and invoke it separately for each group, even when payee Locks match; the helper does not infer group membership, so passing independent groups together overcounts their thresholds and can reject a valid transaction.
These are SDK construction and offline validation helpers. Local package checks do not replace fresh CCC-built signed devnet transactions and exact-byte VM replay for release acceptance. The obsolete SDK PR #142's acceptance evidence does not certify this rewrite.
Next steps
- Compose transactions — understand the declare → fill → fee → send pattern used in every Spore operation.
- UDT tokens — issue fungible tokens on CKB.
- Node.js backend — mint Spores from a server-side script.
Last updated on