Rúnar

Token Contracts

Runar provides built-in patterns for creating both fungible and non-fungible tokens on BSV. Tokens are represented as UTXOs with covenant-enforced rules governing minting, transfers, and burns.

Token Model Overview

Tokens in Runar are stateful contracts. Each token UTXO carries:

  • The token contract logic (locking script).
  • Serialized state (supply for fungible tokens, owner and tokenId for NFTs).
  • A satoshi amount (to keep the UTXO alive on-chain).

Token operations are state transitions on these UTXOs. A transfer creates new UTXOs with updated ownership. A burn destroys the UTXO without creating a continuation. A merge combines multiple UTXOs into one.

All token rules are enforced by the contract script itself — no external indexer or trusted party is needed to validate token operations.

Fungible Tokens

A fungible token contract tracks a supply and a holder. The contract enforces conservation of value: you cannot create tokens out of thin air or transfer more than the supply.

Contract Definition

import {
  StatefulSmartContract,
  assert,
  Sig,
  Ripemd160,
  checkSig,
} from 'runar-lang';

class FungibleToken extends StatefulSmartContract {
  supply: bigint;
  holder: Ripemd160;

  constructor(supply: bigint, holder: Ripemd160) {
    super(supply, holder);
    this.supply = supply;
    this.holder = holder;
  }

  public transfer(
    sig: Sig,
    to: Ripemd160,
  ) {
    // Verify ownership
    assert(checkSig(sig, this.holder));

    // Transfer entire supply to new holder
    this.addOutput(this.ctx.utxo.satoshis, this.supply, to);

    assert(this.checkPreimage());
  }

  public merge(
    sig: Sig,
    otherSupply: bigint,
    otherHolder: Ripemd160,
  ) {
    // Both UTXOs must belong to the same holder
    assert(checkSig(sig, this.holder));
    assert(otherHolder === this.holder);

    // Anti-inflation proof: merged supply must equal sum of inputs
    const mergedSupply = this.supply + otherSupply;
    this.addOutput(this.ctx.utxo.satoshis, mergedSupply, this.holder);

    assert(this.checkPreimage());
  }

  public split(
    sig: Sig,
    amount1: bigint,
    to1: Ripemd160,
    to2: Ripemd160,
  ) {
    // Verify ownership
    assert(checkSig(sig, this.holder));

    // Verify amounts
    assert(amount1 > 0n);
    assert(amount1 < this.supply);

    const amount2 = this.supply - amount1;

    // Output 1: amount1 goes to to1
    this.addOutput(546n, amount1, to1);

    // Output 2: remainder goes to to2
    this.addOutput(546n, amount2, to2);

    assert(this.checkPreimage());
  }
}

export { FungibleToken };

Transfer (Full Transfer)

The transfer method moves the entire supply to a new holder. It takes a Sig (auto-signed) and a to address (Addr / Ripemd160 hash160).

Split (Partial Transfer)

The split method divides a token UTXO into two outputs with specified amounts and recipients. The two amounts must sum to the original supply.

Merge (2-to-1 with Anti-Inflation Proof)

The merge method combines two token UTXOs into one. This is necessary because repeated splits create many small UTXOs. Merging consolidates them.

The anti-inflation proof is critical: the merge method requires that the merged output’s supply equals exactly the sum of the two input supplies. The second input’s supply and holder are passed as arguments and verified against the spending transaction’s second input. This prevents anyone from creating tokens during a merge operation.

Using FungibleToken with the SDK

import { RunarContract, WhatsOnChainProvider, LocalSigner } from 'runar-sdk';
import ftArtifact from './artifacts/FungibleToken.json';

const provider = new WhatsOnChainProvider('testnet');
const signer = new LocalSigner('cN3L4FfPtE7WjknM...');

// Mint: Deploy with initial supply and holder
const token = new RunarContract(ftArtifact, [
  1000000n,                                        // supply
  '89abcdef01234567890abcdef01234567890abcd',       // holder (hash160)
]);

await token.deploy(provider, signer, { satoshis: 10000 });

// Transfer entire supply to another address
const recipientAddr = '1234abcd1234abcd1234abcd1234abcd12345678';
await token.call('transfer', [
  null,            // sig (auto-sign)
  recipientAddr,   // to (Addr / hash160)
], provider, signer);

Non-Fungible Tokens (NFTs)

