ChainBloom Docs
Open ChainBloom

Protocol architecture

Technical referenceChecked against @chainbloom/protocol@0.1.0Last reviewed 2026-07-317 min read

The design in one page: what ChainBloom fixes for everyone, what it leaves open, and the exact bytes of the marker that names an action.

Read this page and you will know exactly what every independent reader of the chain must compute the same way, and exactly what is yours to decide. That line is the whole design. Everything above it is fixed forever; everything below it is where your product gets to be different.

Five ideas#

Keep the shared agreement small#

The consensus surface is one output of at most 72 bytes. Nothing else in a ChainBloom transaction carries meaning that two indexers could read differently.

That is a deliberate ceiling, not a limitation waiting to be lifted. Every byte you add to a shared format is a byte two implementations can disagree about, and a byte every future reader must keep parsing. The record holds numbers that name choices. It holds no images, no text beyond a world title, and no instructions about how anything should look.

A world is created by one transaction#

One CREATE transaction fixes the shape of a and its identity at the same time. The world id is the txid of that transaction. There is no registry, no name reservation, and no second step.

CREATE fixes five things that can never be edited:

FieldRangeWhat it decides
ruleset1Which version of these rules the world follows
laneCount1 to 8How many paths exist
durationBlocks144 to 52,560How long the world stays open, in blocks
maxSteps1 to 512How far one path may travel
seed16 bytesA shared starting point for arranging the world

A title of at most 32 ASCII bytes matching ^[A-Za-z0-9 ._:-]*$ rides along. durationBlocks is measured in blocks because blocks are the only clock everybody already shares: 144 blocks is roughly 1 day, 52,560 is roughly 365 days.

One live output per path#

Each holds exactly one live Bitcoin output, called a and called a lane everywhere in the code. To move a path forward you spend that output and create the next one, worth exactly 1000 satoshis and always .

This is the load-bearing idea. Bitcoin will not let one output be spent twice, so a path cannot fork, cannot be reordered, and cannot be rewritten by whoever shouts loudest. The state engine keeps this as a map from outpoint to lane id (liveOutpoints in src/state.ts), and getLiveLaneByOutpoint is how a transaction is recognised as touching a path at all.

The same idea has a sharp edge. A confirmed transaction that spends a live carrier and is not a valid ChainBloom event ends that path as ABANDONED. Nothing is invented to replace it.

Five operations#

There are 5 operations and no plans for a sixth in ruleset 1.

OpcodeBytePath inputsPath outputsPayload bytes
CREATE0x010one per lane23 + title length
BLOOM0x02114
GRAFT0x031135
RENDEZVOUS0x04224
CLOSE0x05101

GRAFT is shown to people as an echo and RENDEZVOUS as a . The code never uses the friendly names, and neither should your API.

Presentation is left open#

src/render.ts ships projectBloom and renderWorldSvg, and both are explicitly non-consensus. They place events by hashing the world seed, the event txid, and the operation with sha256, and they draw from 16 fixed colours.

None of that is binding. Two galleries may render the same confirmed world completely differently and both be correct, because the record says glyph is 7, not what a 7 looks like. If you want a house style, build one. You cannot be wrong about it.

The marker#

Where it must sit#

A ChainBloom transaction must contain exactly one ChainBloom-looking output, and it must be at vout 0 with a value of zero satoshis.

"ChainBloom-looking" is decided by isPotentialChainBloomScript in src/script.ts: an OP_RETURN output whose first push begins with the four magic bytes 43424c4d. Two such outputs, or one sitting at vout 1, raises MARKER_POSITION. A non-zero value at vout 0 raises MARKER_VALUE.

The script must be one minimal direct push and nothing else: OP_RETURN, one length byte from 1 to 72, then exactly that many bytes. A second push, an OP_PUSHDATA1 where a direct push would fit, or any trailing opcode raises NON_MINIMAL_OP_RETURN from decodeMinimalOpReturn.

The header#

The first 8 bytes are fixed for every operation.

OffsetBytesFieldValue
04magic43424c4d, the letters CBLM
41version1
51networkmainnet 0, testnet4 1, signet 2, regtest 3
61opcode0x01 to 0x05
71payload lengththe exact number of bytes that follow
8variespayloadthe fields of the chosen action

Each header field has its own refusal. Fewer than 8 bytes is TRUNCATED_HEADER. Wrong magic is INVALID_MAGIC. More than 72 bytes is MARKER_TOO_LARGE. A version other than 1 is UNSUPPORTED_VERSION. A network byte outside the table below is RESERVED_NETWORK, an opcode outside 0x01 to 0x05 is RESERVED_OPCODE, and a declared payload length that does not consume exactly the remaining bytes is INVALID_PAYLOAD_LENGTH.

Note the last one carefully: the length byte is not a maximum. decodeMarker in src/codec.ts requires bytes.length === HEADER_BYTES + payloadLength, so one stray trailing byte fails the whole marker.

What each payload carries#

OperationFields, in order
CREATEruleset, laneCount, durationBlocks (2 bytes), maxSteps (2 bytes), seed (16 bytes), title length, title
BLOOMglyph, palette, motion, magnitude
GRAFTtargetEventTxid (32 bytes), relation, glyph, palette
RENDEZVOUSbridgeStyle, glyph, palette, intensity
CLOSEreason

Every field is one byte unless the table says otherwise, and every one is range-checked on both encode and decode. glyph is 0 to 31, palette 0 to 15, motion 0 to 7, relation 0 to 15, bridgeStyle 0 to 15. magnitude, intensity, and reason use the full byte, 0 to 255. Anything outside a range raises INTEGER_OUT_OF_RANGE.

Networks#

The network byte is part of the header, so a marker built for one network is refused on another rather than being read into the wrong world.

NetworkByteConstant
mainnet0NETWORK.MAINNET
testnet41NETWORK.TESTNET4
signet2NETWORK.SIGNET
regtest3NETWORK.REGTEST

The validator compares the marker's network against the context it was given and raises NETWORK_MISMATCH on a difference. An indexer configured for mainnet therefore ignores signet markers instead of half-reading them.

Build a marker by hand#

Without scripts, work the header out on paper. Take a CREATE on mainnet for a world called Dawn Chorus with three lanes.

OffsetBytesValueMeaning
0443424c4dthe letters CBLM
4101version 1
5100mainnet
6101CREATE
712234 payload bytes follow

The payload length is 34 because CREATE is 23 fixed bytes plus the title, and Dawn Chorus is 11 ASCII bytes. The whole marker is 42 bytes, comfortably under the 72-byte ceiling. Change the title to something 50 bytes long and the encoder raises MARKER_TOO_LARGE before the transaction is ever built.

With scripts, the same encoder that ships in the package runs here in your browser and shows every byte as you change a field. Nothing is sent anywhere.

What is deliberately absent#

It is worth being blunt about the things the protocol does not do, because integrators keep looking for them.

There is no owner field. A path is controlled by whoever can spend its carrier, and that is all the chain says. It is not a claim about identity, authorship, or any legal right.

There is no transfer operation, no price, no fee to the protocol, and no marketplace. The 1000 satoshis in a carrier are the smallest practical amount that can carry a path forward, and they come back to the spender when a path is completed.

There is no naming registry and no uniqueness rule on titles. Two worlds may share a title. They can never share a world id, because the id is a txid.

There is no content in the marker beyond the numbers listed above. If a world needs images, audio, or long text, those live in your application and are referenced by txid, not carried on chain.

Next

Now read the transaction shapes

The exact input and output positions for all five operations, in tables you can build against.

Transaction lifecycle