linkex.cclinkex.cc
The Open Tally Protocol

Sequences, chains, commitments

How individual receipts become a tamper-evident, hole-evident ledger — per-channel sequencing, hash chains, Merkle period commitments, and the commitment chain.

Domain separation: every hash says what it is

Every hash in the system is computed over a domain tag, a NUL byte, then the data:

tagged(domain, data) = domain || 0x00 || data
Domain tagProduces
linkex.receipt.self.v1a receipt's self_hash, over the canonical envelope
linkex.receipt.sign.v1the signed message, over the raw 32 bytes of a hash
linkex.receipt.leaf.v1a Merkle leaf, over a receipt's self-hash
linkex.receipt.node.v1a Merkle interior node, over two child hashes
linkex.commitment.v1a period commitment's hash, over the canonical commitment
linkex.receipt.sample.v1the sampling HMAC input, over a big-endian sequence number

Tags are readable ASCII strings rather than single-byte prefixes — "0x00 means leaf" is a fact an implementor has to look up and can get wrong, whereas the tag says what it is. The NUL separator is unambiguous because the data side is always canonical JSON or fixed-length bytes, and canonical JSON never contains a raw NUL.

One subtlety worth knowing if you reimplement: a commitment has its own hashing domain (it is a different kind of object) but shares the receipt's signing domain — because what reaches the signature is 32 bytes that the hashing domain has already made unambiguous.

Sequence numbers: making omission visible

Receipts are sequenced per channel (stream) by a single sequencer:

  • The billing hot path only enqueues an event — committed in the same database transaction as the charge (see instance guarantees); the sequencer asynchronously assigns numbers and issues receipts.
  • A database lease guarantees a single active sequencer per stream at any moment; uniqueness constraints on (channel_id, seq) and on the source event prevent double-sequencing.
  • Because failures are also sequenced, a gap in seq is always evidence of a missing event — never an artifact of "we only number successes."

Sequence arithmetic is performed on the decimal strings, not by parsing into a fixed-width integer: seq is unbounded in the wire format, and a comparison that silently wrapped at 2⁶³ would put a hole in the one check whose entire value is that it has no holes.

The hash chain: making edits visible

self_hash = "sha256:" + hex( SHA256( tagged("linkex.receipt.self.v1", canonical(envelope)) ) )
signature = base64( Ed25519_Sign( key, tagged("linkex.receipt.sign.v1", raw32(self_hash)) ) )

Each receipt's prev_hash points to the previous receipt's self_hash on the same stream — and because prev_hash sits inside the envelope, the chain link is inside the signed bytes rather than appended alongside them. Editing any historical receipt breaks the chain at that link, for everyone who ever fetched anything downstream of it.

Verifying one receipt is two independent checks, neither implying the other: the self_hash must match the envelope (a valid signature over a stale hash means the envelope was altered after signing), and the signature must verify against self_hash under the key the envelope names.

Chain continuity establishes integrity, not authenticity. A chain forged end-to-end by an unrelated key would satisfy every continuity rule — self-hashes recompute, links join, sequences run. That is why every receipt must also verify against the published key history:

The published key history

Keys are published per stream with half-open sequence ranges [from_seq, until_seq) — see linkex.ai's live history at /api/receipt/keys. Scoping by sequence rather than wall-clock time is what makes rotation verifiable: a verifier holding a single receipt decides which key applies from the sequence number alone, with no reference to any clock — including the issuer's.

A published history must satisfy four rules, each closing a specific attack:

  1. key_id must be the fingerprint of the accompanying public key — otherwise the history could name one key and publish another.
  2. Ranges must not overlap within a stream — two keys claiming one sequence would let a forged receipt validate against whichever key an attacker holds.
  3. Ranges must not leave holes — a receipt with no valid key fails through no fault of the counterparty, which looks exactly like tampering.
  4. An open-ended record must be the last in its stream.

A receipt signed by a genuine key but outside that key's published range is rejected — without the range check, a leaked retired key could mint receipts indefinitely and every one would verify.

