ChainBloom Docs
Open ChainBloom

Transaction lifecycle

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

Where every input and output must sit in a ChainBloom transaction, operation by operation, with the value and script type each position requires.

ChainBloom does not search a transaction for meaning. Each operation has fixed positions, and a transaction either sits in them or is not an event. Build against the tables on this page and your transaction will be read the same way by every indexer, first time.

What every action shares#

Five rules apply to all 5 operations, whichever one you are building.

Version 2. Every ChainBloom transaction uses transaction version 2.

Every input signals replacement. Every nSequence, on carrier inputs and fee inputs alike, must be exactly 0xfffffffd. One input with a different sequence raises NON_CANONICAL_SEQUENCE. This is not a preference: a waiting contribution must stay replaceable, so is part of the shape rather than an option.

No input twice. The same outpoint appearing at two positions raises DUPLICATE_INPUT.

The owns vout 0. Zero satoshis, one minimal OP_RETURN push, exactly one ChainBloom-looking output in the whole transaction. See protocol architecture for the byte layout.

Path outputs are exact. Every output, root or successor, is exactly 1000 satoshis and a standard P2TR script: 34 bytes beginning 0x51 0x20. A different value raises INVALID_CARRIER_VALUE; a different script type raises INVALID_CARRIER_SCRIPT. There is no rounding and no tolerance.

CREATE#

CREATE opens a world. It spends no path because none exist yet, and it creates one root output for every lane the marker declares.

PositionContentsRule
vin 0 and upFee inputs onlyNative SegWit. Spending a live carrier raises CREATE_SPENDS_CARRIER
vout 0The markerZero satoshis
vout 1 to vout laneCountRoot path outputs, one per lane1000 satoshis, P2TR. A missing one raises MISSING_ROOT_CARRIER
vout laneCount + 1 and upChange and anything else you needMay not contain a second ChainBloom marker

Lane numbering follows output order and starts at 0, so the lane at vout 1 is lane 0. Once the transaction confirms at height h, its txid becomes the world id, every lane id becomes <worldId>:<laneNumber>, and endHeightExclusive is set to h + durationBlocks.

BLOOM and GRAFT#

These two move one forward by one step, and they have the same shape. BLOOM adds a moment; GRAFT adds a moment that names an earlier confirmed event.

PositionContentsRule
vin 0The live carrier of the path being advancedMust be the only recognised carrier input, or CARRIER_INPUT_MAPPING
vin 1 and upFee inputsNative SegWit
vout 0The markerZero satoshis
vout 1The successor path output1000 satoshis, P2TR. Missing raises MISSING_CARRIER_OUTPUT
vout 2 and upChangeOptional

GRAFT carries one extra obligation. Its targetEventTxid must name an event the indexer already knows (UNKNOWN_GRAFT_TARGET), on the same network (GRAFT_NETWORK_MISMATCH), confirmed in a strictly earlier block (UNCONFIRMED_GRAFT_TARGET). The target may be on any path in any world, including the one you are standing on.

After confirmation the lane's stepCount rises by one, currentOutpoint moves to vout 1, and the txid is appended to eventTxids.

RENDEZVOUS#

A is the only operation that touches two paths, and the only one where input order is part of the rules.

PositionContentsRule
vin 0The carrier whose lane id sorts firstOrdering is checked, see below
vin 1The other carrierRaises RENDEZVOUS_LANE_ORDER if out of order
vin 2 and upFee inputsNative SegWit
vout 0The markerZero satoshis
vout 1Successor for the path spent at vin 01000 satoshis, P2TR
vout 2Successor for the path spent at vin 11000 satoshis, P2TR
vout 3 and upChangeOptional

The two carriers must sit at vin 0 and vin 1 and nowhere else, and their lane ids must be in lexicographic order. Lane ids are <worldId>:<laneNumber> strings, so ordering compares the world txid first and the lane number second, as text. The validator uses localeCompare and rejects anything that is not strictly increasing.

Both paths advance. Neither is merged into the other, nothing is swapped, and each keeps its own successor output.

CLOSE#

CLOSE ends a path on purpose. It is the only operation that spends a carrier and creates no successor.

PositionContentsRule
vin 0The live carrier of the path being endedMust be the only recognised carrier input
vin 1 and upFee inputsNative SegWit
vout 0The markerZero satoshis
vout 1 and upChangeThere is no successor output

The 1000 satoshis that were riding the path are released into whatever you build here, minus the miner fee. After confirmation the lane's status becomes CLOSED, currentOutpoint becomes null, and terminalReason is set to CLOSE_ followed by the one-byte reason from the payload.

CLOSE is also the one action still permitted after a path reaches maxSteps. A path is never trapped: you can always end it deliberately.

Building one#

The five builders in src/builders.ts produce exactly the layouts above.

BuilderOperation
buildCreatePsbtCREATE
buildBloomPsbtBLOOM
buildGraftPsbtGRAFT
buildRendezvousPsbtRENDEZVOUS
buildClosePsbtCLOSE

Each returns an unsigned bitcoinjs-lib at version 2 with every input sequence already set to 0xfffffffd. Carrier inputs you pass in must already be 1000-satoshi P2TR outputs (INVALID_CARRIER_INPUT), fee inputs must be native SegWit (NON_NATIVE_SEGWIT_INPUT), and any extra output you add may not contain a second ChainBloom marker (DUPLICATE_MARKER_OUTPUT).

What happens after you broadcast#

While the transaction waits in the it is a projection, not history. ChainBloomState.preview and MempoolOverlay.project will tell you whether it would be valid at the next height, and conflictsWith lists any other transaction spending the same outpoint. Neither creates a lineage parent: a step whose parent is still unconfirmed is never treated as settled.

Confirmation is what fixes it. And one rule catches almost everyone the first time: a path's parent event must be confirmed in a strictly earlier block than its child. Two steps on the same path in the same block raises UNCONFIRMED_LINEAGE_PARENT, and that includes a BLOOM on a root path in the same block as its own CREATE.

Next

Then check it the way an indexer will

Every rule the validator applies, grouped and named by the issue code it emits.

Validation rules