Radiant FTs are on-chain (not metadata-on-P2PKH)¶
Why this page exists: people coming from Bitcoin tokens (Atomicals, Runes, Ordinals) or Solana SPL often assume Radiant FTs work the same way — plain UTXOs with off-chain meaning assigned by an indexer. That model is wrong for Radiant. The script bytes are the token. This page explains the difference and what it means in practice.
TL;DR¶
A Radiant FT UTXO is a 75-byte locking script with consensus-enforced token semantics. There is no off-chain indexer required to know what it is or how much it holds. Compare this to Atomicals / Runes / SPL tokens, where the on-chain UTXO is a plain P2PKH and an external database tracks “this UTXO holds 100 FOO.”
The two models, side by side¶
❌ NOT how Radiant FTs work |
✅ How Radiant FTs ACTUALLY work |
|
|---|---|---|
Examples of this model |
Atomicals (BTC), Runes, Ordinals, Solana SPL |
Radiant Glyph FTs, BCH CashTokens, BSV STAS |
On-chain script |
Plain P2PKH (25 bytes) |
75-byte FT lock script |
Token semantics enforced by |
Off-chain indexer rules |
Consensus opcodes ( |
What “holds the token” |
An indexer database row pointing at a UTXO |
The UTXO’s locking script bytes |
Wallet without protocol support |
Sees plain RXD/BTC; can spend the token away by accident |
Sees a 75-byte script it can’t unlock without the matching key — token is safe |
Indexer goes down / disagrees |
Token “vanishes” (or worse, double-spend if indexer state diverges) |
Doesn’t matter — the chain is the source of truth |
Where is the token amount stored? |
In the indexer’s database |
The output’s |
The 75-byte FT layout¶
Every Radiant FT UTXO has this exact shape:
┌─ standard P2PKH (25 B) ─┐ ┌─ ref ──┐ ┌── FT-CSH epilogue (12 B) ─┐
│ │ │ │ │ │
76 a9 14 <pkh:20> 88 ac bd d0 <ref:36> de c0 e9 aa 76 e3 78 e4 a2 69 e6 9d
▲ ▲ ▲ ▲
OP_DUP │ │ │
OP_HASH160 │ │ │
PUSH(20) <pkh> │ │ │
OP_EQUALVERIFY │ │ │
OP_CHECKSIG │ │ │
│ │ │
│ │ Hashed by the dMint contract to enforce
│ │ conservation: sum(input ft) == sum(output ft).
│ │ This is the canonical "FT-CSH" fingerprint that
│ │ pyrxd's classifier matches.
│ │
│ OP_PUSHINPUTREF <36-byte wire ref>
│ (consensus opcode 0xd0; the ref is
│ txid_LE_reversed + vout_LE = 36 bytes)
│
OP_STATESEPARATOR (0xbd)
(marks the boundary between owner-spend logic
and FT-conservation logic)
The first 25 bytes are a perfectly normal P2PKH — that’s why a key holder can
sign and spend the UTXO with a regular <sig> <pubkey> scriptSig. The next
38 bytes (bd d0 <ref:36>) bind the UTXO to a specific token via consensus.
The trailing 12 bytes are the conservation “fingerprint” — the dMint
contract hashes them as part of enforcing sum(input ft) == sum(output ft).
The ref is the token’s permanent identity. Every UTXO of the same FT encodes the same 36 bytes there. Different tokens have different refs.
The conservation rule¶
Every OP_PUSHINPUTREF (0xd0) ref appearing in any output script must
also appear in some input being spent. This is enforced at consensus
level by the Radiant node:
INPUTS OUTPUTS
────── ───────
[FT lock with ref=R] ──→ [FT lock with ref=R] ✓ ref R survives
[FT lock with ref=R] ✓ R can split
[P2PKH only] ──→ [FT lock with ref=R] ✗ REJECTED
R never came from input
When the rule is violated, the node rejects the broadcast with:
bad-txns-inputs-outputs-invalid-transaction-reference-operations
Refs cannot be conjured from thin air — only carried forward.
This is why a transfer that funds itself from a plain P2PKH UTXO (regular RXD) and tries to produce an FT output always fails. The output declares “I carry ref R” but no input ever carried R. There’s nothing wrong with the signature or the math; the chain refuses on principle.
Wallets at one address can hold mixed UTXOs¶
A typical Radiant wallet address holds both plain P2PKH UTXOs (regular RXD for fees) and FT lock UTXOs (token balances). They are different shapes at the same address:
Address ──┬── UTXO 1: P2PKH 25 bytes, sats=39825 RXD (RXD for fees)
├── UTXO 2: FT 75 bytes, sats=5_749_199 (RBG token balance)
├── UTXO 3: P2PKH 25 bytes, sats=1 (RXD dust)
└── UTXO 4: FT 75 bytes (different ref), sats=100 (a different FT)
Same address, four UTXOs, four different shapes/meanings. Your wallet scanner returns all of them. When transferring an FT, your code must filter to:
UTXOs whose locking script is the 75-byte FT shape (
is_ft_script)AND whose embedded ref matches the token you want to transfer (
extract_ref_from_ft_script(...) == target_ref)
Skipping that filter does not help. Feeding a P2PKH UTXO into pyrxd’s
FtUtxoSet will produce a tx that violates the conservation rule above.
The script-shape check that rejects “Not a valid FT script” is correct —
it’s protecting you from broadcasting a tx the network would reject.
Implications for transfer code¶
The canonical pattern, also implemented in
examples/ft_transfer_demo.py:
from pyrxd.glyph.script import is_ft_script, extract_ref_from_ft_script
from pyrxd.network.electrumx import script_hash_for_address
from pyrxd.security.types import Txid
from pyrxd.transaction.transaction import Transaction
ft_utxos = []
raw_utxos = await client.get_utxos(script_hash_for_address(my_address))
for u in raw_utxos:
raw = await client.get_transaction(Txid(u.tx_hash))
tx = Transaction.from_hex(bytes(raw))
script = tx.outputs[u.tx_pos].locking_script.serialize()
# Filter 1: must be a 75-byte FT lock (skip plain P2PKH RXD).
if not is_ft_script(script.hex()):
continue
# Filter 2: must be the token we want (skip other FTs).
if extract_ref_from_ft_script(script) != target_token_ref:
continue
# OK, this is a UTXO of the target FT.
ft_utxos.append(FtUtxo(
txid=u.tx_hash, vout=u.tx_pos, value=u.value,
ft_amount=u.value, # 1 photon = 1 FT unit
ft_script=script, # bytes, 75 long
))
# Now feed ft_utxos into FtUtxoSet for transfer construction.
How to verify a UTXO is “actually FT-bearing”¶
Use pyrxd glyph inspect against a tx to see exactly what each output is:
$ pyrxd glyph inspect <txid> --fetch
Transaction: ...
vout 0 type=ft ref=b45dc4...:0 sats=5 (FT — 5 RBG to recipient)
vout 1 type=ft ref=b45dc4...:0 sats=9990 (FT — 9990 RBG change)
vout 2 type=p2pkh sats=39825 RXD (regular RXD change)
If a UTXO classifies as type=p2pkh, it is not an FT — the chain itself
will not let you spend it as one regardless of what an off-chain tool tells
you to do.
Source-of-truth references¶
Consensus opcodes.
OP_PUSHINPUTREF(0xd0),OP_PUSHINPUTREFSINGLETON(0xd8),OP_REQUIREINPUTREF(0xd1),OP_REFTYPE_OUTPUT(0xda),OP_STATESEPARATOR(0xbd). These are Radiant-specific opcodes added by the node fork; they don’t exist on Bitcoin or BCH.Glyph protocol spec. The 75-byte FT layout and the conservation fingerprint
dec0e9aa76e378e4a269e69dare defined by the Glyph protocol (REP-3010 for V2 dMint).Classifier code.
src/pyrxd/glyph/script.pyhas the regex (FT_SCRIPT_RE), constructor (build_ft_locking_script), predicate (is_ft_script), and extractors (extract_ref_from_ft_script,extract_owner_pkh_from_ft_script).Live evidence. Three independent on-chain witnesses confirm the 75-byte form is the canonical FT shape: any FT premine output, any FT transfer output, any dMint reward output. Run
pyrxd glyph inspect <any-FT-tx-id> --fetchto see the bytes yourself.
When in doubt, trust the bytes¶
If a tool, chatbot, or doc tells you Radiant FTs are “P2PKH outputs with metadata interpreted by an indexer,” that tool is describing a different protocol (Atomicals / Runes / Ordinals / SPL — all of which use that model). Radiant is not that.
Three things you can always check yourself:
The chain.
pyrxd glyph inspect <txid> --fetchwill show you the exact byte shape of every output. FT outputs are 75 bytes; P2PKH is 25.The opcodes. A 75-byte script with
bd d0at offset 25-26 is an FT lock. A 25-byte script ending88 acis plain P2PKH.The error. If your transfer is rejected with
bad-txns-inputs-outputs-invalid-transaction-reference-operations, you violated the conservation rule. There is no off-chain workaround.