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
| Method | Description |
|---|---|
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
| Operation | Fungible | NFT |
|---|---|---|
| Mint | Deploy with initial supply | Deploy with tokenId |
| Transfer (full) | transfer(sig, to) — moves entire supply | transfer(sig, to) — state continuation |
| Split (partial) | split(sig, amount1, to1, to2) — splits UTXO | Not applicable |
| Merge | merge() — 2-to-1 with anti-inflation proof | Not applicable |
| Burn | Not in base contract (extend to add) | burn() — no continuation output |
What’s Next
- Stateful Contracts — The underlying pattern that tokens are built on
- Fee and Change Handling — How fees work in token transactions
- DAG Topology and Token Merges — Advanced merge patterns