Period commitments: sealing a window

On a fixed cadence, all receipts of a stream in a time window aggregate into a Merkle tree, producing a signed PeriodCommitment. A real one, from /api/receipt/commitments:

{
  "commitment": {
    "spec": "linkex.period-commitment",
    "spec_version": "1",
    "issuer": "linkex.ai",
    "stream": "ch:10",
    "from_ms": "1788570074617",
    "until_ms": "1788570434593",
    "first_seq": "1",
    "last_seq": "5",
    "count": "5",
    "root": "sha256:4c165425c1201c4f89f52be6df0738a8b7aef75d9c22803c628a412370b5da90",
    "prev_commitment_hash": "sha256:0000…0000",
    "key_id": "ed25519:3d1aa5da8d32…"
  },
  "hash": "sha256:6572a1f3124a…",
  "signature": "ihHjOrnLjfQ9…"
}
FieldWhy it's there
rootMerkle root over the window's receipts, in ascending sequence
first_seq / last_seq / countfixes the window's extent and any holes inside the commitment itself. count must equal last_seq − first_seq + 1; a root alone proves membership in some set, while the declared range and count fix that set's size and position — so a gap has a stated location and cannot be smoothed over by re-issuing the period
prev_commitment_hashcommitments form their own chain
key_id + signaturethe commitment is itself signed, under the same published key history

The commitment's hash covers its whole canonical form (linkex.commitment.v1 domain); the hash and signature fields travel alongside and are not part of the hashed bytes. Building a commitment verifies the chain across the run first — committing to an already-broken run would produce a signed, timestamped statement that the break was fine.

Merkle construction details that matter

leaf = SHA256( tagged("linkex.receipt.leaf.v1", raw32(self_hash)) )
node = SHA256( tagged("linkex.receipt.node.v1", raw32(left) || raw32(right)) )
  • Leaves and interior nodes use different domain tags — the second-preimage defense: without separation, an interior node's two children could be presented as a leaf.
  • Node hashing is not commutative, and must not be — a combiner that sorted the pair before hashing would let a proof for position i also verify at the mirrored position.
  • Odd nodes are promoted, never duplicated. Duplicating is the CVE-2012-2459 shape: a tree of n leaves and a tree of n + 1 leaves whose last leaf repeats produce the same root, so two different declared counts would share one commitment. The conformance suite publishes both roots for a three-leaf tree — the correct one and the mistake — because an implementation that duplicates produces a root that looks perfectly reasonable on its own.
  • An inclusion proof records which side each sibling sits on, and its declared path length is checked before any hashing — bounding the work a hostile proof can impose, and naming the defect precisely ("must have 3 steps, has 5") instead of just "root mismatch."

Inclusion is checked against the recomputed self-hash from the envelope, never the stored one — if an envelope were altered with its stored self_hash left alone, that stored hash genuinely is in the tree, and inclusion would report PASS beside a tampered receipt. Recomputing closes that reading; the chain check separately reports the stored-hash mismatch.

The commitment chain

Between consecutive commitments in one stream, two conditions hold, and they catch different attacks:

CheckCatches
prev_commitment_hash equals the previous commitment's hasha period replaced by another covering the same range
first_seq equals the previous last_seq + 1a period dropped and the chain rebuilt so the links match

A verifier reports them separately, because a report where both redden together cannot say which attack occurred.

The commitment ledger is public and served in canonical form — the exact object that was hashed, decimal strings and all — so recomputing a commitment hash requires no re-typing: /api/receipt/commitments.

The completeness triangle

Three properties interlock (drop any one and the argument collapses):

  1. Sequence continuity + failures-also-signed ⇒ any deletion or omission leaves a visible hole;
  2. Commitments record extent and count ⇒ the hole is fixed inside the signed commitment — you can't erase it by re-issuing the period;
  3. Commitments are fixed in time by third parties ⇒ a re-issued period is distinguishable from the original.

That third leg is time anchoring.

On this page