From efcf69216138c5881ec29ab8c4875f8f39724ebe Mon Sep 17 00:00:00 2001
From: Bitcoin Universe Admin
Date: Tue, 1 Sep 2026 12:14:19 +0000
Subject: [PATCH 1/2] docs(tandem): publish a protocol dossier and enable the
public site
Tandem is one Bitcoin-native object controlled by a matched pair of keys.
The repository already published the frozen specification, three schemas,
and a golden corpus, but nothing explained them or let a reader check them.
This adds a ten-page dossier under docs/ built entirely on the repository's
own artifacts:
- protocol.html, the plain-language introduction and the five places a pair
must match, each with the stable reason emitted when it fails
- specification.html, the normative rules restated as numbered, citable ids
(ID, REC, PAIR, OP, ST, INV, ROOT) with every rule traced to a tandem.md
section
- guide.html, seven worked examples using only values from golden.json
- pair-check.html plus assets/pair-check.js, an in-browser pairing verifier
that takes two candidate records or two candidate keys, names the rule and
the stable reason behind each check, and self-tests against the published
vectors on load
- indexing.html, schemas.html, vectors.html, verifier.html, conformance.html,
considerations.html
Records one erratum. Section 13.2 states the event leaf preimage is 374 bytes
with a 16-byte domain tag. The tag TANDEM/EVENT\0 is 13 bytes and every
preimage published in vectors/generated/golden.json is 371 bytes, which the
independent Rust verifier also asserts. The 358-byte field block in the same
sentence is correct. The specification bytes are hash-pinned so this is
documented rather than repaired, and the tool measures it in the browser.
Also:
- adds llms.txt, robots.txt, sitemap.xml, and sitemap-index.xml
- extends scripts/verify-public.mjs with dossier and site-file checks, so the
new pages are held to the same accessibility, CSP, and boundary standard,
and the pair check script is checked for transmission primitives
- widens the local dev server CSP from connect-src 'none' to 'self' so the
tool can read the published vectors it verifies
- replaces the forbidden word in prose across the tree, keeping the frozen
EXITED_NONCANONICAL and TERMINAL_NONCANONICAL protocol identifiers, and
repairs a broken auto-substitution in docs/integrations.md
- corrects five security-advisory links to the current organization name
- lengthens the thin homepage meta description
No protocol artifact is touched. tandem.md, release/spec.json, the three
schemas, and both vector files keep their exact bytes and digests.
Co-Authored-By: Claude Opus 5
---
.github/CODEOWNERS | 2 +-
CODE_OF_CONDUCT.md | 2 +-
README.md | 19 +-
SECURITY.md | 2 +-
assets/dossier.css | 793 +++++++++++++++++++++++
assets/pair-check.js | 935 ++++++++++++++++++++++++++
docs/conformance.html | 362 +++++++++++
docs/considerations.html | 392 +++++++++++
docs/guide.html | 623 ++++++++++++++++++
docs/index.html | 34 +-
docs/indexing.html | 487 ++++++++++++++
docs/integrations.md | 2 +-
docs/pair-check.html | 352 ++++++++++
docs/protocol.html | 357 ++++++++++
docs/safety.md | 2 +-
docs/schemas.html | 386 +++++++++++
docs/specification.html | 1297 +++++++++++++++++++++++++++++++++++++
docs/vectors.html | 466 +++++++++++++
docs/verifier.html | 428 ++++++++++++
index.html | 16 +-
llms.txt | 59 ++
robots.txt | 8 +
scripts/serve.mjs | 2 +-
scripts/verify-public.mjs | 120 +++-
sitemap-index.xml | 7 +
sitemap.xml | 16 +
26 files changed, 7147 insertions(+), 22 deletions(-)
create mode 100644 assets/dossier.css
create mode 100644 assets/pair-check.js
create mode 100644 docs/conformance.html
create mode 100644 docs/considerations.html
create mode 100644 docs/guide.html
create mode 100644 docs/indexing.html
create mode 100644 docs/pair-check.html
create mode 100644 docs/protocol.html
create mode 100644 docs/schemas.html
create mode 100644 docs/specification.html
create mode 100644 docs/vectors.html
create mode 100644 docs/verifier.html
create mode 100644 llms.txt
create mode 100644 robots.txt
create mode 100644 sitemap-index.xml
create mode 100644 sitemap.xml
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index e8187cd..9727964 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,6 +1,6 @@
* @migberto
-# Canonical public contract changes require explicit maintainer review.
+# Public contract changes require explicit maintainer review.
/tandem.md @migberto
/release/ @migberto
/schemas/ @migberto
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index adf4c7d..e2e5115 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -14,6 +14,6 @@ This standard applies in repository discussions, reviews, issue threads, communi
## Reporting conduct concerns
-Report conduct concerns privately through [GitHub private vulnerability reporting](https://github.com/bitcoinuniverse/tandem/security/advisories/new). Begin the title with `Conduct report:` so maintainers can route it correctly. Security vulnerabilities must use the same private reporting route and should begin with `Security report:`.
+Report conduct concerns privately through [GitHub private vulnerability reporting](https://github.com/bitcoinuniverseio/tandem/security/advisories/new). Begin the title with `Conduct report:` so maintainers can route it correctly. Security vulnerabilities must use the same private reporting route and should begin with `Security report:`.
Maintainers may remove content, restrict participation, or take other proportionate action to protect the community. Reports will be handled with care for privacy and due process.
diff --git a/README.md b/README.md
index 5c00f91..76b829b 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,8 @@ Tandem is one Bitcoin-native object controlled by two people. A pair can create
This repository is the public home of Tandem. It contains the product experience, user and integration documentation, and the exact public protocol artifacts needed for independent inspection.
+The published site is .
+
> Mainnet is not active. The protocol is finalized, but activation remains closed until independent verification, recovery, security, signer, and operational gates are complete.
Public-boundary verification runs on the shared capacity-routed runner pool, so qualified self-hosted workers are used first and overflow is absorbed on ephemeral cloud capacity. Fork pull requests remain excluded from private self-hosted execution.
@@ -27,6 +29,21 @@ Then visit `http://127.0.0.1:4173`.
The interactive object journey is explanatory. It does not create, sign, or broadcast Bitcoin transactions.
+## Protocol dossier
+
+The dossier is the technical layer: the specification restated as numbered, citable rules, worked examples that use the published vectors, and an in-browser pairing verifier.
+
+- [Dossier overview](docs/protocol.html)
+- [Normative specification as numbered rules](docs/specification.html)
+- [Guide and worked examples](docs/guide.html)
+- [Pair check tool](docs/pair-check.html)
+- [Indexer semantics](docs/indexing.html)
+- [JSON Schema reference](docs/schemas.html)
+- [Test vectors](docs/vectors.html)
+- [Independent verifier](docs/verifier.html)
+- [Conformance and implementation checklist](docs/conformance.html)
+- [Security, limitations, and changelog](docs/considerations.html)
+
## Start with the right guide
- [What is Tandem?](docs/what-is-tandem.md)
@@ -46,7 +63,7 @@ The exact public contract is indexed in [protocol/README.md](protocol/README.md)
Product copy, accessibility, public documentation, examples, and safe public integrations are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) and [REPOSITORY_BOUNDARY.md](REPOSITORY_BOUNDARY.md) before opening a change.
-Report security concerns privately through [GitHub private vulnerability reporting](https://github.com/bitcoinuniverse/tandem/security/advisories/new). Do not place sensitive details in a public issue.
+Report security concerns privately through [GitHub private vulnerability reporting](https://github.com/bitcoinuniverseio/tandem/security/advisories/new). Do not place sensitive details in a public issue.
## License
diff --git a/SECURITY.md b/SECURITY.md
index 7fa4c0d..27d4aad 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -4,7 +4,7 @@ Tandem involves Bitcoin transactions, shared control, recovery paths, and indepe
## Report privately
-Use [GitHub private vulnerability reporting](https://github.com/bitcoinuniverse/tandem/security/advisories/new) for suspected vulnerabilities.
+Use [GitHub private vulnerability reporting](https://github.com/bitcoinuniverseio/tandem/security/advisories/new) for suspected vulnerabilities.
Do not open a public issue for a suspected vulnerability. Do not include seed phrases, private keys, wallet backups, production credentials, access tokens, personally identifying wallet data, or unrelated transaction history in any report.
diff --git a/assets/dossier.css b/assets/dossier.css
new file mode 100644
index 0000000..e4e26c1
--- /dev/null
+++ b/assets/dossier.css
@@ -0,0 +1,793 @@
+/*
+ Tandem protocol dossier.
+
+ The visual system carries one idea: a Tandem record is two halves that mean
+ nothing apart. Side A and side B each own a colour and keep it on every page,
+ so a reader can always tell which half of a pair they are looking at. Colour
+ is never the only signal: every side is also labelled A or B in text.
+*/
+
+:root {
+ --side-a: #a82f11;
+ --side-a-bright: #ff4f2e;
+ --side-a-wash: #fbe8e2;
+ --side-b: #1e3a8f;
+ --side-b-bright: #4a67e0;
+ --side-b-wash: #e6ebf9;
+ --inert: #7d7d73;
+ --inert-wash: #e7e5db;
+ --seam: #10120f;
+ --header-height: 76px;
+ --dossier-max: 1180px;
+}
+
+/* ---------------------------------------------------------------- shell -- */
+
+.dossier {
+ background: var(--paper);
+}
+
+.dossier-hero {
+ max-width: var(--dossier-max);
+ margin: 0 auto;
+ padding: 3.5rem 1.5rem 2rem;
+}
+
+.dossier-hero .eyebrow {
+ color: var(--muted);
+}
+
+.dossier-hero h1 {
+ font-family: var(--display);
+ font-size: clamp(2.1rem, 5.4vw, 3.4rem);
+ line-height: 1.05;
+ letter-spacing: -0.02em;
+ margin-bottom: 1rem;
+}
+
+.dossier-hero h1 em {
+ font-style: italic;
+}
+
+.dossier-lede {
+ max-width: 60ch;
+ font-size: 1.08rem;
+ color: var(--ink-soft);
+}
+
+.dossier-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.6rem;
+ margin-top: 1.4rem;
+ padding: 0.55rem 0.95rem;
+ border: 2px solid var(--ink);
+ background: var(--paper-bright);
+ font-size: 0.82rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.dossier-status i {
+ width: 0.6rem;
+ height: 0.6rem;
+ background: var(--side-a-bright);
+ border-radius: 50%;
+}
+
+.dossier-shell {
+ max-width: var(--dossier-max);
+ margin: 0 auto;
+ padding: 0 1.5rem 4rem;
+ display: grid;
+ gap: 2.5rem;
+ grid-template-columns: 15rem minmax(0, 1fr);
+ align-items: start;
+}
+
+/* The site header is sticky and 76px tall, so anything else that sticks or
+ scrolls into view has to clear it. */
+.dossier-side {
+ position: sticky;
+ top: calc(var(--header-height) + 1rem);
+ max-height: calc(100vh - var(--header-height) - 2rem);
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ border: 2px solid var(--ink);
+ background: var(--paper-bright);
+ padding: 1.1rem;
+}
+
+.dossier-side p {
+ margin: 0 0 0.6rem;
+ font-size: 0.72rem;
+ font-weight: 800;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+
+.dossier-side a {
+ display: block;
+ padding: 0.32rem 0;
+ font-size: 0.9rem;
+ text-decoration: none;
+ border-bottom: 1px solid transparent;
+}
+
+.dossier-side a:hover,
+.dossier-side a:focus-visible {
+ border-bottom-color: var(--ink);
+}
+
+.dossier-side hr {
+ border: 0;
+ border-top: 1px solid var(--line);
+ margin: 0.9rem 0;
+}
+
+.dossier-main {
+ min-width: 0;
+}
+
+.dossier-main section {
+ margin-bottom: 3.25rem;
+ scroll-margin-top: calc(var(--header-height) + 1rem);
+}
+
+[id] {
+ scroll-margin-top: calc(var(--header-height) + 1rem);
+}
+
+.dossier-main h2 {
+ font-family: var(--display);
+ font-size: clamp(1.5rem, 3.2vw, 2.1rem);
+ line-height: 1.15;
+ letter-spacing: -0.01em;
+ padding-top: 1.1rem;
+ border-top: 3px solid var(--ink);
+}
+
+.dossier-main h3 {
+ font-size: 1.08rem;
+ letter-spacing: -0.005em;
+ margin: 1.9rem 0 0.6rem;
+}
+
+.dossier-main h4 {
+ font-size: 0.95rem;
+ margin: 1.4rem 0 0.5rem;
+}
+
+.dossier-main p,
+.dossier-main li {
+ max-width: 74ch;
+}
+
+.dossier-main ul,
+.dossier-main ol {
+ padding-left: 1.15rem;
+}
+
+.dossier-main li {
+ margin-bottom: 0.35rem;
+}
+
+.kicker {
+ margin: 0 0 0.35rem;
+ font-size: 0.72rem;
+ font-weight: 800;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+
+.lede {
+ font-size: 1.03rem;
+ color: var(--ink-soft);
+}
+
+/* ------------------------------------------------------------ two sides -- */
+
+.side-tag {
+ display: inline-block;
+ padding: 0.06rem 0.42rem;
+ border: 2px solid currentColor;
+ font-family: var(--mono);
+ font-size: 0.74rem;
+ font-weight: 700;
+ line-height: 1.5;
+ white-space: nowrap;
+}
+
+.side-tag.a {
+ color: var(--side-a);
+ background: var(--side-a-wash);
+}
+
+.side-tag.b {
+ color: var(--side-b);
+ background: var(--side-b-wash);
+}
+
+.side-tag.inert {
+ color: var(--inert);
+ background: var(--inert-wash);
+ border-style: dashed;
+}
+
+/* The seam: two panels that only read as one thing when both are filled. */
+.pair-band {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ border: 2px solid var(--ink);
+ background: var(--paper-bright);
+ margin: 1.4rem 0;
+}
+
+.pair-band > div {
+ padding: 1.1rem 1.2rem;
+ min-width: 0;
+}
+
+.pair-band > div:first-child {
+ border-right: 2px dashed var(--seam);
+ box-shadow: inset 0.35rem 0 0 -0.1rem var(--side-a-bright);
+}
+
+.pair-band > div:last-child {
+ box-shadow: inset -0.35rem 0 0 -0.1rem var(--side-b-bright);
+}
+
+.pair-band h4 {
+ margin: 0 0 0.5rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.pair-band p:last-child,
+.pair-band ul:last-child {
+ margin-bottom: 0;
+}
+
+/* ------------------------------------------------------------ notations -- */
+
+.dossier-main code {
+ font-family: var(--mono);
+ font-size: 0.88em;
+ background: var(--inert-wash);
+ padding: 0.08rem 0.3rem;
+ word-break: break-word;
+}
+
+.code {
+ border: 2px solid var(--ink);
+ background: var(--paper-bright);
+ padding: 0.9rem 1rem;
+ overflow-x: auto;
+ margin: 1rem 0;
+}
+
+.code pre {
+ margin: 0;
+ font-family: var(--mono);
+ font-size: 0.85rem;
+ line-height: 1.6;
+ white-space: pre;
+}
+
+.code code {
+ background: none;
+ padding: 0;
+ font-size: inherit;
+}
+
+.code-label {
+ display: block;
+ margin-bottom: 0.55rem;
+ font-size: 0.7rem;
+ font-weight: 800;
+ letter-spacing: 0.13em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+
+.scroll-x {
+ overflow-x: auto;
+ margin: 1.1rem 0;
+ border: 2px solid var(--ink);
+ background: var(--paper-bright);
+}
+
+table.tbl {
+ border-collapse: collapse;
+ width: 100%;
+ min-width: 30rem;
+ font-size: 0.88rem;
+}
+
+table.tbl caption {
+ text-align: left;
+ padding: 0.7rem 0.85rem 0;
+ font-size: 0.78rem;
+ font-weight: 700;
+ color: var(--muted);
+}
+
+table.tbl th,
+table.tbl td {
+ padding: 0.5rem 0.85rem;
+ text-align: left;
+ vertical-align: top;
+ border-bottom: 1px solid var(--line);
+}
+
+table.tbl thead th {
+ border-bottom: 2px solid var(--ink);
+ font-size: 0.74rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
+table.tbl tbody tr:last-child th,
+table.tbl tbody tr:last-child td {
+ border-bottom: 0;
+}
+
+table.tbl td code,
+table.tbl th code {
+ white-space: nowrap;
+}
+
+/* ---------------------------------------------------------------- rules -- */
+
+.rule {
+ border-left: 4px solid var(--ink);
+ padding: 0.1rem 0 0.1rem 0.95rem;
+ margin: 1.1rem 0;
+}
+
+.rule > p:last-child {
+ margin-bottom: 0;
+}
+
+.rule-id {
+ display: inline-block;
+ margin-bottom: 0.3rem;
+ font-family: var(--mono);
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ color: var(--ink);
+ background: var(--acid);
+ padding: 0.05rem 0.4rem;
+}
+
+.rule-src {
+ display: block;
+ margin-top: 0.35rem;
+ font-size: 0.78rem;
+ color: var(--muted);
+}
+
+.note {
+ border: 2px solid var(--ink);
+ background: var(--paper-bright);
+ padding: 0.95rem 1.1rem;
+ margin: 1.3rem 0;
+}
+
+.note > :last-child {
+ margin-bottom: 0;
+}
+
+.note strong:first-child {
+ display: block;
+ margin-bottom: 0.35rem;
+}
+
+.note.warn {
+ border-color: var(--side-a);
+ background: var(--side-a-wash);
+}
+
+.note.errata {
+ border-style: dashed;
+}
+
+/* ------------------------------------------------------------- diagrams -- */
+
+figure.diagram {
+ margin: 1.6rem 0;
+ border: 2px solid var(--ink);
+ background: var(--paper-bright);
+ padding: 1.1rem;
+}
+
+figure.diagram svg {
+ width: 100%;
+ height: auto;
+}
+
+figure.diagram figcaption {
+ margin-top: 0.8rem;
+ font-size: 0.85rem;
+ color: var(--muted);
+ max-width: 70ch;
+}
+
+.legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem 1rem;
+ margin-top: 0.7rem;
+ font-size: 0.8rem;
+ color: var(--ink-soft);
+}
+
+.legend span {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.legend i {
+ width: 0.85rem;
+ height: 0.85rem;
+ border: 2px solid var(--ink);
+}
+
+.legend i.a { background: var(--side-a-bright); }
+.legend i.b { background: var(--side-b-bright); }
+.legend i.inert { background: var(--inert-wash); border-style: dashed; }
+
+/* ----------------------------------------------------------- link cards -- */
+
+.card-grid {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
+ margin: 1.4rem 0;
+}
+
+.card-grid a,
+.card-grid article {
+ display: block;
+ border: 2px solid var(--ink);
+ background: var(--paper-bright);
+ padding: 1rem 1.05rem;
+ text-decoration: none;
+}
+
+.card-grid a:hover,
+.card-grid a:focus-visible {
+ background: var(--acid);
+}
+
+.card-grid strong {
+ display: block;
+ margin-bottom: 0.25rem;
+}
+
+.card-grid small {
+ color: var(--muted);
+ font-size: 0.83rem;
+ line-height: 1.5;
+ display: block;
+}
+
+.page-nav {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.6rem;
+ justify-content: space-between;
+ border-top: 3px solid var(--ink);
+ padding-top: 1.1rem;
+ margin-top: 2.5rem;
+}
+
+.page-nav a {
+ font-weight: 700;
+ text-decoration: none;
+ border-bottom: 2px solid var(--ink);
+}
+
+/* ------------------------------------------------------------ the tool --- */
+
+.tool {
+ border: 3px solid var(--ink);
+ background: var(--paper-bright);
+ margin: 1.5rem 0;
+}
+
+.tool-head {
+ padding: 1rem 1.15rem;
+ border-bottom: 2px solid var(--ink);
+}
+
+.tool-head h3 {
+ margin: 0 0 0.3rem;
+}
+
+.tool-head p {
+ margin: 0;
+ font-size: 0.9rem;
+ color: var(--ink-soft);
+}
+
+.tool-sides {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+}
+
+.tool-sides > div {
+ padding: 1rem 1.15rem;
+ min-width: 0;
+}
+
+.tool-sides > div:first-child {
+ border-right: 2px dashed var(--seam);
+}
+
+.tool label {
+ display: block;
+ margin-bottom: 0.4rem;
+ font-size: 0.78rem;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.tool textarea,
+.tool input[type="text"],
+.tool select {
+ width: 100%;
+ border: 2px solid var(--ink);
+ background: var(--white);
+ color: var(--ink);
+ padding: 0.55rem 0.6rem;
+ font-family: var(--mono);
+ font-size: 0.82rem;
+ line-height: 1.5;
+}
+
+.tool textarea {
+ min-height: 5.5rem;
+ resize: vertical;
+ word-break: break-all;
+}
+
+.tool .hint {
+ margin: 0.45rem 0 0;
+ font-size: 0.78rem;
+ color: var(--muted);
+ font-family: var(--body);
+}
+
+.tool-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.6rem;
+ align-items: center;
+ padding: 0 1.15rem 1.15rem;
+}
+
+.tool button {
+ border: 2px solid var(--ink);
+ background: var(--ink);
+ color: var(--paper-bright);
+ padding: 0.5rem 1rem;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.tool button.ghost {
+ background: var(--paper-bright);
+ color: var(--ink);
+}
+
+.tool button:hover {
+ background: var(--side-a);
+ border-color: var(--side-a);
+ color: var(--white);
+}
+
+.tool button.ghost:hover {
+ background: var(--acid);
+ color: var(--ink);
+ border-color: var(--ink);
+}
+
+.verdict {
+ border-top: 2px solid var(--ink);
+ padding: 1rem 1.15rem;
+}
+
+.verdict-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 0.6rem;
+ margin-bottom: 0.7rem;
+}
+
+.verdict-flag {
+ font-family: var(--mono);
+ font-weight: 700;
+ font-size: 0.82rem;
+ padding: 0.15rem 0.55rem;
+ border: 2px solid var(--ink);
+}
+
+.verdict-flag.pass {
+ background: var(--acid);
+}
+
+.verdict-flag.fail {
+ background: var(--side-a-wash);
+ border-color: var(--side-a);
+ color: var(--side-a);
+}
+
+.verdict-flag.idle {
+ background: var(--inert-wash);
+ border-style: dashed;
+ color: var(--inert);
+}
+
+.verdict-note {
+ font-size: 0.9rem;
+ color: var(--ink-soft);
+ margin: 0;
+}
+
+ol.checks {
+ list-style: none;
+ padding: 0;
+ margin: 0.9rem 0 0;
+ border-top: 1px solid var(--line);
+}
+
+ol.checks li {
+ display: grid;
+ grid-template-columns: 1.6rem minmax(0, 1fr);
+ gap: 0.7rem;
+ padding: 0.55rem 0;
+ border-bottom: 1px solid var(--line);
+ max-width: none;
+ margin: 0;
+}
+
+ol.checks li > b {
+ font-family: var(--mono);
+ font-weight: 700;
+ font-size: 0.9rem;
+ text-align: center;
+}
+
+ol.checks li.ok > b { color: var(--side-b); }
+ol.checks li.bad > b { color: var(--side-a); }
+ol.checks li.skip > b { color: var(--inert); }
+
+ol.checks .check-rule {
+ font-family: var(--mono);
+ font-size: 0.74rem;
+ color: var(--muted);
+ display: block;
+ margin-top: 0.2rem;
+}
+
+ol.checks .check-detail {
+ font-family: var(--mono);
+ font-size: 0.78rem;
+ word-break: break-all;
+ display: block;
+ margin-top: 0.2rem;
+}
+
+.derived {
+ margin: 0.9rem 0 0;
+}
+
+.derived div {
+ display: grid;
+ grid-template-columns: 11rem minmax(0, 1fr);
+ gap: 0.7rem;
+ padding: 0.35rem 0;
+ border-bottom: 1px solid var(--line);
+}
+
+.derived dt {
+ font-size: 0.8rem;
+ font-weight: 700;
+ color: var(--muted);
+}
+
+.derived dd {
+ margin: 0;
+ font-family: var(--mono);
+ font-size: 0.79rem;
+ word-break: break-all;
+}
+
+.selftest {
+ border: 2px solid var(--ink);
+ background: var(--paper-bright);
+ padding: 1rem 1.15rem;
+ margin: 1.4rem 0;
+}
+
+.selftest ul {
+ list-style: none;
+ padding: 0;
+ margin: 0.7rem 0 0;
+ font-family: var(--mono);
+ font-size: 0.82rem;
+}
+
+.selftest li {
+ padding: 0.25rem 0;
+ border-bottom: 1px solid var(--line);
+ max-width: none;
+}
+
+.selftest li:last-child { border-bottom: 0; }
+.selftest li b { display: inline-block; width: 2.2rem; }
+.selftest li.ok b { color: var(--side-b); }
+.selftest li.bad b { color: var(--side-a); }
+
+.noscript-note {
+ border: 2px dashed var(--ink);
+ padding: 0.9rem 1.1rem;
+ margin: 1.3rem 0;
+ background: var(--inert-wash);
+}
+
+/* --------------------------------------------------------- responsive --- */
+
+@media (max-width: 62rem) {
+ .dossier-shell {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .dossier-side {
+ position: static;
+ max-height: none;
+ overflow-y: visible;
+ }
+}
+
+@media (max-width: 44rem) {
+ .pair-band,
+ .tool-sides {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .pair-band > div:first-child,
+ .tool-sides > div:first-child {
+ border-right: 0;
+ border-bottom: 2px dashed var(--seam);
+ }
+
+ .pair-band > div:first-child {
+ box-shadow: inset 0 0.35rem 0 -0.1rem var(--side-a-bright);
+ }
+
+ .pair-band > div:last-child {
+ box-shadow: inset 0 -0.35rem 0 -0.1rem var(--side-b-bright);
+ }
+
+ .derived div {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 0.15rem;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ html {
+ scroll-behavior: auto;
+ }
+}
diff --git a/assets/pair-check.js b/assets/pair-check.js
new file mode 100644
index 0000000..b18ebb8
--- /dev/null
+++ b/assets/pair-check.js
@@ -0,0 +1,935 @@
+/*
+ Tandem pair check.
+
+ Everything here runs in the reader's browser. Nothing that is typed into the
+ page is transmitted anywhere. The only network request the page makes is a
+ same-origin GET of the repository's own published golden corpus, used to
+ self-test these rules against the frozen vectors.
+
+ The rules implemented below are transcribed from tandem.md. Each check names
+ the numbered rule on docs/specification.html that it enforces and the stable
+ reason code that a real parser emits when it fails.
+*/
+
+"use strict";
+
+/* ------------------------------------------------------------------ SHA256 */
+/* Implemented directly rather than through crypto.subtle so the page also
+ works outside a secure context, for example when opened from a local file. */
+
+const K = new Uint32Array([
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
+]);
+
+function rotr(x, n) {
+ return (x >>> n) | (x << (32 - n));
+}
+
+function sha256(bytes) {
+ const length = bytes.length;
+ const withPadding = new Uint8Array((((length + 9) >> 6) + 1) << 6);
+ withPadding.set(bytes);
+ withPadding[length] = 0x80;
+ const bitLength = length * 8;
+ const view = new DataView(withPadding.buffer);
+ view.setUint32(withPadding.length - 8, Math.floor(bitLength / 0x100000000));
+ view.setUint32(withPadding.length - 4, bitLength >>> 0);
+
+ const h = new Uint32Array([
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
+ ]);
+ const w = new Uint32Array(64);
+
+ for (let offset = 0; offset < withPadding.length; offset += 64) {
+ for (let i = 0; i < 16; i += 1) w[i] = view.getUint32(offset + i * 4);
+ for (let i = 16; i < 64; i += 1) {
+ const a = w[i - 15];
+ const b = w[i - 2];
+ const s0 = rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3);
+ const s1 = rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10);
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
+ }
+
+ let [a, b, c, d, e, f, g, hh] = h;
+ for (let i = 0; i < 64; i += 1) {
+ const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
+ const ch = (e & f) ^ (~e & g);
+ const t1 = (hh + s1 + ch + K[i] + w[i]) >>> 0;
+ const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
+ const maj = (a & b) ^ (a & c) ^ (b & c);
+ const t2 = (s0 + maj) >>> 0;
+ hh = g; g = f; f = e;
+ e = (d + t1) >>> 0;
+ d = c; c = b; b = a;
+ a = (t1 + t2) >>> 0;
+ }
+
+ h[0] = (h[0] + a) >>> 0; h[1] = (h[1] + b) >>> 0;
+ h[2] = (h[2] + c) >>> 0; h[3] = (h[3] + d) >>> 0;
+ h[4] = (h[4] + e) >>> 0; h[5] = (h[5] + f) >>> 0;
+ h[6] = (h[6] + g) >>> 0; h[7] = (h[7] + hh) >>> 0;
+ }
+
+ const out = new Uint8Array(32);
+ const outView = new DataView(out.buffer);
+ for (let i = 0; i < 8; i += 1) outView.setUint32(i * 4, h[i]);
+ return out;
+}
+
+/* ------------------------------------------------------------------- bytes */
+
+function toHex(bytes) {
+ let out = "";
+ for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
+ return out;
+}
+
+function fromHex(text) {
+ const cleaned = String(text).replace(/[\s:]/g, "").toLowerCase();
+ if (cleaned.length === 0) return { error: "empty" };
+ if (cleaned.length % 2 !== 0) return { error: "odd number of hex digits" };
+ if (!/^[0-9a-f]*$/.test(cleaned)) return { error: "contains a character that is not a hex digit" };
+ const bytes = new Uint8Array(cleaned.length / 2);
+ for (let i = 0; i < bytes.length; i += 1) bytes[i] = Number.parseInt(cleaned.slice(i * 2, i * 2 + 2), 16);
+ return { bytes };
+}
+
+function concat(parts) {
+ let total = 0;
+ for (const part of parts) total += part.length;
+ const out = new Uint8Array(total);
+ let at = 0;
+ for (const part of parts) { out.set(part, at); at += part.length; }
+ return out;
+}
+
+function ascii(text) {
+ const out = new Uint8Array(text.length);
+ for (let i = 0; i < text.length; i += 1) out[i] = text.charCodeAt(i) & 0xff;
+ return out;
+}
+
+function domain(tag) {
+ return concat([ascii(tag), new Uint8Array([0])]);
+}
+
+function u32le(value) {
+ const out = new Uint8Array(4);
+ new DataView(out.buffer).setUint32(0, value, true);
+ return out;
+}
+
+function readU32le(bytes, at) {
+ return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(at, true);
+}
+
+function compareBytes(a, b) {
+ const shortest = Math.min(a.length, b.length);
+ for (let i = 0; i < shortest; i += 1) {
+ if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
+ }
+ if (a.length === b.length) return 0;
+ return a.length < b.length ? -1 : 1;
+}
+
+function equalBytes(a, b) {
+ return a.length === b.length && compareBytes(a, b) === 0;
+}
+
+function reverse(bytes) {
+ return Uint8Array.from(bytes).reverse();
+}
+
+/* ------------------------------------------------------------- secp256k1 -- */
+
+const FIELD_P = (2n ** 256n) - (2n ** 32n) - 977n;
+
+function bytesToBig(bytes) {
+ let value = 0n;
+ for (const byte of bytes) value = (value << 8n) | BigInt(byte);
+ return value;
+}
+
+function modPow(base, exponent, modulus) {
+ let result = 1n;
+ let b = base % modulus;
+ let e = exponent;
+ while (e > 0n) {
+ if (e & 1n) result = (result * b) % modulus;
+ b = (b * b) % modulus;
+ e >>= 1n;
+ }
+ return result;
+}
+
+/* A compressed point is on the curve when x is in the field and x^3 + 7 is a
+ quadratic residue, so that some y exists with y^2 = x^3 + 7 (mod p). */
+function isCompressedPointOnCurve(bytes) {
+ if (bytes.length !== 33) return false;
+ if (bytes[0] !== 0x02 && bytes[0] !== 0x03) return false;
+ const x = bytesToBig(bytes.subarray(1));
+ if (x >= FIELD_P) return false;
+ const rhs = (modPow(x, 3n, FIELD_P) + 7n) % FIELD_P;
+ if (rhs === 0n) return false;
+ return modPow(rhs, (FIELD_P - 1n) / 2n, FIELD_P) === 1n;
+}
+
+/* ---------------------------------------------------------- Tandem tables -- */
+
+const MAGIC = ascii("TNDM");
+
+const NETWORKS = new Map([[0x00, "mainnet"], [0x01, "signet"], [0x02, "testnet4"], [0x03, "regtest"]]);
+
+const OPCODES = new Map([
+ [0x00, { name: "INIT", payload: 59, prefix: "6a 3b", script: 61, stateVout: null, hasSeq: false }],
+ [0x01, { name: "CREATE", payload: 40, prefix: "6a 28", script: 42, stateVout: 0x01, hasSeq: false }],
+ [0x02, { name: "MARK", payload: 78, prefix: "6a 4c 4e", script: 81, stateVout: 0x01, hasSeq: true }],
+ [0x03, { name: "ROTATE", payload: 44, prefix: "6a 2c", script: 46, stateVout: 0x01, hasSeq: true }],
+ [0x04, { name: "CLOSE", payload: 80, prefix: "6a 4c 50", script: 83, stateVout: 0xff, hasSeq: true }],
+]);
+
+const MARK_KINDS = new Map([
+ [0x00, "note"], [0x01, "image"], [0x02, "audio"],
+ [0x03, "milestone"], [0x04, "link"], [0x05, "opaque data"],
+]);
+
+const CLOSE_REASONS = new Map([
+ [0x00, "mutual completion"], [0x01, "relationship ended"],
+ [0x02, "migrate outside Tandem"], [0x03, "other"],
+]);
+
+const REASON = {
+ VALID: ["0x0000", "VALID"],
+ MULTIPLE_MARKERS: ["0x0001", "MULTIPLE_MARKERS"],
+ ENCODING: ["0x0002", "BAD_MARKER_ENCODING_OR_LENGTH"],
+ FORMAT: ["0x0003", "UNKNOWN_MARKER_FORMAT"],
+ NETWORK: ["0x0004", "WRONG_NETWORK"],
+ OPCODE: ["0x0005", "UNKNOWN_OPCODE"],
+ NAMESPACE: ["0x0006", "WRONG_NAMESPACE"],
+ RESERVED: ["0x0007", "UNSUPPORTED_OR_RESERVED_FIELD"],
+ INPUT_SCRIPT: ["0x0014", "BAD_INPUT_SCRIPT"],
+ KEYS: ["0x0015", "BAD_KEY_ORDER_OR_BINDING"],
+ PREDECESSOR: ["0x001a", "PREDECESSOR_NOT_ACTIVE"],
+ SEQUENCE: ["0x001b", "BAD_STATE_SEQUENCE"],
+ SUCCESSOR: ["0x001c", "BAD_SUCCESSOR"],
+ COMMITMENT: ["0x001d", "BAD_COMMITMENT"],
+};
+
+/* --------------------------------------------------------- marker parsing -- */
+
+/*
+ Accepts either a full marker script (starting with OP_RETURN) or a bare
+ payload. Returns a record describing what was found plus every structural
+ finding, in the order the specification evaluates them.
+*/
+function parseMarker(input) {
+ const findings = [];
+ const record = { ok: false, findings, fields: null };
+
+ const decoded = fromHex(input);
+ if (decoded.error) {
+ findings.push(fail("input", "REC-1", "Input is readable hexadecimal", decoded.error, REASON.ENCODING));
+ return record;
+ }
+
+ let bytes = decoded.bytes;
+ let scriptBytes = null;
+
+ if (bytes.length > 0 && bytes[0] === 0x6a) {
+ scriptBytes = bytes;
+ const framed = readPush(bytes);
+ if (framed.error) {
+ findings.push(fail("push", "REC-6", "Push prefix is complete and the payload is fully present", framed.error, REASON.ENCODING));
+ return record;
+ }
+ findings.push(pass("push", "REC-1", "Script begins with OP_RETURN and one data push",
+ `push opcode 0x${bytes[1].toString(16).padStart(2, "0")}, declared length ${framed.declared}`));
+ if (framed.trailing > 0) {
+ findings.push(fail("trailing", "REC-4", "No bytes follow the pushed payload", `${framed.trailing} trailing byte(s)`, REASON.ENCODING));
+ return record;
+ }
+ findings.push(pass("trailing", "REC-4", "No bytes follow the pushed payload", "script ends with the payload"));
+ if (!framed.minimal) {
+ findings.push(fail("minimal", "REC-6", "Push uses the minimal encoding", framed.minimalNote, REASON.ENCODING));
+ return record;
+ }
+ findings.push(pass("minimal", "REC-6", "Push uses the minimal encoding", framed.minimalNote));
+ bytes = framed.payload;
+ } else {
+ findings.push(skip("push", "REC-1", "Script framing", "no OP_RETURN prefix supplied, treating the input as a bare payload"));
+ }
+
+ if (bytes.length < 4 || !equalBytes(bytes.subarray(0, 4), MAGIC)) {
+ findings.push(fail("magic", "REC-1", "Payload begins with the magic bytes TNDM", `found ${toHex(bytes.subarray(0, Math.min(4, bytes.length))) || "nothing"}`, REASON.ENCODING));
+ return record;
+ }
+ findings.push(pass("magic", "REC-1", "Payload begins with the magic bytes TNDM", "54 4e 44 4d"));
+
+ if (bytes.length < 7 || bytes.length > 80) {
+ findings.push(fail("length", "REC-6", "Payload length is between 7 and 80 bytes", `${bytes.length} bytes`, REASON.ENCODING));
+ return record;
+ }
+ findings.push(pass("length", "REC-6", "Payload length is between 7 and 80 bytes", `${bytes.length} bytes`));
+
+ const format = bytes[4];
+ const network = bytes[5];
+ const opcode = bytes[6];
+ const spec = OPCODES.get(opcode);
+
+ if (spec && bytes.length !== spec.payload) {
+ findings.push(fail("exact", "REC-6", `Payload length is exactly ${spec.payload} bytes for ${spec.name}`, `${bytes.length} bytes`, REASON.ENCODING));
+ return record;
+ }
+ if (spec) {
+ findings.push(pass("exact", "REC-6", `Payload length is exactly ${spec.payload} bytes for ${spec.name}`, `${bytes.length} bytes`));
+ }
+
+ if (format !== 0x01) {
+ findings.push(fail("format", "REC-7", "Marker format is 0x01", `0x${format.toString(16).padStart(2, "0")}`, REASON.FORMAT));
+ return record;
+ }
+ findings.push(pass("format", "REC-7", "Marker format is 0x01", "0x01"));
+
+ const networkName = NETWORKS.get(network);
+ if (!networkName) {
+ findings.push(fail("network", "ID-3", "Network byte is a defined Tandem network", `0x${network.toString(16).padStart(2, "0")}`, REASON.NETWORK));
+ return record;
+ }
+ findings.push(pass("network", "ID-3", "Network byte is a defined Tandem network", networkName));
+
+ if (!spec) {
+ findings.push(fail("opcode", "REC-7", "Opcode is defined for Tandem", `0x${opcode.toString(16).padStart(2, "0")}`, REASON.OPCODE));
+ return record;
+ }
+ findings.push(pass("opcode", "REC-7", "Opcode is defined for Tandem", spec.name));
+
+ const fields = { operation: spec.name, opcode, format, network, networkName, payload: bytes, payloadBytes: bytes.length };
+ if (scriptBytes) {
+ fields.scriptBytes = scriptBytes.length;
+ if (scriptBytes.length !== spec.script) {
+ findings.push(fail("scriptlen", "REC-5", `Script length is exactly ${spec.script} bytes for ${spec.name}`, `${scriptBytes.length} bytes`, REASON.ENCODING));
+ return record;
+ }
+ findings.push(pass("scriptlen", "REC-5", `Script length is exactly ${spec.script} bytes for ${spec.name}`, `${scriptBytes.length} bytes`));
+ }
+
+ if (spec.name === "INIT") {
+ fields.hOpen = readU32le(bytes, 7);
+ fields.hClose = readU32le(bytes, 11);
+ fields.carrierValue = Number(new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getBigUint64(15, true));
+ fields.refundDelay = readU32le(bytes, 23);
+ fields.specHash = bytes.subarray(27, 59);
+
+ check(findings, fields.carrierValue === 20000, "initcarrier", "OP-1", "INIT carrier value is 20,000", String(fields.carrierValue), REASON.RESERVED);
+ check(findings, fields.refundDelay === 52560, "initrefund", "OP-1", "INIT refund delay is 52,560", String(fields.refundDelay), REASON.RESERVED);
+ check(findings, fields.hOpen + 4320 === fields.hClose, "initwindow", "OP-1", "H_open plus 4,320 equals H_close",
+ `${fields.hOpen} + 4320 = ${fields.hOpen + 4320}, declared ${fields.hClose}`, REASON.RESERVED);
+ } else {
+ fields.stateVout = bytes[7];
+ fields.namespace = bytes.subarray(8, 40);
+ check(findings, fields.stateVout === spec.stateVout, "statevout", "REC-8",
+ `state_vout is 0x${spec.stateVout.toString(16).padStart(2, "0")} for ${spec.name}`,
+ `0x${fields.stateVout.toString(16).padStart(2, "0")}`, REASON.RESERVED);
+
+ if (spec.hasSeq) {
+ fields.stateSeq = readU32le(bytes, 40);
+ findings.push(pass("seqread", "REC-8", "state_seq decoded", String(fields.stateSeq)));
+ }
+
+ if (spec.name === "MARK") {
+ fields.kind = bytes[44];
+ fields.flags = bytes[45];
+ fields.commitment = bytes.subarray(46, 78);
+ check(findings, MARK_KINDS.has(fields.kind), "kind", "REC-9", "MARK kind is defined",
+ MARK_KINDS.get(fields.kind) ?? `0x${fields.kind.toString(16).padStart(2, "0")}`, REASON.RESERVED);
+ check(findings, fields.flags === 0, "flags", "REC-8", "MARK flags byte is zero",
+ `0x${fields.flags.toString(16).padStart(2, "0")}`, REASON.RESERVED);
+ check(findings, fields.commitment.some((byte) => byte !== 0), "commitment", "REC-8",
+ "MARK chapter commitment is not all zero", toHex(fields.commitment), REASON.COMMITMENT);
+ }
+
+ if (spec.name === "CLOSE") {
+ fields.reason = bytes[44];
+ fields.reserved = bytes.subarray(45, 48);
+ fields.commitment = bytes.subarray(48, 80);
+ check(findings, CLOSE_REASONS.has(fields.reason), "closereason", "REC-9", "CLOSE reason is defined",
+ CLOSE_REASONS.get(fields.reason) ?? `0x${fields.reason.toString(16).padStart(2, "0")}`, REASON.RESERVED);
+ check(findings, fields.reserved.every((byte) => byte === 0), "reserved", "REC-8",
+ "CLOSE reserved bytes are all zero", toHex(fields.reserved), REASON.RESERVED);
+ }
+ }
+
+ record.fields = fields;
+ record.ok = findings.every((finding) => finding.status !== "bad");
+ return record;
+}
+
+function readPush(script) {
+ if (script.length < 2) return { error: "script is too short to hold a push opcode" };
+ const opcode = script[1];
+ let declared;
+ let headerLength;
+ let minimal = true;
+ let minimalNote = "";
+
+ if (opcode >= 0x01 && opcode <= 0x4b) {
+ declared = opcode;
+ headerLength = 2;
+ minimalNote = `direct push of ${declared} bytes`;
+ } else if (opcode === 0x4c) {
+ if (script.length < 3) return { error: "OP_PUSHDATA1 length byte is missing" };
+ declared = script[2];
+ headerLength = 3;
+ minimal = declared > 0x4b;
+ minimalNote = minimal ? "OP_PUSHDATA1 is required above 75 bytes" : "OP_PUSHDATA1 used where a direct push fits";
+ } else if (opcode === 0x4d) {
+ if (script.length < 4) return { error: "OP_PUSHDATA2 length bytes are missing" };
+ declared = script[2] | (script[3] << 8);
+ headerLength = 4;
+ minimal = false;
+ minimalNote = "OP_PUSHDATA2 is never minimal for a payload of at most 80 bytes";
+ } else if (opcode === 0x4e) {
+ if (script.length < 6) return { error: "OP_PUSHDATA4 length bytes are missing" };
+ declared = script[2] | (script[3] << 8) | (script[4] << 16) | (script[5] << 24);
+ headerLength = 6;
+ minimal = false;
+ minimalNote = "OP_PUSHDATA4 is never minimal for a payload of at most 80 bytes";
+ } else {
+ return { error: `byte after OP_RETURN is 0x${opcode.toString(16).padStart(2, "0")}, which is not a data push` };
+ }
+
+ const available = script.length - headerLength;
+ if (available < declared) {
+ return { error: `push declares ${declared} bytes but only ${available} are present` };
+ }
+ return {
+ declared,
+ minimal,
+ minimalNote,
+ payload: script.subarray(headerLength, headerLength + declared),
+ trailing: available - declared,
+ };
+}
+
+/* ------------------------------------------------------------- pair rules -- */
+
+function checkRecordPair(a, b) {
+ const findings = [];
+
+ if (!a.ok || !b.ok) {
+ findings.push(skip("pair", "PAIR-5", "Pairing checks", "both records must parse before they can be paired"));
+ return findings;
+ }
+
+ const first = a.fields;
+ const second = b.fields;
+
+ if (first.operation === "INIT" || second.operation === "INIT") {
+ findings.push(fail("initpair", "ID-1", "Neither record is an INIT",
+ "INIT activates a protocol identifier and never forms a predecessor and successor pair with an object operation", REASON.PREDECESSOR));
+ return findings;
+ }
+
+ check(findings, second.operation !== "CREATE", "createsecond", "OP-2",
+ "The successor is not a CREATE", second.operation === "CREATE" ? "CREATE has no predecessor, it begins an object" : second.operation, REASON.PREDECESSOR);
+
+ check(findings, first.operation !== "CLOSE", "closefirst", "ST-3",
+ "The predecessor is not a CLOSE", first.operation === "CLOSE" ? "CLOSE is terminal and has no Tandem successor" : first.operation, REASON.PREDECESSOR);
+
+ check(findings, first.network === second.network, "netmatch", "ID-3",
+ "Both records name the same network",
+ `${first.networkName} and ${second.networkName}`, REASON.NETWORK);
+
+ const sameNamespace = equalBytes(first.namespace, second.namespace);
+ check(findings, sameNamespace, "nsmatch", "ID-5",
+ "Both records carry the same namespace commitment",
+ sameNamespace ? toHex(first.namespace) : `${toHex(first.namespace)} and ${toHex(second.namespace)}`, REASON.NAMESPACE);
+
+ const firstSeq = first.operation === "CREATE" ? 0 : first.stateSeq;
+ if (second.stateSeq === undefined) {
+ findings.push(skip("seq", "PAIR-5", "Successor sequence is predecessor plus one", "the successor carries no state_seq"));
+ } else if (firstSeq === 0xffffffff) {
+ findings.push(fail("seq", "PAIR-5", "Successor sequence is predecessor plus one",
+ "a predecessor sequence of 0xffffffff cannot be incremented", REASON.SEQUENCE));
+ } else {
+ check(findings, second.stateSeq === firstSeq + 1, "seq", "PAIR-5",
+ "Successor sequence is predecessor plus one",
+ `predecessor ${firstSeq}, successor ${second.stateSeq}, expected ${firstSeq + 1}`, REASON.SEQUENCE);
+ }
+
+ if (second.operation === "MARK") {
+ findings.push(note("carrier", "PAIR-6",
+ "A MARK must reproduce the predecessor carrier script byte for byte at vout 1",
+ "that comparison needs the two transactions, which this tool does not take"));
+ } else if (second.operation === "ROTATE") {
+ findings.push(note("carrier", "PAIR-6",
+ "A ROTATE must derive its successor carrier from a pair that differs from the current one",
+ "use the carrier panel below to derive and compare the two scripts"));
+ }
+
+ return findings;
+}
+
+/* --------------------------------------------------------- carrier rules -- */
+
+function checkCarrier(inputA, inputB) {
+ const findings = [];
+ const result = { findings, derived: null };
+
+ const parsedA = fromHex(inputA);
+ const parsedB = fromHex(inputB);
+
+ if (parsedA.error || parsedB.error) {
+ findings.push(fail("hex", "PAIR-1", "Both keys are readable hexadecimal",
+ `side A: ${parsedA.error ?? "ok"}, side B: ${parsedB.error ?? "ok"}`, REASON.INPUT_SCRIPT));
+ return result;
+ }
+
+ const a = parsedA.bytes;
+ const b = parsedB.bytes;
+
+ const lengthOk = a.length === 33 && b.length === 33;
+ check(findings, lengthOk, "len", "PAIR-1", "Both keys are exactly 33 bytes",
+ `side A ${a.length} bytes, side B ${b.length} bytes`, REASON.INPUT_SCRIPT);
+ if (!lengthOk) return result;
+
+ const prefixOk = (a[0] === 0x02 || a[0] === 0x03) && (b[0] === 0x02 || b[0] === 0x03);
+ check(findings, prefixOk, "prefix", "PAIR-1", "Both keys begin with 0x02 or 0x03",
+ `side A 0x${a[0].toString(16).padStart(2, "0")}, side B 0x${b[0].toString(16).padStart(2, "0")}`, REASON.INPUT_SCRIPT);
+ if (!prefixOk) return result;
+
+ const onCurveA = isCompressedPointOnCurve(a);
+ const onCurveB = isCompressedPointOnCurve(b);
+ check(findings, onCurveA && onCurveB, "curve", "PAIR-1", "Both keys are valid secp256k1 points",
+ `side A ${onCurveA ? "on curve" : "not on curve"}, side B ${onCurveB ? "on curve" : "not on curve"}`, REASON.KEYS);
+ if (!onCurveA || !onCurveB) return result;
+
+ const distinct = compareBytes(a, b) !== 0;
+ check(findings, distinct, "distinct", "PAIR-1", "The two keys are distinct",
+ distinct ? "the pair has two different halves" : "both halves are the same key", REASON.KEYS);
+ if (!distinct) return result;
+
+ const sorted = compareBytes(a, b) < 0;
+ check(findings, sorted, "sorted", "PAIR-1", "key0 is below key1 by unsigned bytewise order",
+ sorted ? "side A is key0, side B is key1" : "the two halves are in the wrong order, swap them", REASON.KEYS);
+
+ const key0 = sorted ? a : b;
+ const key1 = sorted ? b : a;
+
+ const witnessScript = concat([
+ new Uint8Array([0x52, 0x21]), key0,
+ new Uint8Array([0x21]), key1,
+ new Uint8Array([0x52, 0xae]),
+ ]);
+
+ check(findings, witnessScript.length === 71, "wslen", "PAIR-2", "The witness script is exactly 71 bytes",
+ `${witnessScript.length} bytes`, REASON.INPUT_SCRIPT);
+
+ const scriptHash = sha256(witnessScript);
+ const scriptPubKey = concat([new Uint8Array([0x00, 0x20]), scriptHash]);
+
+ findings.push(pass("spk", "PAIR-2", "The carrier scriptPubKey is fully determined by the pair", `${scriptPubKey.length} bytes`));
+
+ result.derived = {
+ sorted,
+ key0: toHex(key0),
+ key1: toHex(key1),
+ witnessScript: toHex(witnessScript),
+ scriptHash: toHex(scriptHash),
+ scriptPubKey: toHex(scriptPubKey),
+ };
+ return result;
+}
+
+/* ------------------------------------------------------------- derivation -- */
+
+function deriveIdentity(networkCode, initTxidDisplay, specHashHex, createTxidDisplay) {
+ const init = fromHex(initTxidDisplay);
+ const spec = fromHex(specHashHex);
+ if (init.error || init.bytes.length !== 32) return { error: "the INIT txid must be 32 bytes of hexadecimal in display order" };
+ if (spec.error || spec.bytes.length !== 32) return { error: "the specification digest must be 32 bytes of hexadecimal" };
+
+ const namespace = sha256(concat([
+ domain("TANDEM/NAMESPACE"),
+ new Uint8Array([networkCode]),
+ reverse(init.bytes),
+ spec.bytes,
+ ]));
+
+ const out = { namespace: toHex(namespace) };
+
+ if (createTxidDisplay && createTxidDisplay.trim() !== "") {
+ const create = fromHex(createTxidDisplay);
+ if (create.error || create.bytes.length !== 32) return { error: "the CREATE txid must be 32 bytes of hexadecimal in display order" };
+ out.objectKey = toHex(sha256(concat([
+ domain("TANDEM/OBJECT"),
+ namespace,
+ reverse(create.bytes),
+ u32le(1),
+ ])));
+ out.protocolId = `tndm:${NETWORKS.get(networkCode)}:${toHex(init.bytes)}`;
+ out.displayId = `tandem:${NETWORKS.get(networkCode)}:${toHex(init.bytes)}:${toHex(create.bytes)}:1`;
+ } else {
+ out.protocolId = `tndm:${NETWORKS.get(networkCode)}:${toHex(init.bytes)}`;
+ }
+ return out;
+}
+
+/* ------------------------------------------------------------- finding UI -- */
+
+function pass(id, rule, label, detail) { return { id, rule, label, detail, status: "ok" }; }
+function fail(id, rule, label, detail, reason) { return { id, rule, label, detail, status: "bad", reason }; }
+function skip(id, rule, label, detail) { return { id, rule, label, detail, status: "skip" }; }
+function note(id, rule, label, detail) { return { id, rule, label, detail, status: "skip" }; }
+
+function check(findings, condition, id, rule, label, detail, reason) {
+ findings.push(condition ? pass(id, rule, label, detail) : fail(id, rule, label, detail, reason));
+}
+
+function renderFindings(list, target) {
+ target.textContent = "";
+ for (const finding of list) {
+ const item = document.createElement("li");
+ item.className = finding.status;
+ const mark = document.createElement("b");
+ mark.textContent = finding.status === "ok" ? "OK" : finding.status === "bad" ? "NO" : "--";
+ mark.setAttribute("aria-label", finding.status === "ok" ? "pass" : finding.status === "bad" ? "fail" : "not evaluated");
+ const body = document.createElement("div");
+ const label = document.createElement("span");
+ label.textContent = finding.label;
+ body.append(label);
+
+ const ruleLine = document.createElement("span");
+ ruleLine.className = "check-rule";
+ ruleLine.textContent = finding.status === "bad" && finding.reason
+ ? `rule ${finding.rule} → ${finding.reason[1]} (${finding.reason[0]})`
+ : `rule ${finding.rule}`;
+ body.append(ruleLine);
+
+ if (finding.detail) {
+ const detail = document.createElement("span");
+ detail.className = "check-detail";
+ detail.textContent = finding.detail;
+ body.append(detail);
+ }
+
+ item.append(mark, body);
+ target.append(item);
+ }
+}
+
+function setVerdict(flagNode, noteNode, state, text) {
+ flagNode.className = `verdict-flag ${state}`;
+ flagNode.textContent = state === "pass" ? "PAIR VALID" : state === "fail" ? "NOT A PAIR" : "AWAITING INPUT";
+ noteNode.textContent = text;
+}
+
+function renderDerived(target, entries) {
+ target.textContent = "";
+ for (const [term, value] of entries) {
+ const row = document.createElement("div");
+ const dt = document.createElement("dt");
+ dt.textContent = term;
+ const dd = document.createElement("dd");
+ dd.textContent = value;
+ row.append(dt, dd);
+ target.append(row);
+ }
+}
+
+/* --------------------------------------------------------------- wiring --- */
+
+function byId(id) { return document.getElementById(id); }
+
+function wireRecordPanel() {
+ const inputA = byId("record-a");
+ const inputB = byId("record-b");
+ const listA = byId("record-a-checks");
+ const listB = byId("record-b-checks");
+ const listPair = byId("record-pair-checks");
+ const flag = byId("record-verdict-flag");
+ const noteNode = byId("record-verdict-note");
+
+ function run() {
+ if (inputA.value.trim() === "" && inputB.value.trim() === "") {
+ listA.textContent = "";
+ listB.textContent = "";
+ listPair.textContent = "";
+ setVerdict(flag, noteNode, "idle", "Paste a marker into each side, or load the published vectors below.");
+ return;
+ }
+
+ const a = parseMarker(inputA.value);
+ const b = parseMarker(inputB.value);
+ renderFindings(a.findings, listA);
+ renderFindings(b.findings, listB);
+
+ const pairFindings = checkRecordPair(a, b);
+ renderFindings(pairFindings, listPair);
+
+ const structuralFailure = !a.ok || !b.ok;
+ const pairFailure = pairFindings.some((finding) => finding.status === "bad");
+
+ if (structuralFailure) {
+ const side = !a.ok && !b.ok ? "Both halves are" : !a.ok ? "Side A is" : "Side B is";
+ setVerdict(flag, noteNode, "fail", `${side} malformed, so there is nothing to pair. Fix the structural failures above first.`);
+ } else if (pairFailure) {
+ const first = pairFindings.find((finding) => finding.status === "bad");
+ setVerdict(flag, noteNode, "fail",
+ `Both halves are well formed on their own, but they do not belong together: ${first.label.toLowerCase()} failed. A real parser would emit ${first.reason ? first.reason[1] : "a rejection"}.`);
+ } else {
+ setVerdict(flag, noteNode, "pass",
+ `${a.fields.operation} at sequence ${a.fields.operation === "CREATE" ? 0 : a.fields.stateSeq} pairs with ${b.fields.operation} at sequence ${b.fields.stateSeq}. Everything checkable from the markers alone agrees.`);
+ }
+ }
+
+ inputA.addEventListener("input", run);
+ inputB.addEventListener("input", run);
+ byId("record-clear").addEventListener("click", () => {
+ inputA.value = "";
+ inputB.value = "";
+ run();
+ });
+
+ return { inputA, inputB, run };
+}
+
+function wireCarrierPanel() {
+ const inputA = byId("key-a");
+ const inputB = byId("key-b");
+ const list = byId("carrier-checks");
+ const derived = byId("carrier-derived");
+ const flag = byId("carrier-verdict-flag");
+ const noteNode = byId("carrier-verdict-note");
+
+ function run() {
+ if (inputA.value.trim() === "" && inputB.value.trim() === "") {
+ list.textContent = "";
+ derived.textContent = "";
+ setVerdict(flag, noteNode, "idle", "Paste a compressed public key into each side.");
+ return;
+ }
+ const result = checkCarrier(inputA.value, inputB.value);
+ renderFindings(result.findings, list);
+ if (result.derived) {
+ renderDerived(derived, [
+ ["key0, side A", result.derived.key0],
+ ["key1, side B", result.derived.key1],
+ ["witness script", result.derived.witnessScript],
+ ["SHA256(witnessScript)", result.derived.scriptHash],
+ ["carrier scriptPubKey", result.derived.scriptPubKey],
+ ]);
+ const ordered = result.derived.sorted;
+ setVerdict(flag, noteNode, result.findings.some((f) => f.status === "bad") ? "fail" : "pass",
+ ordered
+ ? "The two halves form one carrier. Both signatures will be required to spend it."
+ : "The halves are valid but were supplied in the wrong order. The carrier below uses the required sorted order.");
+ } else {
+ derived.textContent = "";
+ setVerdict(flag, noteNode, "fail", "These two halves cannot form a carrier. See the failing rule above.");
+ }
+ }
+
+ inputA.addEventListener("input", run);
+ inputB.addEventListener("input", run);
+ byId("carrier-clear").addEventListener("click", () => {
+ inputA.value = "";
+ inputB.value = "";
+ run();
+ });
+
+ return { inputA, inputB, run };
+}
+
+function wireDerivePanel() {
+ const network = byId("derive-network");
+ const init = byId("derive-init");
+ const spec = byId("derive-spec");
+ const create = byId("derive-create");
+ const derived = byId("derive-output");
+ const message = byId("derive-message");
+
+ function run() {
+ const result = deriveIdentity(Number(network.value), init.value, spec.value, create.value);
+ if (result.error) {
+ derived.textContent = "";
+ message.textContent = result.error;
+ return;
+ }
+ message.textContent = "";
+ const rows = [["namespace commitment", result.namespace], ["protocol identifier", result.protocolId]];
+ if (result.objectKey) {
+ rows.push(["object key", result.objectKey], ["object display identifier", result.displayId]);
+ }
+ renderDerived(derived, rows);
+ }
+
+ for (const node of [network, init, spec, create]) node.addEventListener("input", run);
+ network.addEventListener("change", run);
+ return { run };
+}
+
+/* ----------------------------------------------------------- vector tests -- */
+
+function merkleRoot(leaves, tag) {
+ if (leaves.length === 0) return null;
+ let level = leaves;
+ while (level.length > 1) {
+ const next = [];
+ for (let i = 0; i < level.length; i += 2) {
+ const left = level[i];
+ const right = level[i + 1] ?? level[i];
+ next.push(sha256(concat([domain(tag), left, right])));
+ }
+ level = next;
+ }
+ return level[0];
+}
+
+function selfTest(corpus) {
+ const results = [];
+ const record = (ok, label, detail) => results.push({ ok, label, detail });
+
+ record(corpus.schema === "urn:tandem:golden-fixtures", "fixture schema identifier", corpus.schema);
+
+ for (const marker of corpus.markers) {
+ const parsed = parseMarker(marker.scriptHex);
+ const spec = parsed.fields ? OPCODES.get(parsed.fields.opcode) : null;
+ const ok = parsed.ok
+ && parsed.fields.operation === marker.operation
+ && parsed.fields.payloadBytes === marker.payloadBytes
+ && spec !== null && marker.scriptBytes === spec.script;
+ record(ok, `${marker.operation} marker parses to its published shape`,
+ `${marker.payloadBytes} payload bytes, ${marker.scriptBytes} script bytes`);
+ }
+
+ const carrier = checkCarrier(corpus.carrier.key0, corpus.carrier.key1);
+ record(
+ carrier.derived !== null
+ && carrier.derived.witnessScript === corpus.carrier.witnessScript
+ && carrier.derived.scriptPubKey === corpus.carrier.scriptPubKey,
+ "carrier derivation reproduces the published script and address",
+ corpus.carrier.scriptPubKey,
+ );
+
+ const identity = deriveIdentity(
+ 0x03,
+ corpus.identity.protocolId.split(":")[2],
+ corpus.specHash,
+ corpus.identity.objectDisplayId.split(":")[3],
+ );
+ record(identity.namespace === corpus.identity.namespace, "namespace derivation matches the published namespace", identity.namespace);
+ record(identity.objectKey === corpus.identity.objectKey, "object key derivation matches the published object key", identity.objectKey);
+
+ let leafLength = null;
+ let leavesOk = true;
+ const eventLeaves = [];
+ for (const event of corpus.roots.events) {
+ const preimage = fromHex(event.preimageHex).bytes;
+ leafLength = preimage.length;
+ const leaf = sha256(preimage);
+ if (toHex(leaf) !== event.leafHex) leavesOk = false;
+ eventLeaves.push(leaf);
+ }
+ record(leavesOk, "every published event preimage hashes to its published leaf", `${corpus.roots.events.length} leaves checked`);
+ record(leafLength === 371, "event leaf preimage length", `${leafLength} bytes, which is 13 domain bytes plus 358 field bytes`);
+
+ const objectLeaves = [];
+ let snapshotsOk = true;
+ let snapshotLength = null;
+ for (const snapshot of corpus.roots.snapshots) {
+ const preimage = fromHex(snapshot.preimageHex).bytes;
+ snapshotLength = preimage.length;
+ const leaf = sha256(preimage);
+ if (toHex(leaf) !== snapshot.leafHex) snapshotsOk = false;
+ objectLeaves.push(leaf);
+ }
+ record(snapshotsOk, "every published object snapshot hashes to its published leaf", `${corpus.roots.snapshots.length} leaves checked`);
+ record(snapshotLength === 204, "object-state leaf preimage length", `${snapshotLength} bytes, which is 20 domain bytes plus 184 field bytes`);
+
+ const eventRoot = merkleRoot(eventLeaves, "TANDEM/EVENT-NODE");
+ record(toHex(eventRoot) === corpus.roots.eventRoot, "recomputed event root matches", corpus.roots.eventRoot);
+
+ const objectRoot = merkleRoot(objectLeaves, "TANDEM/OBJECT-NODE");
+ record(toHex(objectRoot) === corpus.roots.objectStateRoot, "recomputed object-state root matches", corpus.roots.objectStateRoot);
+
+ const namespaceBytes = fromHex(corpus.identity.namespace).bytes;
+ const initialRoot = sha256(concat([domain("TANDEM/STATE-EMPTY"), namespaceBytes]));
+ record(toHex(initialRoot) === corpus.roots.initialStateRoot, "recomputed pre-INIT state root matches", corpus.roots.initialStateRoot);
+
+ return results;
+}
+
+function renderSelfTest(results, target, summaryNode) {
+ target.textContent = "";
+ for (const result of results) {
+ const item = document.createElement("li");
+ item.className = result.ok ? "ok" : "bad";
+ const mark = document.createElement("b");
+ mark.textContent = result.ok ? "OK" : "NO";
+ const text = document.createElement("span");
+ text.textContent = result.detail ? `${result.label}: ${result.detail}` : result.label;
+ item.append(mark, text);
+ target.append(item);
+ }
+ const failures = results.filter((result) => !result.ok).length;
+ summaryNode.textContent = failures === 0
+ ? `All ${results.length} checks passed against the repository's published vectors.`
+ : `${failures} of ${results.length} checks failed. Do not trust this page until that is explained.`;
+}
+
+/* ------------------------------------------------------------------- boot -- */
+
+document.addEventListener("DOMContentLoaded", () => {
+ const recordPanel = wireRecordPanel();
+ const carrierPanel = wireCarrierPanel();
+ const derivePanel = wireDerivePanel();
+
+ recordPanel.run();
+ carrierPanel.run();
+ derivePanel.run();
+
+ const summary = byId("selftest-summary");
+ const list = byId("selftest-list");
+
+ fetch("../vectors/generated/golden.json")
+ .then((response) => {
+ if (!response.ok) throw new Error(`the published corpus returned status ${response.status}`);
+ return response.json();
+ })
+ .then((corpus) => {
+ renderSelfTest(selfTest(corpus), list, summary);
+
+ byId("load-vectors").addEventListener("click", () => {
+ const mark = corpus.markers.find((marker) => marker.operation === "MARK");
+ const rotate = corpus.markers.find((marker) => marker.operation === "ROTATE");
+ recordPanel.inputA.value = mark.scriptHex;
+ recordPanel.inputB.value = rotate.scriptHex;
+ recordPanel.run();
+
+ carrierPanel.inputA.value = corpus.carrier.key0;
+ carrierPanel.inputB.value = corpus.carrier.key1;
+ carrierPanel.run();
+ });
+ byId("load-vectors").disabled = false;
+
+ byId("break-pair").addEventListener("click", () => {
+ const mark = corpus.markers.find((marker) => marker.operation === "MARK");
+ const close = corpus.markers.find((marker) => marker.operation === "CLOSE");
+ recordPanel.inputA.value = mark.scriptHex;
+ recordPanel.inputB.value = close.scriptHex;
+ recordPanel.run();
+ });
+ byId("break-pair").disabled = false;
+ })
+ .catch((error) => {
+ summary.textContent = `The published corpus could not be loaded, so the self test did not run: ${error.message}. Open this page from the published site, or run node scripts/serve.mjs and visit it over http. The panels above still work.`;
+ });
+});
diff --git a/docs/conformance.html b/docs/conformance.html
new file mode 100644
index 0000000..293518c
--- /dev/null
+++ b/docs/conformance.html
@@ -0,0 +1,362 @@
+
+
+
+
+
+
+
+
+
+
+
+ Tandem conformance and implementation checklist
+
+
+
+
+ Skip to the page
+
+
+
+
+ Tandem
+
+
+ Pair check
+
+
+
+
Conformance
+
What it takes to say this implements Tandem.
+
+ Conformance is not a verdict about individual transactions. It is a claim that your
+ implementation produces the same events, the same reason codes, the same state, and the same
+ three roots as any other conforming implementation, at every height. This page sets out what
+ that requires and gives you an order to build it in.
+
+
Protocol finalized. Mainnet is not active.
+
+
+
+
+
+
+
+
The conformance claim
+
+ The specification states it as invariant 12: given the same deployment binding, the same
+ exact specification bytes, and the same blocks on the active chain, independent
+ implementations produce identical events, reason codes, state, counters, event roots,
+ object-state roots, and chained roots at every height.
+
+
+ Everything else on this page follows from that sentence. Note how much it demands beyond
+ agreeing on valid or invalid. Two implementations that reject the same transactions for
+ different reasons are both nonconforming, because their event leaves differ, so their
+ event roots differ, so every chained root after that block differs forever.
+
+
+ There is no conformance authority.
+
+ Nobody issues a Tandem conformance certificate, and this repository does not run a
+ test service. Conformance is something you demonstrate with reproducible evidence
+ against published artifacts, and that anyone else can re-run. That is the whole
+ mechanism.
+
+
+
+
+
+
Requirements
+
+
+
+
What a conforming implementation must do, and the rules that define it
+
Requirement
Rules
+
+
Bind to exactly one network, one configured INIT txid, and one specification digest, selected by configuration and never by discovery
ID-1, ID-2
+
Derive the namespace commitment and object keys by the exact preimages, using wire byte order for transaction identifiers and never reversing raw digests
ID-5, ID-6
+
Detect marker candidates by the broad rule, so that malformed markers remain detectable and cannot evade multiple-marker counting
REC-1, REC-2
+
Remove foreign INIT candidates before counting markers
REC-3
+
Enforce the exact script and payload lengths, minimal push encoding, and absence of trailing bytes
REC-4, REC-5, REC-6
+
Enforce every fixed payload field, including reserved bytes, flags, defined kinds, and defined reasons
REC-7, REC-8, REC-9
+
Enforce the key pair rules: valid points, distinct, sorted, and bound to their required roles
PAIR-1, PAIR-4
+
Enforce the exact 71-byte witness script and the four-element carrier witness stack in order
PAIR-2, PAIR-3
+
Enforce sequence continuity, carrier continuity, and equal terminal payouts
PAIR-5, PAIR-6, PAIR-7
+
Enforce the exact fee arithmetic, including the ceiling and floor split that gives the odd satoshi to side A
PAIR-3.3
+
Enforce every operation template exactly, with exact input and output counts, roles, and sequences
OP-1 to OP-6
+
Require confirmation in a strictly earlier block for every required prevout
OP-6 note
+
Apply the exact state machine, including terminating an object on any confirmed carrier spend that does not validate
ST-1, ST-2, ST-3
+
Maintain the three post-block counters exactly
ST-6
+
Apply the dispatch order, including its precedence over numeric reason order
INV-1, INV-2
+
Return the lowest failing reason code within a single-marker operation
INV-3, INV-4
+
Use the stable reason registry with its permanent names and numeric values
INV-5
+
Compute event leaves, object-state leaves, both Merkle roots with odd-leaf duplication, and the chained root, for every block including empty ones
ROOT-1 to ROOT-4
+
Reverse block application exactly on disconnection, before applying any replacement branch
ROOT-4
+
Exclude mempool observations from state, counters, and roots entirely
ST-6
+
+
+
+
+
+
+
Evidence you can produce today
+
+ These are reproducible now, against published artifacts, with no network and no
+ permission required.
+
+
+
+
+
Available conformance evidence and its coverage
+
Evidence
Covers
Does not cover
+
+
+
Specification digest and byte contract
+
That you are reading the same protocol as everyone else
+
Anything about your parser
+
+
+
The five published marker encodings
+
Payload field layout, exact lengths, push minimality boundaries
+
Transaction-level rules, signatures, state
+
+
+
Identity derivations
+
Namespace and object key preimages, and byte-order handling
+
Everything downstream of identity
+
+
+
The carrier derivation
+
Key sorting, witness script bytes, P2WSH construction
+
Signature verification
+
+
+
The 31 validation cases
+
All 26 reason codes, and the event type and class each produces
+
Fee arithmetic against real amounts, real signatures, multi-block behaviour
+
+
+
The published preimages and Merkle levels
+
Leaf field order, domain tags, odd-leaf duplication, both roots
+
Root computation over a real chain
+
+
+
The chained root reconstruction
+
The chaining formula and counter encoding
+
Chaining across many blocks and across a reorganization
+
+
+
+
+
+
+ Read the right-hand column carefully. The published corpus is a necessary conformance
+ gate, not a sufficient one. It contains no raw transactions, no signatures, no block
+ data, and no reorganization scenarios. An implementation that passes every published
+ vector has demonstrated that its encodings and hashing are right. It has not yet
+ demonstrated that its transaction validation or its rollback is.
+
+
+
+ Do not claim conformance from the corpus alone.
+
+ The accurate statement is that your implementation reproduces every published Tandem
+ vector. That is a real and checkable claim. Claiming full protocol conformance
+ requires evidence for the parts the corpus does not reach, which you would have to
+ produce and publish yourself.
+
+
+
+
+
+
Implementation checklist
+
+ Build in this order. Each stage is checkable before the next one is written, which is
+ the only realistic way to find a hashing or ordering mistake before it is buried.
+
+
+
Stage 1: bytes
+
+
Hash tandem.md as raw bytes. Compare the digest, byte count, and line count against release/spec.json. Do not normalize whitespace or line endings before hashing.
Derive the vector root from the fixture digest and confirm it equals the published root. Derive it, do not copy it.
+
Implement the primitive encodings: unsigned little-endian integers, the wire and display hash orders, and the rule that a raw digest is never reversed.
+
+
+
Stage 2: identity
+
+
Implement the namespace commitment. Confirm it reproduces the published namespace for the published binding.
+
Implement the object key. Confirm it reproduces the published object key.
+
Implement the protocol identifier and object display identifier strings, and use binary keys everywhere internally.
+
+
+
Stage 3: markers
+
+
Implement candidate detection by the broad rule, including partial payloads and non-minimal pushes.
+
Implement foreign INIT removal, keyed on payload byte 6 and the configured txid.
Implement all five payload grammars, with every fixed field, reserved byte, and defined-value set enforced.
+
Confirm all five published marker scripts parse to their published operation, payload size, and script size.
+
+
+
Stage 4: transactions
+
+
Implement P2WPKH and carrier witness shape checks, including the leading empty element and signature order.
+
Implement key validation: on-curve, distinct, sorted, HASH160-bound to the correct role.
+
Implement signature verification with strict DER, low-S, SIGHASH_ALL, SegWit v0 hashing, and the exact prevout amount.
+
Implement each operation template exactly, with exact counts, roles, and sequences.
+
Implement checked fee arithmetic that cannot wrap, then the exact splits including the ceiling and floor tie-break.
+
Implement confirmation provenance as a strictly-earlier-block test.
+
Implement REFUND recognition, including the exact sequence value and consensus maturity.
+
+
+
Stage 5: classification and state
+
+
Implement the top-level dispatch in its exact step order.
+
Implement single-marker validation in ascending reason-code order, returning the lowest failing code, and deferring state-dependent checks that cannot be made.
+
Implement the state machine and every state delta, including terminal transitions.
+
Implement the three counters as post-block values.
+
Confirm all 31 published validation cases produce the published reason, class, and event type.
+
+
+
Stage 6: roots
+
+
Implement the event leaf preimage. Confirm each published preimage hashes to its published leaf, and confirm its length is 371 bytes, not 374.
+
Implement the object-state leaf preimage. Confirm the published preimages and the 204-byte length.
+
Implement both Merkle trees with odd-leaf duplication and the single-leaf shortcut. Confirm every published intermediate level.
+
Implement the empty-set roots for both trees.
+
Implement the chained root, and confirm it for the published block including its counters.
+
Emit a root for every block from the INIT confirmation block onward, including blocks with no events.
+
+
+
Stage 7: chain handling
+
+
Implement block connection as an atomic unit: events, deltas, both roots, counters, chained root.
+
Implement block disconnection as an exact reversal, completing before any replacement branch is applied.
+
Confirm that a disconnect and reconnect of the same block returns you to an identical chained root.
+
Keep mempool observations in storage with no path into state, counters, or roots.
+
+
+
Stage 8: publishing your view
+
+
Emit agreement tuples matching the published schema, with counters and heights as decimal strings.
+
Normalize the tuple with RFC 8785 JCS and sign only the tuple.
+
Record your own release provenance per height, and do not rewrite historical provenance on a later release.
+
Compare only the nine semantic fields against another pipeline, never the four release fields.
+
Fail closed on disagreement, on any missing dependency, and on a stale tip.
+
+
+
+
+
Where implementations go wrong
+
+ Each of these is a real hazard that the rules single out, and each produces silent
+ divergence rather than an obvious error.
+
+
+
+
+
Common divergence causes
+
Mistake
Symptom
+
+
Reversing a raw digest as though it were a txid
Namespace and object keys differ from everyone else's, and nothing else works
+
Accepting a same-block prevout
Occasional extra valid events that another implementation rejects
+
Returning a true but not lowest reason code
Verdicts agree, event roots do not
+
Padding the event preimage to 374 bytes
Every event leaf differs, so every root differs from the first event onward
+
Skipping blocks with no Tandem events
Chained roots diverge permanently from the first skipped block
+
Giving the odd fee satoshi to side B
Valid transactions rejected, or invalid ones accepted, on odd fees only
+
Ignoring an invalid carrier spend instead of terminating the object
Objects that should be dead stay active, so object-state roots diverge
+
Emitting one event for a multiple-carrier spend
Event count and ordering differ, so the event root differs
+
Parsing agreement counters as numbers and re-serializing
Signatures stop verifying at large values or on round trips
+
Letting a mempool sighting satisfy a predecessor requirement
Constructed transactions fail confirmation provenance once mined
+
Sorting object leaves by display identifier rather than binary key
Object-state root differs whenever ordering differs between the two
+
Treating key_id as authenticated
An unsigned envelope field is trusted to select trust
+
+
+
+
+
+
+
If you are building a product
+
+ Parser conformance is one concern. Shipping something to people who hold keys is
+ another, and the second one has its own rules.
+
+
+
Label the network and the verification status wherever state is shown. Never present state from a height where authorized pipelines disagree.
+
Keep mainnet construction and broadcast unavailable. Mainnet is not active, and no repository, website, or funded address changes that.
+
Fail closed for state-changing actions during any indexer disagreement or unreadiness.
+
Complete, export, checksum, and restore-test every recovery transaction before its parent is broadcast. The exit is prepared before the move, not after.
+
Reconstruct and display inputs, outputs, amounts, scripts, fees, sequences, locktime, key order, and commitments before requesting a signature. Blind signing defeats the pairing property entirely.
+
Never request a seed phrase or a raw private key, and never accept one if offered.
+
Treat all retrieved content as untrusted. Enforce your own size and type limits, render safely, and never execute retrieved content as application code.
+
Explain permanence and correlation before a user commits personal, legal, location, or relationship data to a public chain.
+
+
+
+ There is no marketplace to integrate with.
+
+ The Bitcoin Universe capability snapshot lists 38 protocols, 29 of them with a
+ marketplace entry. Tandem has no entry at all, so no Universe surface implements a
+ buy, sell, list, or transfer action for a Tandem object. If your product needs a trade
+ path, Tandem does not provide one and this repository does not describe one.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/considerations.html b/docs/considerations.html
new file mode 100644
index 0000000..342dcea
--- /dev/null
+++ b/docs/considerations.html
@@ -0,0 +1,392 @@
+
+
+
+
+
+
+
+
+
+
+
+ Tandem security, limitations, and changelog
+
+
+
+
+ Skip to the page
+
+
+
+
+ Tandem
+
+
+ Report privately
+
+
+
+
Honesty
+
What it protects, and what it cannot.
+
+ Shared control changes the shape of the risk. It does not remove risk, and in a few specific
+ ways it adds some. This page states both directions plainly, then records what has changed.
+
+
Protocol finalized. Mainnet is not active.
+
+
+
+
+
+
+
+
1. What Tandem protects
+
+ These properties come from the rules themselves, not from anyone's good behaviour. Each
+ one is enforced either by Bitcoin script or by the deterministic parser, and each names
+ the rule that provides it.
+
+
+
+
+
Protected properties and their sources
+
Property
Enforced by
+
+
Neither participant can advance the object alone
Bitcoin script, the 2-of-2 carrier, rules PAIR-2 and PAIR-3
+
Neither participant can end the object on better terms than the other
Equal, strictly positive payouts, rule PAIR-7
+
Neither participant can silently replace the other
ROTATE requires the old pair to authorize the new one, rule OP-4
+
History cannot be reordered or backdated
Exact sequence increments and the chained root, rules PAIR-5 and ROOT-4
+
An operation cannot be replayed onto another object
Namespace, genesis outpoint, and predecessor outpoint are all inside the commitment preimages
+
A deployment cannot be quietly substituted
The binding pins network, INIT txid, and specification digest; a different INIT is a different protocol, rules ID-1 and ID-2
+
No administrator can rewrite state
No rule grants any coordinator, host, indexer, wallet, or issuer that authority
+
Two implementations cannot silently differ
Stable reason codes and three roots, invariant 12
+
A prepared exit exists before the object moves
REFUND is pre-signed by both sides and matures on a relative lock, rule OP-6
+
+
+
+
+
+
+
2. Risks that pairing adds
+
+ This is the section most protocol documentation leaves out. Requiring two signatures
+ solves one problem by creating another: now two things can go wrong instead of one.
+
+
+
+
+
SIDE A can stop everything
+
+ A participant who stops cooperating stops every cooperative operation. There is no
+ majority, no tie-break, and no arbitration. The only path that does not need live
+ cooperation is a REFUND that was already signed by both sides before it was needed.
+
+
+
+
SIDE B can stop everything too
+
+ The situation is exactly symmetric, which is the design working as intended. Neither
+ side has a lever the other lacks. Symmetric deadlock is the cost of symmetric
+ control.
+
+
+
+
+
Key loss is doubled
+
+ Two keys means two chances to lose one. A 2-of-2 has no redundancy: losing either key
+ has the same effect as losing both. If a prepared REFUND exists and its relative lock
+ has matured, the funds can still be recovered. If it does not, they cannot.
+
+
+
The recovery clock keeps restarting
+
+ The REFUND relative delay of 52,560 blocks is measured from the confirmation height of
+ the exact carrier being spent. Every confirmed MARK or ROTATE creates a new carrier and
+ restarts the delay. An active pair that adds chapters regularly is continually pushing
+ their own recovery path further into the future. A pair that intends to rely on recovery
+ should understand that using the object delays it.
+
+
+
A recovery path must be prepared before, not after
+
+ A REFUND spends a specific carrier and needs both signatures. Once cooperation has broken
+ down, it is too late to obtain the second signature. The rule is therefore operational
+ rather than protocol-level, and it is absolute: complete, export, checksum, and
+ restore-test the recovery transaction for a carrier before broadcasting the parent that
+ creates it.
+
+
+
An invalid spend is fatal, not merely rejected
+
+ If a confirmed transaction consumes an active carrier but does not match an operation
+ template, the object terminates as EXITED_NONCANONICAL. It does not fail
+ harmlessly. A wallet bug, an accidental coin selection that grabs a 20,000-satoshi
+ carrier as ordinary change, or a malformed marker all end the object permanently. Rule
+ ST-2 is not a punishment, it is an acknowledgement that the output is gone.
+
+
+
+ Protect carriers from ordinary coin selection.
+
+ A carrier is a normal-looking 20,000-satoshi P2WSH output. Wallet software that does
+ not know about Tandem will happily treat it as spendable value. Any product that holds
+ keys near a Tandem object must mark carrier outpoints as protected, and should behave
+ conservatively around outputs it cannot classify.
+
+
+
+
+
+
3. Threat notes
+
+
Blind signing defeats the whole design
+
+ Every protection on this page assumes both participants know what they signed. A signing
+ flow that shows an opaque hash converts a 2-of-2 into a single point of failure operated
+ by whoever composed the transaction. Reconstruct and display inputs, outputs, amounts,
+ scripts, fees, sequences, locktime, key order, and commitments before asking for a
+ signature, and do it for the recovery transaction too.
+
+
+
A single parser is a trusted third party
+
+ Bitcoin consensus does not enforce Tandem rules, so the answer to what state an object
+ is in comes from a parser. A user reading one indexer's answer is trusting that
+ indexer. The mitigation is the two-pipeline agreement described on
+ the verifier page, and it only helps if products actually
+ fail closed on disagreement rather than falling back to one pipeline.
+
+
+
Content is a separate trust domain
+
+ A commitment authenticates exact bytes. It says nothing about whether those bytes are
+ available, whether they are safe to render, or whether the person who committed them was
+ honest about what they are. Manifests arrive from whoever put a commitment on chain,
+ which is to say from anyone. Enforce your own size and type limits, verify bytes against
+ content_sha256 before display, and never execute retrieved content.
+
+
+
Everything is public and permanent
+
+ Bitcoin events are observable by anyone, forever. A Tandem object publishes that two
+ specific keys are jointly controlling something, at specific times, with a specific
+ cadence of activity. Even with encrypted content, the existence, timing, and pattern of a
+ relationship is on a public ledger. Plan for correlation before committing anything
+ personal, legal, locational, or relational.
+
+
+
Coercion and disputes are outside the protocol
+
+ Tandem enforces that two keys signed. It cannot tell whether a person signed freely, and
+ it defines no ownership, no identity, and no dispute resolution. A signature obtained
+ under pressure is indistinguishable from one given willingly.
+
+
+
+
+
4. Limitations
+
+ Stated as facts, not as future work.
+
+
+
Mainnet is not active. The protocol is finalized; activation remains closed until independent verification, recovery, security, signer, and operational outcomes are complete and an explicit decision is recorded. No repository, website, or funded address constitutes activation.
+
Exactly two participants. Not three, not a threshold, not a quorum. The carrier is a 2-of-2 and the payload has room for exactly one pair.
+
No partial or conditional operations. There is no escrow, no timelock other than the fixed refund delay, and no conditional transfer.
+
No transfer of an object. ROTATE replaces keys with the current pair's authorization; it is not a unilateral sale or handover, and there is no transfer primitive.
+
No marketplace. The Bitcoin Universe capability snapshot lists 38 protocols, 29 with marketplace entries. Tandem has no entry in that snapshot, so no Universe product implements a buy, sell, list, or transfer path for it.
+
Fixed constants. Carrier value 20,000 satoshis, refund delay 52,560 blocks, founding window 4,320 blocks, INIT lead 1,008 blocks. None are configurable; changing one is a different protocol.
+
The protocol is immutable. There is no version negotiation and no extension mechanism. Unknown formats, opcodes, kinds, reasons, flags, reserved values, and oversized payloads are invalid, permanently.
+
The specification prose contains one known error. See erratum 1. It cannot be corrected in place without changing the digest and therefore the protocol identity.
+
No conformance authority exists. No certification, no test service, no registry.
+
The published corpus is partial. It contains no raw transactions, no signatures, no block data, and no reorganization scenarios.
+
Neither implementation is a released product. Both public implementations describe themselves as source with unit coverage, not deployed infrastructure, and both disclaim live chain, database, and signing evidence.
+
Continuous two-pipeline agreement has not been published. The agreement design is real and runnable against the fixed corpus. Sustained agreement over a live chain is recorded as not yet performed.
+
Content availability is not provided. Tandem stores no files and guarantees no URI keeps resolving.
+
No recovery from key loss beyond a prepared REFUND. There is no social recovery, no backup signer, and no administrator path.
+
+
+
+
+
5. What Tandem is not
+
+
+
IT IS
+
+
A deterministic Bitcoin metaprotocol
+
One jointly controlled object with a continuous history
+
An exact set of transaction templates
+
A recovery path prepared in advance
+
Verifiable independently, in another language
+
+
+
+
IT IS NOT
+
+
A token, an NFT, or an issuance scheme
+
A marketplace, an investment, or a yield product
+
A governance, ranking, or reputation system
+
An identity proof or a legal ownership record
+
A guarantee against key loss, coercion, or misuse
+
A storage layer for files
+
A platform with an administrator key
+
+
+
+
+ Nothing in Tandem defines supply, price, rarity, royalties, or transferability. If a
+ product presents a Tandem object as any of those, the presentation is the product's
+ claim and not the protocol's.
+
+
+
+
+
6. Reporting a problem
+
+ Treat fund-loss risk, signing confusion, unsafe coin selection, incorrect recovery,
+ divergent interpretation between implementations, and exposed credentials as sensitive.
+ Report them privately through
+ GitHub private vulnerability reporting
+ rather than in a public issue, and begin the title with Security report: so
+ maintainers can route it. See SECURITY.md for the full
+ policy.
+
+
+ A specification ambiguity that two reasonable implementations could resolve differently
+ is a security issue for a deterministic protocol, not a documentation nit. Please report
+ it as one.
+
+
+
+
+
7. Changelog
+
+ This records changes to the public artifacts and to this documentation. The protocol
+ bytes themselves are frozen: any change to tandem.md would
+ change its digest and therefore create a different protocol, so there will never be a
+ version 2 of it under this identity.
+
+
+
+
+
Public artifact and documentation history
+
Date
Change
+
+
+
2026-09-01
+
+ Added this protocol dossier: a normative specification restated as numbered
+ rules, a worked-example guide, an in-browser pair check tool, indexer semantics,
+ a JSON Schema reference, a test-vector reference, an independent-verifier page,
+ conformance material with an ordered implementation checklist, and this page.
+ Documented erratum 1, the event-leaf preimage length. Enabled the published site.
+ No protocol artifact was modified.
+
+
+
+
2026-09-01
+
Added a repository documentation manifest describing this repository to the Bitcoin Universe documentation portal.
+
+
+
2026-08-31
+
Public verification workflow restricted so that pull requests from forks do not run on private capacity.
+
+
+
2026-08-31
+
Public verification scheduling updated to use capacity-routed runners with overflow.
+
+
+
2026-08-31
+
Workspace text policy applied to public prose.
+
+
+
2026-08-31
+
Validated release promoted to the public branch, publishing the finalized specification, the release record, the three schemas, and the golden vector corpus.
+
+
+
+
+
+
Frozen artifact digests
+
+ These have not changed since the release promotion and are pinned in three independent
+ places: this repository's verifier, the vector manifest, and the independent Rust
+ verifier's input lock.
+
+ Run node scripts/verify-public.mjs from the repository root to check all of
+ them, along with the specification byte contract and the manifest-to-fixture binding.
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/guide.html b/docs/guide.html
new file mode 100644
index 0000000..aeca698
--- /dev/null
+++ b/docs/guide.html
@@ -0,0 +1,623 @@
+
+
+
+
+
+
+
+
+
+
+
+ Tandem guide and worked examples
+
+
+
+
+ Skip to the guide
+
+
+
+
+ Tandem
+
+
+ Golden corpus
+
+
+
+
Guide
+
Worked examples, using the published bytes.
+
+ Every number on this page is taken from
+ vectors/generated/golden.json in this
+ repository. Nothing here is invented for illustration. If you follow along you will get the
+ same values, and the pair check tool will confirm them in
+ your browser.
+
+
Protocol finalized. Mainnet is not active.
+
+
+
+
+
+
+
+
Example 1
+
Fix a binding and derive its identity
+
+ Nothing in Tandem means anything until you choose a network, an INIT transaction, and a
+ specification digest. The golden corpus fixes one regtest binding, so we will use it.
+
+ The specification digest is not a magic constant. It is the SHA256 of
+ tandem.md read as raw bytes, with no whitespace or
+ line-ending normalization. Confirm it before you trust anything downstream:
+
+ Now derive the namespace commitment by rule ID-5. The INIT txid must be converted from
+ display order to wire order first, which reverses its 32 bytes exactly once. This
+ particular txid is 32 copies of 0x10, so its reversal happens to be
+ identical, which is convenient for a first example and dangerous to generalize from.
+
+ That value appears in the corpus as identity.namespace, and it is also the
+ 32 bytes carried in the namespace field of every non-INIT marker in the corpus. Those
+ two facts agreeing is the first real consistency check available to you.
+
+
+
+ The object key follows from the namespace and the CREATE transaction, by rule ID-6. The
+ corpus fixes a CREATE txid of 32 copies of 0x20:
+
+ The human-readable form of the same object is
+ tandem:regtest:1010…1010:2020…2020:1, but never use it as a
+ key. Ordering, equality, database indexes, and both Merkle trees all use the 32 binary
+ bytes.
+
+
+
+
+
Example 2
+
Build a carrier from two halves
+
+ This is the moment where two independent people become one object. Take two compressed
+ public keys, sort them, and the address is fully determined. There is nothing to
+ negotiate and nothing to configure.
+
Higher by the same comparison, so it takes the key1 slot.
+
+
+
+
+ Sorting is not a convention you may vary. Compare the two 33-byte strings as unsigned
+ bytes, left to right. Here the first bytes tie at 0x02, and the second
+ bytes decide: 0x79 is below 0xc6. If you swap the two keys
+ the resulting witness script is different, the P2WSH address is different, and the
+ protocol treats the transaction as bound to the wrong carrier. The failure is
+ BAD_KEY_ORDER_OR_BINDING, code 0x0015.
+
+ Both values appear verbatim in the corpus under carrier. Note the hash
+ here is a plain SHA256, not a double SHA256 and not a HASH160. That is the native
+ P2WSH rule, and getting it wrong produces a valid-looking address that no Tandem
+ operation will ever match.
+
+
+
+ Try it with your own keys.
+
+ The pair check tool does exactly this
+ derivation in your browser, shows you which side each key landed on, and names the
+ rule behind every check. It never transmits what you paste.
+
+
+
+
+
+
Example 3
+
Decode a marker byte by byte
+
+ Here is a real MARK marker script from the corpus, taken apart completely. Every byte
+ has a job and there are no spare ones.
+
The first three bytes are script framing, not payload:
+
+
+
+
Script framing
+
Bytes
Meaning
+
+
6a
OP_RETURN
+
4c
OP_PUSHDATA1
+
4e
Push length 78, the exact MARK payload size
+
+
+
+
+
+ OP_PUSHDATA1 is required here rather than a direct push, because a direct
+ data push only reaches 0x4b, which is 75. A 78-byte payload cannot use one,
+ so OP_PUSHDATA1 is the minimal encoding and using anything larger would
+ fail rule REC-6 with BAD_MARKER_ENCODING_OR_LENGTH. CREATE and ROTATE, at
+ 40 and 44 bytes, are small enough for a direct push and must use one.
+
+
+
+
+
The 78 payload bytes, decoded in full
+
Range
Bytes
Field
Value
+
+
[0,4)
544e444d
magic
TNDM
+
[4,5)
01
marker format
1, the only defined format
+
[5,6)
03
network
regtest
+
[6,7)
02
opcode
MARK
+
[7,8)
01
state_vout
1, the carrier output index
+
[8,40)
38025b6f…c608d221
namespace
matches the derived namespace from example 1
+
[40,44)
01000000
state_seq
1, little-endian
+
[44,45)
00
kind
note
+
[45,46)
00
flags
must be zero
+
[46,78)
de2e2206…c732b84a
chapter commitment
nonzero, as required
+
+
+
+
+
+ A sequence of 1 tells you this MARK is the first operation after the CREATE that
+ produced sequence 0. It is not a claim the marker gets to make on its own: the parser
+ must find an active carrier at sequence 0 and confirm that this transaction consumes it.
+ If no such predecessor exists, the failure is PREDECESSOR_NOT_ACTIVE, code
+ 0x001a, and the sequence check at 0x001b is never reached.
+
+
+
The other four markers, for comparison
+
+
+
Every marker in the published corpus, with its distinguishing fields
reason 0 (mutual completion), three zero reserved bytes, close commitment
+
+
+
+
+
+ CLOSE is the only operation whose state_vout is 0xff rather
+ than 0x01. That single byte is how the marker declares there is no
+ successor carrier. The INIT payload confirms the founding window arithmetic from rule
+ OP-1: 1108 plus 4,320 is 5,428, and since the INIT lead is 1,008 blocks, this INIT must
+ have confirmed at height 100 or lower.
+
+
+
+
+
Example 4
+
Split a fee between two sides
+
+ Tandem does not let the two participants agree on who pays. The split is arithmetic, so
+ an implementation can check it without knowing anything about the relationship.
+
+
+
+ The corpus records a CREATE fee of 3,030 satoshis. Applying the CREATE equations from
+ rule PAIR-3.3:
+
+
+
+ Even fee, CREATE
+
F = 3030
+D0 = 10000 + ceil(3030 / 2) = 10000 + 1515 = 11515 // side A debit
+D1 = 10000 + floor(3030 / 2) = 10000 + 1515 = 11515 // side B debit
+
+D0 + D1 - 20000 = 23030 - 20000 = 3030 = F // closes exactly
+
+
+
+ An even fee divides cleanly and both sides pay the same. An odd fee is where the
+ tie-break matters. Take a fee of 3,031 on the same CREATE:
+
+
+
+ Odd fee, CREATE
+
F = 3031
+D0 = 10000 + ceil(3031 / 2) = 10000 + 1516 = 11516 // side A pays the extra satoshi
+D1 = 10000 + floor(3031 / 2) = 10000 + 1515 = 11515
+
+D0 + D1 - 20000 = 23031 - 20000 = 3031 = F
+
+
+
+ Side A always absorbs the odd satoshi, on both CREATE and ROTATE. A wallet that gives
+ it to side B instead produces a transaction that fails with
+ BAD_FEE_SPLIT_OR_CHANGE, code 0x0019, even though the total
+ fee is correct and both parties consented. The rule exists so that two implementations
+ never round differently and then disagree about validity.
+
+
+
+ Note the ordering of the fee checks. Positivity is 0x0018 and the split is
+ 0x0019, so a transaction that both fails to pay a positive fee and
+ misallocates it reports the positivity failure. This matters when you write tests: the
+ reason code you assert must be the lowest failing one, not merely a true one.
+
+
+
+
+
Fees recorded by the published corpus for each valid operation
+
Operation
Fee, satoshis
Who pays
+
+
INIT
1,000
The single funding input
+
CREATE
3,030
Both sides, split by the equations above
+
MARK
1,000
One sponsor, who must be side A or side B
+
ROTATE
1,000
Both successor sides
+
CLOSE
1,000
Taken from the 20,000-satoshi carrier before the equal payout
+
REFUND
340
Taken from the carrier the same way
+
+
+
+
+
+ MARK is the one operation where a single side can carry the cost. The fee sponsor is
+ one extra P2WPKH input, and its key must equal current key0 or current key1. It cannot
+ be an unrelated third party. Both signatures on the carrier are still required, so
+ paying the fee alone grants no unilateral power.
+
+
+
+
+
Example 5
+
Follow an object from first to last
+
+ The corpus fixes one object snapshot with real values. Here is what each field means as
+ the object moves.
+
+
+
+
+
+ Sequence numbers in this figure are the ones carried by the corpus markers: MARK at 1,
+ ROTATE at 2, CLOSE at 3. The heights are the ones the corpus records for the INIT
+ opening and for the example block.
+
+
+
+
Reading a snapshot leaf
+
+ The corpus publishes an object snapshot for the object we derived in example 1. Its
+ fields decode as follows.
+
+
+
+
+
Object-state snapshot from the corpus, decoded
+
Field
Value
What it tells you
+
+
object_key
5e355bac…8659cb52
The key derived in example 1
+
founding
1
Created inside the 4,320-block founding window
+
status
0
ACTIVE
+
create_height
1108
Exactly H_open, the first permitted height
+
state_seq
1
One operation has happened since CREATE
+
current_outpoint
3030…3030, vout 1
The live carrier, so the object is spendable
+
key0, key1
The pair from example 2
Both keys are still required
+
terminal_txid
zero32
Absent, consistent with an active object
+
chapter_count
1
Exactly one valid MARK so far
+
+
+
+
+
+ create_height being exactly 1108 is worth noticing. That is
+ H_open itself, and rule OP-2 uses h >= H_open, so this is
+ the earliest height at which any object could exist under this binding. One block
+ earlier the same transaction would be invalid with BAD_HEIGHT_OR_PHASE.
+
+
+
+ The active and terminal fields are mutually exclusive by construction. An active object
+ has a current outpoint and a zero terminal txid. A terminal object has
+ zero36 as its current outpoint and a real terminal txid. There is no state
+ where both are populated, and no state where neither is.
+
+
+
+
+
Example 6
+
Classify a failure the way the protocol does
+
+ Getting the verdict right is only half of conformance. You must also select the same
+ reason code, and the selection is ordered.
+
+
+
+ Suppose a transaction spends an active carrier, carries a single MARK marker, and has
+ three separate problems: its locktime is 7 instead of 0, its sponsor input reveals a key
+ belonging to neither participant, and its chapter commitment is all zeroes.
+
+
+
+
Dispatch runs first. One marker remains and one carrier is spent, so we take the single-marker branch rather than any of the multiple-marker or markerless paths.
+
Marker structure passes. The push is minimal, the payload is exactly 78 bytes, format is 0x01, network matches, opcode is defined, and the namespace matches.
+
Group 0x0010 is the first failing group: locktime is not 0. That is BAD_TX_VERSION_OR_LOCKTIME.
+
+
+
+ The answer is 0x0010. Not 0x0015 for the sponsor key, and not
+ 0x001d for the zero commitment, even though both are genuinely wrong. An
+ implementation that reports either of those is nonconforming, because rule INV-3
+ requires the lowest failing code.
+
+
+
+ The consequences do not stop at the reason code. Because a carrier was consumed, the
+ event is class 2, its type is EXITED_NONCANONICAL rather than the attempted
+ MARK, and the object terminates. A reader who only checks that the transaction was
+ rejected would miss that an object died.
+
+
+
+ Rejection is not the same as no effect.
+
+ An invalid transaction with no carrier spend leaves state untouched. An invalid
+ transaction that consumes a carrier destroys the object it consumed. The distinction
+ is rule ST-2, and it exists because Bitcoin has already spent the output regardless of
+ what Tandem thinks of the spend.
+
+
+
+
Precedence beats numeric order
+
+ One case inverts what you might expect. A transaction with two markers and two carriers
+ gets MULTIPLE_MARKERS (0x0001), while a transaction with one
+ marker and two carriers gets MULTIPLE_CARRIERS (0x0020). The
+ numeric order does not decide this, dispatch step order does, and rule INV-2 says so
+ explicitly. In the two-carrier cases both objects terminate and each gets its own
+ terminal event, ordered by binary object key with consecutive sub_index
+ values starting at zero.
+
+
+
+
+
Example 7
+
Commit to content without putting it on chain
+
+ A MARK commits 32 bytes. Those bytes are not the content, and they are not a hash of
+ the content either. They are a hash of a manifest, bound to the exact object and
+ position in its history.
+
+
+
+ Expected chapter commitment
+
SHA256(
+ "TANDEM/CHAPTER\0" || // 15 bytes
+ namespace_commitment ||
+ genesis_outpoint36 || // the object's CREATE outpoint
+ predecessor_outpoint36 || // the carrier this MARK consumes
+ state_seq_u32le ||
+ kind_u8 ||
+ manifest_sha256
+)
+
+
+
+ Because the object, the exact predecessor, the sequence, and the kind are all inside the
+ preimage, the same manifest committed at a different point in the same object's history
+ produces a different commitment. A commitment cannot be lifted from one object and
+ replayed into another.
+
+
+
+ CLOSE has the parallel construction with the TANDEM/CLOSE\0 domain, the
+ close reason in place of the kind, and one extra allowance: a CLOSE commitment may be
+ all zeroes, which means no close manifest. A MARK commitment may not, which is rule
+ BAD_COMMITMENT, code 0x001d.
+
+
+
+ Availability is never validity.
+
+ A MARK is protocol-valid when its on-chain commitment is nonzero, even if no manifest
+ is available anywhere or a supplied manifest does not match. Manifest matching is a
+ presentation result. It never changes an on-chain event's validity or the object's
+ state. Treat a missing or mismatched manifest as a display problem, not a consensus
+ one, and never let it change what you report about the chain.
+
+
+
+
+ The manifest itself is ordinary JSON, constrained by
+ the published schemas. Normalize it with RFC 8785 JCS
+ before hashing so that two implementations producing semantically identical manifests
+ produce identical manifest_sha256 values.
+
One jointly controlled object with a continuous history.
What makes it distinct
Shared control is structural. Cooperation lives in the Bitcoin output, not an account permission.
-
The history follows exact rules. Independent implementations can derive the same canonical state.
+
The history follows exact rules. Independent implementations can derive the same state.
Recovery is prepared before progress. Each state-changing parent needs a completed recovery path first.
No administrator can rewrite it. No platform key can replace participants or edit object state.
@@ -201,7 +202,7 @@
Signing
Verification
-
State-changing construction and broadcast should fail closed whenever independent indexers disagree at the same canonical height.
+
State-changing construction and broadcast should fail closed whenever independent indexers disagree at the same block height.
Content
@@ -256,7 +257,7 @@
Independent agreement
Content manifests
- Canonicalize chapter and close manifests using RFC 8785 JCS before hashing. Retrieve
+ Normalize chapter and close manifests with RFC 8785 JCS before hashing. Retrieve
only the payload identified by content_sha256, verify its bytes before
display, and treat URI availability as separate from on-chain validity.
@@ -271,15 +272,33 @@
Exact artifacts, stable paths, independent inspection.
line endings before hashing.
+ The protocol dossier restates the specification as numbered
+ rules, works through examples using the published vectors, documents indexer
+ semantics and the independent verifier, and provides an in-browser
+ pair check tool.
+
divergent interpretation, exposed credentials, and private conduct concerns as
sensitive. Never place them in a public issue.
- Open a private report
+ Open a private report
Begin the title with Security report: or Conduct report: so
maintainers can route it correctly. Include only the minimum safe reproduction or
@@ -374,7 +393,7 @@
Make the public experience clearer and safer.
Keep claims accurate about Bitcoin, custody, recovery, privacy, availability, and network status.
Never submit secrets, private transaction data, internal plans, operational procedures, or sensitive implementation detail.
-
Do not edit canonical bytes, schemas, release metadata, or golden vectors as part of a product copy change.
+
Do not edit protocol bytes, schemas, release metadata, or golden vectors as part of a product copy change.
Run node scripts/verify-public.mjs and review visual changes across keyboard, motion, zoom, narrow, and wide conditions.
- Explore the canonical actions. This demonstration explains the state model and
+ Explore the defined actions. This demonstration explains the state model and
never creates, signs, or broadcasts a transaction.
@@ -230,20 +231,20 @@
It is not
The public contract is exact and inspectable.
- Read the canonical protocol bytes, validate chapter and close manifests, and test
+ Read the authoritative protocol bytes, validate chapter and close manifests, and test
against the published golden vectors. Independent implementations can conform to
the same public contract without sharing a codebase.
Some objects belong to one person. This one begins
One Bitcoin-native object, controlled together.
Documentation
+ Protocol dossierSecurityContributeRepository boundary
diff --git a/llms.txt b/llms.txt
new file mode 100644
index 0000000..1e73123
--- /dev/null
+++ b/llms.txt
@@ -0,0 +1,59 @@
+# Tandem
+
+> Tandem is an original Bitcoin Universe protocol. It defines one Bitcoin-native object controlled by a matched pair of keys, where every state change requires both halves of the pair. The pairing requirement is enforced by an exact 2-of-2 native P2WSH carrier output and by a deterministic parser with an exhaustive stable reason registry.
+
+Status: the protocol is finalized and its bytes are frozen. Mainnet is NOT active. No repository, website, or funded address constitutes activation. Do not construct or broadcast a Tandem transaction with real funds.
+
+Scope note: Tandem has no entry in the Bitcoin Universe capability snapshot, which records 38 protocols and 29 marketplace integrations. No Universe product implements a buy, sell, list, or transfer path for a Tandem object. Tandem defines shared control and a history, not a tradable asset.
+
+This site publishes the authoritative protocol bytes, three JSON Schemas, a golden vector corpus, and a documentation dossier. The bytes at /tandem.md are the source of truth; every other page is navigation and explanation.
+
+## Authoritative artifacts
+
+- [Normative specification](https://bitcoinuniverseio.github.io/tandem/tandem.md): the complete protocol. SHA256 caa77ce0122c0b833fc5f099191b54280b0481be325bdc98f2b48b0b905b923f, 47343 bytes, UTF-8, LF endings, no BOM, exactly one final LF. Hash it as raw bytes with no normalization.
+- [Specification release record](https://bitcoinuniverseio.github.io/tandem/release/spec.json): digest, byte count, line count, encoding, and finalization status.
+- [Public contract index](https://bitcoinuniverseio.github.io/tandem/protocol/README.md): the stable paths of every published artifact.
+
+## Protocol dossier
+
+- [Dossier overview](https://bitcoinuniverseio.github.io/tandem/docs/protocol.html): plain-language introduction, the five places a pair must match, and the object model.
+- [Normative specification as numbered rules](https://bitcoinuniverseio.github.io/tandem/docs/specification.html): identity and binding, record format, pairing rules, operation validity, state transitions, invalidity and rejection precedence, authoritative roots, and one documented erratum.
+- [Guide and worked examples](https://bitcoinuniverseio.github.io/tandem/docs/guide.html): derive a binding, build a carrier from two keys, decode a marker byte by byte, split a fee, follow an object, classify a failure, commit to content.
+- [Pair check tool](https://bitcoinuniverseio.github.io/tandem/docs/pair-check.html): an in-browser verifier that takes two candidate records or two candidate keys and reports which numbered rule and stable reason code each check maps to. Client-side only.
+- [Indexer semantics](https://bitcoinuniverseio.github.io/tandem/docs/indexing.html): block scope, confirmation provenance, block connection, reorganization, why the mempool never reaches protocol state, and two-pipeline agreement.
+- [JSON Schema reference](https://bitcoinuniverseio.github.io/tandem/docs/schemas.html): all three published schemas explained field by field.
+- [Test vectors](https://bitcoinuniverseio.github.io/tandem/docs/vectors.html): the golden corpus, all 31 validation cases, and every root with the formula that reproduces it.
+- [Independent verifier](https://bitcoinuniverseio.github.io/tandem/docs/verifier.html): the second implementation in Rust, what it proves, and what it does not.
+- [Conformance and implementation checklist](https://bitcoinuniverseio.github.io/tandem/docs/conformance.html): the conformance claim, available evidence, an ordered build checklist, and common divergence causes.
+- [Security, limitations, and changelog](https://bitcoinuniverseio.github.io/tandem/docs/considerations.html): protected properties, the risks pairing adds, honest limitations, and the change record.
+
+## Machine-readable contracts
+
+- [Chapter manifest schema](https://bitcoinuniverseio.github.io/tandem/schemas/chapter.schema.json): JSON Schema Draft 2020-12, urn:tandem:schema:chapter.
+- [Close manifest schema](https://bitcoinuniverseio.github.io/tandem/schemas/close.schema.json): JSON Schema Draft 2020-12, urn:tandem:schema:close.
+- [Agreement envelope schema](https://bitcoinuniverseio.github.io/tandem/schemas/agreement-envelope.schema.json): JSON Schema Draft 2020-12, urn:tandem:schema:agreement-envelope.
+- [Golden fixture corpus](https://bitcoinuniverseio.github.io/tandem/vectors/generated/golden.json): markers, reason registry, 31 validation cases, event and object leaves with full preimages, Merkle levels, and roots.
+- [Golden vector manifest](https://bitcoinuniverseio.github.io/tandem/vectors/generated/manifest.json): binds the specification digest, the fixture digest, and the vector root.
+- [CREATE marker example](https://bitcoinuniverseio.github.io/tandem/vectors/create-marker.example.json): a minimal standalone marker input.
+
+## Key protocol facts
+
+- Operations: INIT (opcode 0x00), CREATE (0x01), MARK (0x02), ROTATE (0x03), CLOSE (0x04), plus a markerless REFUND recovery path.
+- Object statuses: ACTIVE, CLOSED, REFUNDED, EXITED_NONCANONICAL. A confirmed carrier spend that does not validate as an operation terminates the object.
+- Fixed constants: carrier value 20,000 satoshis, refund delay 52,560 blocks, founding window 4,320 blocks, INIT lead 1,008 blocks, wire magic TNDM, marker format 0x01.
+- The carrier is an exact 71-byte 2-of-2 witness script wrapped in native P2WSH, with keys sorted so key0 is below key1 by unsigned bytewise order.
+- The reason registry has 26 permanent entries, from VALID (0x0000) to UNMARKED_CARRIER_SPEND (0x0030). An implementation must return the lowest failing code, not merely a true one.
+- Every required prevout must have confirmed in a strictly earlier block. Same-block chaining fails with UNCONFIRMED_OR_SAME_BLOCK_PREVOUT.
+- Mempool observations never change protocol state, counters, or roots.
+- Known erratum: tandem.md section 13.2 states the event leaf preimage is 374 bytes with a 16-byte domain tag. The published vectors and the independent Rust verifier both use 371 bytes with a 13-byte domain tag. The 358-byte field block stated in the same sentence is correct. Follow the byte layout and the vectors.
+
+## Related repositories
+
+- [index-tandem](https://github.com/bitcoinuniverseio/index-tandem): TypeScript indexer, pipeline A, with a published OpenAPI contract. Describes itself as an implementation scaffold, not deployed infrastructure.
+- [tandem-verifier-rs](https://github.com/bitcoinuniverseio/tandem-verifier-rs): Rust independent verifier, pipeline B. Shares only frozen artifacts pinned by digest and does not import pipeline A.
+
+## Optional
+
+- [Product documentation](https://bitcoinuniverseio.github.io/tandem/docs/): the non-technical explanation of the product, its safety boundary, and its readiness path.
+- [Repository boundary](https://bitcoinuniverseio.github.io/tandem/REPOSITORY_BOUNDARY.md): what belongs in this public repository and what stays private.
+- [Security policy](https://bitcoinuniverseio.github.io/tandem/SECURITY.md): report suspected vulnerabilities privately, never in a public issue.
diff --git a/robots.txt b/robots.txt
new file mode 100644
index 0000000..2158bab
--- /dev/null
+++ b/robots.txt
@@ -0,0 +1,8 @@
+# Tandem public product and protocol site.
+# Everything published here is intended to be public and is safe to index.
+
+User-agent: *
+Allow: /
+
+Sitemap: https://bitcoinuniverseio.github.io/tandem/sitemap.xml
+Sitemap: https://bitcoinuniverseio.github.io/tandem/sitemap-index.xml
diff --git a/scripts/serve.mjs b/scripts/serve.mjs
index eb43750..0396e86 100644
--- a/scripts/serve.mjs
+++ b/scripts/serve.mjs
@@ -29,7 +29,7 @@ const server = createServer(async (request, response) => {
return;
}
- response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'");
+ response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'");
response.setHeader("Cross-Origin-Opener-Policy", "same-origin");
response.setHeader("Referrer-Policy", "no-referrer");
response.setHeader("X-Content-Type-Options", "nosniff");
diff --git a/scripts/verify-public.mjs b/scripts/verify-public.mjs
index 0b53aa8..7b13445 100644
--- a/scripts/verify-public.mjs
+++ b/scripts/verify-public.mjs
@@ -19,24 +19,38 @@ const approvedPaths = new Set([
".github/PULL_REQUEST_TEMPLATE.md",
".github/workflows/verify-public.yml",
".gitignore",
+ "assets/dossier.css",
+ "assets/pair-check.js",
"assets/site.css",
"assets/site.js",
"CODE_OF_CONDUCT.md",
"CONTRIBUTING.md",
"docs.manifest.json",
+ "docs/conformance.html",
+ "docs/considerations.html",
"docs/faq.md",
"docs/getting-started.md",
"docs/how-it-works.md",
+ "docs/guide.html",
"docs/index.html",
+ "docs/indexing.html",
"docs/integrations.md",
+ "docs/pair-check.html",
+ "docs/protocol.html",
"docs/roadmap.md",
"docs/safety.md",
+ "docs/schemas.html",
+ "docs/specification.html",
"docs/use-cases.md",
+ "docs/vectors.html",
+ "docs/verifier.html",
"docs/what-is-tandem.md",
"index.html",
"LICENSE",
+ "llms.txt",
"protocol/README.md",
"README.md",
+ "robots.txt",
"release/spec.json",
"REPOSITORY_BOUNDARY.md",
"schemas/agreement-envelope.schema.json",
@@ -45,6 +59,8 @@ const approvedPaths = new Set([
"scripts/serve.mjs",
"scripts/verify-public.mjs",
"SECURITY.md",
+ "sitemap-index.xml",
+ "sitemap.xml",
"tandem.md",
"vectors/create-marker.example.json",
"vectors/generated/golden.json",
@@ -63,7 +79,7 @@ const approvedArtifacts = new Map([
["vectors/generated/manifest.json", { bytes: 369, sha256: "d443d9b6e178b95b707620593e471b2146c2747be0f7789dc06f54ce133c33ac" }],
]);
-const textExtensions = new Set([".css", ".html", ".js", ".json", ".md", ".mjs", ".txt", ".yaml", ".yml"]);
+const textExtensions = new Set([".css", ".html", ".js", ".json", ".md", ".mjs", ".txt", ".xml", ".yaml", ".yml"]);
const forbiddenTopLevel = new Set([
"backend",
"deployments",
@@ -328,6 +344,106 @@ async function verifyWorkflow() {
if (/\b(?:deploy|publish|release)\b/i.test(workflow)) fail("public workflow must not deploy, publish, or release");
}
+const dossierPages = [
+ "docs/protocol.html",
+ "docs/specification.html",
+ "docs/guide.html",
+ "docs/pair-check.html",
+ "docs/indexing.html",
+ "docs/schemas.html",
+ "docs/vectors.html",
+ "docs/verifier.html",
+ "docs/conformance.html",
+ "docs/considerations.html",
+];
+
+async function verifyDossier() {
+ for (const path of dossierPages) {
+ const html = await readFile(resolve(root, path), "utf8");
+ const essentials = [
+ [//i, "language"],
+ [/name="viewport"/i, "responsive viewport"],
+ [/name="description"\s*\n?\s*content="[^"]{80,}"/i, "a description of at least 80 characters"],
+ [/class="skip-link"/i, "skip link"],
+ [/]*id="dossier-main"/i, "main landmark"],
+ [/Content-Security-Policy/i, "content security policy"],
+ [/mainnet is not active/i, "mainnet status"],
+ [/assets\/dossier\.css/i, "dossier stylesheet"],
+ [/[^<]+<\/title>/i, "title"],
+ [/property="og:title"/i, "Open Graph title"],
+ ];
+ for (const [pattern, label] of essentials) if (!pattern.test(html)) fail(`${path} is missing ${label}`);
+
+ const headings = html.match(/
]+(?:src|href)="([^"]+)"/gi)) {
+ if (/^(?:https?:)?\/\//i.test(match[1])) fail(`external runtime dependency found in ${path}: ${match[1]}`);
+ }
+ for (const match of html.matchAll(/]*>/gi)) {
+ if (!/\balt=/i.test(match[0])) fail(`image without alt text in ${path}`);
+ }
+ for (const match of html.matchAll(/