ChainBloom Docs
Open ChainBloom

Validation rules

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

Every check validateProtocolTransaction runs, grouped and named by the issue code it emits, including the two rules people get wrong most often.

One function decides whether a Bitcoin transaction is a ChainBloom event: validateProtocolTransaction in src/validator.ts. This page lists every rule it applies and names the issue code each one emits, so you can build a transaction that passes and explain to a person exactly why one did not.

How a result is shaped#

The validator never throws for a bad transaction. It collects ValidationIssue records, each with a code, a human message, and a path such as inputs[1] or outputs[0].value, and it returns valid: issues.length === 0.

Two things follow from that, and both matter when you write an interface.

First, you get all the reasons, not the first one. A transaction with a wrong sequence and a wrong value reports both.

Second, one failure is special. If the cannot be decoded at all, the validator returns immediately with marker: null and skips every per-operation rule. Your error display should say the marker is unreadable rather than listing everything else as fine.

Marker rules#

Checked first, in parseMarker.

RuleIssue code
Exactly one ChainBloom-looking output, and it is at vout 0MARKER_POSITION
The transaction has a vout 0 at allMISSING_MARKER
That output holds zero satoshisMARKER_VALUE
The script is an OP_RETURNNOT_OP_RETURN
One minimal direct push, 1 to 72 bytes, nothing trailingNON_MINIMAL_OP_RETURN
At least 8 header bytesTRUNCATED_HEADER
The header begins 43424c4dINVALID_MAGIC
The whole marker is at most 72 bytesMARKER_TOO_LARGE
The version byte is 1UNSUPPORTED_VERSION
The network byte is 0, 1, 2, or 3RESERVED_NETWORK
The opcode is 0x01 to 0x05RESERVED_OPCODE
The declared length consumes exactly the remaining bytesINVALID_PAYLOAD_LENGTH
CREATE fixed fields decode and the ruleset is 1INVALID_CREATE, UNSUPPORTED_RULESET
CREATE title length matches the payload lengthINVALID_TITLE_LENGTH
CREATE title is at most 32 ASCII bytes matching ^[A-Za-z0-9 ._:-]*$INVALID_TITLE
CREATE seed is exactly 16 bytesINVALID_SEED_LENGTH
Every numeric field is inside its rangeINTEGER_OUT_OF_RANGE

One rule sits just after the marker decodes: the marker's network must match the network the validator was configured with, or NETWORK_MISMATCH. An indexer set to mainnet does not half-read a signet marker; it refuses it.

Input rules#

Applied to every input, whatever the operation.

RuleIssue code
Every nSequence is exactly 0xfffffffdNON_CANONICAL_SEQUENCE
No outpoint appears at two input positionsDUPLICATE_INPUT
Recognised carriers sit at exactly the positions the operation requiresCARRIER_INPUT_MAPPING
Every fee input has prevout context suppliedMISSING_PREVOUT
Every fee input spends a native SegWit outputNON_NATIVE_SEGWIT_FEE_INPUT

CARRIER_INPUT_MAPPING is stricter than it looks. It does not only check that the required positions hold carriers; it checks that no other input is a recognised live carrier. Adding a second path's carrier as a "fee input" to a BLOOM fails here, which is exactly what you want, because that carrier would otherwise be quietly destroyed.

Per-operation rules#

CREATE#

RuleIssue code
No input spends a live carrierCREATE_SPENDS_CARRIER
An output exists at vout 1 through vout laneCountMISSING_ROOT_CARRIER
Each root output is exactly 1000 satoshisINVALID_CARRIER_VALUE
Each root output is standard P2TR, 34 bytesINVALID_CARRIER_SCRIPT

Fee inputs are checked from vin 0, because a CREATE has no path inputs.

BLOOM and GRAFT#

RuleIssue code
Exactly one recognised carrier, at vin 0CARRIER_INPUT_MAPPING
A successor output exists at vout 1MISSING_CARRIER_OUTPUT
It is exactly 1000 satoshisINVALID_CARRIER_VALUE
It is standard P2TRINVALID_CARRIER_SCRIPT

Fee inputs are checked from vin 1.

RENDEZVOUS#

