Rivellum NFT Standard
Overview
The Rivellum NFT Standard provides a canonical implementation for non-fungible tokens (NFTs) with built-in royalty support, metadata conventions, and indexer integration.
Architecture
Core Types
NftId (32 bytes)
Deterministic identifier computed as:
NftId = BLAKE3("NFT:" || collection_id || creator || mint_nonce)
Properties:
- Globally unique
- Deterministic (same inputs always produce same ID)
- Collision-resistant
CollectionId (32 bytes)
Deterministic identifier computed as:
CollectionId = BLAKE3("COLLECTION:" || creator || name)
Properties:
- Unique per creator + name combination
- Deterministic
- Collections are immutable once created
NftMeta
pub struct NftMeta {
pub id: NftId,
pub collection: CollectionId,
pub owner: Address,
pub name: String,
pub description: Option<String>,
pub media_uri: Option<String>,
pub attributes: HashMap<String, String>,
}
Metadata Strategy:
- Minimal on-chain: Name, owner, collection reference
- Off-chain: Detailed metadata, images, attributes via
media_uri - Hybrid: Optional on-chain attributes for critical properties
Collection
pub struct Collection {
pub id: CollectionId,
pub creator: Address,
pub name: String,
pub description: Option<String>,
pub max_supply: Option<u64>, // None = unlimited
pub minted_count: u64,
pub royalty_bps: u16, // Basis points (100 = 1%)
pub royalty_recipient: Address,
}
NFT Registry
Global state structure tracking all NFTs:
pub struct NftRegistry {
/// NFT metadata by ID
pub nfts: HashMap<NftId, NftMeta>,
/// NFTs owned by address
pub owned_by: HashMap<Address, Vec<NftId>>,
/// Collections registry
pub collections: HashMap<CollectionId, Collection>,
}
Operations:
create_collection(): Register a new collectionmint_nft(): Create a new NFT in a collectiontransfer_nft(): Transfer ownershipburn_nft(): Permanently destroy an NFTowner_of(): Query NFT ownernfts_owned_by(): Get all NFTs owned by an address
Move Module
Location: crates/rivellum-move/sources/nft.move
Entry Functions
initialize(account: &signer)
Initialize the NFT module (one-time setup).
create_collection(...)
public entry fun create_collection(
creator: &signer,
name: String,
description: String,
max_supply: u64,
royalty_bps: u16,
royalty_recipient: address,
)
Parameters:
max_supply: Set to 0 for unlimited supplyroyalty_bps: 0-10000 (e.g., 250 = 2.5%)
Events: CollectionCreatedEvent
mint_nft(...)
public entry fun mint_nft(
minter: &signer,
collection_id: vector<u8>,
name: String,
description: String,
media_uri: String,
recipient: address,
)
Checks:
- Collection exists
- Supply limit not exceeded
- Minter is collection creator (current implementation)
Events: NftMintedEvent
transfer_nft(...)
public entry fun transfer_nft(
from: &signer,
nft_id: vector<u8>,
to: address,
)
Checks:
- NFT exists
- Sender is current owner
Events: NftTransferredEvent
burn_nft(...)
public entry fun burn_nft(
owner: &signer,
nft_id: vector<u8>,
)
Checks:
- NFT exists
- Signer is current owner
Events: NftBurnedEvent
View Functions
#[view]
public fun get_nft(owner: address, nft_id: vector<u8>): (String, String, String, vector<u8>)
#[view]
public fun get_collection(collection_id: vector<u8>): (String, String, address, u64, u64)
#[view]
public fun owner_of(nft_id: vector<u8>): address
Payload Types
PlainPayload Variants
CreateCollection {
name: String,
description: String,
max_supply: Option<u64>,
royalty_bps: u16,
royalty_recipient: Address,
}
MintNft {
collection_id: CollectionId,
name: String,
description: Option<String>,
media_uri: Option<String>,
attributes: HashMap<String, String>,
recipient: Address,
}
TransferNft {
nft_id: NftId,
to: Address,
}
BurnNft {
nft_id: NftId,
}
Metadata Conventions
Off-Chain Metadata Format
JSON structure pointed to by media_uri:
{
"name": "NFT Name",
"description": "Detailed description of the NFT",
"image": "ipfs://QmXxx.../image.png",
"external_url": "https://example.com/nft/123",
"attributes": [
{
"trait_type": "Background",
"value": "Blue"
},
{
"trait_type": "Rarity",
"value": "Legendary",
"display_type": "string"
},
{
"trait_type": "Power",
"value": 95,
"display_type": "number",
"max_value": 100
}
],
"properties": {
"category": "Art",
"creators": [
{
"address": "0x...",
"share": 100
}
]
}
}
Standard Fields:
name: Display namedescription: Long-form descriptionimage: Primary asset URL (IPFS/HTTP/HTTPS)external_url: External linkattributes: Array of trait objectsproperties: Extended metadata
Trait Types:
string: Text traitsnumber: Numeric traits (with optionalmax_value)boost_percentage: Percentage boostsboost_number: Additive boostsdate: Unix timestamp
Storage Recommendations
IPFS: Recommended for decentralization and permanence
- Use pinning services (Pinata, NFT.Storage)
- Include CID in
media_uriasipfs://Qm...
HTTP/HTTPS: Acceptable but centralized
- Ensure high availability
- Consider CDN for performance
On-Chain Storage: Use sparingly
- Only critical attributes that affect on-chain logic
- Store in
attributesfield ofNftMeta
Indexer Integration
Schema Tables
CREATE TABLE collections (
id BYTEA PRIMARY KEY,
creator BYTEA NOT NULL,
name TEXT NOT NULL,
description TEXT,
max_supply BIGINT,
minted_count BIGINT NOT NULL,
royalty_bps INTEGER NOT NULL,
royalty_recipient BYTEA NOT NULL,
created_at TIMESTAMP NOT NULL
);
CREATE TABLE nft_meta (
id BYTEA PRIMARY KEY,
collection_id BYTEA NOT NULL REFERENCES collections(id),
owner BYTEA NOT NULL,
name TEXT NOT NULL,
description TEXT,
media_uri TEXT,
attributes JSONB,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE nft_transfers (
id SERIAL PRIMARY KEY,
nft_id BYTEA NOT NULL,
from_address BYTEA NOT NULL,
to_address BYTEA NOT NULL,
block_height BIGINT NOT NULL,
timestamp TIMESTAMP NOT NULL,
transaction_hash BYTEA NOT NULL
);
CREATE INDEX idx_nft_owner ON nft_meta(owner);
CREATE INDEX idx_nft_collection ON nft_meta(collection_id);
CREATE INDEX idx_transfers_nft ON nft_transfers(nft_id);
CREATE INDEX idx_transfers_from ON nft_transfers(from_address);
CREATE INDEX idx_transfers_to ON nft_transfers(to_address);
Event Consumption
Indexer subscribes to:
CollectionCreatedEvent: Insert intocollectionsNftMintedEvent: Insert intonft_metaNftTransferredEvent: Updatenft_meta.owner, insert intonft_transfersNftBurnedEvent: Delete fromnft_meta
RPC Endpoints
Get NFT Metadata
GET /nfts/:nft_id
Response:
{
"id": "0x...",
"collection_id": "0x...",
"owner": "0x...",
"name": "NFT Name",
"description": "Description",
"media_uri": "ipfs://...",
"attributes": {
"key": "value"
}
}
Get NFTs Owned by Address
GET /nfts/owned/:address?page=1&limit=50
Response:
{
"nfts": [...],
"total": 123,
"page": 1,
"limit": 50
}
Get Collection
GET /collections/:collection_id
Get Collection NFTs
GET /collections/:collection_id/nfts?page=1&limit=50
Get NFT Transfer History
GET /nfts/:nft_id/transfers
Security Considerations
Supply Limit Enforcement
- Checked at mint time
- Immutable after collection creation
- Consider migration path for adjusting supply
Royalty Enforcement
- Stored on-chain
- Marketplaces should honor
royalty_bps - Consider automatic royalty distribution in transfer logic
Ownership Verification
- All operations require signature from owner
- Use multi-sig for high-value NFTs (see PQ Accounts)
Metadata Integrity
- Off-chain metadata can be modified
- Consider content-addressed storage (IPFS)
- Store critical attributes on-chain
SDK Integration
TypeScript SDK example:
import { RivellumClient } from '@rivellum/sdk';
const client = new RivellumClient('https://rpc.rivellum.network');
// Create collection
const collectionTx = await client.createCollection({
name: 'My Collection',
description: 'A collection of unique NFTs',
maxSupply: 10000,
royaltyBps: 250, // 2.5%
royaltyRecipient: '0x...',
});
// Mint NFT
const mintTx = await client.mintNft({
collectionId: '0x...',
name: 'NFT #1',
description: 'The first NFT',
mediaUri: 'ipfs://Qm...',
attributes: {
rarity: 'legendary',
power: '95',
},
recipient: '0x...',
});
// Transfer NFT
const transferTx = await client.transferNft({
nftId: '0x...',
to: '0x...',
});
// Query NFTs
const owned = await client.getNftsOwnedBy('0x...');
const nft = await client.getNftMeta('0x...');
Examples
Creating a Simple Collection
use rivellum_types::{CollectionId, Address, PlainPayload};
let creator = Address([1u8; 32]);
let payload = PlainPayload::CreateCollection {
name: "Rivellum Heroes".to_string(),
description: "A collection of legendary heroes".to_string(),
max_supply: Some(1000),
royalty_bps: 250, // 2.5%
royalty_recipient: creator,
};
Minting an NFT
use rivellum_types::{NftId, CollectionId, PlainPayload};
let collection_id = CollectionId::new(&creator, "Rivellum Heroes");
let payload = PlainPayload::MintNft {
collection_id,
name: "Hero #1".to_string(),
description: Some("The first legendary hero".to_string()),
media_uri: Some("ipfs://QmXxx.../hero1.json".to_string()),
attributes: {
let mut attrs = HashMap::new();
attrs.insert("class".to_string(), "warrior".to_string());
attrs.insert("level".to_string(), "50".to_string());
attrs
},
recipient: owner_address,
};
Future Enhancements
Composable NFTs
- NFTs that own other NFTs
- Nesting and parent-child relationships
Dynamic NFTs
- On-chain state updates
- Game-linked metadata
Fractional Ownership
- Split NFT ownership among multiple addresses
- Voting mechanisms for shared NFTs
Soulbound Tokens
- Non-transferable NFTs
- Permanent binding to an address
Batch Operations
- Batch minting for airdrops
- Batch transfers
References
- ERC-721 - Ethereum NFT Standard
- Metaplex Token Metadata - Solana NFT Standard
- OpenSea Metadata Standards
- Move Resources - Resource-based ownership model