Rúnar

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 readonly and mutable properties in a StatefulSmartContract
  • How immutable metadata is embedded in the locking script alongside mutable ownership state
  • How the transfer covenant ensures correct state propagation
  • How to implement a burn method that permanently destroys a token

Prerequisites

Designing the NFT Contract

An NFT differs from a fungible token in two important ways:

  1. Uniqueness — Each NFT has a tokenId and metadata that never change. These are readonly properties.
  2. Indivisibility — You cannot split an NFT. You transfer the whole thing or you don’t.

The contract state:

PropertyTypeMutabilityDescription
tokenIdByteStringreadonlyUnique identifier for this token
metadataByteStringreadonlyOn-chain metadata (name, description, image hash, etc.)
ownerPubKeymutableCurrent owner’s public key

The contract methods:

MethodDescription
transferTransfer ownership to a new public key
burnPermanently 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:

  1. AuthorizationcheckSig(sig, this.owner) verifies only the current owner can transfer.
  2. Preimage verificationcheckPreimage(txPreimage) enables covenant enforcement.
  3. State updatethis.owner = newOwner changes the mutable state.
  4. Output specificationaddOutput(1n, this.tokenId, this.metadata, this.owner) defines the new UTXO. Notice all three values are included — the readonly tokenId and metadata along with the updated owner.
  5. Covenant check — The extractOutputHash assertion 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

  1. Mint — Deploy the contract with a unique tokenId, metadata, and the creator as owner.
  2. Transfer — The owner calls transfer with a new owner’s public key. The covenant creates a new UTXO with the same token identity but updated ownership.
  3. Transfer chain — Each subsequent owner can transfer to the next. The tokenId and metadata remain constant through every transfer.
  4. 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

ConceptWhat You Learned
readonly + mutableCombining immutable identity with mutable ownership in one contract
NFT identitytokenId and metadata as permanent, covenant-enforced properties
Transfer covenantaddOutput + extractOutputHash ensures correct state propagation
Terminal methodsburn spends the UTXO without creating a continuation output
ByteStringVariable-length byte type for arbitrary on-chain data

What’s Next