Fungible Token
In this tutorial you will build a fungible token contract from scratch using Runar. The token uses the UTXO model natively — each token holder’s balance is a separate UTXO on the BSV blockchain, and all transfers, sends, and merges are enforced by on-chain covenants.
By the end of this tutorial, you will understand:
- How
StatefulSmartContractmaintains mutable state across transactions - The UTXO-native token model (one UTXO per balance)
- Covenant enforcement using
SigHashPreimage,checkPreimage, andextractOutputHash - Multi-output transactions with
addOutput - Testing stateful contracts with
TestContract
Prerequisites
- Runar CLI installed and a project initialized (see Hello World Tutorial)
- Familiarity with
SmartContractbasics
Understanding the UTXO Token Model
Traditional account-based blockchains store token balances in a global mapping. Runar tokens work differently: each token balance is a UTXO. When Alice holds 100 tokens, that is a specific UTXO on the blockchain containing the token contract code and the state { owner: Alice, balance: 100 }.
Transferring tokens means spending the current UTXO and creating new UTXOs with updated state. The contract’s covenant enforces that the total balance is conserved — you cannot create tokens out of thin air or destroy them without the owner’s consent.
This model has important advantages:
- Parallelism — Different token holders can transact simultaneously without contention.
- Privacy — Token transfers do not touch a global state; only the involved UTXOs are affected.
- Scalability — The BSV network processes UTXO transactions efficiently at scale.
Designing the Token Contract
The token contract needs three operations:
| Method | Description |
|---|---|
transfer | Split a balance: send some tokens to a new owner, keep the rest |
send | Spend the entire UTXO (full balance transfer) |
merge | Consolidate a token UTXO (used when combining balances) |
The contract state consists of an owner (public key) and a balance (bigint). The owner is readonly because it is set at creation and identifies who can operate on this specific UTXO. The balance is mutable because transfers change it.
Writing the Contract
Create src/contracts/FungibleToken.runar.ts:
import {
StatefulSmartContract,
assert,
PubKey,
Sig,
checkSig,
SigHashPreimage,
checkPreimage,
extractOutputHash,
hash256,
num2bin,
} from 'runar-lang';
class FungibleTokenExample extends StatefulSmartContract {
readonly owner: PubKey;
balance: bigint;
constructor(owner: PubKey, balance: bigint) {
super(owner, balance);
this.owner = owner;
this.balance = balance;
}
public transfer(
sig: Sig,
amount: bigint,
newOwner: PubKey,
txPreimage: SigHashPreimage
) {
assert(checkSig(sig, this.owner));
assert(amount > 0n);
assert(amount <= this.balance);
assert(checkPreimage(txPreimage));
this.balance = this.balance - amount;
this.addOutput(1n, this.owner, this.balance);
this.addOutput(1n, newOwner, amount);
const expectedHash = hash256(this.getStateScript());
assert(extractOutputHash(txPreimage) === expectedHash);
}
public send(sig: Sig, txPreimage: SigHashPreimage) {
assert(checkSig(sig, this.owner));
assert(checkPreimage(txPreimage));
}
public merge(sig: Sig, txPreimage: SigHashPreimage) {
assert(checkSig(sig, this.owner));
assert(checkPreimage(txPreimage));
this.addOutput(1n, this.owner, this.balance);
const expectedHash = hash256(this.getStateScript());
assert(extractOutputHash(txPreimage) === expectedHash);
}
}
export { FungibleTokenExample };
Line-by-Line Walkthrough
The Base Class
class FungibleTokenExample extends StatefulSmartContract {
This extends StatefulSmartContract instead of SmartContract. Stateful contracts can have mutable properties and use the OP_PUSH_TX pattern to enforce state transitions. The compiler automatically injects preimage verification and state continuation logic.
Properties
readonly owner: PubKey;
balance: bigint;
ownerisreadonly— it is fixed when the UTXO is created and embedded in the locking script.balanceis mutable — it changes when tokens are transferred. The updated value is encoded in the new output’s locking script.
The Constructor
constructor(owner: PubKey, balance: bigint) {
super(owner, balance);
this.owner = owner;
this.balance = balance;
}
Both parameters are passed to super(). For StatefulSmartContract, this registers them as the initial state that gets serialized into the first UTXO.
The transfer Method
This is the core operation. Let’s examine each line:
public transfer(sig: Sig, amount: bigint, newOwner: PubKey, txPreimage: SigHashPreimage) {
The method takes four arguments from the unlocking script:
sig— The current owner’s signature authorizing the transferamount— How many tokens to sendnewOwner— The recipient’s public keytxPreimage— The serialized transaction preimage (needed for covenant enforcement)
assert(checkSig(sig, this.owner));
Only the current owner can transfer tokens. This is the authorization check.
assert(amount > 0n);
assert(amount <= this.balance);
Validate the transfer amount: must be positive and cannot exceed the available balance. These two lines prevent token creation from nothing and ensure the sender has sufficient funds.
assert(checkPreimage(txPreimage));
Verify the preimage matches the current transaction. This is the foundation of covenant enforcement — it binds the contract logic to the actual transaction being constructed.
this.balance = this.balance - amount;
this.addOutput(1n, this.owner, this.balance);
this.addOutput(1n, newOwner, amount);
Update the state and define the outputs:
- The first output is the sender’s change UTXO with the remaining balance.
- The second output is the recipient’s new UTXO with the transferred amount.
The 1n is the satoshi amount for each output (1 satoshi, the minimum). The remaining arguments define the state serialized into each output.
const expectedHash = hash256(this.getStateScript());
assert(extractOutputHash(txPreimage) === expectedHash);
The covenant enforcement: getStateScript() serializes the outputs defined by addOutput calls. extractOutputHash(txPreimage) extracts the hash of the transaction’s actual outputs from the preimage. By asserting they are equal, the contract guarantees the spending transaction creates exactly the outputs the contract specified. The spender cannot redirect tokens elsewhere.
The send Method
public send(sig: Sig, txPreimage: SigHashPreimage) {
assert(checkSig(sig, this.owner));
assert(checkPreimage(txPreimage));
}
send is a simple spend — the owner authorizes spending the entire UTXO without constraining the outputs. This is used when the full balance is being transferred as part of a larger transaction (for example, when the recipient constructs a transaction that consumes this UTXO as an input alongside other inputs).
The merge Method
public merge(sig: Sig, txPreimage: SigHashPreimage) {
assert(checkSig(sig, this.owner));
assert(checkPreimage(txPreimage));
this.addOutput(1n, this.owner, this.balance);
const expectedHash = hash256(this.getStateScript());
assert(extractOutputHash(txPreimage) === expectedHash);
}
merge consolidates a token UTXO. When an owner has multiple small-balance UTXOs, they can merge them into a single UTXO in one transaction. Each input UTXO calls merge, and the transaction produces one output with the combined balance.
Compiling the Contract
runar compile contracts/FungibleToken.runar.ts --output ./artifacts --asm
This produces artifacts/FungibleTokenExample.json. The artifact will be significantly larger than the P2PKH artifact because stateful contracts include the OP_PUSH_TX preimage verification logic in the script.
Testing the Token
Create tests/FungibleToken.test.ts:
import { describe, it, expect } from 'vitest';
import { TestContract, ALICE, BOB } from 'runar-testing';
import { readFileSync } from 'fs';
describe('FungibleTokenExample', () => {
const source = readFileSync('./src/contracts/FungibleToken.runar.ts', 'utf-8');
it('should transfer tokens to a new owner', () => {
// Alice starts with 100 tokens
const contract = TestContract.fromSource(source, {
owner: ALICE.pubKey,
balance: 100n,
});
// Transfer 30 tokens to Bob
const result = contract.call('transfer', {
sig: ALICE.privKey,
amount: 30n,
newOwner: BOB.pubKey,
txPreimage: 'auto',
});
expect(result.success).toBe(true);
// Verify outputs: Alice has 70, Bob has 30
expect(result.outputs).toHaveLength(2);
});
it('should reject transfer exceeding balance', () => {
const contract = TestContract.fromSource(source, {
owner: ALICE.pubKey,
balance: 50n,
});
const result = contract.call('transfer', {
sig: ALICE.privKey,
amount: 100n,
newOwner: BOB.pubKey,
txPreimage: 'auto',
});
expect(result.success).toBe(false);
});
it('should reject transfer with wrong signature', () => {
const contract = TestContract.fromSource(source, {
owner: ALICE.pubKey,
balance: 100n,
});
// Bob tries to transfer Alice's tokens
const result = contract.call('transfer', {
sig: BOB.privKey,
amount: 50n,
newOwner: BOB.pubKey,
txPreimage: 'auto',
});
expect(result.success).toBe(false);
});
it('should allow owner to send entire balance', () => {
const contract = TestContract.fromSource(source, {
owner: ALICE.pubKey,
balance: 100n,
});
const result = contract.call('send', {
sig: ALICE.privKey,
txPreimage: 'auto',
});
expect(result.success).toBe(true);
});
});
Run the tests:
runar test
Token Lifecycle Example
Here is a concrete example of how tokens flow through the system:
-
Mint — Deploy the contract with
owner: Alice, balance: 1000. This creates the first token UTXO. -
Transfer — Alice transfers 300 to Bob. The transaction spends Alice’s UTXO and creates two new UTXOs:
- Alice:
{ owner: Alice, balance: 700 } - Bob:
{ owner: Bob, balance: 300 }
- Alice:
-
Transfer again — Bob transfers 100 to Carol. Bob’s UTXO is spent:
- Bob:
{ owner: Bob, balance: 200 } - Carol:
{ owner: Carol, balance: 100 }
- Bob:
-
Merge — If Alice receives tokens from multiple sources, she might have several UTXOs. She can merge them into one UTXO for cleaner accounting.
Every step is enforced on-chain. The covenant in the transfer method guarantees that balances are conserved — the sum of output balances always equals the input balance.
Deploying the Token
runar compile contracts/FungibleToken.runar.ts --output ./artifacts
runar deploy ./artifacts/FungibleTokenExample.json \
--network testnet \
--key <your-WIF-private-key> \
--satoshis 1
The --satoshis 1 is the dust amount for the UTXO. The token’s value is tracked in the contract state (balance), not in the satoshi amount of the UTXO.
Key Concepts Recap
| Concept | What You Learned |
|---|---|
StatefulSmartContract | Contracts with mutable state carried forward across transactions |
SigHashPreimage | Serialized transaction data used for covenant enforcement |
checkPreimage | Verifies the preimage matches the current transaction |
extractOutputHash | Extracts the hash of actual outputs from the preimage |
addOutput | Defines required outputs in the spending transaction |
getStateScript | Serializes all outputs defined by addOutput calls |
| UTXO token model | Each balance is a separate UTXO; transfers create new UTXOs |
What’s Next
- NFT Tutorial — Build a non-fungible token with unique metadata and transfer/burn operations
- Multi-Party Escrow Tutorial — Learn multi-signature patterns with buyer, seller, and arbiter roles
- Example Gallery — Browse annotated contract examples