An NFT contract represents a unique asset. Unlike fungible tokens, NFTs are not split or merged — they are transferred whole or burned.

Contract Definition

import {
  StatefulSmartContract,
  assert,
  PubKey,
  Sig,
  ByteString,
  checkSig,
} from 'runar-lang';

class NFT extends StatefulSmartContract {
  owner: PubKey;
  tokenId: ByteString;

  constructor(owner: PubKey, tokenId: ByteString) {
    super(owner, tokenId);
    this.owner = owner;
    this.tokenId = tokenId;
  }

  public transfer(
    sig: Sig,
    to: PubKey,
  ) {
    // Verify current owner
    assert(checkSig(sig, this.owner));

    // State continuation with new owner, same tokenId
    this.addOutput(
      this.ctx.utxo.satoshis,
      to,
      this.tokenId,
    );

    assert(this.checkPreimage());
  }

  public burn(
    sig: Sig,
  ) {
    // Verify current owner
    assert(checkSig(sig, this.owner));

    // No addOutput() call -- UTXO is destroyed
    // Funds go to a change output (handled by the SDK)
    assert(this.checkPreimage());
  }
}

export { NFT };

Transfer (State Continuation)

The transfer method creates a continuation output with a new owner while preserving the tokenId. This ensures the NFT’s identity is maintained across transfers.

Burn (No Continuation)

The burn method does not call addOutput(). This means no continuation UTXO is created — the NFT is destroyed. The satoshis locked in the UTXO are released to a change output.

Using NFT with the SDK

import { RunarContract, WhatsOnChainProvider, LocalSigner } from 'runar-sdk';
import nftArtifact from './artifacts/NFT.json';

const provider = new WhatsOnChainProvider('testnet');
const signer = new LocalSigner('cN3L4FfPtE7WjknM...');

// Mint an NFT
const nft = new RunarContract(nftArtifact, [
  signer.publicKey,   // owner (PubKey)
  'deadbeef01',       // tokenId
]);

await nft.deploy(provider, signer, { satoshis: 1000 });

// Transfer to a new owner
const newOwnerPubKey = '02' + 'abcd1234'.repeat(8);
await nft.call('transfer', [
  null,               // sig (auto-sign)
  newOwnerPubKey,     // to (PubKey)
], provider, signer);

console.log(nft.state.owner);    // new owner public key
console.log(nft.state.tokenId);  // 'deadbeef01' (unchanged)

The TokenWallet Class

For application code that manages many token UTXOs, the SDK provides the TokenWallet class. It wraps a provider and signer and provides high-level methods for token operations.

import { TokenWallet } from 'runar-sdk';
import ftArtifact from './artifacts/FungibleToken.json';

const wallet = new TokenWallet(ftArtifact, provider, signer);

TokenWallet Methods

MethodDescription
getBalance()Sum balances across all token UTXOs owned by the signer
getUtxos()List all token UTXOs owned by the signer
transfer(recipientAddr, amount)Transfer tokens, automatically selecting UTXOs
merge()Merge all owned token UTXOs into one

Example: Using TokenWallet

const wallet = new TokenWallet(ftArtifact, provider, signer);

// Check total balance across all UTXOs
const balance = await wallet.getBalance();
console.log('Total balance:', balance); // 1000000n

// List individual UTXOs
const utxos = await wallet.getUtxos();
console.log('UTXOs:', utxos.length); // might be 3 UTXOs of varying balances

// Transfer (wallet picks UTXOs automatically)
await wallet.transfer(recipientAddr, 5000n);

// Merge all remaining UTXOs into one
await wallet.merge();
const utxosAfterMerge = await wallet.getUtxos();
console.log('UTXOs after merge:', utxosAfterMerge.length); // 1

The TokenWallet.transfer() method is smart about UTXO selection. If you have three UTXOs with balances [100000, 500000, 400000] and want to transfer 150000, it will select the 500000 UTXO (or combine smaller ones) and produce a remainder output.

Token Operations Summary

OperationFungibleNFT
MintDeploy with initial supplyDeploy with tokenId
Transfer (full)transfer(sig, to) — moves entire supplytransfer(sig, to) — state continuation
Split (partial)split(sig, amount1, to1, to2) — splits UTXONot applicable
Mergemerge() — 2-to-1 with anti-inflation proofNot applicable
BurnNot in base contract (extend to add)burn() — no continuation output

What’s Next