RuleIssue code
Exactly two recognised carriers, at vin 0 and vin 1CARRIER_INPUT_MAPPING
Their lane ids are in strictly increasing lexicographic orderRENDEZVOUS_LANE_ORDER
Successor outputs exist at vout 1 and vout 2MISSING_CARRIER_OUTPUT
Both are exactly 1000 satoshis and standard P2TRINVALID_CARRIER_VALUE, INVALID_CARRIER_SCRIPT

Fee inputs are checked from vin 2. Both paths are also put through the world and step rules below, independently.

CLOSE#

RuleIssue code
Exactly one recognised carrier, at vin 0CARRIER_INPUT_MAPPING
No successor output is required or checkednone

Fee inputs are checked from vin 1.

World and step rules#

Applied to each a transaction advances, in checkWorldCanAdvance.

RuleIssue code
The path's world is known to this viewMISSING_WORLD
The world is ACTIVE and the height is below endHeightExclusiveWORLD_ENDED
stepCount is below the world's maxStepsMAX_STEPS_REACHED
The path's own last event confirmed in a strictly earlier blockUNCONFIRMED_LINEAGE_PARENT

Echo target rules#

GRAFT alone names an earlier event, and all three checks below run against the view's own record of it.

RuleIssue code
targetEventTxid names an event this view knowsUNKNOWN_GRAFT_TARGET
The target is on the same networkGRAFT_NETWORK_MISMATCH
The target confirmed in a strictly earlier blockUNCONFIRMED_GRAFT_TARGET

The target may be on any path in any , including the path making the echo. It cannot be a transaction that is still waiting, and it cannot be in the same block.

Signature rules#

These run only when the validator is given requireWitnessSignatures: true. An indexer reading confirmed blocks should turn them on; a tool previewing an unsigned draft should not.

RuleIssue code
A confirmed input carries witness dataMISSING_WITNESS
A Taproot input carries at least one 64 or 65 byte signatureMISSING_TAPROOT_SIGNATURE
A 65 byte Taproot signature ends in SIGHASH_ALL, so the type is SIGHASH_DEFAULT or SIGHASH_ALLUNSAFE_SIGHASH
A P2WPKH signature ends in SIGHASH_ALLUNSAFE_SIGHASH
Every P2WSH signature ends in SIGHASH_ALLUNSAFE_SIGHASH

The point of the sighash rule is narrow and worth stating: a signature that does not commit to every output would let someone else re-point your successor carrier while keeping your signature valid. Only SIGHASH_DEFAULT and SIGHASH_ALL commit to the whole transaction.

The two rules people get wrong#

Almost every invalid transaction that reaches an indexer fails one of these two. Neither is obvious from the outside, and both are easy to design around once you know.

A parent must be confirmed in a strictly earlier block#

The check is lane.lastEventHeight >= height, and it emits UNCONFIRMED_LINEAGE_PARENT.

Two steps on the same path cannot share a block. Not "should not" -- cannot. That includes the case that catches people first: a CREATE and a BLOOM on one of its own root paths, broadcast together and mined together. The BLOOM is invalid, and because it confirmed while spending a live carrier, that path becomes ABANDONED.

A meeting's two path inputs must be ordered by lane id#

The check is lanes[0].id.localeCompare(lanes[1].id) >= 0, and it emits RENDEZVOUS_LANE_ORDER.

Lane ids are <worldId>:<laneNumber> text, so the sort compares the world txid first and then the lane number as characters. Two paths in the same world sort by lane number as a string: lane 10 comes before lane 2.

The fix is to never do it by hand. buildRendezvousPsbt sorts the two participants for you before laying out the transaction.

Where CLOSE is exempt#

checkWorldCanAdvance takes an allowAtMaxSteps flag, and it is true for exactly one operation: CLOSE.

So a path that has reached maxSteps refuses BLOOM, GRAFT, and RENDEZVOUS with MAX_STEPS_REACHED, but still accepts a CLOSE. A path is never trapped at its limit: you can always end it on purpose and release its 1000 satoshis.

The exemption is narrow. It does not extend to WORLD_ENDED or to UNCONFIRMED_LINEAGE_PARENT. Once a world reaches endHeightExclusive, nothing more confirms in it, and the still-live paths become EXPIRED by the passage of the block rather than by anyone acting.

Next

Now handle the chain moving under you

What a view must do when Bitcoin replaces its newest blocks, and why determinism is what makes replay agree.

Reorganizations