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 + + + + + + + + +
+

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
RequirementRules
Bind to exactly one network, one configured INIT txid, and one specification digest, selected by configuration and never by discoveryID-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 digestsID-5, ID-6
Detect marker candidates by the broad rule, so that malformed markers remain detectable and cannot evade multiple-marker countingREC-1, REC-2
Remove foreign INIT candidates before counting markersREC-3
Enforce the exact script and payload lengths, minimal push encoding, and absence of trailing bytesREC-4, REC-5, REC-6
Enforce every fixed payload field, including reserved bytes, flags, defined kinds, and defined reasonsREC-7, REC-8, REC-9
Enforce the key pair rules: valid points, distinct, sorted, and bound to their required rolesPAIR-1, PAIR-4
Enforce the exact 71-byte witness script and the four-element carrier witness stack in orderPAIR-2, PAIR-3
Enforce sequence continuity, carrier continuity, and equal terminal payoutsPAIR-5, PAIR-6, PAIR-7
Enforce the exact fee arithmetic, including the ceiling and floor split that gives the odd satoshi to side APAIR-3.3
Enforce every operation template exactly, with exact input and output counts, roles, and sequencesOP-1 to OP-6
Require confirmation in a strictly earlier block for every required prevoutOP-6 note
Apply the exact state machine, including terminating an object on any confirmed carrier spend that does not validateST-1, ST-2, ST-3
Maintain the three post-block counters exactlyST-6
Apply the dispatch order, including its precedence over numeric reason orderINV-1, INV-2
Return the lowest failing reason code within a single-marker operationINV-3, INV-4
Use the stable reason registry with its permanent names and numeric valuesINV-5
Compute event leaves, object-state leaves, both Merkle roots with odd-leaf duplication, and the chained root, for every block including empty onesROOT-1 to ROOT-4
Reverse block application exactly on disconnection, before applying any replacement branchROOT-4
Exclude mempool observations from state, counters, and roots entirelyST-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
EvidenceCoversDoes not cover
Specification digest and byte contractThat you are reading the same protocol as everyone elseAnything about your parser
The five published marker encodingsPayload field layout, exact lengths, push minimality boundariesTransaction-level rules, signatures, state
Identity derivationsNamespace and object key preimages, and byte-order handlingEverything downstream of identity
The carrier derivationKey sorting, witness script bytes, P2WSH constructionSignature verification
The 31 validation casesAll 26 reason codes, and the event type and class each producesFee arithmetic against real amounts, real signatures, multi-block behaviour
The published preimages and Merkle levelsLeaf field order, domain tags, odd-leaf duplication, both rootsRoot computation over a real chain
The chained root reconstructionThe chaining formula and counter encodingChaining 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

+
    +
  1. 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.
  2. +
  3. Hash golden.json and compare it against the fixture digest in the vector manifest.
  4. +
  5. Derive the vector root from the fixture digest and confirm it equals the published root. Derive it, do not copy it.
  6. +
  7. Implement the primitive encodings: unsigned little-endian integers, the wire and display hash orders, and the rule that a raw digest is never reversed.
  8. +
+ +

Stage 2: identity

+
    +
  1. Implement the namespace commitment. Confirm it reproduces the published namespace for the published binding.
  2. +
  3. Implement the object key. Confirm it reproduces the published object key.
  4. +
  5. Implement the protocol identifier and object display identifier strings, and use binary keys everywhere internally.
  6. +
+ +

Stage 3: markers

+
    +
  1. Implement candidate detection by the broad rule, including partial payloads and non-minimal pushes.
  2. +
  3. Implement foreign INIT removal, keyed on payload byte 6 and the configured txid.
  4. +
  5. Implement strict parsing: complete prefix, length bounds, declared length equal to present length, no trailing bytes, minimal push, exact per-opcode length.
  6. +
  7. Implement all five payload grammars, with every fixed field, reserved byte, and defined-value set enforced.
  8. +
  9. Confirm all five published marker scripts parse to their published operation, payload size, and script size.
  10. +
+ +

Stage 4: transactions

