No trust required
Take nobody's word for it.
Depth is a subtraction between two block heights, and your own node already knows both. The first three sections need nothing but bitcoind and a shell. The indexer sections after them are optional, and none of them ask you to take a server's word for anything.
-
Depth
One call to
gettxout. If the output is still there, its confirmation count carries the answer. -
Identity
One SHA-256 over an ASCII tag and a few bytes you already have. No secrets and no server.
-
State
A pure function of confirmed blocks, so two honest indexers at the same height must agree exactly.
Before you start
Local only- A Bitcoin node
- Bitcoin Core with RPC access. Two of the commands here want
-txindex, and every other one works without it. - A POSIX shell
- Plus
jqfor reading JSON, andxxdandsha256sumfor the hashes. macOS substitutions are noted where they differ. - An indexer, if you want one
- Three of the sections below use one. Everything before them runs on your own node, and nothing an indexer reports is taken as final.
Angle brackets mark the parts you replace.
01 The only step that matters
Read depth from any Bitcoin node.
An unspent output reports how many confirmations it has, and the block that created it counts as the first. Depth is that count minus one, with no index and no third party anywhere in the answer.
# the whole check, in one line bitcoin-cli gettxout <txid> <vout> true | jq '.confirmations - 1' # null output means the carrier has been spent # a spent outpoint has no depth, because the output no longer exists
#!/bin/sh
# usage: patina-depth <txid> <vout>
OUT=$(bitcoin-cli gettxout "$1" "$2" true)
if [ -z "$OUT" ] || [ "$OUT" = "null" ]; then
echo "spent or unknown, depth is not defined for this outpoint"
exit 1
fi
DEPTH=$(( $(printf '%s' "$OUT" | jq -r '.confirmations') - 1 ))
VALUE=$(printf '%s' "$OUT" | jq -r '.value')
if [ "$DEPTH" -ge 210000 ]; then TIER=Elder
elif [ "$DEPTH" -ge 105120 ]; then TIER=Oxide
elif [ "$DEPTH" -ge 52560 ]; then TIER=Bronze
elif [ "$DEPTH" -ge 26280 ]; then TIER=Umber
elif [ "$DEPTH" -ge 12960 ]; then TIER=Verdigris
elif [ "$DEPTH" -ge 4032 ]; then TIER=Cast
elif [ "$DEPTH" -ge 1008 ]; then TIER=Sheen
else TIER=Raw
fi
echo "depth $DEPTH blocks"
echo "tier $TIER"
echo "value $VALUE BTC"
Seven thresholds and a fallback for everything below the first one: that is the whole tier ladder. Nothing else decides a tier, which is why a shell script can compute one. The names and the arithmetic behind them are set out on the tiers page.
# needs -txindex, or the transaction has to be in your wallet
TIP=$(bitcoin-cli getblockcount)
BLOCKHASH=$(bitcoin-cli getrawtransaction <txid> true | jq -r '.blockhash')
BIRTH=$(bitcoin-cli getblockheader "$BLOCKHASH" | jq -r '.height')
echo $(( TIP - BIRTH ))
What this proves
That the outpoint has not moved since the height you just computed. Any node on earth reaches the same number from the same chain.
What it does not prove
Who holds the key, or that the same person held it the whole time. For liveness of the key, ask the holder for a signed attestation, which is the third command in the identifiers section below.
02 Bytes, not descriptions
Read the marker with your own eyes.
A reveal transaction announces itself in an OP_RETURN output. Pull the nulldata outputs out of it, then check the bytes against the layout on the protocol page.
bitcoin-cli getrawtransaction <reveal_txid> true \
| jq -r '.vout[] | select(.scriptPubKey.type == "nulldata")
| "vout \(.n) \(.scriptPubKey.hex)"'
# the marker is the lowest vout whose push starts with 50544e41
# if two outputs both start with 50544e41 the marker is void
Two markers cancel each other
A transaction carrying more than one payload that begins with 50544e41 has a void marker. There is no tie break and no first wins rule, so a second copy destroys the claim rather than duplicating it.
What each field of a SEED payload is
- Magic, 4 bytes
50544e41, the ASCII letters PTNA. A push that does not open with these bytes is not a PATINA marker at all.- Version, 1 byte
01. Any other value isMARKER_UNKNOWN_VERSIONand the marker creates nothing, so a byte you do not recognise is a payload you should not guess at.- Op, 1 byte
01marks a SEED, the claim that brings an artifact into existence.02marks a KEEP, which routes carriers onto successor outputs. Anything else isMARKER_UNKNOWN_OP.- Salt, 16 bytes
- The random value that was hidden inside the commit. It is what makes the commitment unguessable while it waits.
- Flags, 1 byte
00, and nothing else. Every bit is reserved at marker version 1 and has to be zero, so any other flags byte isSEED_BAD_GRAMMAR.- Carrier vout, 1 byte
- Which output of this reveal transaction becomes the carrier. Depth starts counting at the height of the block that confirms it. One byte, so a SEED cannot name an output past 255.
HEX=6a1850544e4101013f8a1c5d9e04b7226ce14093aa57db080000 printf '%s' "$HEX" | cut -c1-2 # 6a OP_RETURN printf '%s' "$HEX" | cut -c3-4 # 18 push 24 bytes printf '%s' "$HEX" | cut -c5-12 # 50544e41 PTNA printf '%s' "$HEX" | cut -c13-14 # 01 version printf '%s' "$HEX" | cut -c15-16 # 01 SEED printf '%s' "$HEX" | cut -c17-48 # 16 byte salt printf '%s' "$HEX" | cut -c49-50 # 00 flags printf '%s' "$HEX" | cut -c51-52 # 00 carrier vout # the hex above is the worked example from the protocol page, # not a marker from any real transaction
Four plus one plus one plus sixteen plus one plus one is twenty four bytes, which is the 18 in the push opcode. If those two numbers do not match, stop reading the payload.
03 Names you can derive
Recompute the identifiers.
Each derivation is a single SHA-256 over an ASCII tag and some bytes. If an indexer hands you an artifact id, you can confirm it without asking anyone whether it is right.
Artifact id
TXID=<reveal txid as your explorer shows it, 64 hex characters> VOUT=<carrier vout, decimal> # txid in internal byte order, which is the display order reversed WIRE=$(printf '%s' "$TXID" | fold -w2 | tac | tr -d '\n') # carrier vout as four little endian bytes VOUTLE=$(printf '%08x' "$VOUT" | fold -w2 | tac | tr -d '\n') { printf 'PTNA/artifact'; printf '%s%s' "$WIRE" "$VOUTLE" | xxd -r -p; } | sha256sum # on macOS, replace tac with "tail -r" and sha256sum with "shasum -a 256"
The byte order reversal is the step people get wrong. An explorer shows a txid backwards from the way it sits on the wire, and the hash is taken over the wire order.
Commit commitment
XONLY=<claimant x only public key, 64 hex characters>
SALT=<the 16 byte salt from the SEED payload, 32 hex characters>
{ printf 'PTNA/commit'; printf '%s%s' "$XONLY" "$SALT" | xxd -r -p; } | sha256sum
# the result must equal the 32 bytes pushed in the commit leaf that the
# reveal transaction spent: the 68 byte reduced-data leaf that new
# construction uses, or the 70 byte legacy leaf for older commits
This is the check that ties a claim to the key that made it. If the recomputed commitment does not appear in the spent leaf, the reveal does not belong to that commit.
Attestation message
# build the exact string the holder has to sign with BIP-322 printf 'PTNA/attest%s%s' <artifact_id_hex> <block_hash_hex> # pick a recent block hash yourself, so an old signature cannot be replayed # a valid signature over this string, from the key behind the carrier, # tells you the key is alive right now # it still does not tell you who has been holding it
Choose the block hash yourself
A signature over a string you chose, containing a hash that did not exist yesterday, cannot have been made yesterday. If the holder supplies the block hash instead of you, the signature proves much less.
04 Optional, and never final
Ask an indexer, then check its answer.
An indexer is a convenience, not an authority. Use it to find things quickly, then confirm the parts that matter against your own node.
The commands below use one shell variable. Set it to the base URL of an indexer you trust. The line under this paragraph prints whichever one this site is configured against.
export PATINA_API="https://your-indexer.example.org/patina"
If that line shows an example host, this site has no indexer configured. The routes below are the ones the frozen indexer API contract names, so any conforming indexer answers them under its own base path.
curl -s "$PATINA_API/status" | jq '{network, protocol_id, spec_sha256,
tip_height, indexed_height, synced,
parser_version, indexer_version}'
# check spec_sha256 against the hash of the specification you read
# check tip_height against your own node: bitcoin-cli getblockcount
curl -s "$PATINA_API/artifacts/<artifact_id>" \
| jq '{status, founding, depth, tier, tier_name,
next_tier, blocks_to_next_tier, carrier, rings}'
curl -s "$PATINA_API/carriers/<txid>/<vout>" | jq
curl -s "$PATINA_API/addresses/<address>/holdings" | jq
# now confirm the depth it reported, with your own node:
# bitcoin-cli gettxout <txid> <vout> true | jq '.confirmations - 1'
curl -s -X POST "$PATINA_API/safety/outpoints" \
-H 'content-type: application/json' \
-d '{"outpoints":["<txid>:<vout>"]}' | jq
# tells you whether an outpoint is a carrier or a commit before a wallet
# picks it as an ordinary coin and resets something by accident
# the endpoint stores nothing
Coin selection is the real hazard
A wallet that does not know about carriers will happily spend one to pay a fee. That spend is valid Bitcoin, the depth goes to zero, and nobody can undo it. The risks page covers what else can go wrong.
05 The strongest position
Run the index yourself.
Your own index sitting next to your own node answers to nobody. The indexer lives in a separate repository from the protocol library.
git clone https://github.com/bitcoinuniverse/index-patina
cd index-patina
# the README in that repository is the authoritative setup guide
# it needs a Bitcoin node RPC endpoint and a deployment record for the
# network you are indexing, and nothing else
We are not going to print configuration flags here that could drift out of date. The repository is the source of truth for how to run it.
06 Two answers, one chain
Set two indexers against each other.
PATINA state is a pure function of the confirmed chain read in block order. Two honest indexers at the same indexed height must agree exactly. Disagreement means one of them is wrong, and that is worth knowing.
A=<base URL of the first indexer>
B=<base URL of the second indexer>
diff \
<(curl -s "$A/status" | jq -S '{spec_sha256, indexed_height, counters}') \
<(curl -s "$B/status" | jq -S '{spec_sha256, indexed_height, counters}')
# if indexed_height differs, wait for the slower one and run it again
# if spec_sha256 differs, they are not running the same specification
# if the counters differ at the same height, one of them has a bug
ID=<artifact_id>
diff \
<(curl -s "$A/artifacts/$ID" | jq -S '{status, founding, carrier, rings}') \
<(curl -s "$B/artifacts/$ID" | jq -S '{status, founding, carrier, rings}')
# depth is deliberately left out of this comparison, because it is a
# function of the tip and the two indexers may be at different tips
About state roots
The specification defines a state root as a single SHA-256 over the tag PTNA/state and a canonical snapshot encoding, so two indexers holding the same state at the same height produce the same 32 bytes. The frozen API contract does not name a field that exposes it, so where an implementation publishes its root is up to that implementation. Check the indexer's own documentation. Until you find it, the counter comparison above is the check that works against any conforming indexer.
07 When numbers collide
If the answers disagree, the chain wins.
Trust your node over any indexer, and trust an indexer you run over one somebody else runs. If an indexer's depth does not match gettxout, the indexer is wrong.
Report a mismatch
Open an issue on the indexer repository with the outpoint and both answers. The outpoint is enough for anyone to reproduce it.
A wrong index changes nothing
An indexer reporting the wrong depth has not altered your artifact. The carrier is where it was, the rings are where they were, and a corrected index recomputes both from the same blocks.
What we publish about our own indexer, including how it fails and what it cannot reach, is on the transparency page.
08 Go further
Where the harder checks live.
- I want fixed inputs Golden vectors Known transactions with known outcomes, and a state root at every height, so an implementation can prove itself before it ever touches mainnet.
- Two indexers differ Resolving disagreements How to narrow a mismatch down to the block that caused it, and which side of the difference is usually at fault.
- I am scripting this Endpoint reference Every indexer route with request and response examples, plus the error taxonomy behind the codes you will hit.