NFT Contract
This tutorial guides you through building a non-fungible token (NFT) contract on BSV with Runar. Each token is a unique UTXO with immutable metadata, transferable ownership enforced by a covenant, and an owner-initiated burn operation.
By the end of this tutorial, you will understand:
- How to combine
readonlyand mutable properties in aStatefulSmartContract - How immutable metadata is embedded in the locking script alongside mutable ownership state
- How the
transfercovenant ensures correct state propagation - How to implement a
burnmethod that permanently destroys a token
Prerequisites
- Completed the Hello World Tutorial and the Fungible Token Tutorial
- Understanding of
StatefulSmartContract,checkPreimage, andextractOutputHash
Designing the NFT Contract
An NFT differs from a fungible token in two important ways:
- Uniqueness — Each NFT has a
tokenIdandmetadatathat never change. These arereadonlyproperties. - Indivisibility — You cannot split an NFT. You transfer the whole thing or you don’t.
The contract state:
| Property | Type | Mutability | Description |
|---|---|---|---|
tokenId | ByteString | readonly | Unique identifier for this token |
metadata | ByteString | readonly | On-chain metadata (name, description, image hash, etc.) |
owner | PubKey | mutable | Current owner’s public key |
The contract methods:
| Method | Description |
|---|---|
transfer | Transfer ownership to a new public key |
burn | Permanently destroy the token |
Writing the Contract
Create src/contracts/NFT.runar.ts:
import {
StatefulSmartContract,
assert,
PubKey,
Sig,
ByteString,
checkSig,
SigHashPreimage,
checkPreimage,
extractOutputHash,
hash256,
} from 'runar-lang';
class NFTExample extends StatefulSmartContract {
readonly tokenId: ByteString;
readonly metadata: ByteString;
owner: PubKey;
constructor(tokenId: ByteString, metadata: ByteString, owner: PubKey) {
super(tokenId, metadata, owner);
this.tokenId = tokenId;
this.metadata = metadata;
this.owner = owner;
}
public transfer(sig: Sig, newOwner: PubKey, txPreimage: SigHashPreimage) {
assert(checkSig(sig, this.owner));
assert(checkPreimage(txPreimage));
this.owner = newOwner;
this.addOutput(1n, this.tokenId, this.metadata, this.owner);
const expectedHash = hash256(this.getStateScript());
assert(extractOutputHash(txPreimage) === expectedHash);
}
public burn(sig: Sig) {
assert(checkSig(sig, this.owner));
}
}
export { NFTExample };
Walkthrough
Readonly vs. Mutable Properties
readonly tokenId: ByteString;
readonly metadata: ByteString;
owner: PubKey;
This is the key design pattern for NFTs. The tokenId and metadata are permanent — they are baked into the locking script and never change across the token’s lifetime. The owner is mutable — it updates with each transfer.
When the token is transferred, the new output’s locking script still contains the same tokenId and metadata, but with the updated owner. This is how the covenant preserves the token’s identity across transactions.
The transfer Method
public transfer(sig: Sig, newOwner: PubKey, txPreimage: SigHashPreimage) {
assert(checkSig(sig, this.owner));
assert(checkPreimage(txPreimage));
this.owner = newOwner;
this.addOutput(1n, this.tokenId, this.metadata, this.owner);
const expectedHash = hash256(this.getStateScript());
assert(extractOutputHash(txPreimage) === expectedHash);
}
Step by step:
- Authorization —
checkSig(sig, this.owner)verifies only the current owner can transfer. - Preimage verification —
checkPreimage(txPreimage)enables covenant enforcement. - State update —
this.owner = newOwnerchanges the mutable state. - Output specification —
addOutput(1n, this.tokenId, this.metadata, this.owner)defines the new UTXO. Notice all three values are included — the readonlytokenIdandmetadataalong with the updatedowner. - Covenant check — The
extractOutputHashassertion guarantees the spending transaction creates exactly this output. The spender cannot tamper with the token’s identity or redirect it.
The burn Method
public burn(sig: Sig) {
assert(checkSig(sig, this.owner));
}
Burn is deliberately simple. It requires the owner’s signature and nothing else. There is no addOutput call and no preimage verification. When this method is called, the UTXO is spent without creating a continuation output — the token ceases to exist.
This is the difference between a stateful method (which constrains outputs) and a terminal method (which does not). Both are valid public methods; the difference is whether the contract continues.
Compiling
runar compile contracts/NFT.runar.ts --output ./artifacts --asm
Testing the NFT
Create tests/NFT.test.ts:
import { describe, it, expect } from 'vitest';
import { TestContract, ALICE, BOB } from 'runar-testing';
import { readFileSync } from 'fs';
describe('NFTExample', () => {
const source = readFileSync('./src/contracts/NFT.runar.ts', 'utf-8');
const tokenId = '0001';
const metadata = Buffer.from(
JSON.stringify({ name: 'Runar Genesis', description: 'First NFT' })
).toString('hex');
it('should transfer ownership to a new owner', () => {
const contract = TestContract.fromSource(source, {
tokenId,
metadata,
owner: ALICE.pubKey,
});
const result = contract.call('transfer', {
sig: ALICE.privKey,
newOwner: BOB.pubKey,
txPreimage: 'auto',
});
expect(result.success).toBe(true);
// The output should have Bob as the new owner
expect(result.outputs).toHaveLength(1);
});
it('should reject transfer by non-owner', () => {
const contract = TestContract.fromSource(source, {
tokenId,
metadata,
owner: ALICE.pubKey,
});
// Bob tries to transfer Alice's NFT
const result = contract.call('transfer', {
sig: BOB.privKey,
newOwner: BOB.pubKey,
txPreimage: 'auto',
});
expect(result.success).toBe(false);
});
it('should allow owner to burn the token', () => {
const contract = TestContract.fromSource(source, {
tokenId,
metadata,
owner: ALICE.pubKey,
});
const result = contract.call('burn', {
sig: ALICE.privKey,
});
expect(result.success).toBe(true);
});
it('should reject burn by non-owner', () => {
const contract = TestContract.fromSource(source, {
tokenId,
metadata,
owner: ALICE.pubKey,
});
const result = contract.call('burn', {
sig: BOB.privKey,
});
expect(result.success).toBe(false);
});
it('should preserve tokenId and metadata across transfers', () => {
const contract = TestContract.fromSource(source, {
tokenId,
metadata,
owner: ALICE.pubKey,
});
// Transfer from Alice to Bob
const result = contract.call('transfer', {
sig: ALICE.privKey,
newOwner: BOB.pubKey,
txPreimage: 'auto',
});
expect(result.success).toBe(true);
// The output script still contains the original tokenId and metadata
// but with the updated owner (Bob)
});
});
Run the tests:
runar test
Metadata Design Considerations
The metadata field is a raw ByteString. You have full flexibility in what you store:
On-chain metadata (small tokens):
const metadata = Buffer.from(JSON.stringify({
name: 'Runar Genesis #1',
description: 'The first NFT minted on Runar',
image: 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9',
attributes: { rarity: 'legendary', edition: 1 }
})).toString('hex');
On-chain hash with off-chain data (large tokens):
import { createHash } from 'crypto';
// Store only the hash of the metadata; full data lives off-chain
const metadataHash = createHash('sha256')
.update(JSON.stringify(fullMetadata))
.digest('hex');
Both approaches are valid. On-chain metadata is fully self-contained but costs more in transaction fees. Hash-only metadata is cheaper but requires an external service to resolve the full data.
Because metadata is readonly, it is immutable for the token’s lifetime. This is by design — NFT metadata should not change after minting. If you need updateable metadata, you would use a separate mutable property (but consider whether that undermines the NFT’s trustworthiness).
Deploying the NFT
runar compile contracts/NFT.runar.ts --output ./artifacts
runar deploy ./artifacts/NFTExample.json \
--network testnet \
--key <your-WIF-private-key> \
--satoshis 1
After deployment, the returned TxID identifies the token. The tokenId you passed to the constructor is embedded in the locking script and serves as the token’s on-chain identifier.
NFT Lifecycle
- Mint — Deploy the contract with a unique
tokenId,metadata, and the creator asowner. - Transfer — The owner calls
transferwith a new owner’s public key. The covenant creates a new UTXO with the same token identity but updated ownership. - Transfer chain — Each subsequent owner can transfer to the next. The
tokenIdandmetadataremain constant through every transfer. - Burn — Any owner in the chain can burn the token. The UTXO is spent without creating a continuation output, and the token is permanently destroyed.
Key Concepts Recap
| Concept | What You Learned |
|---|---|
readonly + mutable | Combining immutable identity with mutable ownership in one contract |
| NFT identity | tokenId and metadata as permanent, covenant-enforced properties |
| Transfer covenant | addOutput + extractOutputHash ensures correct state propagation |
| Terminal methods | burn spends the UTXO without creating a continuation output |
ByteString | Variable-length byte type for arbitrary on-chain data |
What’s Next
- Multi-Party Escrow Tutorial — Learn multi-signature patterns with three-party escrow
- Auction Example — See a stateful contract with competitive bidding and deadlines
- Example Gallery — Browse all annotated contract examples