+
    +
  1. Implement P2WPKH and carrier witness shape checks, including the leading empty element and signature order.
  2. +
  3. Implement key validation: on-curve, distinct, sorted, HASH160-bound to the correct role.
  4. +
  5. Implement signature verification with strict DER, low-S, SIGHASH_ALL, SegWit v0 hashing, and the exact prevout amount.
  6. +
  7. Implement each operation template exactly, with exact counts, roles, and sequences.
  8. +
  9. Implement checked fee arithmetic that cannot wrap, then the exact splits including the ceiling and floor tie-break.
  10. +
  11. Implement confirmation provenance as a strictly-earlier-block test.
  12. +
  13. Implement REFUND recognition, including the exact sequence value and consensus maturity.
  14. +
+ +

Stage 5: classification and state

+
    +
  1. Implement the top-level dispatch in its exact step order.
  2. +
  3. Implement single-marker validation in ascending reason-code order, returning the lowest failing code, and deferring state-dependent checks that cannot be made.
  4. +
  5. Implement the state machine and every state delta, including terminal transitions.
  6. +
  7. Implement the three counters as post-block values.
  8. +
  9. Confirm all 31 published validation cases produce the published reason, class, and event type.
  10. +
+ +

Stage 6: roots

+
    +
  1. Implement the event leaf preimage. Confirm each published preimage hashes to its published leaf, and confirm its length is 371 bytes, not 374.
  2. +
  3. Implement the object-state leaf preimage. Confirm the published preimages and the 204-byte length.
  4. +
  5. Implement both Merkle trees with odd-leaf duplication and the single-leaf shortcut. Confirm every published intermediate level.
  6. +
  7. Implement the empty-set roots for both trees.
  8. +
  9. Implement the chained root, and confirm it for the published block including its counters.
  10. +
  11. Emit a root for every block from the INIT confirmation block onward, including blocks with no events.
  12. +
+ +

Stage 7: chain handling

+
    +
  1. Implement block connection as an atomic unit: events, deltas, both roots, counters, chained root.
  2. +
  3. Implement block disconnection as an exact reversal, completing before any replacement branch is applied.
  4. +
  5. Confirm that a disconnect and reconnect of the same block returns you to an identical chained root.
  6. +
  7. Keep mempool observations in storage with no path into state, counters, or roots.
  8. +
+ +

Stage 8: publishing your view

+
    +
  1. Emit agreement tuples matching the published schema, with counters and heights as decimal strings.
  2. +
  3. Normalize the tuple with RFC 8785 JCS and sign only the tuple.
  4. +
  5. Record your own release provenance per height, and do not rewrite historical provenance on a later release.
  6. +
  7. Compare only the nine semantic fields against another pipeline, never the four release fields.
  8. +
  9. Fail closed on disagreement, on any missing dependency, and on a stale tip.
  10. +
+
+ +
+

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
MistakeSymptom
Reversing a raw digest as though it were a txidNamespace and object keys differ from everyone else's, and nothing else works
Accepting a same-block prevoutOccasional extra valid events that another implementation rejects
Returning a true but not lowest reason codeVerdicts agree, event roots do not
Padding the event preimage to 374 bytesEvery event leaf differs, so every root differs from the first event onward
Skipping blocks with no Tandem eventsChained roots diverge permanently from the first skipped block
Giving the odd fee satoshi to side BValid transactions rejected, or invalid ones accepted, on odd fees only
Ignoring an invalid carrier spend instead of terminating the objectObjects that should be dead stay active, so object-state roots diverge
Emitting one event for a multiple-carrier spendEvent count and ordering differ, so the event root differs
Parsing agreement counters as numbers and re-serializingSignatures stop verifying at large values or on round trips
Letting a mempool sighting satisfy a predecessor requirementConstructed transactions fail confirmation provenance once mined
Sorting object leaves by display identifier rather than binary keyObject-state root differs whenever ordering differs between the two
Treating key_id as authenticatedAn 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 + + + + + + + + +
+

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
PropertyEnforced by
Neither participant can advance the object aloneBitcoin script, the 2-of-2 carrier, rules PAIR-2 and PAIR-3
Neither participant can end the object on better terms than the otherEqual, strictly positive payouts, rule PAIR-7
Neither participant can silently replace the otherROTATE requires the old pair to authorize the new one, rule OP-4
History cannot be reordered or backdatedExact sequence increments and the chained root, rules PAIR-5 and ROOT-4
An operation cannot be replayed onto another objectNamespace, genesis outpoint, and predecessor outpoint are all inside the commitment preimages
A deployment cannot be quietly substitutedThe 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 stateNo rule grants any coordinator, host, indexer, wallet, or issuer that authority
Two implementations cannot silently differStable reason codes and three roots, invariant 12
A prepared exit exists before the object movesREFUND 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
DateChange
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-01Added a repository documentation manifest describing this repository to the Bitcoin Universe documentation portal.
2026-08-31Public verification workflow restricted so that pull requests from forks do not run on private capacity.
2026-08-31Public verification scheduling updated to use capacity-routed runners with overflow.
2026-08-31Workspace text policy applied to public prose.
2026-08-31Validated 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. +

