Bundle v1 — Satsignal .mbnt Receipt Spec
Status: implementer-facing consolidation, v1. Audience: anyone writing a verifier, CLI, or SDK that consumes .mbnt bundles produced by Satsignal. Goal: a single self-contained document an implementer reads start-to-finish to write a conformant verifier. Older specs remain authoritative for protocol rationale; field-level shape is inline here.
Reference implementation: the official satsignal-cli (pip install satsignal-cli) implements §7 of this spec in pure Python (stdlib + requests; HKDF inline). Read its src/satsignal/verify.py alongside this document for a worked example of the conformant verification procedure.
1. Scope
A Satsignal .mbnt receipt binds an off-chain canonical document to an on-chain BSV transaction. This document specifies:
- the
.mbntenvelope (Section 2), - the three JSON entries it carries —
manifest.json,canonical.json,proofs.json— and their field-level shape (Sections 3–5), - how the canonical document commits to an on-chain
doc_hash(Section 6), - the ordered procedure a conformant verifier MUST perform (Section 7),
- the error model and confirmation-depth semantics (Section 8),
- versioning and forward-compatibility rules (Section 9).
Relation to existing specs — for protocol rationale, cross-reference:
| topic | authoritative source | this doc inlines |
|---|---|---|
| OP_RETURN wire format, TLV registry, subtype codes | SPEC_mbnt.md | header layout an implementer needs (§6.1) |
| canonical-doc schema rationale, scheme tags, Merkle construction | SPEC_v2.md | parser-facing field list (§4) |
| sealed-mode threat model, HKDF derivation, blind protocol | SPEC_v2_sealed.md | sealed-mode manifest deltas (§3.2) + verification (§7.3) |
row-reveal schemes merkle-row-v1, merkle-row-sealed-v1 | SPEC_merkle_row.md | scheme-tag dispatch (§4.3) |
This document does not supersede those specs. It is the entry point for writing a verifier; they remain the entry point for understanding why the construction is what it is.
2. The .mbnt envelope
A .mbnt file is a standard ZIP archive (PKZIP, deflate or store). The magic bytes 50 4B 03 04 identify it; any ZIP reader opens it.
A conformant bundle contains exactly these entries at the root:
| entry | required | content type | purpose |
|---|---|---|---|
manifest.json | yes | UTF-8 JSON | bundle metadata, mode, chain anchor reference (§3) |
canonical.json | yes | UTF-8 JSON, compact (no whitespace) | the document whose hash is committed on-chain (§4) |
proofs.json | when chunk_merkle is present in canonical.json | UTF-8 JSON | Merkle leaves + scheme re-derivation hints (§5) |
attachments/<name> | no | any bytes | optional original-file material; legacy bundles only — see §2.1 |
Bundles MAY contain additional entries beyond this list. Verifiers MUST tolerate unknown entries (skip and continue) and MUST NOT treat their presence as a failure. Verifiers MUST NOT treat any unknown entry as proof material.
canonical.json MUST be stored byte-identical to the canonicalized form whose SHA-256 was committed on-chain (Section 6). The compact-JCS encoding rule in Section 4.4 is what produces that form. A bundle that re-pretty-prints canonical.json will not verify.
2.1 Legacy entries
Bundles produced before mbnt_version 2.0 may include an attachments/ directory carrying the original file bytes the canonical doc commits to. Verifiers MUST NOT rely on its presence. Modern bundles never include it — the canonical doc commits to file hashes, and the original file is supplied separately at verification time (Section 7.2). A verifier reading a legacy bundle MAY use attachments/ to source the file for hash-recomputation, but the spec-defined input is the user-supplied file path.
3. manifest.json
manifest.json is bundle metadata: which mode, which mbnt version, the txid that anchors it, optional ARC acceptance, optional category. It does not carry proof material — the cryptographic content lives in canonical.json (§4) and proofs.json (§5).
3.1 Common fields (every mode)
{
"mbnt_version": "2.1",
"txid": "<64 hex>",
"network": "bsv-mainnet",
"doc_hash_expected": "<40 hex>"
}
| field | type | required | meaning |
|---|---|---|---|
mbnt_version | string | yes | one of "1.1", "2.0", "2.1". Selects verifier codepath (§9). |
txid | string | yes | 64 lowercase hex chars. The BSV transaction whose OP_RETURN carries the on-chain commitment. |
network | string | yes | currently always "bsv-mainnet". Future deployments may add other values; verifiers MUST refuse unknown networks unless they explicitly support them. |
doc_hash_expected | string | yes | 40 lowercase hex chars = first 20 bytes of sha256(canonical_doc_canonical_bytes), hex-encoded. Used as a sanity check before the chain fetch (§7). |
Verifiers MUST also tolerate these optional informational fields:
| field | type | meaning |
|---|---|---|
category | string | off-chain bucket tag (e.g. "agent-run", "evidence"). Display-only. |
acceptance | object | ARC miner-signed acceptance metadata when the broadcast went via ARC. Keys: endpoint, miner, status, accepted_at_utc. Absent on ElectrumX broadcasts, mock receipts, dry-runs. |
proof_mode | string | legacy hint emitted by standard-mode bundles, value "private_receipt". The name predates sealed mode and means "proofs.json material is kept off-chain" — it does NOT indicate sealed mode. Sealed bundles do not emit this field; they use mode: "sealed" (§3.3). Verifiers MUST NOT switch on proof_mode; dispatch on mode instead. Retained for shape compatibility with pre-2.0 verifiers. |
3.2 Standard mode (the default)
In standard mode the manifest has no mode field — its absence is the signal. The canonical doc carries the file's sha256 directly in subject (§4), so the manifest's job is purely chain-anchor metadata.
{
"mbnt_version": "2.0",
"txid": "<64 hex>",
"network": "bsv-mainnet",
"doc_hash_expected": "<40 hex>",
"filename": "report.pdf"
}
Additional optional fields:
| field | type | meaning |
|---|---|---|
filename | string | source filename for receipt-page rendering. Display-only; does not affect verification. |
Correction to SPEC_v2_sealed.md §4.1: that document lists sha256 and file_size among public-mode manifest fields. The shipping implementation does not emit them at the manifest level (those values live in canonical.json.subject and canonical.json.subject.proofs.byte_exact). The standard-mode manifest is leaner than the sealed-spec narration implied. This document is authoritative.
3.3 Sealed mode
A sealed manifest carries the bearer secret (salt_b64) and an explicit mode field. Verifiers MUST treat the presence of mode == "sealed" as the dispatch signal.
{
"mbnt_version": "2.1",
"mode": "sealed",
"txid": "<64 hex>",
"network": "bsv-mainnet",
"doc_hash_expected": "<40 hex>",
"salt_version": "salt_v1",
"salt_b64": "<base64url, 32 bytes decoded>",
"bearer_secret": true,
"server_retain_until_utc": "2026-05-18T14:30:01Z"
}
| field | type | required | meaning |
|---|---|---|---|
mode | string | yes | exactly "sealed". |
salt_version | string | yes | currently "salt_v1". Names the salt-derivation scheme; future schemes will introduce new tags. |
salt_b64 | string | yes | base64url encoding of 32 random bytes (the master salt). Re-decoded as raw bytes for HMAC and HKDF. Bearer secret. |
bearer_secret | bool | yes | always true in sealed manifests. Renderers (verifiers, receipt pages) MUST display a prominent warning that this bundle is privacy-bearing. |
server_retain_until_utc | string (ISO 8601 UTC) | no | when the server-side copy of the bundle is scheduled for deletion. Display-only; does not affect verification. Absent in blind-submission bundles (no server-side copy exists). |
Sealed manifests MUST NOT include sha256, file_size, or any other field that would leak the original file's identity. The omission is intentional — see SPEC_v2_sealed.md §4.1 for the threat model.
3.4 Manifest mode (Phase 8b)
Manifest-mode bundles bind multiple files via a Merkle root. The manifest layer carries the same fields as standard mode; the distinction lives in canonical.json (subject.kind == "manifest", scheme: "manifest-items-v1"). A verifier dispatches on the canonical-doc subject, not on the manifest. See SPEC_v2_sealed.md §7.2 for /lookup_hash behavior on manifest-mode bundles.
4. canonical.json
The canonical document is the object whose hash is committed on-chain. Its shape depends on schema_version:
schema_version: 1— legacy single-proof shape (byte_exactonly).schema_version: 2— current multi-proof shape supporting any ofbyte_exact,content_canonical,chunk_merkle. Required fieldbyte_exact; the other two are optional, present when a Tier-1 canonicalizer ran.
A verifier MUST support both. Codepath selection is by canonical.json.schema_version.
4.1 v2 shape (default for current anchors)
{
"schema_version": 2,
"subtype": "generic",
"issuer": "did:web:satsignal.cloud",
"issued_at": "2026-05-11T14:30:01Z",
"nonce": "<16 bytes hex-encoded, 32 hex chars>",
"subject": {
"proofs": {
"byte_exact": {
"algo": "sha256",
"size": 12345,
"hash": "<64 hex>"
},
"content_canonical": {
"scheme": "text-norm-v1",
"algo": "sha256",
"hash": "<64 hex>"
},
"chunk_merkle": {
"scheme": "text-line-v1",
"algo": "sha256",
"leaf_count": 42,
"root": "<64 hex>"
}
}
},
"attestation": {
"method": "operator_attested",
"operator_id": "satsignal-hosted-demo"
},
"attachments": []
}
Required fields: schema_version, issuer, issued_at, nonce, subject, and within subject.proofs at least byte_exact. content_canonical and chunk_merkle are optional. attachments is always present and always empty (legacy field, retained for shape compatibility).
4.2 Sealed-mode subject.proofs
In sealed mode, each whole-file proof carries algo: "hmac-sha256", a salt_version, and a commitment (in place of hash). The chunk_merkle proof carries algo: "merkle-hmac-sha256", a salt_version, and keeps the root field name (the root itself is computed over HMAC-keyed leaves).
{
"schema_version": 2,
"subtype": "generic",
"issuer": "did:web:satsignal.cloud",
"issued_at": "2026-05-11T14:30:01Z",
"nonce": "<16 bytes hex-encoded, 32 hex chars>",
"subject": {
"kind": "file_anchor",
"proofs": {
"byte_exact": {
"algo": "hmac-sha256",
"salt_version": "salt_v1",
"commitment": "<64 hex>"
},
"content_canonical": {
"algo": "hmac-sha256",
"salt_version": "salt_v1",
"scheme": "text-norm-v1",
"commitment": "<64 hex>"
},
"chunk_merkle": {
"algo": "merkle-hmac-sha256",
"salt_version": "salt_v1",
"scheme": "text-line-v1",
"leaf_count": 17,
"root": "<64 hex>"
}
}
},
"attestation": {
"method": "operator_attested",
"operator_id": "satsignal-hosted-demo"
},
"attachments": []
}
Field rules:
| field | sealed-mode meaning |
|---|---|
algo | "hmac-sha256" for byte_exact and content_canonical; "merkle-hmac-sha256" for chunk_merkle. |
salt_version | required on every proof. Currently "salt_v1". Names which salt-derivation scheme produced the keys. Verifier dispatches on this to know how to re-derive per-leaf salts (§5). |
commitment | 64 lowercase hex chars = HMAC-SHA256(master_salt, target_bytes). Replaces the hash field used in standard mode. |
root (chunk_merkle only) | 64 lowercase hex chars = Merkle root computed over HMAC-keyed leaves (§5). The field name remains root, not commitment — only byte_exact and content_canonical use commitment. |
scheme, leaf_count | unchanged from standard mode. |
For protocol rationale (why HMAC + HKDF per-leaf rather than a single salt), see SPEC_v2_sealed.md §3. The construction above is the field-level shape a verifier consumes.
4.3 Scheme tags
The scheme tag is opaque from the chain's point of view but selects which canonicalizer / chunker the verifier must re-run to recompute the proof. Tier-1 schemes shipping today:
text-norm-v1 (content_canonical) / text-line-v1 (chunk_merkle)
Applies to text/plain, text/markdown, source code, and the extracted-text canonicalization of PDFs (see PDF-specific note below).
Canonical-form construction:
- Decode source bytes as UTF-8. If a BOM (
U+FEFF) is present at the start, strip it. - Unicode NFC normalize the entire string.
- Split on
\n(after normalizing CRLF / CR to LF — i.e. replace\r\nand standalone\rwith\n). - For each line, strip trailing whitespace where "whitespace" means ASCII space (
\x20) and ASCII tab (\x09) only. Non-breaking space (), other Unicode spaces, and form-feed are NOT stripped. - Rejoin lines with
\n. - Apply
.trim()on the final string (strips leading/trailing ASCII whitespace and blank lines). - UTF-8 encode → canonical bytes.
content_canonical.hash (or commitment) is sha256 (or HMAC-SHA256) of those bytes.
Merkle leaves for text-line-v1: one leaf per non-empty line in the canonical form (empty lines after normalization are not leaves). Each leaf is sha256(line_bytes_utf8) in standard mode or HMAC-SHA256(salt_i, line_bytes_utf8) in sealed mode.
PDF note: for application/pdf, the canonicalizer extracts text page-by-page via PDF.js (operators) or equivalent (verifiers), applies steps 1–6 to each page's text, then joins pages with \n\n. Merkle leaves are per-page, not per-line. Reference: see recomputePdfProofs in verifier.html.
json-jcs-v1 (content_canonical) / json-keypath-v1 (chunk_merkle)
Applies to application/json.
Canonical-form construction follows RFC 8785 (JSON Canonicalization Scheme):
- Parse the source as JSON (UTF-8).
- UTF-8 output.
- NFC-normalize all string values.
- Sort all object keys lexicographically (by UTF-16 code-unit order per RFC 8785 §3.2.3).
- No insignificant whitespace.
- Reject
NaN,+Inf,-Inf. - Canonical number encoding per RFC 8785 §3.2.2 (no trailing zeros after the decimal point, no positive sign, exponential form only when necessary).
Merkle leaves for json-keypath-v1: one leaf per top-level key in the canonical form, in sorted order. Each leaf is sha256(key_utf8_bytes ‖ canonical_value_bytes) (concatenation, no separator). canonical_value_bytes is the JCS encoding of that key's value as a standalone JSON value.
csv-norm-v1 (content_canonical) / csv-row-v1 (chunk_merkle)
Applies to text/csv.
Canonical-form construction:
- Parse the source as RFC 4180 CSV (UTF-8, double-quote escaping,
,delimiter). - Normalize line endings to
\n. - Preserve the header row.
- Preserve column order from the source.
- Re-emit each field with canonical quoting: quote a field iff it contains
,,",\n, or\r. Quoted fields escape"as"". - UTF-8 encode → canonical bytes.
Merkle leaves for csv-row-v1: one leaf per data row (header excluded), in source row order. Each leaf is sha256(row_canonical_bytes).
manifest-items-v1
Manifest-mode bundles (Phase 8b) — binds multiple files via a Merkle root over their hashes. The canonical doc has subject.kind == "manifest" and subject.scheme == "manifest-items-v1". See notary/manifest.py in the source repo for the leaf-construction rule and SPEC_v2_sealed.md §7.2 for /lookup_hash behavior.
merkle-row-v1 / merkle-row-sealed-v1
Selective row-reveal schemes (per-row proof for tabular data). Full spec: SPEC_merkle_row.md. A verifier that supports row-reveal MUST follow that spec for leaf construction and reveal-bundle parsing.
Reference implementation
The canonical byte-level behavior for every scheme above is fixed by the production verifier: verifier.html (functions recomputeProofsFor, recomputePdfProofs, recomputeImageProofs, plus the text/JSON/CSV recompute paths). Where this spec is ambiguous or silent, the verifier's output is authoritative. A planned follow-on SPEC_canonicalizers.md will lift this dependency by pinning each scheme byte-for-byte; until then, port from the verifier source and add cross-impl test vectors.
Unknown schemes
A verifier that does not implement a referenced scheme MUST report the on-chain commitment + the txid + the unsupported scheme name, and MUST NOT claim to have validated that proof. (Cross-ref: SPEC_mbnt.md §4 — same "unknown subtype" rule applies at the canonical-doc layer.)
4.4 Canonicalization for doc_hash
The 20-byte doc_hash is computed from canonical.json via:
canonical_bytes = JCS(canonical.json)
doc_hash = sha256(canonical_bytes)[:20]
JCS (RFC 8785) means:
- UTF-8 output
- NFC-normalize all string values
- sort all object keys
- no whitespace, no trailing newline
- no
NaN,+Inf,-Inf,+0distinct from-0for numbers - canonical number encoding per RFC 8785 §3.2.2
A bundle's canonical.json entry MUST already be the canonical encoding — a verifier SHOULD re-encode and compare to defend against malformed bundles, but in well-formed bundles the bytes-on-disk equal the bytes-that-were-hashed.
4.5 v1 shape (legacy bundles)
{
"schema_version": 1,
"subtype": "generic",
"issuer": "did:web:satsignal.demo",
"issued_at": "...",
"nonce": "...",
"subject": {
"document_bytes": 51,
"document_name": "tier2-smoke.txt",
"document_sha256": "<64 hex>",
"memo": "...",
"submitter_label": "..."
},
"subtype": "generic"
}
Verifiers MUST accept v1 bundles. The single proof is subject.document_sha256 — verify by recomputing sha256(file_bytes) and comparing.
5. proofs.json
Present when canonical.json.subject.proofs.chunk_merkle is present. Carries the per-leaf material needed for the verifier to rebuild the Merkle root.
{
"scheme": "text-line-v1",
"merkle_leaves": ["<64 hex>", "<64 hex>", "..."],
"metadata": {
"canonical_scheme": "text-norm-v1",
"non_empty_lines": 42
}
}
In sealed mode, an additional field appears:
{
"scheme": "text-line-v1",
"salt_version": "salt_v1",
"merkle_leaves": ["<64 hex>", "..."],
"metadata": {...}
}
The per-leaf salts themselves are NOT stored — they are deterministically derivable from master_salt via HKDF (RFC 5869).
The full HKDF call for each leaf index i ∈ {0, 1, ..., leaf_count-1}:
salt_i = HKDF(
hash = SHA-256,
ikm = master_salt, // 32 bytes (decoded from manifest.salt_b64)
salt = b"satsignal-sealed-v1/per-leaf", // 28 ASCII bytes, literal
info = b"chunk/" || u32_be(i), // 10 bytes total: 6 (b"chunk/") + 4 (big-endian u32 of i)
length = 32, // bytes of output keying material
)
Standard HKDF-Extract then HKDF-Expand per RFC 5869. The salt parameter here is HKDF's "extract salt," not to be confused with the notary protocol's master_salt (which is HKDF's ikm). The literal string "satsignal-sealed-v1/per-leaf" provides domain separation — a future v2 sealed scheme would change this literal so derived keys under different scheme versions are independent.
The leaf commitment is then:
leaf_commitment_i = HMAC-SHA256(salt_i, chunk_i_canonical_bytes)
Merkle inner-node hashing is plain SHA-256 (no HMAC at inner nodes — the HMAC is the leaf-level salting point). See SPEC_v2_sealed.md §3.3 for the security argument (why per-leaf rather than per-tree salting).
Merkle tree construction is the plain concatenated-SHA-256 binary tree from SPEC_v2.md §"Merkle tree construction": pair adjacent nodes, duplicate the last node at odd levels, parent = sha256(left ‖ right). Root MUST equal canonical.json.subject.proofs.chunk_merkle.root (or commitment in sealed mode).
6. On-chain anchor
6.1 OP_RETURN payload layout (inline)
The MBNT payload appears inside the first OP_FALSE OP_RETURN output of the transaction at manifest.txid. The script is:
OP_FALSE OP_RETURN <push N> <payload bytes>
<push N> is either a 1-byte push opcode (0x01..0x4b) or OP_PUSHDATA1 (0x4c) followed by a 1-byte length. Payload is 28–220 bytes.
Payload byte layout:
offset size field encoding
------ ---- ----------- ------------------------------------
0 4 magic ASCII "MBNT" — 0x4D 0x42 0x4E 0x54
4 1 version currently 0x01
5 1 subtype 0x01 = generic (every public anchor)
6 2 tlv_len uint16 big-endian, bytes of TLV section
8 20 doc_hash first 20 bytes of sha256(canonical_doc_canonical_bytes)
28 N tlvs see SPEC_mbnt.md §5–§7 for the TLV registry
A verifier needs only the 28-byte header to validate the binding. TLVs (currency, issuer_id, timestamp_unix, etc.) carry optional public metadata; a verifier MUST tolerate unknown TLV tags. See SPEC_mbnt.md §6–§7 for the full TLV registry.
6.2 Resolving txid → raw transaction (universal)
A verifier resolves manifest.txid to a raw transaction via any public BSV node. This is the standard verify-path; it requires no Satsignal-operated endpoint and works for bundles produced by any deployment.
Production verifiers use:
- WhatsOnChain
https://api.whatsonchain.com/v1/bsv/main/tx/hash/{txid}— returns JSON withvout[]and aconfirmationsfield. - Bitails
https://api.bitails.io/tx/{txid}— fallback when WoC is rate-limiting.
Any BSV node exposing raw-tx-by-hash works; the explorer choice is not normative. The verifier needs vout[].scriptPubKey (raw hex) and confirmations from whatever resource it queries.
6.3 Resolving file-hash → txid (Satsignal-issued bundles only)
This section applies only to a discovery workflow — a verifier that holds the file but not the bundle, and needs to look up the anchoring transaction by the file's SHA-256. It is not part of the verify procedure when a .mbnt is in hand (Section 6.2 already provides the txid via manifest.txid).
For Satsignal-issued bundles, the /lookup_hash?h={byte_exact_sha256} endpoint on https://proof.satsignal.cloud is the canonical resolver. It returns {txid, mode, confirmations, ...} keyed on the naked file SHA-256.
Sealed-mode and manifest-mode bundles are excluded from /lookup_hash by design (see SPEC_v2_sealed.md §7.2). The lookup endpoint only resolves standard-mode anchors; for sealed and manifest bundles, the verifier MUST already have the bundle (or be supplied the txid out-of-band) and use Section 6.2 directly.
/lookup_hash is a Satsignal-operator-run convenience and is not required for protocol conformance. A third-party deployment running its own notary would expose its own resolver or none at all; the core verify path in Section 6.2 + Section 7 stays unchanged.
6.4 Deriving doc_hash from the transaction
Walk the outputs; for each scriptPubKey, check for the 00 6a prefix (OP_FALSE OP_RETURN) and a single push; the pushed bytes whose first 4 bytes are MBNT is the payload. Slice doc_hash_on_chain = payload[8:28]. Constant-time compare to sha256(JCS(canonical.json))[:20]. Equal means the document was committed in this transaction; not equal means the document does not match this anchor — full stop.
7. Conformant verification procedure
A verifier MUST perform these checks in order. Failing any check is a verification failure with the error class given in Section 8.
7.1 Bundle structure (any mode)
- Open the
.mbntZIP archive. Readmanifest.jsonandcanonical.json. (Error class CRYPTO if either is missing or not parseable as UTF-8 JSON.) - If
canonical.json.subject.proofs.chunk_merkleis present, readproofs.json. (Error class CRYPTO if absent.) - Confirm
manifest.mbnt_versionis supported (one of"1.1","2.0","2.1"). Refuse unknown values. (Error class VERSION.)
7.2 Cryptographic checks
In standard mode (manifest has no mode field):
- Recompute
sha256(file_bytes)from the user-supplied file. Compare tocanonical.json.subject.proofs.byte_exact.hash(v2) orcanonical.json.subject.document_sha256(v1). MUST match. (Error class CRYPTO.) - If
content_canonicalis present, re-canonicalize the file according to itsscheme, recomputesha256(canonical_bytes), compare tocontent_canonical.hash. MUST match if computed. - If
chunk_merkleis present, chunk the file according to itsscheme, compute leaf hashes, build the Merkle root per §5, compare tochunk_merkle.root. MUST match. MUST also confirmlen(proofs.json.merkle_leaves) == chunk_merkle.leaf_count.
In sealed mode (manifest mode == "sealed"):
- Decode
master_salt = base64url_decode(manifest.salt_b64). - Recompute
HMAC-SHA256(master_salt, file_bytes), compare tosubject.proofs.byte_exact.commitment. - If
content_canonicalis present, repeat for the canonicalized form. - If
chunk_merkleis present, derive per-leaf salts via HKDF (full parameters in §5), computeHMAC-SHA256(salt_i, chunk_i_canonical), build the Merkle root (inner nodes plain SHA-256), compare tochunk_merkle.root. (Note the field name:byte_exactandcontent_canonicalusecommitment, butchunk_merkleusesrooteven in sealed mode — see §4.2.)
7.3 doc_hash consistency
- Re-encode
canonical.jsonto JCS (Section 4.4), SHA-256, slice to 20 bytes. Hex-encode. - Compare to
manifest.doc_hash_expected. MUST match. (Error class CRYPTO — a mismatch here means the canonical doc in the bundle has been altered relative to what was anchored.)
7.4 Chain confirmation
- Resolve
manifest.txidto a raw transaction (Section 6.2). On network failure, error class NETWORK (retry-friendly). - Parse the OP_RETURN payload per Section 6.1. Confirm magic
MBNT, version0x01, subtype0x01. - Slice
doc_hash_on_chain = payload[8:28]. Constant-time compare to the 20-byte form ofmanifest.doc_hash_expected. MUST match. (Error class CHAIN.) - Read
confirmationsfrom the explorer response. If0, report PENDING (Section 8) — the anchor is broadcast but not yet mined. A verifier MUST NOT claim "verified" on a 0-confirmation receipt; it MUST report "broadcast, awaiting confirmation" or equivalent. SeeSPEC_mbnt.md §10for use-case-specific minimum-depth recommendations.
A bundle that passes 7.1–7.3 with confirmations >= 1 is verified. A bundle that passes 7.1–7.3 with confirmations == 0 is pending.
7.5 Offline mode
A verifier MAY skip Section 7.4 if the caller explicitly opts in (e.g. --offline). Such a verifier MUST report results as "cryptographic checks pass; on-chain status NOT verified" and MUST NOT downgrade the warning under any flag. The reason is a documented threat: locally-fabricated bundles pass crypto-only checks; only the chain confirmation distinguishes a real anchor from a forged one. (Cross-ref the project's chain-confirm-in-helpers policy.)
8. Error model
A conformant verifier distinguishes these error classes and exposes them to its caller:
| class | meaning | retry semantics |
|---|---|---|
CRYPTO | bundle is malformed, hashes don't match, or contents have been altered | not retryable — the bundle is invalid |
CHAIN | bundle parses cleanly but the on-chain anchor does not commit to this canonical doc | not retryable — the bundle does not match its claimed transaction |
VERSION | mbnt_version or OP_RETURN version byte is unsupported | not retryable in this verifier; may succeed in a newer one |
NETWORK | could not reach explorer / /lookup_hash | retryable |
PENDING | crypto + on-chain checks pass but confirmations == 0 | retryable after the next block (~10 min on BSV) — not a failure per se |
OFFLINE | caller opted into offline mode; chain confirmation skipped | success-with-caveat, not a failure |
Recommended CLI exit codes (the satsignal verify sketch will use these; SDK callers MAY surface the class directly):
| exit | class |
|---|---|
| 0 | verified, pending, or offline-with-warning |
| 1 | CRYPTO |
| 2 | CHAIN |
| 3 | NETWORK |
| 4 | auth error (verifier needed credentialed lookup) |
| 5 | bundle not found / unreadable |
| 6 | VERSION |
| 7 | STRICT_INCOMPLETE — anchor broadcast on-chain but local sidecar was not written (e.g. server returned no bundle_url); only emitted when the caller opted in via --strict or equivalent |
| 8 | SPV — standard verify passed but SPV check (TSC merkle proof against the caller's local validated headers chain) failed: target block not in the local chain, bad merkle path, or proof fetch error. Only emitted when the caller opted in via --spv or equivalent |
Codes 7 and 8 are CLI-level extensions, not verifier-conformance requirements — they only fire when the caller opts in (--strict, --spv). An SDK or verifier surfacing classes directly can omit them; an SDK building an SPV-strict verifier MAY adopt 8 for parallel exit semantics.
PENDING returning exit 0 is intentional: a verify && commit sequence should succeed the moment a real anchor is broadcast, with the pending status surfaced as a stderr warning. Callers wanting strict confirmation gating opt in via --min-confirmations N. STRICT_INCOMPLETE is a strict-mode opt-in for anchor flows where a missing local sidecar is a script-correctness failure even when the on-chain anchor itself succeeded. SPV is the opt-in for self-sovereign verification: the explorer is reduced to a network proxy that can supply a proof but cannot lie about chain inclusion, because the authoritative merkleroot comes from the caller's own PoW-validated headers chain.
9. Versioning and forward compatibility
9.1 mbnt_version values
| value | introduced | meaning |
|---|---|---|
"1.1" | v1 launch | single-proof bundles (byte_exact only); canonical doc schema_version: 1 |
"2.0" | v2 launch | multi-proof bundles; canonical doc schema_version: 2; standard mode |
"2.1" | sealed launch | adds sealed-mode manifest fields (mode, salt_b64, bearer_secret, salt_version) |
A v1-only verifier MUST refuse "2.0" and "2.1" rather than attempt a partial parse. A v2-only verifier MUST refuse "2.1" unless it also supports sealed mode. A future "3.0" will keep the shape additive; existing fields will not be silently re-interpreted.
9.2 Unknown JSON fields
Verifiers MUST tolerate unknown top-level keys in manifest.json, canonical.json, and proofs.json (skip and continue). The Satsignal service may add informational fields in any release; existing verifiers continue to operate.
9.3 Unknown OP_RETURN fields
Verifiers MUST tolerate unknown TLV tags in the OP_RETURN payload (record as (tag, value) opaquely). Verifiers MUST refuse unknown version bytes and unknown subtype bytes — both are protocol-changing signals, and silently accepting them defeats the spec-as-contract.
9.4 Standard-mode dispatch — fragility and migration
Standard-mode dispatch today relies on the absence of manifest.mode (Section 3.2). This is a fragile signal — a forwarder, log-pipeline, or schema-conformant proxy that drops unknown top-level keys (per the permissive rule in §9.2) could silently transform a sealed manifest into one that parses as standard. The bundle would still fail verification at the cryptographic step (commitments won't match plain SHA-256), but the mode signal itself is not robust.
Planned two-stage migration:
Stage 1 — additive, no version bump. customer/anchor.py starts emitting "mode": "standard" explicitly on new standard bundles. Verifiers stay permissive: missing mode continues to mean standard. Bundles with explicit "mode": "standard" parse identically. No coordination required across implementations; existing verifiers operate unchanged.
Stage 2 — mbnt_version 3.0. Verifiers in this version reject manifests with no mode field. Bundles produced under mbnt_version "1.1" / "2.0" / "2.1" continue to parse under the v2 codepath; only new "3.0" bundles enforce the explicit-mode rule. Wire-format dispatch on mbnt_version keeps existing bundles verifiable indefinitely.
Stage 1 is a unilateral change on the producer side and may land at any time. Stage 2 is a coordinated version bump and waits for the next mbnt_version rev. This spec is authoritative for the intent; the actual rollout schedule is tracked separately.
10. Conformance summary
A verifier is conformant with bundle v1 if it:
- Parses the
.mbntZIP envelope per Section 2. - Reads
manifest.json,canonical.json, andproofs.jsonper Sections 3–5. - Distinguishes standard vs. sealed mode via the presence/absence of
manifest.mode("sealed"→ sealed mode; absent → standard mode; see §3.2 and §9.4) and applies the appropriate cryptographic checks (Section 7.2). - Re-derives
doc_hashper Section 4.4 and Section 7.3. - Fetches the raw transaction and parses the OP_RETURN payload per Section 6.
- Distinguishes
verified/pending/offline/CRYPTO/CHAIN/VERSION/NETWORKper Section 8. - Refuses unknown versions and unknown subtypes; tolerates unknown JSON fields, unknown TLV tags, and extra ZIP entries.
A verifier that omits Section 7.4 by default (no chain confirmation) is not conformant. The chain check is the load-bearing distinction between this protocol and any locally re-derivable hash chain; making it opt-in inverts the safety property the protocol exists to provide.
Source: docs/notary_spec/bundle-v1.md.
Email hello@satsignal.cloud
for clarifications.