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:
@@ -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** | | | ~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
|
||||
|
||||
|
||||
@@ -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 ~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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user