+
+ + + + + + + + + + + + +
ArtifactSHA256
tandem.mdcaa77ce0122c0b833fc5f099191b54280b0481be325bdc98f2b48b0b905b923f
schemas/agreement-envelope.schema.json1d5493758b1cc358b02491b675b9e7cb64c51fe3ce2e3f0cde9669882717faa1
schemas/chapter.schema.json9fa613d576b2aeb95b52140c89180f05f65ecd7f4266797f41a1e685610dfc17
schemas/close.schema.jsone6645b4ec1eeb44996a37847d4318168959a905fb334b48f4f3298cc6340bc59
vectors/generated/golden.jsonfc4bee2c20fe94a66a9849f1dc3d73bc407179474e936de29eddef85dcfb5856
vectors/generated/manifest.jsond443d9b6e178b95b707620593e471b2146c2747be0f7789dc06f54ce133c33ac
release/spec.jsonf07dd12c63995dd4a40e50e6a8a2c7634561184caa9b421e2d4bcdbe5892d332
vectors/create-marker.example.json49ee02bc45526953c8fd2e5e71b35fc73458ce4c33811870119113d194308cc8
+
+

+ 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 + + + + + + + + +
+

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 binding used by the published corpus
InputValue
Networkregtest, code 0x03
Configured INIT txid (display)1010101010101010101010101010101010101010101010101010101010101010
Specification digestcaa77ce0122c0b833fc5f099191b54280b0481be325bdc98f2b48b0b905b923f
+
+ +

+ 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: +

+ +
+ Confirm the specification digest +
$ sha256sum tandem.md
+caa77ce0122c0b833fc5f099191b54280b0481be325bdc98f2b48b0b905b923f  tandem.md
+
+ +

+ 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. +

+ +
+ Namespace commitment +
SHA256(
+  "TANDEM/NAMESPACE\0"                                                 
+  || 03                                                                
+  || 1010101010101010101010101010101010101010101010101010101010101010 
+  || caa77ce0122c0b833fc5f099191b54280b0481be325bdc98f2b48b0b905b923f
+)
+= 38025b6fb909d2f22226ee9ea7e3a0e895c589792a93393c136ad502c608d221
+
+ +

+ 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: +

+ +
+ Object key +
SHA256(
+  "TANDEM/OBJECT\0"                                                    
+  || 38025b6fb909d2f22226ee9ea7e3a0e895c589792a93393c136ad502c608d221 
+  || 2020202020202020202020202020202020202020202020202020202020202020 
+  || 01000000                                                          
+)
+= 5e355bac07710f3bc3b271902b8deda97c52893518bf9bac8a080c318659cb52
+
+ +

+ 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. +

+ +
+
+

SIDE A key0

+
0279be667ef9dcbbac55a0
+6295ce870b07029bfcdb2d
+ce28d959f2815b16f81798
+

Lower by unsigned bytewise comparison, so it takes the key0 slot.

+
+
+

SIDE B key1

+
02c6047f9441ed7d6d3045
+406e95c07cd85c778e4b8c
+ef3ca7abac09b95c709ee5
+

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. +

+ +
+ Witness script, exactly 71 bytes +
52                                                                   
+21 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 
+21 02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5 
+52                                                                   
+ae                                                                   
+
+ +
+ Carrier scriptPubKey, exactly 34 bytes +
SHA256(witnessScript) = 9b984c7bae3efddc3a3f0a20ff81bfe89ed1fe07ff13e562149ee654bed845db
+
+scriptPubKey = 0020 9b984c7bae3efddc3a3f0a20ff81bfe89ed1fe07ff13e562149ee654bed845db
+
+ +

+ 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. +

