Skip to content

Transaction IDs: wtxid and dtxid

Why two representations?

A transaction ID is a SHA-256d hash — 32 bytes. Those bytes have a natural order, the order the hash function produces them. This is the wire order: the byte sequence you find inside serialised transactions, block headers, and merkle trees. Every time Bitcoin's binary protocol references a transaction, it uses this order.

But early Bitcoin had a cosmetic quirk. When Satoshi wrote the RPC code to display transaction and block hashes as hex strings, the bytes were reversed. The likely reason: SHA-256d hashes used as proof-of-work targets have leading zero bytes, and reversing puts those zeros at the front of the displayed string, making difficulty visually obvious. A block hash like 000000000019d6689c... reads naturally as "many leading zeros = hard to find".

This reversed representation became the display order — the format shown by block explorers, returned by JSON-RPC calls, and copy-pasted by developers. It stuck, and every Bitcoin-derived system has inherited it.

The consequence is that Bitcoin has always had two ways to express the same 32-byte hash, with no reliable way to tell which one you're holding. The variable name txid could mean either. This ambiguity has caused an extraordinary number of bugs across every Bitcoin implementation over the past 15+ years, from double-counted transactions to malformed merkle proofs.

The SDK's naming convention

This SDK eliminates the ambiguity by making the byte order explicit in every name:

Name Type Byte order When to use
wtxid Binary string (32 bytes) Wire order Internal computation, storage, binary protocols
dtxid / dtxid_hex Hex string (64 chars) Display order JSON APIs, user interfaces, logs, block explorers
txid Varies Spec-mandated Only where an external specification requires this exact name

wtxid — wire-order transaction ID. The raw bytes as SHA-256d produces them, and as they appear in serialised Bitcoin data. This is what you store, compare, and compute with.

dtxid — display-order transaction ID. The bytes reversed and hex-encoded. This is what you show to humans and send over JSON interfaces.

txid — reserved for cases where an external specification (BRC-100, BRC-74, ARC API) mandates this exact field name. These always appear with a boundary comment explaining which spec requires them and what byte order is expected.

Examples

tx = BSV::Transaction::Tx.from_hex(raw_hex)

# Wire order — for computation, storage, binary protocols
tx.wtxid            # => 32-byte binary string
tx.inputs.first.prev_wtxid  # => 32 bytes referencing the previous output

# Display order — for JSON, UIs, logs
tx.dtxid            # => "a1b2c3d4..." (64-char hex, reversed bytes)
tx.dtxid_hex        # => same as dtxid

# Conversion at input boundaries
BSV::Transaction::TransactionInput.wtxid_from_hex("a1b2c3d4...")
# => 32-byte binary (reverses the hex back to wire order)

# Display conversion on inputs
tx.inputs.first.dtxid_hex   # => display-order hex of the referenced tx

Wire order by default

The SDK's internal principle is straightforward: use wire order everywhere, convert only when forced to.

Transaction IDs are born in wire order — SHA256d(serialised_tx) produces wire-order bytes. Reversing them to display order and back again is wasted work and a source of bugs. So the SDK keeps everything in wire order internally and only converts to display order at the boundaries where an external system demands it.

This means:

  • All internal variables hold wtxid (wire-order binary)
  • All method parameters that accept transaction IDs expect wire-order binary
  • Cross-gem interfaces (SDK → wallet, SDK → attest) pass wire-order binary
  • Storage uses wire-order binary (bytea columns, binary fields)
  • Merkle computations use wire-order throughout — no reversals inside the tree
  • Beef serialisation uses wire-order throughout — transaction references, dedup keys, sorting

No conversion happens until you reach a boundary that requires display order.

The boundaries

Conversion to display order (dtxid) happens at exactly two kinds of boundary:

1. JSON interfaces

