The TSL1 Token Protocol allows for the creation of P2P tokens on Bitcoin (BSV) that have the following features:
- Fully miner-validated tokens
- No indexers are required to track token state or to guard against double-spends
- No back-to-genesis tracing within the UTXO set
- No transaction bloating with successive token transfers
- Double-spend protection with the same level of security as the native token units (satoshis)
- On-chain identity anchoring via Rabin signature verification (NFT)
Download a copy of the whitepaper for a full technical explanation
The library supports two token types:
| Type | API Class | Use Case |
|---|---|---|
| NFT | TokenTool |
Unique, indivisible tokens (1:1 ownership) |
| Fungible | FungibleTokenTool |
Divisible token amounts (split, merge, transfer) |
Both token types support the full lifecycle: minting, witness creation, transfers, and burns. Fungible tokens additionally support splitting a token into two amounts and merging two UTXOs back into one.
Code contributions are welcome and encouraged.
Add to your pubspec.yaml:
dependencies:
tstokenlib:
git:
url: https://github.com/twostack/tstokenlibThe primary API is the TokenTool class. For a full working example, see
example/token_lifecycle_example.dart.
Token issuance creates a transaction with 5 outputs:
| Output | Purpose |
|---|---|
| output[0] | Change output (remaining satoshis back to issuer) |
| output[1] | PP1 — Proof Point 1 (inductive proof locked to owner, embeds tokenId and Rabin pubkey hash) |
| output[2] | PP2 — Proof Point 2 (validates witness funding outpoint and owner PKH) |
| output[3] | PartialWitness — enables transfer authorization via partial SHA-256 |
| output[4] | Metadata — OP_RETURN output carrying optional metadata or issuer identity |
import 'package:dartsv/dartsv.dart';
import 'package:tstokenlib/tstokenlib.dart';
import 'package:tstokenlib/src/crypto/rabin.dart';
var issuerPrivateKey = SVPrivateKey.fromWIF("your_WIF_here");
var issuerPubKey = issuerPrivateKey.publicKey;
var issuerAddress = Address.fromPublicKey(issuerPubKey, NetworkType.TEST);
var sigHashAll = SighashType.SIGHASH_FORKID.value | SighashType.SIGHASH_ALL.value;
var fundingSigner = TransactionSigner(sigHashAll, issuerPrivateKey);
var fundingTx = Transaction.fromHex("..."); // from the blockchain
// Generate a Rabin keypair for identity anchoring
var rabinKeyPair = Rabin.generateKeyPair(1024);
var rabinNBytes = Rabin.bigIntToScriptNum(rabinKeyPair.n).toList();
var rabinPubKeyHash = hash160(rabinNBytes); // 20-byte hash embedded in PP1
var service = TokenTool();
var issuanceTx = await service.createTokenIssuanceTxn(
fundingTx, fundingSigner, issuerPubKey, issuerAddress, fundingTx.hash,
rabinPubKeyHash,
);var issuanceTx = await service.createTokenIssuanceTxn(
fundingTx, fundingSigner, issuerPubKey, issuerAddress, fundingTx.hash,
rabinPubKeyHash,
metadataBytes: utf8.encode('{"name": "MyToken", "supply": 1}'),
);To cryptographically anchor the token to an issuer identity:
import 'package:cryptography/cryptography.dart';
var algorithm = Ed25519();
var keyPair = await algorithm.newKeyPair();
var wand = await algorithm.newSignatureWandFromKeyPair(keyPair);
var identityBuilder = IdentityAnchorBuilder({'name': 'My Issuer', 'org': 'My Org'});
var identityTx = await identityBuilder.buildTransaction(
fundingTx, fundingSigner, issuerPubKey, issuerAddress, wand,
);
var issuanceTx = await service.createTokenIssuanceTxn(
issuanceFundingTx, fundingSigner, issuerPubKey, issuerAddress, issuanceFundingTx.hash,
rabinPubKeyHash,
identityTxId: identityTx.hash,
issuerWand: wand,
);
// Verify the identity link
var isValid = await IdentityVerification.verifyIssuanceIdentity(issuanceTx, identityTx);After issuance (or after a transfer), a witness transaction proves ownership by spending PP1 and PP2 from the token transaction.
For issuance witnesses, a Rabin signature over sha256(identityTxId || ed25519PubKey) must
be provided to prove the issuer is authorized by the identity anchor:
// Compute Rabin signature for identity binding
var identityTxId = ...; // 32-byte identity anchor txid
var ed25519PubKey = ...; // 32-byte ED25519 public key from identity anchor
var messageHash = Rabin.sha256ToScriptInt([...identityTxId, ...ed25519PubKey]);
var rabinSig = Rabin.sign(messageHash, rabinKeyPair.p, rabinKeyPair.q);
var witnessTx = service.createWitnessTxn(
fundingSigner, fundingTx, issuanceTx,
List<int>.empty(), // empty for issuance (no parent)
issuerPubKey,
issuerAddress.pubkeyHash160,
TokenAction.ISSUANCE,
rabinN: rabinNBytes,
rabinS: Rabin.bigIntToScriptNum(rabinSig.s).toList(),
rabinPadding: rabinSig.padding,
identityTxId: identityTxId,
ed25519PubKey: ed25519PubKey,
);For a transfer witness, provide the full serialized parent token transaction:
var transferWitnessTx = service.createWitnessTxn(
recipientFundingSigner, recipientFundingTx, transferredTokenTx,
hex.decode(parentTokenTx.serialize()),
recipientPubKey,
senderPubkeyHash,
TokenAction.TRANSFER,
);var pp1 = PP1LockBuilder.fromScript(currentTokenTx.outputs[1].script);
var tokenId = pp1.tokenId ?? [];
var transferTx = service.createTokenTransferTxn(
currentWitnessTx, currentTokenTx,
currentOwnerPubKey, recipientAddress,
transferFundingTx, currentOwnerFundingSigner, currentOwnerPubKey,
recipientFundingTx.hash, tokenId,
);var burnTx = service.createBurnTokenTxn(
currentTokenTx, ownerSigner, ownerPubKey,
burnFundingTx, fundingSigner, fundingPubKey,
);The primary API is the FungibleTokenTool class. For a full working example, see
example/fungible_token_lifecycle_example.dart.
Fungible tokens use a "triplet" of outputs (PP5, PP2-FT, PP3-FT) that carries the token amount, owner PKH, and tokenId. The amount is embedded in the PP5 locking script and enforced by the Bitcoin Script interpreter at spending time.
Creates a new fungible token with a specified amount. The tokenId is derived from the funding transaction hash.
| Output | Purpose |
|---|---|
| output[0] | Change (remaining satoshis) |
| output[1] | PP5 — embeds ownerPKH, tokenId, and amount |
| output[2] | PP2-FT — validates witness funding outpoint |
| output[3] | PP3-FT — enables transfer via partial SHA-256 |
| output[4] | Metadata — OP_RETURN |
var tokenTool = FungibleTokenTool();
var mintTx = await tokenTool.createFungibleMintTxn(
fundingTx, fundingSigner, ownerPubKey, ownerAddress,
fundingTx.hash, // becomes the tokenId
1000, // amount to mint
);
var pp5 = PP5LockBuilder.fromScript(mintTx.outputs[1].script);
var tokenId = pp5.tokenId;
var amount = pp5.amount; // 1000After any fungible token operation (mint, transfer, split, merge), a witness must be created.
// Mint witness (no parent tx)
var mintWitnessTx = tokenTool.createFungibleWitnessTxn(
ownerSigner, fundingTx, mintTx,
ownerPubKey, ownerPubkeyHash,
FungibleTokenAction.MINT,
);
// Transfer witness (requires parent tx bytes)
var transferWitnessTx = tokenTool.createFungibleWitnessTxn(
recipientSigner, recipientFundingTx, transferTx,
recipientPubKey, changePubkeyHash,
FungibleTokenAction.TRANSFER,
parentTokenTxBytes: hex.decode(parentTx.serialize()),
parentOutputCount: 5,
);Transfers the full token amount to a new owner.
var transferTx = tokenTool.createFungibleTransferTxn(
witnessTx, tokenTx,
currentOwnerPubKey, recipientAddress,
fundingTx, fundingSigner, fundingPubKey,
recipientFundingTx.hash, tokenId, 1000,
);Divides a token into two outputs: a recipient amount and change. Creates an 8-output transaction with two triplets (recipient at indices 1-3, change at indices 4-6).
var splitTx = tokenTool.createFungibleSplitTxn(
witnessTx, tokenTx,
ownerPubKey, recipientAddress, 700, // send 700 to recipient
fundingTx, fundingSigner, fundingPubKey,
recipientWitnessFundingTx.hash, // recipient's witness funding
changeWitnessFundingTx.hash, // change witness funding
tokenId, 1000, // total amount before split
);
// Both triplets need separate witnesses
var recipientWitnessTx = tokenTool.createFungibleWitnessTxn(
recipientSigner, recipientFundingTx, splitTx,
recipientPubKey, changePubkeyHash,
FungibleTokenAction.SPLIT_TRANSFER,
parentTokenTxBytes: hex.decode(parentTx.serialize()),
parentOutputCount: 5,
tripletBaseIndex: 1, // recipient triplet
);
var changeWitnessTx = tokenTool.createFungibleWitnessTxn(
changeSigner, changeFundingTx, splitTx,
changePubKey, changePubkeyHash,
FungibleTokenAction.SPLIT_TRANSFER,
parentTokenTxBytes: hex.decode(parentTx.serialize()),
parentOutputCount: 5,
tripletBaseIndex: 4, // change triplet
);Combines two token UTXOs (same tokenId, same owner) into a single output. PP3-FT inputs are burned via P2PKH rather than fully unlocked.
var mergeTx = tokenTool.createFungibleMergeTxn(
witnessA, tokenTxA, // first UTXO
witnessB, tokenTxB, // second UTXO
ownerPubKey, ownerSigner,
fundingTx, fundingSigner, fundingPubKey,
mergedWitnessFundingTx.hash,
tokenId, 1000, // total = amountA + amountB
prevTripletBaseIndexA: 1,
prevTripletBaseIndexB: 4,
);
// Merge witness requires both parent tx bytes
var mergeWitnessTx = tokenTool.createFungibleWitnessTxn(
ownerSigner, fundingTx, mergeTx,
ownerPubKey, ownerPubkeyHash,
FungibleTokenAction.MERGE,
parentTokenTxBytes: hex.decode(tokenTxA.serialize()),
parentTokenTxBytesB: hex.decode(tokenTxB.serialize()),
parentOutputCount: 8,
parentOutputCountB: 8,
parentPP5IndexA: 1,
parentPP5IndexB: 4,
);var burnTx = tokenTool.createFungibleBurnTxn(
tokenTx, ownerSigner, ownerPubKey,
fundingTx, fundingSigner, fundingPubKey,
);NFT tokens enforce issuer identity on-chain using Rabin signature verification. During issuance, the PP1 locking script verifies that:
hash160(rabinN) == rabinPubKeyHash— the Rabin public key matches the hash embedded in PP1s² mod n == sha256(identityTxId || ed25519PubKey) + padding— the signature is valid
This ensures that only the holder of the Rabin private key (the issuer) can create tokens linked to a given identity anchor. The verification adds only ~48 bytes to the PP1 script (8 opcodes), keeping the total PP1 script size at ~2.5KB.
The rabinPubKeyHash is preserved across transfers as part of the PP1 constructor parameters,
alongside ownerPKH and tokenId.
import 'package:tstokenlib/src/crypto/rabin.dart';
// Generate a keypair (1024-bit is sufficient; 2048-bit for production)
var keyPair = Rabin.generateKeyPair(1024);
// Encode the public key for use in scripts
var rabinNBytes = Rabin.bigIntToScriptNum(keyPair.n).toList();
var rabinPubKeyHash = hash160(rabinNBytes);
// Sign a message
var messageHash = Rabin.sha256ToScriptInt(messageBytes);
var sig = Rabin.sign(messageHash, keyPair.p, keyPair.q);
// Verify (in Dart, for testing)
var isValid = Rabin.verify(messageHash, sig, keyPair.n);The library throws ScriptException (from the dartsv package) for invalid inputs:
- Wrong-length pubkey hash (PKH): Must be exactly 20 bytes (40 hex characters).
- Wrong-length tokenId: Must be exactly 32 bytes.
- Wrong-length Rabin pubkey hash: Must be exactly 20 bytes (hash160 of the encoded Rabin public key).
- Invalid outpoint format: Witness funding outpoints must be 36 bytes.
try {
var tx = await service.createTokenIssuanceTxn(...);
} on ScriptException catch (e) {
print('Script error: ${e.error} - ${e.cause}');
}Apache License 2.0