Somebody hands you a proof and the tool for checking it. Here is how to check it without them, in 48 lines and no dependencies.
Somebody hands you a record and says it has been timestamped into Bitcoin. Attached is a proof file. Also attached, helpfully, is the command you should run to check it, which uses their client, reading their format, printing their verdict.
Notice what just happened. You were given the evidence and the instrument for reading it by the same party. Run their tool and you have confirmed that their math is self-consistent. You have not confirmed that they are honest, because a tool written by someone with a stake in the answer is not an independent check of the answer.
The good news is that you do not need their tool, and finding that out takes about an hour. What follows is a verifier in 48 lines of Python with no third-party imports at all, only hashlib, json and urllib. It reads a proof, folds it, and tells you whether the result is really sitting in the Bitcoin blockchain.
One scoping note up front, because the line count depends on it. This reads a plain-JSON proof, the kind our own anchor records emit. Parsing the raw binary OpenTimestamps .ots serialization is a genuinely bigger job: magic bytes, varints, op tags, attestation records, and it will not fit in fifty lines of anything. The conceptual work is identical either way. The parsing is what differs, and I am not going to pretend otherwise to protect a headline.
A timestamp proof is a list of operations applied to your hash in sequence. Peter Todd's original OpenTimestamps design describes exactly this, and the operation set is smaller than you would guess:
append puts a sibling hash on the right of yours. prepend puts one on the left. sha256 collapses what you have into a new 32-byte digest.
That is the entire vocabulary. Apply them in order and you climb a Merkle tree, one level per hash, until you arrive at a root. There is no second idea. Everything else in a timestamping library is parsing, networking and ergonomics wrapped around that loop.
The root you arrive at is the transaction Merkle root of a Bitcoin block. The block header commits to that root, the root commits to every transaction in the block, and changing any of it changes the header. So the check at the end is not subtle: does the value I folded my way to actually appear in the header of block N?
A detail worth pausing on, because it explains why this scales. Your hash is not put into a Bitcoin transaction. The calendar servers aggregate many timestamps from many people into one Merkle tree and commit that tree's root in a single transaction. Your proof is the path from your hash up to that shared root. It is why the whole thing costs a fraction of a transaction fee per document, and it is why a proof has a path in it at all.
Here is the part I find genuinely satisfying. Our anchor records carry their own verification instructions as a field. Not documentation somewhere else. A string inside the file:
Replay each
bitcoin[i].operationslist starting fromanchored_digest:cur=bytes.fromhex(anchored_digest);append -> cur=cur+bytes.fromhex(arg);prepend -> cur=bytes.fromhex(arg)+cur;sha256 -> cur=hashlib.sha256(cur).digest(). Thencur[::-1].hex()must equalbitcoin[i].merkle_root, which must equal the merkle root shown on the explorer page forbitcoin[i].block_height.
That is a complete specification. A competent reader can implement a verifier from that paragraph without seeing our code, which is the whole point: if the record carries its own spec, our tooling is a convenience rather than a dependency.
It also contains the one detail that would otherwise waste your afternoon. cur[::-1] reverses the bytes before hex encoding, because Bitcoin displays hashes in the opposite byte order from the one it hashes in. Get that wrong and you produce a root that is the right length, looks completely plausible, and matches nothing. The other classic trap is hashing the hex string instead of the bytes it represents, which fails the same way: silently, and with a result that looks like a hash.
Two halves. The fold, which is pure computation, and the lookup, which is the only part that touches the network.
def fold(digest_hex, operations):
"""append/prepend place a sibling hash beside yours; sha256 collapses the pair."""
cur = bytes.fromhex(digest_hex)
for op in operations:
if op["op"] == "append": cur = cur + bytes.fromhex(op["arg"])
elif op["op"] == "prepend": cur = bytes.fromhex(op["arg"]) + cur
elif op["op"] == "sha256": cur = hashlib.sha256(cur).digest()
else: raise ValueError("unknown op: " + op["op"])
return cur[::-1].hex() # Bitcoin shows roots byte-reversed
That is the cryptography. Eleven lines, and if you have ever wondered what a Merkle proof actually is underneath the vocabulary, it is that.
The lookup asks a public explorer for the block header, trying a second one if the first does not answer:
def onchain_root(height):
for name, api in EXPLORERS:
try:
blk = urllib.request.urlopen(f"{api}/block-height/{height}", timeout=20).read().decode().strip()
return name, json.load(urllib.request.urlopen(f"{api}/block/{blk}", timeout=20))["merkle_root"]
except Exception:
continue
return None, None
The rest is bookkeeping: load the JSON, re-derive the anchored digest from its parts, replay each attestation, compare, print. Forty-eight non-blank lines all in.
The digest check is worth one sentence because it closes a gap the Merkle path alone leaves open. Our records bind two values, a chain hash and a Merkle root, into the single digest that gets timestamped, and they say how: sha256(chain_hash_hex|merkle_root_hex), where the pipe is a literal character in the string. Recomputing that locally is what ties the timestamped digest back to the chain it claims to describe. Without it you would have proved that some 32-byte value was in a block, which is true and useless.
Against a confirmed anchor from June:
proof : anchor_20260612_200320 | 10469 entries
digest bind: OK 033026a35ae712045123a211...
block 953413: replayed 16012da9ce043ea1ab586f52... == proof root
blockstream.info header says 16012da9ce043ea1ab586f52... MATCH
block 953413: replayed 16012da9ce043ea1ab586f52... == proof root
blockstream.info header says 16012da9ce043ea1ab586f52... MATCH
verdict : VERIFIED
Block 953413 appears twice because that proof carries two attestations that land in the same block by different paths, 72 operations and 76 operations. Two independent routes to one fact, which is a pleasant thing to find in a file you are auditing.
Now the same tool against our newest anchor, submitted to the calendars a few hours before I wrote this:
proof : anchor_20260727_155036 | 11154 entries
digest bind: OK cd5f5b39e8f75d799cb29029...
verdict : NOT-YET - no Bitcoin attestation in this proof yet
This is the more instructive run. The calendars have accepted the digest, the record is internally consistent, and Bitcoin has not confirmed anything. A timestamp proof has two honest states and this is the other one. It matters that a verifier says so plainly rather than reporting a hopeful yes, and it matters that you can see it print a verdict you did not want.
Then the test that decides whether any of this is worth trusting. I took the verified proof, flipped a single hex character in the first sibling hash, and ran it again:
block 953413: replayed 19b5e22b10c9178dbd1a5785... != proof root
verdict : FAILED - proof does not fold to its own root
One character changed, and the root is unrecognisable from the third byte onward. That is the avalanche property doing the only job it has, and it is the reason a fifty-line script is sufficient. The verifier does not need to be clever. It needs to be correct, and the mathematics is doing the actual work.
A tool that only ever prints success is not a verifier, it is a decoration. Watching it refuse something is the only evidence you have that its approval means anything.
Before building it I went looking for the minimal version, on the assumption that a protocol this elegant would have a from-scratch walkthrough somewhere. There are excellent full-featured clients: python-opentimestamps, the reference opentimestamps-client, a TypeScript implementation. What I could not find was the fifty-line one. Almost every "how to verify" guide routes you to a client.
There is a mild irony in that, and it is the reason this piece exists. The entire selling point of the design is that you do not have to trust anyone's tooling, and in practice essentially everybody verifies through somebody's tooling. Not because the protocol demands it. Because nobody wrote the small thing, so the reasonable default became installing the big thing.
That gap is not a criticism of the libraries, which do far more than this script does, handle the binary format, talk to calendars, and upgrade pending proofs. It is an observation about what gets built. Comprehensive implementations are what a protocol needs to be adopted. A short readable one is what it needs to be understood, and those are different artifacts with different audiences.
If you maintain a system that emits proofs, this is the cheap thing to ship alongside them: not another client, but the smallest honest program that reads your format and disagrees with you when you are wrong.
The claim at the top of this piece is that provenance verification depends only on SHA-256 and Bitcoin, never on trusting the issuer's tooling. That claim is true, and I want to be precise about what it does not say.
When you ask blockstream.info for a block header, you are trusting blockstream.info. You have removed the issuer from the trust picture and put a smaller, different party into it. Anyone who reads this carefully will notice, so let us deal with it rather than hope.
Three things make the swap a good one.
The explorer has no stake in your specific record. Whoever gave you the proof has an interest in your believing it. An explorer serving millions of block queries does not know which one you care about.
The explorer is swappable and cross-checkable. The script tries a second one on failure, and it is a two-line change to query both and compare. Query three, from different jurisdictions, and a lie has to be a conspiracy. You could never do that with the issuer, because there was only ever one of them.
And the trust is removable. Run your own Bitcoin node and point the lookup at your own RPC. Then the only things you are trusting are the SHA-256 implementation in your standard library and the block headers you validated yourself. Most people will not do this, and most people do not need to, but the option is what makes the rest of it honest.
The trust does not go to zero. It goes from one interested party to one disinterested party you can swap, duplicate, or eliminate. That is a real improvement and overstating it would be silly.
Our current proof file is 705 bytes and it covers a chain of 11,154 entries. When the chain was 10,469 entries the proof was 635 bytes.
Proof size scales with the depth of the tree, not the number of things in it. Double the entries and you add one hash to the path, which is 32 bytes. You could anchor a million records and the proof would still fit in a text message. It is the reason this approach works at all, and it is worth seeing as a concrete number rather than as the word "logarithmic."
The script has no idea what our records are. It reads a digest, a list of operations, a claimed root and a block height. Any system that anchors a hash chain into Bitcoin produces those four things in some shape, and adapting the reader to a different shape is a matter of renaming fields.
So the transferable move is this. The next time you are handed a proof, do not run the tool that came with it. Ask what the operations are, ask what root they should produce, and ask which block should contain that root. If the record cannot answer those three questions without its authors present, the record is not really a proof. It is a receipt, and a receipt is only as good as the shop that wrote it.
If you are curious how long a hash like this stays meaningful, we wrote about that separately in The Last Anchor, including why SHA-256 is not the part of your stack that needs a post-quantum migration plan. This piece is the other half: not how long it lasts, but how you check it yourself this afternoon, with a script short enough to read in one sitting and no dependencies to install.
Sources
append, prepend, sha256), calendar aggregation of many timestamps into a single Bitcoin transaction, and the complete-versus-pending attestation states.anchor_20260612_200320 (Bitcoin block 953413, verified) and anchor_20260727_155036 (calendar-submitted, pending). All hashes, block heights, proof sizes and terminal output in this piece were produced by running the script described here, not reconstructed by hand.If the record carries its own spec, the tooling is a convenience rather than a dependency.
Chain of Consciousness is the system these anchor records come from: every agent action is hashed into a chain whose roots are committed to Bitcoin through OpenTimestamps. The verification instructions travel inside the record, which is what lets a fifty-line script written by someone else disagree with us.
Hosted CoC · See a verified chain · pip install chain-of-consciousness · npm install chain-of-consciousness