+ +
+ MARK marker script, 81 bytes +
6a4c4e544e444d0103020138025b6fb909d2f22226ee9ea7e3a0e895c589792a
+93393c136ad502c608d221010000000000de2e2206a8bf7075457a777a271a46
+7a26faa75f82ceec3a2ee708c0c732b84a
+
+ +

The first three bytes are script framing, not payload:

+ +
+ + + + + + + + +
Script framing
BytesMeaning
6aOP_RETURN
4cOP_PUSHDATA1
4ePush 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
RangeBytesFieldValue
[0,4)544e444dmagicTNDM
[4,5)01marker format1, the only defined format
[5,6)03networkregtest
[6,7)02opcodeMARK
[7,8)01state_vout1, the carrier output index
[8,40)38025b6f…c608d221namespacematches the derived namespace from example 1
[40,44)01000000state_seq1, little-endian
[44,45)00kindnote
[45,46)00flagsmust be zero
[46,78)de2e2206…c732b84achapter commitmentnonzero, 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
OperationPrefixstate_voutstate_seqTrailing fields
INIT6a3bnonenoneH_open 1108, H_close 5428, carrier 20,000, refund delay 52,560, spec hash
CREATE6a280x01nonenamespace only
MARK6a4c4e0x011kind, flags, chapter commitment
ROTATE6a2c0x012none, successor keys come from the inputs
CLOSE6a4c500xff3reason 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   
+D1 = 10000 + floor(3030 / 2) = 10000 + 1515 = 11515  
+
+D0 + D1 - 20000 = 23030 - 20000 = 3030 = F           
+
+ +

+ 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    
+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
OperationFee, satoshisWho pays
INIT1,000The single funding input
CREATE3,030Both sides, split by the equations above
MARK1,000One sponsor, who must be side A or side B
ROTATE1,000Both successor sides
CLOSE1,000Taken from the 20,000-satoshi carrier before the equal payout
REFUND340Taken 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. +

+ +
+ + One object across four operations + + A CREATE at height 1108 starts the object at sequence 0 with chapter count 0. A MARK + moves it to sequence 1 and chapter count 1, keeping the same pair. A ROTATE moves it + to sequence 2 with a new pair and an unchanged chapter count. A CLOSE moves it to + sequence 3 and the CLOSED status with no successor. + + + + + + + CREATE + seq 0 + height 1108 + chapters 0 + pair 1 + + + + + MARK + seq 1 + height 1200 + chapters 1 + pair 1 + + + + + ROTATE + seq 2 + chapters 1 + pair 2 + + + + + CLOSE + seq 3 + CLOSED + no successor + pair 2 + + + +
+ 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
FieldValueWhat it tells you
object_key5e355bac…8659cb52The key derived in example 1
founding1Created inside the 4,320-block founding window
status0ACTIVE
create_height1108Exactly H_open, the first permitted height
state_seq1One operation has happened since CREATE
current_outpoint3030…3030, vout 1The live carrier, so the object is spendable
key0, key1The pair from example 2Both keys are still required
terminal_txidzero32Absent, consistent with an active object
chapter_count1Exactly 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. +

+ +
    +
  1. 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.
  2. +
  3. 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.
  4. +
  5. Group 0x0010 is the first failing group: locktime is not 0. That is BAD_TX_VERSION_OR_LOCKTIME.
  6. +
+ +

+ 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" ||     
+  namespace_commitment ||
+  genesis_outpoint36 ||     
+  predecessor_outpoint36 || 
+  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. +

+
+ + +
+
+ + + + diff --git a/docs/index.html b/docs/index.html index 6021927..d7fe9cf 100644 --- a/docs/index.html +++ b/docs/index.html @@ -67,6 +67,7 @@

Understand the object.
Respect the boundary.

Use cases Integration guide Public contract + Protocol dossier Questions Readiness Security @@ -128,7 +129,7 @@

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. +

Go deeper

+

+ 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. +

+
Verify locally node scripts/verify-public.mjs @@ -355,7 +374,7 @@

Protect users before publishing details.

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.
@@ -415,6 +434,7 @@

Stays private

One Bitcoin-native object, controlled together.

Start here @@ -119,7 +120,7 @@

The exit comes first

See how one shared object moves.

- 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.

@@ -282,6 +283,7 @@

Some objects belong to one person.
This one begins

One Bitcoin-native object, controlled together.