Any API that serialises data as JSON. JSON has no binary type, so transaction IDs must be hex-encoded. By convention (inherited from Bitcoin's RPC layer), JSON APIs use display-order hex.

# ARC broadcast response
{ "txid" => "a1b2c3d4..." }  # display-order hex — ARC API spec

# BRC-100 create_action return value
{ txid: tx.dtxid }            # display-order hex — BRC-100 spec

# WhatsOnChain REST API
"/tx/#{tx.dtxid}"             # display-order hex in URL path

2. User interfaces

Any context where a human reads a transaction ID: HTML pages, CLI output, log messages, error messages, block explorer links.

# Log message
logger.info("Broadcast #{tx.dtxid}")

# Error message
raise "Input #{input.dtxid_hex} not found"

# Link to block explorer
"https://whatsonchain.com/tx/#{tx.dtxid}"

What is NOT a boundary

These are internal interfaces — they stay in wire order:

  • Ruby method calls between SDK classes (Transaction, Beef, MerklePath)
  • Ruby method calls between gems (bsv-sdk, bsv-wallet, bsv-attest)
  • Database storage (PostgreSQL bytea, binary columns)
  • Binary serialisation (transaction format, BEEF format, merkle paths)
  • Hash table keys for deduplication or indexing
  • Comparisons between transaction IDs
# Internal — always wire order
beef.find_transaction(tx.wtxid)
merkle_path.compute_root(tx.wtxid)
seen[tx.wtxid] = true
input.prev_wtxid == source_tx.wtxid

The conversion rule

If you're writing code that touches transaction IDs, the decision is simple:

  1. Am I producing JSON output or displaying text to a human? → Use dtxid
  2. Everything else → Use wtxid

There is no third case. If you find yourself reaching for txid without either prefix, stop and determine which of the two you actually need.

The principle generalises (with one exception)

The wire-order-by-default rule isn't specific to transaction IDs. The same shape applies to most binary/hex value pairs across the SDK: keep the binary form internally, convert to hex only at JSON or human boundaries.

This rule covers:

Value Internal Boundary
Transaction IDs wtxid (32-byte binary, wire order) dtxid / dtxid_hex
SHA-256 / RIPEMD-160 hashes binary bytes hex at log / JSON emit
Scripts binary bytes hex via Script#to_hex at emit
Signatures (DER) binary bytes hex at JSON emit
Raw transactions, BEEF bytes binary throughout hex at JSON / display

Public keys are the documented exception

A compressed secp256k1 public key is 33 bytes of curve-point material. JSON contexts represent it as a 66-character hex string — BRC-100 names this type PubKeyHex. The SDK and the wallet both hold pubkeys as hex throughout, not binary. This is a deliberate carve-out from the wtxid/dtxid rule.

The reasoning (recovered during HLR #797 audit fallout — see sgbett/bsv-wallet#300 for the long form):

  1. The canonical internal form isn't bytes — it's a PublicKey object (curve point). Hex and binary are both serialisations of the same underlying value, and most SDK code that handles a pubkey holds the PublicKey instance, not the bytes.
  2. Pubkeys are protocol identifiers, not binary content. Unlike txids — which get hashed, recomputed from raw tx, and indexed by structural bytes — pubkey bytes don't have wire-order semantics. They flow through the SDK as identity tokens.
  3. Pubkeys cross BRC boundaries more often than any other type. Every BRC-100 method has a pubkey somewhere (identity_key, counterparty, subject, certifier, verifier). BRC-29 and BRC-43 also specify hex at the wire. Hex storage moves conversion off the boundary-heavy path.
  4. The binary-internal rule already carves out spec-mandated hex. Pubkey BRC fields meet that test directly. Txids are an exception in the other direction — we choose binary even though TXIDHexString is BRC-canonical — because txid bytes have structural meaning. Pubkey bytes don't.

What this means in practice:

  • BSV::Primitives::PublicKey is the in-memory form. When a string is needed, prefer hex (to_hex).
  • Every Interface::BRC100 implementation (ProtoWallet, WalletWireTransceiver, bsv-wallet's Engine) returns pubkey fields as 66-character hex strings. The wire bytes are still 33-byte compressed binary (the wire layer is binary by definition); the deserialiser converts to hex before placing the value into the Ruby result hash, matching the BRC-100 contract.
  • KeyDeriver#identity_key returns hex. A binary accessor (identity_key_bytes) exists for the rare sites that need the raw bytes (e.g. feeding hash160), to avoid binary → hex → binary round-trips.
  • Storage of pubkey-shaped fields is :text / String. bytea would be wrong.

A reviewer seeing .to_hex / Utils.toHex / .unpack1('H*') on a pubkey at a BRC-100 boundary should not flag it — that conversion is the rule.

Runtime validation

Because wtxid and dtxid have distinct physical formats — 32-byte binary vs 64-character hex — the SDK validates at every entry point. Pass a hex string where binary is expected and you get an immediate ArgumentError with a diagnostic message:

ArgumentError: expected 32-byte wire-order wtxid for prev_wtxid,
got 64-byte string (looks like a hex txid — use wtxid_from_hex to convert)

This is only possible because the naming convention enforces a consistent type split. When everything was called txid, there was no way to know what format a parameter expected without reading the implementation. With wtxid and dtxid, the format is encoded in the name — and the SDK enforces it.

BRC-74 note

MerklePath::PathElement has a txid boolean attribute. This is not a transaction ID value — it's a BRC-74 spec field that flags whether a given leaf in the merkle path is a transaction (as opposed to a provided hash). The name comes from the specification and is not subject to the wtxid/dtxid convention.