From 24cf3fdc5fe36f64383d07e638c6bc43a328b92d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 19:17:36 +0000 Subject: [PATCH] docs: update onchain-zap + headers-explorer plans for NIP-BC inline SPV tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nostr-protocol/nips#2332 adds optional ["block", …] and ["proof", …] tags on kind:8333 that ship the SPV proof inline. The data-model side is already shipped in Quartz (BlockTag, ProofTag, OnchainZapEvent.block()/.proof() and the TagArray helpers), but no production code path produces or consumes either tag yet. amethyst/plans/2026-05-14-onchain-zaps.md — onchain zaps plan: - Update the "Chain backend" decision bullet to flag the spec change and the two production gaps (send-side emit, receive-side consume). - Add a dedicated "Inline SPV proofs" section covering: spec status and the merkle-proof encoding ambiguity flagged back to the PR; per-layer status matrix (shipped vs gap, with file locations); send-side two-publish design (Design B — keep instant receipt, add post-confirm republish with block+proof; dedupe by (txid, target)); receive-side fast-path with fall-through on proof failure (never hard-reject); phased delivery G.1–G.7 with effort estimates (~3–4 d after S1 ships and spec encoding lands). - Add the two new pending items to the existing "What's still pending" list to keep that section authoritative. quartz/plans/2026-05-08-local-headers-explorer.md — headers-explorer plan: - Rewrite §19 (follow-up onchain-zap verification) to reflect the spec change. Original section assumed we'd need BIP-37 merkleblock or full-block fetch over P2P; with the proof inline none of that infrastructure is required. Estimate collapses from 10–15 d to ~3–4 d. - Point §19 at the full implementation plan in the onchain-zaps file, keeping S1 focused on OTS while the follow-up details live with the rest of the NIP-BC work. --- amethyst/plans/2026-05-14-onchain-zaps.md | 124 +++++++++++++++++- .../2026-05-08-local-headers-explorer.md | 59 ++++++--- 2 files changed, 159 insertions(+), 24 deletions(-) diff --git a/amethyst/plans/2026-05-14-onchain-zaps.md b/amethyst/plans/2026-05-14-onchain-zaps.md index f61494e015..d2725b58e5 100644 --- a/amethyst/plans/2026-05-14-onchain-zaps.md +++ b/amethyst/plans/2026-05-14-onchain-zaps.md @@ -19,10 +19,15 @@ rest of the system. there. `NostrSignerRemote` (NIP-46) is deferred. - **Chain backend.** User-configured Esplora-compatible API (mempool.space, blockstream.info, self-hosted). The configured server sees - the user's UTXO queries — accepted tradeoff for v1. Header-only SPV mode is - a future-phase add. The explorer endpoint is shared with OpenTimestamps via - `BitcoinExplorerEndpoint`: same user-configured server, same Tor-aware - default selection. + the user's UTXO queries — accepted tradeoff for v1. **Spec PR + nostr-protocol/nips#2332 adds optional `["block", …]` and + `["proof", …]` tags on `kind:8333` that let a verifier with only the + Bitcoin header chain check a zap end-to-end without trusting the + explorer. Amethyst already parses both tags (see `BlockTag.kt` / + `ProofTag.kt`) but no production code path produces or consumes them + yet — see the "Inline SPV proofs" section below.** The explorer + endpoint is shared with OpenTimestamps via `BitcoinExplorerEndpoint`: + same user-configured server, same Tor-aware default selection. - **Scope.** Full send + receive + display loop on Android. Desktop is out of scope for v1. @@ -232,6 +237,117 @@ Lightning fast path. Two minimal hooks: 2. **NIP-46 `sign_psbt`.** `NostrSignerRemote.signPsbt` still throws `UnsupportedMethodException` — the bunker-side command is not standardized yet. +3. **Send-side `block` + `proof` emit.** See "Inline SPV proofs" below. +4. **Receive-side SPV verification.** See "Inline SPV proofs" below. + +## Inline SPV proofs (`block` + `proof` tags) — pending + +### Spec status (2026-05-19) + +`nostr-protocol/nips#2332` adds two new optional tags on `kind:8333`: + +``` +["block", "", ""] +["proof", "", ""] +``` + +Together they let a verifier with only the Bitcoin header chain check a +zap end-to-end without trusting the chain backend. The recipient hashes +`raw-tx-hex` to recover the txid, walks the merkle proof from that leaf +up to the header's `merkleRoot`, parses outputs from `raw-tx-hex`, and +sums those that pay the recipient's derived Taproot script. + +**One spec ambiguity outstanding** (raised back to the PR author): +direction-at-each-step encoding for `` — Bitcoin's +merkle tree is tx-order-sensitive, so concatenated siblings alone don't +specify left vs right. Suggested resolution: `1-byte direction || 32-byte +sibling` per step (33 B stride). This blocks Phase G.2 (walker +implementation), nothing else. + +### Current state in Amethyst + +| Layer | Status | Location | +|---|---|---| +| `BlockTag(blockHashHex, height: Long)` | ✅ shipped | `quartz/.../nipBCOnchainZaps/zap/tags/BlockTag.kt` | +| `ProofTag(rawTxHex, merkleProofHex)` | ✅ shipped | `…/zap/tags/ProofTag.kt` | +| `OnchainZapEvent.block()` / `.proof()` accessors | ✅ shipped | `…/zap/OnchainZapEvent.kt:89-93` | +| TagArray + TagArrayBuilder helpers | ✅ shipped | `…/zap/TagArrayExt.kt`, `…/zap/TagArrayBuilderExt.kt` | +| Tx parser + witness-stripped txid (reusable for the walker) | ✅ shipped | `…/psbt/BitcoinTransaction.kt` | +| Recipient scriptPubKey derivation | ✅ shipped | `taproot/TaprootAddress.scriptPubKeyHexForRecipient` | +| `OnchainZapSender` populates `block` / `proof` after broadcast | ❌ gap | `commons/.../onchain/OnchainZapSender.kt:209-214` (calls `build()` / `buildProfileZap()` with no SPV tags) | +| `OnchainBackend.getMerkleProof(txid)` to source the proof on send | ❌ gap | `chain/OnchainBackend.kt` | +| Merkle-proof parser + walker | ❌ gap | new `verify/MerkleProofVerifier.kt` | +| `OnchainZapVerifier` prefers inline proof when present | ❌ gap | `verify/OnchainZapVerifier.kt` | +| Validated `header.merkleRoot` lookup by block hash | ❌ gap | depends on the headers-explorer plan (S1) at `quartz/plans/2026-05-08-local-headers-explorer.md` | + +### Send-side: Design B (two-publish) + +`OnchainZapSender` publishes the `kind:8333` receipt **immediately** after +`backend.broadcast(rawTxHex)` returns, when confirmations = 0. The chosen +design keeps that instant UX and adds a second publish after the tx +confirms: + +1. Today's path stays. Publish `kind:8333` right after broadcast, with + `i`/`p`/`amount`/`alt` only — no `block`, no `proof`. +2. A new post-confirmation worker watches the tx via the backend. When it + sees ≥1 confirmation: + - Call `backend.getMerkleProof(txid)` → `(block_hash, height, siblings, pos)`. + - Encode `siblings + pos` into the spec's `` wire format + (depends on the resolved spec encoding). + - Build a second `kind:8333` referencing the same `i` tag, with `block` + and `proof` filled in. Publish. +3. Verifiers MUST dedupe by `(txid, target)` and prefer the variant + carrying a valid SPV proof. (This dedupe rule is also in the suggested + spec amendment.) + +Two relay broadcasts per zap, ~2 KB extra each. The post-confirmation +worker is per-account, persists across app restarts, and exits cleanly +once each pending tx is either confirmed (publish second receipt) or +abandoned (after some timeout — TBD). + +### Receive-side + +`OnchainZapVerifier.verify(event)` gets a new fast path: + +1. If `event.block() != null && event.proof() != null`: + - Look up header by hash via `LocalHeadersBitcoinExplorer.byHash()` (S1). + If unavailable yet, fall through to the existing `backend.getTx()` path. + - `BitcoinTransaction.parse(rawTxHex)` → tx; recompute + `dsha256(stripWitness(tx))` → expected_txid; check it matches the + `i` tag's txid. + - `MerkleProofVerifier.verify(expected_txid, proofBytes, header.merkleRoot)` → boolean. + - Parse outputs from the same parsed tx; sum outputs paying the + recipient's `scriptPubKeyHexForRecipient`, skipping outputs paying + the sender's own derived script (change). + - Confirmations = `tip.height − block.height + 1`. + - Return `Confirmed`. +2. If the inline path fails verification (bad proof, missing header, + tx-mismatch), the event is treated as if `block` / `proof` were + absent — fall back to the existing Esplora path. **Never** treat a + failed SPV proof as a hard-reject of the whole event; that would let + a buggy producer poison legitimate zaps. +3. If neither tag is present (old-shape events), the existing + `backend.getTx()` path runs unchanged. + +The end state: a strict-mode user with HTTP fallback disabled can verify +any zap whose sender included `block` + `proof`, and only those. + +### Phased delivery for the SPV work + +| Phase | Deliverable | Depends on | Effort | +|---|---|---|---| +| **G.1** | `MerkleProofVerifier` in `nipBCOnchainZaps/verify/`; test vectors per the resolved spec encoding | spec encoding locked | ½ d | +| **G.2** | `OnchainZapVerifier` consumes inline proof, falls back on failure; needs `LocalHeadersBitcoinExplorer.byHash()` from S1 | S1 Phase 2 (storage) | ½ d | +| **G.3** | `OnchainBackend.getMerkleProof(txid)` interface + `EsploraBackend` impl (`/tx/{txid}/merkle-proof`) | — | ½ d | +| **G.4** | Post-confirmation worker + second-publish flow in `OnchainZapSender` | G.3 | 1 d | +| **G.5** | Dedupe by `(txid, target)` preferring SPV-attached variant; `Note.zapsAmount` only counts the winner | — | ½ d | +| **G.6** | End-to-end tests: real mainnet block + proof + zap; negative cases (wrong root, truncated, wrong direction) | G.1 | 1 d | +| **G.7** | (Optional) Settings toggle "Verify zaps trustlessly when possible" — off by default until S1 ships | S1 Phase 7 | ½ d | +| **Total** | | | ~3–4 d (after S1 ships and spec encoding lands) | + +S1 (the headers explorer at `quartz/plans/2026-05-08-local-headers-explorer.md`) +is the long pole. The SPV work itself is small — every primitive needed +already exists in the codebase, the only new thing is the merkle walker. ## Risks / open questions diff --git a/quartz/plans/2026-05-08-local-headers-explorer.md b/quartz/plans/2026-05-08-local-headers-explorer.md index 5103663dd8..fe499e3fcc 100644 --- a/quartz/plans/2026-05-08-local-headers-explorer.md +++ b/quartz/plans/2026-05-08-local-headers-explorer.md @@ -853,29 +853,48 @@ a unit test for. ## 19. Follow-up: trustless NIP-BC onchain zap verification -*Not in this plan. Tracked here so the design choices above stay aligned.* +*Not in this plan. Tracked here so the design choices above stay aligned. +Full implementation plan lives in `amethyst/plans/2026-05-14-onchain-zaps.md` +under "Inline SPV proofs (`block` + `proof` tags) — pending".* -The shipped `OnchainZapVerifier` (`quartz/.../nipBCOnchainZaps/verify/`) -trusts `OnchainBackend.getTx(txid)` — i.e. Esplora — for both -tx-existence-in-a-block and the output values used to compute -`verifiedSats`. A malicious Esplora can fabricate or suppress zaps. +### What the 2026-05-19 spec change buys us -Closing that gap is a **separate plan** that builds on this one: +`nostr-protocol/nips#2332` adds optional `["block", "", ""]` +and `["proof", "", ""]` tags on `kind:8333`. +The proof rides inline in the Nostr event, so the trustless-verify path +needs **no new P2P messages on top of S1** — no `getdata MSG_BLOCK`, no +BIP-37 `filterload`, no full-block parser. Just header lookup + merkle +walk against bytes already in the event. -- **Foundation reused:** `BitcoinPeer`, `PeerPool`, `HeaderStore`, - `HeaderValidator`, `LocalHeadersBitcoinExplorer`. -- **New work (rough ~10–15 days on top of S1):** BIP-37 `filterload` / - `merkleblock` / matched-`tx` handling, or `getdata MSG_BLOCK` + stream - parse; partial-merkle-tree verification; new `OnchainBackend` variant - that asks the local stack for the inclusion proof and the matched tx - while keeping Esplora for UTXO listing / broadcast / fee estimates on - the *send* side. -- **Net win:** zap-receive verification stops trusting Esplora. Zap-send - still needs an address-indexed server (no headers-only equivalent - exists), so that part of the trust story is unchanged. -- **Filename:** `quartz/plans/YYYY-MM-DD-onchain-zap-merkle-proofs.md`, - written once S1 has shipped and we have real numbers on first-run sync, - store size, and battery cost. +This collapses the original "follow-up plan" estimate from 10–15 days to +**~3–4 days** of work after S1 ships and the spec's merkle-proof +encoding is locked. + +### Foundation reused from S1 + +- `LocalHeadersBitcoinExplorer.byHash(blockHash)` → validated header (for + the `merkleRoot`). +- `HeaderStore` for the by-hash index. +- `LocalHeadersBitcoinExplorer.tipHeight()` for confirmation count. + +### New work (in `nipBCOnchainZaps/`, not in `bitcoin/`) + +- `MerkleProofVerifier.verify(txid, proofBytes, expectedMerkleRoot)` — small + pure-function module. Test-vector-driven. +- `OnchainZapVerifier` rewire to prefer the inline proof path when both + tags are present; fall back to `backend.getTx` on absence or proof + failure (never hard-reject). +- `OnchainBackend.getMerkleProof(txid)` on the send side, plus a + post-confirmation worker in `OnchainZapSender` that publishes a second + `kind:8333` once the tx confirms, this time carrying `block` + `proof`. + Receivers dedupe by `(txid, target)` preferring the variant with a + valid SPV proof. + +Detailed phase breakdown (G.1–G.7), gap matrix, and design rationale +live in the onchain-zaps plan referenced above. The previous text of +this section (BIP-37 / full-block / partial-merkle-tree options) is +obsolete now that the proof is carried inline — kept in git history if +anyone needs to revisit the assumption. ## 20. References