docs: update onchain-zap + headers-explorer plans for NIP-BC inline SPV tags

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.
This commit is contained in:
Claude
2026-05-19 19:17:36 +00:00
parent d19bcf07b0
commit 24cf3fdc5f
2 changed files with 159 additions and 24 deletions
+120 -4
View File
@@ -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", "<block-hash-hex>", "<height>"]
["proof", "<raw-tx-hex>", "<merkle-proof-hex>"]
```
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 `<merkle-proof-hex>` — 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 `<merkle-proof-hex>` 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** | | | ~34 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
@@ -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", "<hash>", "<height>"]`
and `["proof", "<raw-tx-hex>", "<merkle-proof-hex>"]` 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 ~1015 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 1015 days to
**~34 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.1G.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