MBNT on-chain wire format
The MBNT payload is the bytes that appear inside the OP_FALSE OP_RETURN output of a Satsignal anchor transaction. Everything else about a Satsignal receipt (canonical doc, manifest, miner acceptance, merkle proofs) is off-chain — but the chain commitment lives here, and a non-Satsignal verifier reading a transaction needs to parse this payload to walk from txid to a 20-byte document hash.
This page is the standalone wire-format spec. It supersedes the description embedded in parseMbnt() in verifier.html. The reference implementations are:
- Python encode/parse:
opreturn.pyin the (private) Satsignal repo - JavaScript parse:
parseMbnt()in/verify's page source
Both produce/accept byte-identical payloads.
1. Where the payload lives
A Satsignal anchor is a single-output BSV transaction whose first OP_FALSE OP_RETURN output carries one MBNT payload. The script is
OP_FALSE OP_RETURN <push N> <payload bytes>
<push N> is either a single-byte push opcode (0x01..0x4b) or OP_PUSHDATA1 (0x4c) followed by a 1-byte length, depending on total payload size. Total payload is between 28 and 220 bytes (the upper bound is BSV's data-carrier relay norm — Satsignal does not emit larger payloads).
To extract the payload from a raw tx hex: walk inputs/outputs; for each output's scriptPubKey, check for the 00 6a prefix and a single push; the pushed bytes whose first 4 bytes are MBNT is the payload.
A reference implementation in 25 lines lives in opreturn.find_mbnt_payload_in_raw_tx. Other-language ports tend to be the same shape.
2. Byte layout
offset size field encoding
------ ---- ----------- ------------------------------------
0 4 magic ASCII "MBNT" — 0x4D 0x42 0x4E 0x54
4 1 version currently 0x01 (sole shipped value)
5 1 subtype see §4 (subtype registry)
6 2 tlv_len uint16, big-endian, bytes of TLV section
8 20 doc_hash first 20 bytes of sha256(canonical_doc)
28 N tlvs tag-length-value entries, see §5
N = tlv_len, 0..192
------ ----
total 28+N payload 28..220 bytes
A minimal MBNT payload (no TLVs) is exactly 28 bytes:
4D 42 4E 54 01 01 00 00 <20 bytes of doc_hash>
└ "MBNT" ┘ v st tlv_len └─── doc_hash ─────┘
A payload with two TLVs (currency=USD, timestamp_unix=epoch s) is 36 bytes:
4D 42 4E 54 01 01 00 0E <20 bytes>
01 03 55 53 44 // tag=01 (currency), len=3, "USD"
06 08 00 00 00 00 68 31 7C 80 // tag=06 (timestamp_unix), len=8, BE u64
3. Magic and version
- Magic is the literal ASCII bytes
MBNT(0x4D 0x42 0x4E 0x54). Verifiers MUST check the magic before reading any other field — the transaction may carry an OP_RETURN belonging to a different protocol. - Version is currently
0x01. There is no version0x00. Verifiers MUST refuse unknown versions; future versions will be additive in shape but never silently re-interpret v1 fields. - Forward compatibility. A v2 introduction will keep the magic and bump the version byte. A v1 verifier that hits a v2 payload refuses to parse — that is the correct behavior. No "best effort" partial-parse path.
4. Subtype registry
The subtype byte selects the schema applied to the off-chain canonical document the doc_hash commits to. Codes are 1 byte, never reused:
| code | name | meaning |
|---|---|---|
0x01 | generic | unspecified document; the canonical doc itself names its schema in a subject.kind field |
0x02 | wire | a financial-wire receipt schema |
0x03 | doc_sign | a document-signing receipt schema |
0x04 | event | an event-log receipt schema |
generic (0x01) is what every Satsignal anchor produced by the public API ships today. The other three codes are reserved for private deployments running their own canonical-doc schemas.
A verifier that doesn't recognize a subtype byte SHOULD still report the on-chain commitment (doc_hash, txid, version) but MUST NOT claim to have validated the canonical document — it doesn't know which schema applies.
5. doc_hash
doc_hash is exactly 20 bytes, computed as
doc_hash = sha256(canonical_doc_bytes)[:20]
canonical_doc_bytes is the JCS-canonical UTF-8 encoding of the canonical-doc JSON object. The canonicalization algorithm (NFC normalize all strings, sort all dict keys, emit minimal JSON, no whitespace, no NaN/Infinity, no floats) is the same one used in merkle_row.py §1.1 and the commit_reveal.py helper.
The canonical-doc shape itself depends on the receipt category and mode. For each shape, the spec is:
- File proof (categories:
output,evidence_bundle) with multi-proof v2 (byte_exact/content_canonical/chunk_merkle): see SPEC_v2 in the source repo, plus the generic-v2 schema embedded innotary/schema.py. - Manifest mode (Phase 8b) —
subject.kind = "manifest"withscheme: "manifest-items-v1",root,leaf_count: seenotary/manifest.pyin the source repo. - Sealed mode: see
/spec(SPEC_v2_sealed.md). - Selective row-reveal (
merkle-row-v1,merkle-row-sealed-v1): see/spec-merkle-row.
In every case the recipe is the same: re-canonicalize the same JSON, sha256 it, take the first 20 bytes, compare to the on-chain doc_hash.
6. TLV section
The 0–192 bytes after the header carry zero or more tag-length-value entries:
[tag (1 B)] [length (1 B)] [value (length B)]
- tag is a 1-byte code from §7. Tags are sorted ascending in serialized form so that two callers with the same logical TLV set produce byte-identical on-chain bytes regardless of dict order.
- length is 1 byte (0–255). MBNT does not use 2-byte lengths.
- value is
lengthbytes, encoded per the tag's spec in §7.
Verifiers MUST:
- reject duplicate tags (parse error — payload is ambiguous)
- reject TLVs that overrun
tlv_len - accept unknown tag values (record them as
(tag, value)pairs; do not derive any meaning) - treat the TLV section as optional metadata only — anything load-bearing for the receipt's claim lives in the canonical doc, not on chain
The total TLV section size is capped at 192 bytes so that 28 + tlv_len ≤ 220 (BSV relay norm).
7. TLV tag registry
Public API anchors today emit one TLV by default — issuer_id (tag 0x05) — committing the 4-byte hash of the operator's DID. See §11 for what that means as a public chain-property and how to opt out. The other tags below are reserved for private deployments that want additional indexable on-chain metadata:
| tag | name | length | value encoding |
|---|---|---|---|
0x01 | currency | 3 | ASCII (e.g. b"USD") |
0x02 | amount_bucket | 1 | floor(log10(amount_in_minor_units)) (0..9) |
0x03 | reference_hash | 8 | sha256(reference_id_utf8)[:8] |
0x04 | counterparty_hash | 16 | sha256(counterparty_id_utf8)[:16] |
0x05 | issuer_id | 4 | sha256(issuer_pubkey)[:4] |
0x06 | timestamp_unix | 8 | unsigned 64-bit big-endian seconds since epoch |
0x07 | subdoc_hash | 20 | secondary 20-byte document commitment |
Values are public on chain — never put PII or secrets in TLVs. Use the off-chain canonical doc and let doc_hash commit to them.
New tags are appended to this registry as private deployments need them; existing codes are never repurposed.
8. End-to-end verification recipe
A non-helper-language third party who has only (txid, canonical_doc_bytes) can verify the binding in any language with stdlib JSON + SHA-256:
- Fetch the raw transaction by txid from any public BSV node (e.g. WhatsOnChain or Bitails).
- Walk the outputs; find the first
OP_FALSE OP_RETURN <push>whose pushed bytes start with the magicMBNT. - Parse the payload per §2: confirm magic, confirm version
0x01, read subtype, readtlv_len, slicedoc_hash = payload[8:28]. - Re-canonicalize the supplied canonical doc (NFC, sort keys, no whitespace, no floats) and compute
expected = sha256(...)[:20]. - If
doc_hash == expected(constant-time compare, byte-equal), the document was committed in this transaction and the chain timestamp is its anchor time. If they differ, the document does not match this anchor — full stop.
That is the entire chain-side check. No Satsignal API is involved. For category-specific claims (the document's payload, manifest leaves, sealed reveals) the auditor then follows the spec for that category to walk the canonical doc.
For a worked example walking a real preview-tier txid byte by byte, see /whats-on-chain.
9. Operational notes
- Single MBNT per tx. Satsignal anchors emit exactly one MBNT output per transaction. A verifier MAY accept multiple — but the current production wallet does not produce them.
- Total payload ≤ 220 bytes. The TLV cap of 192 bytes plus the 28-byte header keeps Satsignal under BSV's data-carrier relay norm. Larger payloads would not reach miners reliably.
- No on-chain compression. Bytes are exactly as specified above — no varint length, no zlib, no encoding stage.
- No on-chain secrets. Only the 20-byte
doc_hashand any TLVs named in §7. Sealed-mode salts and revealed payloads are off-chain.
10. Confirmation depth for verifiers
The chain commitment is observable from the moment a transaction is broadcast, but a transaction is not final until it is mined into a block and that block is buried under further work. Verifiers SHOULD display confirmation count prominently next to the txid and apply a minimum-depth gate appropriate to the use case:
| use case | recommended minimum |
|---|---|
| audit-trail / evidence dispositioning | ≥ 1 |
| sealed-bid auction reveal-after / fairness | ≥ 1 |
| settlement / counterparty-risk gating | ≥ 6 |
0 confirmations (mempool only) | display "unconfirmed" — never claim "anchored" |
A 0-confirmation receipt is a valid commitment to the broadcast network but not yet to chain history; a verifier that treats it as anchored is conflating two distinct properties. Anchors typically land within 3–8 confirmations (~30–80 minutes on BSV mainnet).
This is a verifier-side rule, not a protocol-byte change — the MBNT payload itself carries no confirmation field. Verifiers fetch confirmation count from the explorer they use to retrieve the raw tx (e.g. WhatsOnChain /v1/bsv/main/tx/hash/{txid} returns a confirmations field).
11. What an anchor publicly commits to
Beyond doc_hash, a Satsignal anchor publicly commits the operator's DID fingerprint via the issuer_id TLV (§7, tag 0x05):
issuer_id = sha256(doc["issuer"].encode("utf-8"))[:4] # 4 bytes
For Satsignal's hosted preview tier this resolves to a constant — d5b0b0c6 == sha256("did:web:satsignal.cloud")[:4] — so every preview-tier anchor is publicly chain-tagged with these same 4 bytes.
Consequence. A chain observer who knows the operator's DID can enumerate every anchor that operator has issued. A multi-tenant deployment that gives each customer their own DID makes each customer's anchors enumerable by anyone watching the chain, even though the underlying documents stay sealed by the 20-byte hash.
For most use cases this is the intended property — discoverability of "Satsignal-issued anchors" by ecosystem indexers is half the reason a public protocol prefix exists. It is not appropriate for sealed-bid auctions among non-trivial counterparties or whistleblower-class users where "the bid was placed via Satsignal customer X" leaks before the reveal.
Opt-out. A caller can suppress the issuer_id TLV per-receipt by setting publish.issuer = False on the pipeline call (see pipeline.prepare_receipt in the source repo). The result is a strict 28-byte payload — no operator fingerprint on chain. Operators running deployments where unlinkability matters should set this as the default and treat opt-in as the exceptional path.
12. Versioning
MBNT v1 is stable. A future MBNT v2 would change the version byte to 0x02, may extend the header, and may introduce new subtype codes. This document specifies v1 only.
Verifiers MUST refuse unknown versions and unknown subtypes (with the note in §4 about reporting the on-chain commitment for unknown subtypes). Silently accepting a future version would defeat the spec-as-contract.
Source: docs/notary_spec/SPEC_mbnt.md.
Email hello@satsignal.cloud
for clarifications.