Wire format
Protostone payload encoding
This page gives the exact bytes. Every claim here is checked against
alkanes-rs: the Rust decoder in
crates/protorune-support/src/protostone.rs, the tag table in
crates/ordinals/src/runestone/tag.rs, and the encoder in
ts-sdk/src/protostone/. The two sides agree, and the examples below were produced by running the
encoder and feeding the result back through the decoder.
Why this page matters
An alkanes transfer is an encoded protostone. The output script must begin with
6a 5d, OP_RETURN followed by OP_PUSHNUM_13. A plain 6a
OP_RETURN carrying JSON, a text string, or any other blob is not a runestone. It is skipped
entirely.
The transaction still confirms. The fee is still spent. The input that held your alkanes is still gone from the UTXO set. But the index never learned that the balance should move, so it stays recorded against an outpoint that can never be spent again. There is no error, no refund, and no recovery.
The correct payload for a simple transfer is what the ts-sdk produces from
encodeRunestoneProtostone with
ProtoStone.edicts({ protocolTag: 1n, edicts: [{ id, amount, output }] }). Everything on this page
explains what that call actually emits, so you can produce it in any language and verify it yourself.
The five layers
A protostone is nested twice inside LEB128. That is the part people get wrong. Reading outward from the data you care about:
1. The script envelope
The runestone lives in one OP_RETURN output whose value is zero. The script is:
OP_RETURN 0x6a
OP_PUSHNUM_13 0x5d
<data push> the LEB128 runestone payload, in chunks of at most 520 bytes
alkanes-rs looks for exactly these two leading bytes.
crates/ordinals/src/runestone.rs defines the magic as a single little-endian u16
constant 0x5d6a, which is the byte pair 6a 5d read as a 16-bit little-endian value.
If the second opcode is anything else, the output is not a runestone and the transaction carries no protostone.
- Only data pushes may follow. A non-push opcode makes the runestone a cenotaph.
- Each push carries at most
MAX_SCRIPT_ELEMENT_SIZE= 520 bytes. Longer payloads are split across consecutive pushes and concatenated by the decoder. - If a transaction has more than one
OP_RETURNoutput with the magic, the first one in output order is the runestone.
2. Runestone fields
The payload is a flat list of LEB128 varints read as tag and value pairs. Tag 0 is special: it
ends the field section and everything after it is the Runes-layer edict body. The protostone data rides in
repeated fields with tag 16383.
| Tag | Name | Role for alkanes |
|---|---|---|
0 | Body | Runes-layer edicts. Alkanes transfers do not use this; they use the protostone body instead. |
20 | Mint | Runes minting. Not used by alkanes. |
22 | Pointer | Default output for unallocated Runes-layer balances. Frequently present alongside a protostone. |
16383 | Protocol | One 15-byte chunk of the protostone byte stream. Repeated as many times as needed. |
16383 is 214 - 1, and its LEB128 encoding is always the two bytes ff 7f.
Spotting ff7f repeatedly in an OP_RETURN is a reliable sign that you are looking at
a protostone.
3. The 15-byte rule
Layer 3 is the step most re-implementations miss. The protostone byte stream is cut into groups of 15 bytes.
Each group is read as a little-endian integer and becomes one u128 value.
A u128 LEB128-encodes to at most 19 bytes. The Runes decoder treats a varint longer than 18
bytes as a flaw and turns the runestone into a cenotaph. Capping each value at 15 bytes of payload keeps the
top bits clear so the re-encoded varint always fits. The comment in
crates/protorune-support/src/protostone.rs puts it directly: never write to the 16th byte of
the u128.
The two directions are exact inverses:
- Encoding (
split_bytes,unpack): take bytes 15 at a time, pad the final group with zeros to 16 bytes, read little-endian. - Decoding (
join_to_bytes,snap_to_15_bytes): write eachu128as 16 little-endian bytes and drop the 16th, always emitting exactly 15 bytes per value.
Because the decoder always re-expands to a full 15 bytes, a short final group comes back with trailing zero
bytes. That is expected and harmless at the protostone level: the record loop stops as soon as it reads a
protocol tag of 0. It is not harmless inside a message field, which is covered
below.
4. Protostone records
The reassembled byte stream is LEB128-decoded again into a flat integer list, which is then read as a sequence of records:
[ protocol_tag, length, value_0, value_1, ... value_(length-1) ]
[ protocol_tag, length, ... ]
...
protocol_tagidentifies the sub-protocol. Alkanes is tag 1.lengthis the number of values that belong to this record, not a byte count.- A
protocol_tagof0ends the list. This is how the zero padding from layer 3 is absorbed. - If
lengthclaims more values than remain, decoding fails with less values than expected and the entire protostone list is rejected.
Within a record the values are again tag and value pairs, with tag 0 ending the fields and
starting the body:
| Tag | Name | Type | Meaning |
|---|---|---|---|
0 | Body | quadruples | Edicts. Every remaining value in the record belongs here. |
81 | Message | repeated u128 | 15-byte chunks of the cellpack calldata. |
83 | Burn | u128 | Marks the record as a protoburn. |
91 | ProtoPointer | u32 | Output that receives the result when the message succeeds. |
93 | Refund | u32 | Output that receives the balances when the message fails. |
95 | From | u32 | Edict index selector used by protoburns. |
Any other tag is parsed as a pair and then ignored. The ts-sdk enum additionally names
SPLIT = 85, but the Rust Tag enum has no such variant and the decoder does nothing
with it, so do not emit it.
5a. The edict body
Edicts are the transfer instructions. They are encoded as a flat run of values in groups of four:
block_delta, tx_delta, amount, output
Rules, from protostone_edicts_from_integers and next_protostone_edict_id:
- Edicts are sorted by alkane id before encoding, and the ids are delta encoded against the
previous edict, starting from
0:0. - If
block_deltais0, thentx_deltais added to the previoustx. Otherwisetx_deltais the absolutetxof the new id. amountis a rawu128in the alkane's own base units. Divisibility is defined by the asset, and the protocol layer never rescales it.amountof0means the entire remaining balance of that alkane at that point in edict processing. It does not mean zero.outputis the destination index. Real outputs are0 .. n-1. Indexnspreads the amount across all non-OP_RETURNoutputs. Indexes at or abovenalso address protostone virtual outputs, which is how you hand balances to a message.
protostone_edicts_from_integers returns
edict values did not appear in sets of four, but from_fields_and_tag discards that
error and substitutes an empty edict list. The protostone is still valid, the message still runs if there is
one, and the transfer you intended simply never happens.
5b. The message field
A message carries a cellpack: the call to make. Its integer form is
[ target.block, target.tx, input_0, input_1, ... ]
target is the AlkaneId to invoke, and input_0 is by convention the
opcode the contract dispatches on. The list is LEB128-encoded, then cut into 15-byte chunks exactly as in
layer 3, and each chunk is emitted as a tag 81 field.
Because join_to_bytes always writes 15 bytes per chunk, the calldata the indexer reconstructs is
padded with zero bytes, and decode_varint_list turns each of those into a zero-valued
u128. The cellpack [2, 1, 77] is 3 bytes on the wire and arrives as
target 2:1 with inputs [77, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0].
Pass every argument explicitly rather than relying on a default, and design opcodes so that trailing zeros are harmless.
A message record must also carry ProtoPointer (91) and Refund (93). Both are
required by Protostone::process_message; a message without them is an error that rolls the
transaction's protostone processing back rather than refunding anything.
A cellpack that decodes to fewer than two varints is rejected before anything runs. That guard is explicit in
crates/alkanes-support/src/cellpack.rs and exists because the slice expression that reads
target.block and target.tx would otherwise panic, and a panic stops the indexer for
everyone.
Worked example: a transfer, byte by byte
Move 100000000 base units of alkane 2:1 to output 1.
Step 1: protostone integers
[1, 5, 0, 2, 1, 100000000, 1]
| | | | | | |
| | | | | | output index 1
| | | | | amount, raw u128
| | | | tx delta (0 -> 1)
| | | block delta (0 -> 2)
| | Tag 0, Body: edicts follow
| five values in this record
protocol tag 1, alkanes
Step 2: LEB128
| Bytes | Value | Meaning |
|---|---|---|
01 | 1 | protocol tag, alkanes |
05 | 5 | record length |
00 | 0 | Tag 0, Body |
02 | 2 | block delta |
01 | 1 | tx delta |
80 c2 d7 2f | 100000000 | amount |
01 | 1 | output index |
010500020180c2d72f01 10 bytes
Step 3: 15-byte chunk
Ten bytes is a single group. Reversed and read as a little-endian integer:
bytes 01 05 00 02 01 80 c2 d7 2f 01
LE 0x012fd7c2800102000501
u128 5604910583939447391489
Step 4: runestone fields
[16383, 5604910583939447391489]
ff 7f tag 16383
81 8a 80 90 90 80 a0 e1 d7 df 04 the chunk 13 bytes total
Step 5: script
6a 5d 0d ff7f 818a80909080a0e1d7df04
6a5d0dff7f818a80909080a0e1d7df04
16 bytes. 6a OP_RETURN, 5d OP_PUSHNUM_13,
0d push 13 bytes, then the payload. Put it in one output with value 0.
The equivalent ts-sdk call:
const { encodedRunestone } = encodeRunestoneProtostone({
protostones: [
ProtoStone.edicts({
protocolTag: 1n,
edicts: [{
id: new ProtoruneRuneId(2n, 1n),
amount: 100000000n,
output: 1,
}],
}),
],
});
Worked example: a contract call
Call alkane 2:1 with opcode 77, sending the result to output 0 and
refunding to output 1.
cellpack [2, 1, 77]
cellpack LEB128 02 01 4d 3 bytes
one 15-byte chunk 0x4d0102 = 5046530
protostone [1, 6, 91, 0, 93, 1, 81, 5046530]
| | | | | | | calldata chunk
| | | | | | Tag 81, Message
| | | | | refund pointer, output 1
| | | | Tag 93, Refund
| | | proto pointer, output 0
| | Tag 91, ProtoPointer
| six values
protocol tag 1
LEB128 01 06 5b 00 5d 01 51 82 82 b4 02 11 bytes
chunk 3270285073163573723334145
runestone [16383, 3270285073163573723334145]
payload ff7f 818cec82d0abc0a88285d215 14 bytes
6a5d0eff7f818cec82d0abc0a88285d215
17 bytes. Note that 91 is 0x5b and 93 is
0x5d: the byte 5d inside the payload is the Refund tag, not a second
OP_PUSHNUM_13.
Worked example: a real mainnet payload
This script appears in the alkanes-rs test corpus as a captured mainnet OP_RETURN:
6a5d101600ff7f818cec8ad0abc0a8a081d215
Decoded, layer by layer:
| Layer | Result |
|---|---|
| Script | 6a 5d, then a 16-byte push |
| Runestone integers | [22, 0, 16383, 3270250341403247458846209] |
| Runes-layer fields | Tag 22 Pointer = 0. Unallocated Runes-layer balances go to output 0. |
| Protostone integers | [1, 6, 91, 1, 93, 1, 81, 5046304] |
| Protostone | tag 1, pointer output 1, refund output 1, one message chunk |
| Cellpack | target 32:0, opcode 77 |
Alkane 32:0 is the fr-BTC system contract, so this is a wrap: opcode 77 against fr-BTC, with both
the success pointer and the refund pointer aimed at output 1. You can paste it into the
decoder and watch it come apart.
Three ways to lose an asset
All three confirm on chain. None of them produce an error a wallet can show you.
No magic byte
Script is 6a followed by JSON, text, or any non-runestone payload.
Runestone::decipher finds no output with the magic and returns nothing, so the protorune indexing
path is never entered. Alkane balances are not moved and not cleared: they stay recorded against the outpoint
you just spent. That outpoint can never be spent again, so the balance is stranded permanently.
6a 4c 0d 7b2270223a226a736f6e227d not a runestone
Right envelope, wrong protocol tag
Script is a valid runestone with a valid protostone, but the protocol tag is not 1. This is
worse. The runestone is valid, so the indexer does run, loads the alkane balances from the inputs,
finds no protostone claiming tag 1 to assign them to, and then clears the input balances as part of normal
processing. The balances are destroyed rather than stranded.
Edict body not a multiple of four
The protostone parses, the record is accepted, and the edict list silently becomes empty. Any balance you did not otherwise direct follows the default assignment instead of your intended recipient.
Encoder checklist
- Script starts with the two bytes
6a 5d. - The
OP_RETURNoutput has value 0 and is the only runestone in the transaction. - Everything after
5dis data pushes only, each at most 520 bytes. - The protocol tag inside the protostone record is
1. - The record length equals the number of values that follow it, exactly.
- Edict ids are sorted and delta encoded from
0:0, and the body length is a multiple of four. - Amounts are raw base units.
0means the whole remaining balance, so never use it as a placeholder. - Output indexes are real outputs, or deliberate virtual protostone indexes.
- Message records carry both
ProtoPointer(91) andRefund(93). - Cellpacks contain at least the two target values, and every argument is passed explicitly.
- Round-trip your own output through a decoder before you sign. The decoder on this site does that without sending anything anywhere.