Skip to content

Instantly share code, notes, and snippets.

@trbouma
Last active June 1, 2026 12:28
Show Gist options
  • Select an option

  • Save trbouma/77648ebe1005b181b67d1c4b42c7f31d to your computer and use it in GitHub Desktop.

Select an option

Save trbouma/77648ebe1005b181b67d1c4b42c7f31d to your computer and use it in GitHub Desktop.
Nostr Silent Payments

Nostr Silent Payments (NSP) Summary Brief

Jump to Use Case | Jump to Infographic | Validate Payment Example Script | Sweep Silent Payment Example Script

The BIP-352 Silent Payments proposal creates an opportunity to define a distinct Nostr Silent Payments (NSP) derivation model for a Nostr identity.

This work arose from related efforts to derive a Taproot (p2tr) address from a Nostr public key and to understand how Nostr identity material could map deterministically into Bitcoin wallet semantics. That earlier exploration made it clear that the same identity-linked approach could be extended beyond a single visible Taproot address into a richer Silent Payments receive model.

One of its most important properties is that Silent Payments can provide a static payment address: a stable Silent Payments receive identity that can be reused by senders without creating a reusable on-chain receive address.

Under the Nostr Silent Payments derivation rule, a Silent Payment address can be deterministically derived from a known npub. This means every Nostr identity can be treated as having a corresponding Nostr Silent Payments address model, even if the identity owner has never explicitly published or acknowledged it.

This creates several important properties:

  • Independent verifiability: anyone who knows the npub and the Nostr Silent Payments derivation rule can derive the expected Silent Payment address and verify it independently.
  • Anti-spoofing assurance: a sender does not need to trust a pasted or manually shared address. The correct Silent Payment address is fixed by the recipient identity and can be derived locally.
  • Plausible deniability: because anyone can derive the Silent Payment address from the npub, the existence of that address does not prove the nsec holder intentionally created, published, or even knew about it.
  • Private receipt detection: while the Silent Payment address is publicly derivable, only the holder of the matching private scan key can detect which on-chain outputs belong to the identity-derived Nostr Silent Payments model.
  • Private fund control: only the holder of the matching private spend path can sweep or spend the detected outputs.

Important Security Caveats

NSP has a strong sender-verification and receiver-privacy story, but it also has an important scanning constraint that must be understood clearly.

In the NSP derivation model:

  • scan_priv = d + t_scan mod n
  • t_scan is publicly computable from the npub
  • anyone who learns scan_priv can recover d
  • once d is known, the observer can also derive spend_priv

This means:

  • the NSP scan private key is root-equivalent
  • disclosing scan_priv discloses the underlying nsec
  • disclosing scan_priv also discloses the NSP spend private key
  • NSP is a strong local-scanning model
  • NSP is not a safe untrusted remote-scanning model

Operationally, that means:

  • local scanning is the preferred NSP model
  • self-hosted or fully trusted scanner infrastructure can be acceptable
  • untrusted third-party remote scanners should not be treated as safe for NSP
  • the sender-side benefits of public derivability remain intact even though the receiver-side scan key must be handled as a root secret

This protects both the sender and the recipient.

  • The sender is protected because they can derive the correct receive address themselves and avoid spoofed payment instructions.
  • The recipient is protected because incoming payments do not expose a reusable on-chain receive address, and detected outputs can be swept to unrelated addresses.

As a result, the funding relationship between donor and recipient is difficult to establish from public chain data alone.

The key architectural insight is that Nostr Silent Payments differs from a wallet-style Silent Payments implementation in how the receiver's base Silent Payments keys are derived.

  • The Nostr Silent Payments model is derived from Nostr identity using deterministic additive tweaks.
  • In a wallet-style implementation, the Silent Payments keys are usually derived from private seed material through a BIP-32 tree.

The resulting sp1... address is still the same kind of Silent Payments object in both cases, so a sender paying to it may see no practical difference. The difference shows up on the receiver side: scanning, recovery, and wallet interoperability depend on whether the wallet can reconstruct the matching private scan and spend keys from the same derivation contract.

For practical purposes, Nostr Silent Payments should be treated as its own distinct derivation and recovery model:

  • it is identity-linked
  • it is privately discoverable
  • it is publicly verifiable
  • it is difficult to attribute to intentional publication
  • and it preserves the core on-chain privacy benefits of Silent Payments

There is also a direct traceability tradeoff to understand.

  • NSP gives stronger independent verification than a published sp1... address string alone, because a sender can derive the correct address from a known npub or human-readable NIP-05
  • this is especially useful when the recipient has no better public communication channel than a profile, website, or NIP-05 identifier
  • but that same public derivability means the static Silent Payment address is more attributable to the public Nostr identity than in a seed-derived BIP-352 wallet
  • the tradeoff is therefore stronger sender assurance and anti-spoofing on one side, and weaker off-chain identity unlinkability on the other

What does not materially change is the on-chain privacy model:

  • the static sp1... address still does not appear on-chain
  • each payment still lands as a fresh Taproot-looking output
  • outsiders still cannot trivially identify which outputs belong to that static address without the relevant scan key material

BIP352 On Its Own Is Dangerous

BIP352 contains important ideas, but on its own it creates a serious risk by normalizing exposure of receiver-side private key material for receipt detection. The proposal itself states: "Since Bob needs his private key b to check for incoming payments, this requires b to be exposed to an online device."

That is not a minor implementation detail. It is a dangerous trust assumption. Even if the spend key remains safe and the receiver's funds are not immediately spendable, exposing the scan private key still creates a meaningful privacy failure mode.

The core problem is not only receiver risk. It is sender and donor risk. If the receiver-side scan key is exposed, leaked, logged, intercepted, or later doxxed, an observer may be able to identify the full set of payments made to that silent payment address. In that case:

  • the receiver's incoming payment graph is exposed
  • the privacy of donors and counterparties is exposed
  • the receiver may rotate to a new silent payment address, but prior donors remain exposed

That means the privacy harm is durable and asymmetric. The receiver can move forward with a new address, but the senders who already paid cannot undo their exposure.

For that reason, any design that treats disclosure of private scan material as operationally acceptable should be described with much more caution. Telling users to share any form of private key material, even a hardened derived key, is a bad security pattern because it trains users and implementers to normalize private-key exposure as long as it appears scoped.

On this view, BIP352 by itself asks too little about the privacy of senders and too much from the receiver's operational security. It protects spend authority, but it does not adequately protect the privacy of everyone who paid that address if the scan key becomes exposed.

This is why BIP352, on its own, should be treated as dangerous unless the scanning trust model is made explicit and tightly controlled.

Public Scanner and Reverse-Proxy Risk

Using a public scanning server adds another serious risk layer on top of the key-exposure problem. Even if the connection uses JSON-RPC over TLS, that protection exists only hop by hop. It does not mean the payload is protected from the infrastructure that terminates, forwards, inspects, or relays the request.

If a reverse proxy is used in front of the scanning service, the private scan material may be exposed at that proxy layer. The proxy can terminate TLS, observe the full RPC payload, and then forward it onward to the underlying scanner. If the downstream connection is re-encrypted, that only protects the next hop. It does not protect the data from the proxy itself.

That means a user cannot safely assume that a public endpoint is only the scanner they think they are talking to. With a domain name, there is no reliable way for the user to know whether there is a reverse proxy, logging layer, WAF, traffic inspector, or other middleware in the middle. With a raw IP address, the problem does not go away. The user still has no way to know whether the server is running additional software that siphons off the scan key material before forwarding the request to a Frigate instance.

So the trust question is not just whether Frigate itself is honest. The trust question is whether every server, proxy, process, log pipeline, and middleware component that touches the request is fully trusted not to retain, inspect, or exfiltrate the scan key.

This is why public remote scanning should be treated as especially dangerous. The RPC call may be encrypted in transit between hops, but the payload is still fully available to each hop that handles it. That is not a safe model for root-equivalent scan key material.

Receiver Culpability and Donor Entrapment

There is also a major omission in the ordinary BIP352 threat model: receiver culpability. The model often treats scan-key exposure as an unfortunate operational failure, but it should also account for the possibility that the receiver themselves may deliberately expose the scan key after receiving and spending funds.

A malicious or reckless receiver can follow a simple pattern:

  1. Generate a Silent Payment address.
  2. Solicit funds from donors.
  3. Spend the funds.
  4. Dox the scan private key.
  5. Dox all donors who paid that address.
  6. Deny that it was ever really their Silent Payment address, or deny responsibility for the exposure.
  7. Generate a new address and repeat.

This is not just a technical edge case. It is a structural asymmetry in the trust model. The receiver controls the address lifecycle, benefits from the incoming funds, and can later reveal the scan key in a way that permanently harms donors and counterparties. The donors have no way to revoke the payments they already made, and no way to repair the privacy loss once the scan key is public.

That makes sender and donor privacy dependent not only on technical competence, but also on receiver integrity. A receiver who is careless, compromised, malicious, pressured, or strategically opportunistic can externalize the privacy cost onto everyone who paid them.

This is one place where the Nostr Silent Payments approach changes the incentive structure in an important way. When the Silent Payment address is derived from the npub, the receiver has much stronger reason to protect the corresponding nsec. Revealing the scan key in the NSP model is not merely exposing a disposable scanning secret. It is exposing root-equivalent identity material. That creates a much stronger deterrent against careless disclosure because the receiver would also be harming their own key security and identity continuity.

By contrast, in a plain BIP352 operating model, the receiver can more plausibly treat the scan key as something operationally separable from their broader public identity. That weaker binding makes post hoc donor betrayal easier to imagine, and therefore makes receiver culpability an essential part of the threat model rather than an afterthought.

Machine and Agentic Identity

NSP is also a strong fit for machine or agentic identity.

In many automated systems there is no human-maintained social profile at all. A component, service, agent, or device may simply have:

  • a generated npub
  • a corresponding nsec
  • and no published profile, website, or human-facing payment page

In that environment, NSP provides a clean private payment primitive:

  • a machine can expose only its npub
  • another component can derive the correct Silent Payment address independently
  • the sender does not need a separate address-distribution channel
  • and the resulting payment still lands on-chain as a fresh Taproot-looking output

This is useful for:

  • agent-to-agent settlement
  • component-to-component billing
  • service payments between autonomous systems
  • private treasury flows between software-controlled identities

This also changes how the traceability tradeoff should be evaluated.

  • the npub-derived NSP model does create a stronger public correlation between the static sp1... address and the public identity than a hardened seed-derived BIP-352 wallet
  • but for bare, ephemeral, or non-human npub identities, that correlation may be a very acceptable tradeoff
  • if the npub is not carrying a rich human-facing social graph, profile history, or public persona, then the off-chain linkage cost can be much lower
  • in those settings, the benefits of independent verification and private machine-to-machine payment coordination may outweigh the weaker identity unlinkability

In short, NSP makes it possible for components that hold Nostr key material to pay each other privately using only identity-derived address discovery, even when there is no human-readable profile or social presence at all.

High-Risk and Adversarial Environments

This model has especially important implications in high-risk or adversarial environments where counterparties may be required to:

  • send payment to a known identity
  • later confirm receipt
  • produce signed confirmations or acknowledgements

In those environments, ordinary payment coordination often creates trust gaps that must be managed by:

  • intermediaries
  • compliance staff
  • auditors
  • counterparties maintaining off-chain address books and attribution records

The Nostr Silent Payments (NSP) approach reduces those gaps significantly.

The sender can derive the correct Silent Payment address directly from the recipient identity, so there is no need to trust:

  • a copied payment address
  • an address provided by a third party
  • an address embedded in a message that could have been altered or spoofed

That means the sender has strong assurance they paid the correct identity without relying on a separate trusted address-distribution channel.

At the same time, the recipient can later confirm receipt using private scan knowledge and, if needed, produce signed statements about receipt or sweeping without the blockchain itself exposing a reusable public funding relationship.

In practice, for NSP, that receipt detection should be treated as a local or fully trusted-operator function. A third-party scanner can technically discover NSP receipts, but using one requires disclosing a root-equivalent scan key and therefore giving up the wallet's key isolation.

This changes the operational trust model in an important way:

  • address authenticity can be derived independently
  • receipt detection can be performed privately by the intended recipient
  • receipt confirmation can be made explicitly and deliberately, rather than inferred from public chain data

As a result, many of the trust gaps that would otherwise need to be:

  • maintained by third parties
  • documented through shared address registries
  • or risk-managed through manual verification procedures

are reduced or eliminated by the cryptographic structure itself.

In short, this approach lets counterparties:

  • derive the correct payment destination independently
  • avoid spoofed payment instructions
  • confirm receipt deliberately and privately
  • and do so without exposing a durable public linkage between sender and recipient on-chain

Protecting Vulnerable Donors and Recipients

This model is also important for protecting vulnerable donors who might otherwise reveal themselves unintentionally through ordinary Bitcoin payment coordination.

A useful real-world example is the 2022 Canadian trucker protest funding environment. During that period:

  • donor information associated with crowdfunding support for the convoy was leaked and reported on publicly, exposing names, email addresses, locations, and other identifying details in many cases; see ABC News and The Guardian
  • crypto-related accounts and addresses associated with convoy funding were identified and targeted by authorities and intermediaries; see Reuters via Investing.com and reporting on address blacklisting such as CryptoAdventure

That episode shows how vulnerable both donors and recipients can become when:

  • payment destinations are publicly reused
  • recipient infrastructure is easy to map
  • donor activity can be linked to known recipient endpoints
  • third parties can identify, freeze, or pressure visible funding paths

The Nostr Silent Payments model helps prevent or sharply reduce that exposure.

With the Nostr Silent Payments model:

  • the sender can derive the correct destination from identity without relying on a publicly circulated payment address
  • that independent derivation is stronger than merely trusting an sp1... address published on a website, profile page, or message thread, because those published address strings can be spoofed, replaced, or tampered with
  • in many real cases the recipient may have no safer public communication channel than a human-readable NIP-05 address, and NSP lets the sender resolve that identity and derive the correct Silent Payment address locally
  • the reusable sp1... receive identity does not appear on-chain
  • the actual received outputs are not publicly obvious without the private scan key
  • the recipient can later sweep funds to unrelated addresses, reducing durable public linkage

This makes it much harder to:

  • track down vulnerable donors from a known public Bitcoin address reuse pattern
  • map incoming payments to a publicly attributed recipient address
  • establish a clear public funding relationship between a specific donor and recipient from chain data alone

In that sense, the Nostr Silent Payments model protects both sides:

  • the donor is less likely to reveal themselves by paying a publicly watchable recipient address
  • the donor is less likely to be tricked by a spoofed or substituted Silent Payment address, because the correct destination can be derived independently from the intended Nostr identity
  • the recipient is less likely to have their funding flows mapped and attributed through visible receive infrastructure

Everything here comes with tradeoffs, and that should be stated plainly.

  • NSP gives stronger sender-side address assurance than a published sp1... string alone
  • NSP is especially useful when the only practical public identifier is an npub or a human-readable NIP-05
  • NSP improves recipient privacy on-chain and in payment coordination
  • but NSP also requires that receipt scanning be kept local or limited to fully trusted scanner infrastructure because the NSP scan key is root-equivalent

So the correct conclusion is not that NSP removes every risk. It is that NSP removes one of the largest and most common privacy failures in Bitcoin payments, while introducing a clear operational requirement around how the receiver performs scanning.

The Nostr Silent Payments (NSP) model shows that Bitcoin payments do not have to force a tradeoff between identity assurance and financial privacy. By making the correct receive identity independently derivable from Nostr while keeping on-chain receipt discovery and fund control private, this approach creates a stronger, safer, and more trustworthy payment model for both senders and recipients. It reduces spoofing risk, narrows operational trust gaps, protects vulnerable counterparties, and opens the door to a form of Bitcoin coordination that is more resilient in ordinary use and far more defensible in adversarial environments.

This also offers an important practical privacy advantage relative to layered Bitcoin privacy solutions such as Lightning and Cashu. Those systems can provide strong privacy properties, but they introduce additional infrastructure and additional trust or operational dependencies, including node operators, routing assumptions, channel management, or mint operators. The Nostr Silent Payments (NSP) model achieves a comparable improvement in payment privacy at the address-coordination layer without requiring the user to rely on a Lightning node, a federated or custodial mint, or other specialized intermediary infrastructure. The result is a simpler privacy model built directly on Bitcoin and Nostr identity semantics, with fewer moving parts and fewer third-party trust assumptions at the address-coordination layer, provided NSP receipt scanning remains local or confined to fully trusted scanner infrastructure.

Technical Addendum

The NSP approach leverages the BIP-352 payment model, but changes the base derivation layer.

BIP-352:

  • starts from already-existing Silent Payments private keys (usually generated by a wallet hardware or app)
  • does not prescribe where these keys came from

NSP:

  • derives Silent Payments keys deterministically from the publicly known Nostr identity key (npub)

So the BIP-352 base derivation is essentially:

  • “pick or derive private keys, then compute pubkeys”

NSP is simply adding another base-derivation option:

  • “start from the publicly known Nostr key d/P, derive additive tweaks, and turn that into scan/spend keys”

So in a nutshell, the core difference is this: instead of starting from an already-existing Silent Payments wallet keyset, NSP derives the receiver’s base Silent Payments keys from the publicly known Nostr identity (npub). Everything else is the same at the sender and address-format layer. There is no difference in how the Silent Payments addresses are used on the sending side, but the receiver (the nsec holder) needs to know the NSP recovery rule.

Math details on the NSP base derivation:

Let:

d = Nostr private key
P = dG = Nostr public key

where d is the scalar value of the nsec

Derive deterministic tweaks:

t_scan = H_tag("nostr-sp/scan", P)
t_spend = H_tag("nostr-sp/spend", P)

Then:

ScanPub = P + t_scan G
SpendPub = P + t_spend G

the two public keys are encoded into the address:

sp1... = bech32m(v0 || ScanPub || SpendPub)

and privately:

scan_priv = d + t_scan mod n
spend_priv = d + t_spend mod n

This additive derivation also creates the most important security caveat in the NSP model:

d = scan_priv - t_scan mod n

Because t_scan is publicly computable from the npub, anyone who learns scan_priv can recover d and then derive spend_priv. So NSP scanning should be treated as local-only or trusted-scanner-only. It should not be described as a safe untrusted remote-scanning model.

As far as any sending wallet is concerned, the sp1... address is nothing special; it is just another Silent Payments address. However for the receiver, they need to know how to recover against those address using d (their nsec)

The opsec recommendation is to never spend from this wallet to an address outside of your control. Always sweep to a fresh address under your control.

Some might philosophically object to using the nsec this way, but NSP gives every Nostr identity an attributable Silent Payments derivation by default while still preserving plausible deniability about intentional publication or awareness.

Use Case

Use Case

Infographic

Infographic

Further Reading

This script is a minimal-dependency Python script that demonstrates how to detect and sweep a Silent Payments receipt using only standard libraries shipped with core Python.

It is intended for demonstration and experimental review, with an emphasis on keeping the trust surface small and understandable. By avoiding imports and third-party packages, the script serves as a compact reference for how Silent Payments receiver derivation, transaction matching, and spendability checks can be implemented in a self-contained Python tool.

#!/usr/bin/env python3
"""Sweep a matched Silent Payments receipt with only Python standard libraries.

This script is provided for demonstration and experimental purposes only.
It uses only standard libraries that ship with core Python to make trust review
easier, but it is not a production wallet, not a hardened signing system, and
not a substitute for a full independent security review.

It intentionally avoids importing OpenETR modules or third-party packages. It supports:
- nsec-only input
- NSP or BIP352 receiver derivation
- scanning a single txid for matching Silent Payments outputs
- reconstructing the matched receipt private key
- building/signing a Taproot key-path sweep transaction
- optional Esplora broadcast

Examples:
    scripts/sweep_silent_payment_standalone nsec1... <txid> bc1p...
    scripts/sweep_silent_payment_standalone nsec1... <txid> bc1p... --mode bip352 --broadcast
"""

from __future__ import annotations

import argparse
import hashlib
import hmac
import json
import math
import sys
import time
from typing import NoReturn
from urllib import error, parse, request


BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
BECH32M_CONST = 0x2BC830A3
BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
GX = 55066263022277343669578718895168534326250603453777594175500187360389116729240
GY = 32670510020758816978083085130507043184471273380659243275938904335757337482424
SILENT_PAYMENT_SCAN_TAG = "nostr-sp/scan"
SILENT_PAYMENT_SPEND_TAG = "nostr-sp/spend"
SCAN_OUTPUT_SEARCH_LIMIT = 4096
DEFAULT_DUST_THRESHOLD_SATS = 546
DUST_THRESHOLD_BY_TYPE = {
    "p2tr": 330,
    "p2wpkh": 294,
    "p2wsh": 330,
    "p2pkh": 546,
    "p2sh": 540,
}


def fail(message: str) -> NoReturn:
    raise SystemExit(f"error: {message}")


def sha256(data: bytes) -> bytes:
    return hashlib.sha256(data).digest()


def hash256(data: bytes) -> bytes:
    return sha256(sha256(data))


def tagged_hash(tag: str, payload: bytes) -> bytes:
    tag_hash = sha256(tag.encode("utf-8"))
    return sha256(tag_hash + tag_hash + payload)


def hmac_sha512(key: bytes, data: bytes) -> bytes:
    return hmac.new(key, data, hashlib.sha512).digest()


def xor_bytes(left: bytes, right: bytes) -> bytes:
    return bytes(a ^ b for a, b in zip(left, right))


def bech32_polymod(values: list[int]) -> int:
    generators = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
    checksum = 1
    for value in values:
        top = checksum >> 25
        checksum = ((checksum & 0x1FFFFFF) << 5) ^ value
        for i in range(5):
            if (top >> i) & 1:
                checksum ^= generators[i]
    return checksum


def bech32_hrp_expand(hrp: str) -> list[int]:
    return [ord(char) >> 5 for char in hrp] + [0] + [ord(char) & 31 for char in hrp]


def bech32_verify_checksum(hrp: str, data: list[int], expect_bech32m: bool = False) -> bool:
    constant = BECH32M_CONST if expect_bech32m else 1
    return bech32_polymod(bech32_hrp_expand(hrp) + data) == constant


def bech32_decode(value: str, expect_bech32m: bool = False) -> tuple[str | None, list[int] | None]:
    value = (value or "").strip().lower()
    pos = value.rfind("1")
    if pos < 1:
        return None, None
    hrp = value[:pos]
    data: list[int] = []
    for char in value[pos + 1:]:
        if char not in BECH32_CHARSET:
            return None, None
        data.append(BECH32_CHARSET.find(char))
    if not bech32_verify_checksum(hrp, data, expect_bech32m=expect_bech32m):
        return None, None
    return hrp, data[:-6]


def convertbits(data: bytes | list[int], frombits: int, tobits: int, pad: bool = True) -> list[int] | None:
    acc = 0
    bits = 0
    ret: list[int] = []
    maxv = (1 << tobits) - 1
    for value in data:
        if value < 0 or value >> frombits:
            return None
        acc = (acc << frombits) | value
        bits += frombits
        while bits >= tobits:
            bits -= tobits
            ret.append((acc >> bits) & maxv)
    if pad:
        if bits:
            ret.append((acc << (tobits - bits)) & maxv)
    elif bits >= frombits or ((acc << (tobits - bits)) & maxv):
        return None
    return ret


def b58decode(value: str) -> bytes:
    value = (value or "").strip()
    if not value:
        fail("base58 value cannot be empty")
    number = 0
    for char in value:
        idx = BASE58_ALPHABET.find(char)
        if idx == -1:
            fail("invalid base58 character")
        number = number * 58 + idx
    encoded = number.to_bytes((number.bit_length() + 7) // 8, "big") if number else b""
    pad = 0
    for char in value:
        if char == "1":
            pad += 1
        else:
            break
    return b"\x00" * pad + encoded


def b58check_decode(value: str) -> tuple[int, bytes]:
    raw = b58decode(value)
    if len(raw) < 5:
        fail("invalid base58check payload")
    payload, checksum = raw[:-4], raw[-4:]
    if hash256(payload)[:4] != checksum:
        fail("invalid base58check checksum")
    return payload[0], payload[1:]


def inverse_mod(a: int, modulus: int) -> int:
    return pow(a % modulus, modulus - 2, modulus)


def point_add(p1: tuple[int, int] | None, p2: tuple[int, int] | None) -> tuple[int, int] | None:
    if p1 is None:
        return p2
    if p2 is None:
        return p1

    x1, y1 = p1
    x2, y2 = p2

    if x1 == x2 and (y1 + y2) % P == 0:
        return None

    if p1 == p2:
        if y1 == 0:
            return None
        slope = ((3 * x1 * x1) * inverse_mod(2 * y1, P)) % P
    else:
        slope = ((y2 - y1) * inverse_mod(x2 - x1, P)) % P

    x3 = (slope * slope - x1 - x2) % P
    y3 = (slope * (x1 - x3) - y1) % P
    return x3, y3


def scalar_mult(k: int, point: tuple[int, int]) -> tuple[int, int] | None:
    result = None
    addend = point
    while k:
        if k & 1:
            result = point_add(result, addend)
        addend = point_add(addend, addend)
        k >>= 1
    return result


def compress_point(point: tuple[int, int] | None) -> bytes:
    if point is None:
        fail("invalid secp256k1 point")
    x, y = point
    prefix = 0x02 if y % 2 == 0 else 0x03
    return bytes([prefix]) + x.to_bytes(32, "big")


def decompress_compressed_pubkey(pubkey: bytes) -> tuple[int, int]:
    if len(pubkey) != 33 or pubkey[0] not in (0x02, 0x03):
        fail("compressed public key must be 33 bytes with prefix 02 or 03")
    prefix = pubkey[0]
    x = int.from_bytes(pubkey[1:], "big")
    alpha = (pow(x, 3, P) + 7) % P
    beta = pow(alpha, (P + 1) // 4, P)
    if (beta % 2 == 0 and prefix == 0x02) or (beta % 2 == 1 and prefix == 0x03):
        y = beta
    else:
        y = P - beta
    return x, y


def privkey_to_compressed_pubkey(privkey_bytes: bytes) -> bytes:
    scalar = int.from_bytes(privkey_bytes, "big")
    if scalar <= 0 or scalar >= N:
        fail("private key is outside the valid secp256k1 scalar range")
    return compress_point(scalar_mult(scalar, (GX, GY)))


def pubkey_point_add_scalar(pubkey: bytes, tweak_scalar: int) -> bytes:
    if tweak_scalar <= 0 or tweak_scalar >= N:
        fail("tweak scalar is outside the valid secp256k1 scalar range")
    point = decompress_compressed_pubkey(pubkey)
    tweak_point = scalar_mult(tweak_scalar, (GX, GY))
    return compress_point(point_add(point, tweak_point))


def pubkey_point_mul_scalar(pubkey: bytes, scalar: int) -> bytes:
    if scalar <= 0 or scalar >= N:
        fail("scalar is outside the valid secp256k1 scalar range")
    point = decompress_compressed_pubkey(pubkey)
    return compress_point(scalar_mult(scalar, point))


def combine_pubkeys(pubkeys: list[bytes]) -> bytes | None:
    combined: tuple[int, int] | None = None
    for pubkey in pubkeys:
        combined = point_add(combined, decompress_compressed_pubkey(pubkey))
    return None if combined is None else compress_point(combined)


def normalize_bip340_private_key(privkey_bytes: bytes) -> tuple[bytes, bytes, bool]:
    compressed_pubkey = privkey_to_compressed_pubkey(privkey_bytes)
    if compressed_pubkey[0] == 0x02:
        return privkey_bytes, compressed_pubkey, False
    scalar = int.from_bytes(privkey_bytes, "big")
    normalized_scalar = (N - scalar) % N
    if normalized_scalar == 0:
        fail("private key normalization produced zero")
    normalized_bytes = normalized_scalar.to_bytes(32, "big")
    normalized_pubkey = privkey_to_compressed_pubkey(normalized_bytes)
    if normalized_pubkey[0] != 0x02:
        fail("failed to normalize private key to even-y form")
    return normalized_bytes, normalized_pubkey, True


def xonly_bytes_from_compressed(pubkey: bytes) -> bytes:
    if len(pubkey) != 33 or pubkey[0] not in (0x02, 0x03):
        fail("compressed public key must be 33 bytes")
    return pubkey[1:]


def nsec_to_privkey(nsec: str) -> bytes:
    hrp, data = bech32_decode((nsec or "").strip())
    if hrp != "nsec" or data is None:
        fail("invalid nsec")
    decoded = convertbits(data, 5, 8, False)
    if decoded is None:
        fail("invalid nsec payload")
    raw = bytes(decoded)
    if len(raw) != 32:
        fail("expected 32-byte nsec private key")
    return raw


def bip32_master_from_seed(seed_bytes: bytes) -> tuple[bytes, bytes]:
    i = hmac_sha512(b"Bitcoin seed", seed_bytes)
    master_secret = i[:32]
    master_chain_code = i[32:]
    if int.from_bytes(master_secret, "big") == 0 or int.from_bytes(master_secret, "big") >= N:
        fail("invalid master key derived from seed")
    return master_secret, master_chain_code


def bip32_ckd_priv(parent_key: bytes, parent_chain_code: bytes, index: int) -> tuple[bytes, bytes]:
    if index < 0 or index > 0xFFFFFFFF:
        fail("invalid child index")
    hardened = index >= 0x80000000
    if hardened:
        data = b"\x00" + parent_key + index.to_bytes(4, "big")
    else:
        data = privkey_to_compressed_pubkey(parent_key) + index.to_bytes(4, "big")
    i = hmac_sha512(parent_chain_code, data)
    il = int.from_bytes(i[:32], "big")
    if il >= N:
        fail("invalid BIP32 child tweak")
    child_scalar = (il + int.from_bytes(parent_key, "big")) % N
    if child_scalar == 0:
        fail("invalid zero BIP32 child key")
    return child_scalar.to_bytes(32, "big"), i[32:]


def derive_bip352_material(seed_bytes: bytes) -> dict[str, str]:
    key, chain = bip32_master_from_seed(seed_bytes)
    for index in (352 + 0x80000000, 0 + 0x80000000, 0 + 0x80000000):
        key, chain = bip32_ckd_priv(key, chain, index)
    spend_key, _ = bip32_ckd_priv(*bip32_ckd_priv(key, chain, 0 + 0x80000000), 0)
    scan_key, _ = bip32_ckd_priv(*bip32_ckd_priv(key, chain, 1 + 0x80000000), 0)
    return {
        "scan_private_key_hex": scan_key.hex(),
        "spend_private_key_hex": spend_key.hex(),
        "scan_public_key_hex": privkey_to_compressed_pubkey(scan_key).hex(),
        "spend_public_key_hex": privkey_to_compressed_pubkey(spend_key).hex(),
    }


def derive_nsp_material(seed_bytes: bytes) -> dict[str, str]:
    base_privkey_bytes, base_pubkey, normalized = normalize_bip340_private_key(seed_bytes)
    base_scalar = int.from_bytes(base_privkey_bytes, "big")
    scan_tweak = int.from_bytes(tagged_hash(SILENT_PAYMENT_SCAN_TAG, base_pubkey), "big") % N
    spend_tweak = int.from_bytes(tagged_hash(SILENT_PAYMENT_SPEND_TAG, base_pubkey), "big") % N
    if scan_tweak == 0 or spend_tweak == 0:
        fail("silent payment derivation produced an invalid zero tweak")
    scan_scalar = (base_scalar + scan_tweak) % N
    spend_scalar = (base_scalar + spend_tweak) % N
    if scan_scalar == 0 or spend_scalar == 0:
        fail("silent payment derivation produced an invalid zero private key")
    return {
        "scan_private_key_hex": scan_scalar.to_bytes(32, "big").hex(),
        "spend_private_key_hex": spend_scalar.to_bytes(32, "big").hex(),
        "scan_public_key_hex": pubkey_point_add_scalar(base_pubkey, scan_tweak).hex(),
        "spend_public_key_hex": pubkey_point_add_scalar(base_pubkey, spend_tweak).hex(),
        "bip340_normalized": "yes" if normalized else "no",
        "base_public_key_hex": base_pubkey.hex(),
    }


def resolve_wallet_material(nsec: str, mode: str) -> dict[str, str]:
    seed_bytes = nsec_to_privkey(nsec)
    normalized_mode = (mode or "nsp").strip().lower()
    if normalized_mode not in {"nsp", "bip352"}:
        fail("mode must be 'nsp' or 'bip352'")
    material = derive_nsp_material(seed_bytes) if normalized_mode == "nsp" else derive_bip352_material(seed_bytes)
    material["wallet_mode"] = normalized_mode
    return material


def _normalize_script_type_name(script_type: str) -> str:
    normalized = (script_type or "").strip().lower()
    aliases = {
        "v0_p2wpkh": "p2wpkh",
        "v1_p2tr": "p2tr",
        "v0_p2wsh": "p2wsh",
        "p2sh-p2wpkh": "p2sh",
    }
    return aliases.get(normalized, normalized)


def _script_type(script_hex: str) -> str:
    if script_hex.startswith("76a914") and script_hex.endswith("88ac") and len(script_hex) == 50:
        return "p2pkh"
    if script_hex.startswith("a914") and script_hex.endswith("87") and len(script_hex) == 46:
        return "p2sh"
    if script_hex.startswith("0014") and len(script_hex) == 44:
        return "p2wpkh"
    if script_hex.startswith("0020") and len(script_hex) == 68:
        return "p2wsh"
    if script_hex.startswith("5120") and len(script_hex) == 68:
        return "p2tr"
    return "unknown"


def _compressed_pub_from_xonly(xonly_hex: str) -> bytes:
    xonly = bytes.fromhex(xonly_hex)
    if len(xonly) != 32:
        fail("x-only public key must be 32 bytes")
    return b"\x02" + xonly


def _xonly_pubkey_bytes(xonly_hex: str) -> bytes:
    xonly = bytes.fromhex(xonly_hex)
    if len(xonly) != 32:
        fail("x-only public key must be 32 bytes")
    return xonly


def _outpoint_bytes(txid: str, vout: int) -> bytes:
    return bytes.fromhex(txid)[::-1] + int(vout).to_bytes(4, "little")


def _ser32(value: int) -> bytes:
    return int(value).to_bytes(4, "big")


def _extract_input_pubkey(vin: dict[str, object]) -> bytes | None:
    prevout = vin.get("prevout")
    if not isinstance(prevout, dict):
        return None
    script_hex = str(prevout.get("scriptpubkey") or "")
    script_type = _normalize_script_type_name(str(prevout.get("scriptpubkey_type") or _script_type(script_hex)))
    witness = vin.get("witness") or []
    scriptsig_hex = str(vin.get("scriptsig") or "")

    if script_type == "p2wpkh":
        if isinstance(witness, list) and witness:
            pubkey = bytes.fromhex(str(witness[-1]))
            if len(pubkey) == 33:
                return pubkey
        return None

    if script_type == "p2sh":
        redeem_hex = scriptsig_hex[2:] if scriptsig_hex.startswith("16") else scriptsig_hex
        if redeem_hex.startswith("0014") and isinstance(witness, list) and witness:
            pubkey = bytes.fromhex(str(witness[-1]))
            if len(pubkey) == 33:
                return pubkey
        return None

    if script_type == "p2tr":
        if script_hex.startswith("5120"):
            return _compressed_pub_from_xonly(script_hex[4:])
        return None

    if script_type == "p2pkh":
        script_bytes = bytes.fromhex(scriptsig_hex)
        for idx in range(len(script_bytes), 32, -1):
            candidate = script_bytes[idx - 33:idx]
            if len(candidate) == 33 and candidate[0] in (0x02, 0x03):
                return candidate
        return None

    return None


def _get_input_hash(outpoint_bytes: list[bytes], summed_input_pubkey: bytes) -> bytes:
    return tagged_hash("BIP0352/Inputs", sorted(outpoint_bytes)[0] + summed_input_pubkey)


def _http_json(url: str, timeout: float) -> object:
    last_rate_limit_error: error.HTTPError | None = None
    for attempt in range(4):
        req = request.Request(url, headers={"Accept": "application/json", "User-Agent": "sweep-silent-payment-standalone/0.2"})
        try:
            with request.urlopen(req, timeout=timeout) as response:
                return json.loads(response.read().decode("utf-8"))
        except error.HTTPError as exc:
            if exc.code == 429 and attempt < 3:
                last_rate_limit_error = exc
                retry_after = exc.headers.get("Retry-After") if exc.headers is not None else None
                try:
                    delay = float(retry_after) if retry_after else (1.5 * (2 ** attempt))
                except ValueError:
                    delay = 1.5 * (2 ** attempt)
                time.sleep(min(delay, 12.0))
                continue
            fail(f"HTTP {exc.code} for {url}")
        except error.URLError as exc:
            fail(f"request failed for {url}: {exc.reason}")
        except TimeoutError:
            fail(f"request timed out for {url}")
        except json.JSONDecodeError as exc:
            fail(f"invalid JSON from {url}: {exc}")
    fail(f"HTTP 429 after multiple retries for {url}: {last_rate_limit_error}")


def fetch_transaction(txid: str, api_base: str, timeout: float) -> dict[str, object]:
    payload = _http_json(f"{api_base.rstrip('/')}/tx/{parse.quote(txid)}", timeout)
    if not isinstance(payload, dict):
        fail(f"unexpected transaction payload for {txid}")
    return payload


def fetch_address_utxos(address: str, api_base: str, timeout: float) -> list[dict[str, object]]:
    payload = _http_json(f"{api_base.rstrip('/')}/address/{parse.quote(address)}/utxo", timeout)
    if not isinstance(payload, list):
        fail(f"unexpected UTXO payload for {address}")
    utxos: list[dict[str, object]] = []
    for item in payload:
        if not isinstance(item, dict):
            continue
        status = item.get("status") or {}
        utxos.append(
            {
                "txid": str(item["txid"]),
                "vout": int(item["vout"]),
                "value": int(item["value"]),
                "confirmed": bool(status.get("confirmed")),
                "block_height": int(status.get("block_height", 0) or 0),
            }
        )
    return utxos


def confirmed_utxos_only(utxos: list[dict[str, object]]) -> list[dict[str, object]]:
    return [utxo for utxo in utxos if bool(utxo.get("confirmed"))]


def scan_silent_payment_transaction(nsec: str, tx: dict[str, object], mode: str) -> dict[str, object]:
    material = resolve_wallet_material(nsec, mode=mode)
    vin = tx.get("vin")
    vout = tx.get("vout")
    if not isinstance(vin, list) or not isinstance(vout, list):
        fail("transaction payload is missing vin/vout arrays")

    input_pubkeys: list[bytes] = []
    outpoints: list[bytes] = []
    for txin in vin:
        if not isinstance(txin, dict):
            continue
        pubkey = _extract_input_pubkey(txin)
        txid = str(txin.get("txid") or "")
        vout_index = int(txin.get("vout", 0) or 0)
        if pubkey is None or not txid:
            continue
        input_pubkeys.append(pubkey)
        outpoints.append(_outpoint_bytes(txid, vout_index))

    if not input_pubkeys or not outpoints:
        return {"txid": str(tx.get("txid", "")), "matched_outputs": [], "warning": "No eligible input public keys were found."}

    summed_input_pubkey = combine_pubkeys(input_pubkeys)
    if summed_input_pubkey is None:
        return {"txid": str(tx.get("txid", "")), "matched_outputs": [], "warning": "Input public keys summed to an invalid point."}

    scan_priv_bytes = bytes.fromhex(material["scan_private_key_hex"])
    spend_pubkey = bytes.fromhex(material["spend_public_key_hex"])
    input_hash_scalar = int.from_bytes(_get_input_hash(outpoints, summed_input_pubkey), "big") % N
    if input_hash_scalar == 0:
        return {"txid": str(tx.get("txid", "")), "matched_outputs": [], "warning": "Silent Payments input hash resolved to zero."}

    scan_priv_scalar = int.from_bytes(scan_priv_bytes, "big")
    shared_scalar = (input_hash_scalar * scan_priv_scalar) % N
    if shared_scalar == 0:
        return {"txid": str(tx.get("txid", "")), "matched_outputs": [], "warning": "Silent Payments shared secret resolved to zero."}

    ecdh_compressed = pubkey_point_mul_scalar(summed_input_pubkey, shared_scalar)

    remaining_outputs: dict[bytes, dict[str, object]] = {}
    for output in vout:
        if not isinstance(output, dict):
            continue
        script_hex = str(output.get("scriptpubkey") or "")
        script_type = _normalize_script_type_name(str(output.get("scriptpubkey_type") or _script_type(script_hex)))
        if script_type != "p2tr":
            continue
        remaining_outputs[_xonly_pubkey_bytes(script_hex[4:])] = output

    matched_outputs: list[dict[str, object]] = []
    for k in range(SCAN_OUTPUT_SEARCH_LIMIT):
        if not remaining_outputs:
            break
        tweak = tagged_hash("BIP0352/SharedSecret", ecdh_compressed + _ser32(k))
        tweak_scalar = int.from_bytes(tweak, "big") % N
        if tweak_scalar == 0:
            continue
        derived_pubkey = pubkey_point_add_scalar(spend_pubkey, tweak_scalar)
        output = remaining_outputs.pop(derived_pubkey[1:], None)
        if output is not None:
            matched_outputs.append(
                {
                    "vout": int(output.get("vout", 0) or 0),
                    "value": int(output.get("value", 0) or 0),
                    "scriptpubkey_address": str(output.get("scriptpubkey_address") or ""),
                    "scriptpubkey_hex": str(output.get("scriptpubkey") or ""),
                    "output_pubkey_hex": derived_pubkey.hex(),
                    "priv_key_tweak_hex": tweak.hex(),
                    "shared_secret_index": k,
                }
            )

    return {"txid": str(tx.get("txid", "")), "matched_outputs": matched_outputs, "warning": ""}


def _silent_payment_output_private_key_hex(spend_private_key_hex: str, tweak_hex: str, output_pubkey_hex: str) -> str:
    spend_scalar = int(spend_private_key_hex, 16)
    tweak_scalar = int(tweak_hex, 16) % N
    if tweak_scalar == 0:
        fail("receipt tweak resolved to zero")
    output_scalar = (spend_scalar + tweak_scalar) % N
    if output_scalar == 0:
        fail("receipt spend key resolved to zero")
    if output_pubkey_hex.startswith("03"):
        output_scalar = (N - output_scalar) % N
        if output_scalar == 0:
            fail("receipt spend key normalization resolved to zero")
    return output_scalar.to_bytes(32, "big").hex()


def encode_varint(value: int) -> bytes:
    if value < 0xFD:
        return bytes([value])
    if value <= 0xFFFF:
        return b"\xfd" + value.to_bytes(2, "little")
    if value <= 0xFFFFFFFF:
        return b"\xfe" + value.to_bytes(4, "little")
    return b"\xff" + value.to_bytes(8, "little")


def serialize_script(script_pubkey: bytes) -> bytes:
    return encode_varint(len(script_pubkey)) + script_pubkey


def serialize_tx_input(utxo: dict[str, object], script_sig: bytes = b"", sequence: int = 0xFFFFFFFD) -> bytes:
    return (
        bytes.fromhex(str(utxo["txid"]))[::-1]
        + int(utxo["vout"]).to_bytes(4, "little")
        + serialize_script(script_sig)
        + int(sequence).to_bytes(4, "little")
    )


def serialize_tx_output(value_sats: int, script_pubkey: bytes) -> bytes:
    return int(value_sats).to_bytes(8, "little") + serialize_script(script_pubkey)


def serialize_witness_item(item: bytes) -> bytes:
    return encode_varint(len(item)) + item


def serialize_witness_stack(items: list[bytes]) -> bytes:
    return encode_varint(len(items)) + b"".join(serialize_witness_item(item) for item in items)


def address_to_script_pubkey(address: str) -> tuple[bytes, str]:
    address = (address or "").strip()
    if not address:
        fail("destination address cannot be empty")

    if address.lower().startswith(("bc1", "tb1", "bcrt1")):
        hrp = address[: address.rfind("1")].lower()
        for expect_bech32m in (False, True):
            decoded_hrp, data = bech32_decode(address, expect_bech32m=expect_bech32m)
            if decoded_hrp != hrp or data is None or not data:
                continue
            witness_version = data[0]
            program_data = convertbits(data[1:], 5, 8, False)
            if program_data is None:
                continue
            program = bytes(program_data)
            if witness_version == 0 and expect_bech32m:
                continue
            if witness_version > 0 and not expect_bech32m:
                continue
            if witness_version == 0 and len(program) not in {20, 32}:
                continue
            if witness_version == 1 and len(program) != 32:
                continue
            if witness_version > 16:
                continue
            opcode = b"\x00" if witness_version == 0 else bytes([0x50 + witness_version])
            if len(program) > 75:
                fail("unsupported segwit program length")
            if witness_version == 1 and len(program) == 32:
                return opcode + bytes([len(program)]) + program, "p2tr"
            if witness_version == 0 and len(program) == 20:
                return opcode + bytes([len(program)]) + program, "p2wpkh"
            if witness_version == 0 and len(program) == 32:
                return opcode + bytes([len(program)]) + program, "p2wsh"
            return opcode + bytes([len(program)]) + program, f"segwit_v{witness_version}"
        fail("invalid bech32/bech32m address")

    version, payload = b58check_decode(address)
    if len(payload) != 20:
        fail("unsupported base58 address payload length")
    if version in {0x00, 0x6F}:
        return b"\x76\xa9\x14" + payload + b"\x88\xac", "p2pkh"
    if version in {0x05, 0xC4}:
        return b"\xa9\x14" + payload + b"\x87", "p2sh"
    fail("unsupported base58 address version")


def dust_threshold_for_type(script_type: str) -> int:
    return DUST_THRESHOLD_BY_TYPE.get(script_type, DEFAULT_DUST_THRESHOLD_SATS)


def serialize_base_transaction(inputs: list[dict[str, object]], outputs: list[tuple[int, bytes]], locktime: int = 0, sequence: int = 0xFFFFFFFD) -> bytes:
    return (
        (2).to_bytes(4, "little")
        + encode_varint(len(inputs))
        + b"".join(serialize_tx_input(utxo, sequence=sequence) for utxo in inputs)
        + encode_varint(len(outputs))
        + b"".join(serialize_tx_output(value, script) for value, script in outputs)
        + int(locktime).to_bytes(4, "little")
    )


def serialize_transaction_with_witness(inputs: list[dict[str, object]], outputs: list[tuple[int, bytes]], witnesses: list[list[bytes]], locktime: int = 0, sequence: int = 0xFFFFFFFD) -> bytes:
    return (
        (2).to_bytes(4, "little")
        + b"\x00\x01"
        + encode_varint(len(inputs))
        + b"".join(serialize_tx_input(utxo, sequence=sequence) for utxo in inputs)
        + encode_varint(len(outputs))
        + b"".join(serialize_tx_output(value, script) for value, script in outputs)
        + b"".join(serialize_witness_stack(stack) for stack in witnesses)
        + int(locktime).to_bytes(4, "little")
    )


def estimate_signed_p2tr_vsize(input_count: int, outputs: list[tuple[int, bytes]]) -> tuple[int, int]:
    placeholder_inputs = [{"txid": "00" * 32, "vout": i} for i in range(input_count)]
    dummy_sig = b"\x00" * 64
    base = serialize_base_transaction(placeholder_inputs, outputs)
    full = serialize_transaction_with_witness(placeholder_inputs, outputs, [[dummy_sig] for _ in range(input_count)])
    weight = len(base) * 4 + (len(full) - len(base))
    vsize = math.ceil(weight / 4)
    return vsize, weight


def taproot_sighash_all_default(inputs: list[dict[str, object]], outputs: list[tuple[int, bytes]], prevouts: list[dict[str, object]], input_index: int, locktime: int = 0, sequence: int = 0xFFFFFFFD) -> bytes:
    if len(inputs) != len(prevouts):
        fail("prevouts length must match input count")
    outpoints_blob = b"".join(bytes.fromhex(str(utxo["txid"]))[::-1] + int(utxo["vout"]).to_bytes(4, "little") for utxo in inputs)
    amounts_blob = b"".join(int(prev["value"]).to_bytes(8, "little") for prev in prevouts)
    scriptpubkeys_blob = b"".join(serialize_script(bytes(prev["script_pubkey"])) for prev in prevouts)
    sequences_blob = b"".join(int(sequence).to_bytes(4, "little") for _ in inputs)
    outputs_blob = b"".join(serialize_tx_output(value, script) for value, script in outputs)

    payload = bytearray()
    payload.append(0x00)  # epoch
    payload.append(0x00)  # SIGHASH_DEFAULT
    payload += (2).to_bytes(4, "little")
    payload += int(locktime).to_bytes(4, "little")
    payload += sha256(outpoints_blob)
    payload += sha256(amounts_blob)
    payload += sha256(scriptpubkeys_blob)
    payload += sha256(sequences_blob)
    payload += sha256(outputs_blob)
    payload.append(0x00)  # spend_type: ext_flag=0, annex_present=0
    payload += int(input_index).to_bytes(4, "little")
    return tagged_hash("TapSighash", bytes(payload))


def schnorr_sign_bip340(secret_key_bytes: bytes, message: bytes) -> bytes:
    if len(secret_key_bytes) != 32:
        fail("secret key must be 32 bytes")
    if len(message) != 32:
        fail("message must be 32 bytes")

    d0 = int.from_bytes(secret_key_bytes, "big")
    if d0 <= 0 or d0 >= N:
        fail("secret key scalar is outside the valid secp256k1 scalar range")
    p = scalar_mult(d0, (GX, GY))
    if p is None:
        fail("failed to derive signing public key")
    px, py = p
    d = d0 if py % 2 == 0 else N - d0
    p_bytes = px.to_bytes(32, "big")
    aux = b"\x00" * 32
    t = xor_bytes(d.to_bytes(32, "big"), tagged_hash("BIP0340/aux", aux))
    k0 = int.from_bytes(tagged_hash("BIP0340/nonce", t + p_bytes + message), "big") % N
    if k0 == 0:
        fail("BIP340 nonce resolved to zero")
    r_point = scalar_mult(k0, (GX, GY))
    if r_point is None:
        fail("failed to derive nonce point")
    rx, ry = r_point
    k = k0 if ry % 2 == 0 else N - k0
    r_bytes = rx.to_bytes(32, "big")
    e = int.from_bytes(tagged_hash("BIP0340/challenge", r_bytes + p_bytes + message), "big") % N
    s = (k + e * d) % N
    if s == 0:
        fail("BIP340 signature scalar resolved to zero")
    return r_bytes + s.to_bytes(32, "big")


def build_signed_p2tr_transaction(taproot_private_key_hex: str, source_script_pubkey_hex: str, utxos: list[dict[str, object]], destination_address: str, fee_rate: float) -> dict[str, object]:
    if not utxos:
        fail("no UTXOs are available to spend")
    if fee_rate <= 0:
        fail("fee_rate must be greater than zero")

    source_script_pubkey = bytes.fromhex(source_script_pubkey_hex)
    destination_script_pubkey, destination_type = address_to_script_pubkey(destination_address)

    total_in = sum(int(utxo["value"]) for utxo in utxos)
    dust_threshold = dust_threshold_for_type(destination_type)
    _, estimated_weight = estimate_signed_p2tr_vsize(len(utxos), [(0, destination_script_pubkey)])
    fee_sats = math.ceil((estimated_weight / 4) * fee_rate)
    amount_sats = total_in - fee_sats
    if amount_sats <= 0:
        fail("insufficient funds to sweep the matched receipt at the requested fee rate")
    if amount_sats < dust_threshold:
        fail(f"destination amount {amount_sats} sats is dust for {destination_type}")

    outputs = [(amount_sats, destination_script_pubkey)]
    prevouts = [{"value": int(utxo["value"]), "script_pubkey": source_script_pubkey} for utxo in utxos]
    signatures: list[list[bytes]] = []
    secret_key_bytes = bytes.fromhex(taproot_private_key_hex)
    for index in range(len(utxos)):
        sighash = taproot_sighash_all_default(utxos, outputs, prevouts, index)
        signatures.append([schnorr_sign_bip340(secret_key_bytes, sighash)])

    base_tx = serialize_base_transaction(utxos, outputs)
    witness_tx = serialize_transaction_with_witness(utxos, outputs, signatures)
    weight = len(base_tx) * 4 + (len(witness_tx) - len(base_tx))
    vsize = math.ceil(weight / 4)

    return {
        "tx_hex": witness_tx.hex(),
        "txid": hash256(base_tx)[::-1].hex(),
        "vsize": vsize,
        "weight": weight,
        "fee_sats": fee_sats,
        "fee_rate": fee_rate,
        "amount_sats": amount_sats,
        "change_sats": 0,
        "source_script_pubkey_hex": source_script_pubkey_hex,
        "destination_address": destination_address,
        "change_address": "",
        "change_policy": "full_sweep_no_change",
        "selected_utxos": utxos,
        "input_count": len(utxos),
        "output_count": len(outputs),
        "total_in_sats": total_in,
    }


def broadcast_transaction(tx_hex: str, api_base: str, timeout: float) -> str:
    url = f"{api_base.rstrip('/')}/tx"
    req = request.Request(
        url,
        data=tx_hex.encode("utf-8"),
        headers={
            "Content-Type": "text/plain",
            "Accept": "text/plain",
            "User-Agent": "sweep-silent-payment-standalone/0.2",
        },
        method="POST",
    )
    try:
        with request.urlopen(req, timeout=timeout) as response:
            return response.read().decode("utf-8").strip()
    except error.HTTPError as exc:
        detail = exc.read().decode("utf-8", errors="replace").strip()
        fail(f"broadcast failed: HTTP {exc.code}{': ' + detail if detail else ''}")
    except error.URLError as exc:
        fail(f"broadcast failed: {exc.reason}")
    except TimeoutError:
        fail("broadcast timed out")


def create_sweep_result(nsec: str, txid: str, destination_address: str, fee_rate: float, api_base: str, timeout: float, vout: int | None, mode: str) -> dict[str, object]:
    material = resolve_wallet_material(nsec, mode=mode)
    tx = fetch_transaction(txid, api_base=api_base, timeout=timeout)
    scan_result = scan_silent_payment_transaction(nsec, tx, mode=mode)
    matches = scan_result["matched_outputs"]
    if not matches:
        fail(f"no Silent Payments receipt was detected in transaction {txid}")

    selected_match = None
    if vout is not None:
        for match in matches:
            if int(match["vout"]) == vout:
                selected_match = match
                break
        if selected_match is None:
            fail(f"transaction {txid} does not contain a matched receipt at vout {vout}")
    elif len(matches) == 1:
        selected_match = matches[0]
    else:
        fail("transaction contains multiple matched receipts; specify --vout")

    source_address = str(selected_match["scriptpubkey_address"])
    source_vout = int(selected_match["vout"])
    all_utxos = fetch_address_utxos(source_address, api_base=api_base, timeout=timeout)
    confirmed_utxos = confirmed_utxos_only(all_utxos)
    selected_utxos = [utxo for utxo in confirmed_utxos if str(utxo["txid"]) == txid and int(utxo["vout"]) == source_vout]
    if not selected_utxos:
        pending_utxos = [utxo for utxo in all_utxos if str(utxo["txid"]) == txid and int(utxo["vout"]) == source_vout]
        if pending_utxos:
            raise SystemExit(
                f"matched output status: not yet confirmed\n"
                f"sweepable: no\n"
                f"sweepable_note: matched output {txid}:{source_vout} is still in the mempool"
            )
        raise SystemExit(
            f"matched output status: already spent\n"
            f"sweepable: no\n"
            f"sweepable_note: matched output {txid}:{source_vout} has already been spent"
        )

    receipt_private_key_hex = _silent_payment_output_private_key_hex(
        material["spend_private_key_hex"],
        str(selected_match["priv_key_tweak_hex"]),
        str(selected_match["output_pubkey_hex"]),
    )
    result = build_signed_p2tr_transaction(
        receipt_private_key_hex,
        str(selected_match["scriptpubkey_hex"]),
        selected_utxos,
        destination_address,
        fee_rate,
    )
    result.update(
        {
            "wallet_mode": material["wallet_mode"],
            "matched_txid": txid,
            "matched_vout": source_vout,
            "matched_value": int(selected_match["value"]),
            "matched_tweak_hex": str(selected_match["priv_key_tweak_hex"]),
            "matched_output_pubkey_hex": str(selected_match["output_pubkey_hex"]),
            "matched_shared_secret_index": int(selected_match["shared_secret_index"]),
            "source_address": source_address,
            "api_base": api_base.rstrip("/"),
        }
    )
    return result


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Sweep a matched Silent Payments receipt using only Python standard libraries.")
    parser.add_argument("nsec", help="Receiver nsec")
    parser.add_argument("txid", help="Transaction id containing the receipt")
    parser.add_argument("destination_address", help="Address to receive the swept funds")
    parser.add_argument("--mode", choices=["nsp", "bip352"], default="nsp", help="Silent Payments receiver derivation mode")
    parser.add_argument("--vout", type=int, default=None, help="Matched receipt vout when the tx contains multiple matches")
    parser.add_argument("--fee-rate", type=float, default=2.0, help="Target fee rate in sats/vbyte")
    parser.add_argument("--api-base", default="https://blockstream.info/api", help="Esplora-compatible API base URL")
    parser.add_argument("--timeout", type=float, default=5.0, help="HTTP timeout in seconds")
    parser.add_argument("--broadcast", action="store_true", help="Broadcast the signed transaction")
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    print(
        "WARNING: demonstration and experimental script only. "
        "Uses only standard libraries shipped with core Python. "
        "Not production-approved and not a substitute for independent security review."
    )
    try:
        result = create_sweep_result(
            args.nsec,
            args.txid,
            args.destination_address,
            args.fee_rate,
            args.api_base,
            args.timeout,
            args.vout,
            args.mode,
        )
    except SystemExit as exc:
        if isinstance(exc.code, str):
            print(exc.code, file=sys.stderr)
            return 1
        raise

    print(f"wallet_mode:          {result['wallet_mode']}")
    print(f"matched_txid:         {result['matched_txid']}")
    print(f"matched_vout:         {result['matched_vout']}")
    print(f"source_p2tr:          {result['source_address']}")
    print(f"matched_value_sats:   {result['matched_value']}")
    print("sweepable:            yes")
    print("sweepable_note:       matched receipt is confirmed, unspent, and can be swept")
    print(f"sweep_amount_sats:    {result['amount_sats']}")
    print(f"destination_address:  {result['destination_address']}")
    print(f"fee_rate:             {result['fee_rate']}")
    print(f"fee_sats:             {result['fee_sats']}")
    print(f"change_sats:          {result['change_sats']}")
    print(f"change_policy:        {result['change_policy']}")
    print(f"shared_secret_index:  {result['matched_shared_secret_index']}")
    print(f"input_count:          {result['input_count']}")
    print(f"output_count:         {result['output_count']}")
    print(f"vsize:                {result['vsize']}")
    print(f"weight:               {result['weight']}")
    print(f"api_base:             {result['api_base']}")
    print(f"txid:                 {result['txid']}")
    print(f"tx_hex:               {result['tx_hex']}")

    if args.broadcast:
        broadcast_txid = broadcast_transaction(result["tx_hex"], args.api_base, args.timeout)
        print(f"broadcast_txid:       {broadcast_txid}")
    else:
        print("broadcast:            no (dry run)")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

This script is a minimal-dependency Python script that demonstrates how to validate a Bitcoin transaction against a Silent Payments receiver derived from an nsec, using only standard libraries shipped with core Python. It can derive the resulting Silent Payments address from the key material and, when given a transaction id, locally inspect the transaction to determine whether it contains a matching Silent Payments receipt and whether the matched output is still spendable.

It is intended for demonstration and experimental review, with an emphasis on keeping the trust surface small and understandable. By avoiding imports and third-party packages, the script serves as a compact reference for how Silent Payments receiver derivation, transaction matching, and spendability checks can be implemented in a self-contained Python tool.

#!/usr/bin/env python3
"""Validate a Silent Payments transaction against an nsec with minimal dependencies.

This script uses only the Python standard library. It supports:
- deriving a Silent Payments address from an nsec
- optionally validating a bitcoin transaction id locally
- NSP validation from nsec
- BIP352-compatible validation from nsec

WARNING:
This script is provided for demonstration and experimental purposes only.
It is intentionally dependency-minimized to make trust review easier, but it
is not a production wallet, not a hardened validation system, and not a
substitute for a full independent security review.

Examples:
    scripts/validate_silent_payment nsec1...
    scripts/validate_silent_payment nsec1... <txid>
    scripts/validate_silent_payment nsec1... <txid> --mode bip352
"""

from __future__ import annotations

import argparse
import hashlib
import json
import hmac
import sys
import time
from urllib import error, parse, request


BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
BECH32M_CONST = 0x2BC830A3
P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
GX = 55066263022277343669578718895168534326250603453777594175500187360389116729240
GY = 32670510020758816978083085130507043184471273380659243275938904335757337482424
SILENT_PAYMENT_SCAN_TAG = "nostr-sp/scan"
SILENT_PAYMENT_SPEND_TAG = "nostr-sp/spend"
SCAN_OUTPUT_SEARCH_LIMIT = 4096


def fail(message: str) -> "NoReturn":
    raise SystemExit(f"error: {message}")


def sha256(data: bytes) -> bytes:
    return hashlib.sha256(data).digest()


def tagged_hash(tag: str, payload: bytes) -> bytes:
    tag_hash = sha256(tag.encode("utf-8"))
    return sha256(tag_hash + tag_hash + payload)


def bech32_polymod(values: list[int]) -> int:
    generators = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
    checksum = 1
    for value in values:
        top = checksum >> 25
        checksum = ((checksum & 0x1FFFFFF) << 5) ^ value
        for i in range(5):
            if (top >> i) & 1:
                checksum ^= generators[i]
    return checksum


def bech32_hrp_expand(hrp: str) -> list[int]:
    return [ord(char) >> 5 for char in hrp] + [0] + [ord(char) & 31 for char in hrp]


def bech32_verify_checksum(hrp: str, data: list[int], expect_bech32m: bool = False) -> bool:
    constant = BECH32M_CONST if expect_bech32m else 1
    return bech32_polymod(bech32_hrp_expand(hrp) + data) == constant


def bech32_decode(value: str, expect_bech32m: bool = False) -> tuple[str | None, list[int] | None]:
    value = value.strip().lower()
    pos = value.rfind("1")
    if pos < 1:
        return None, None
    hrp = value[:pos]
    data: list[int] = []
    for char in value[pos + 1:]:
        if char not in BECH32_CHARSET:
            return None, None
        data.append(BECH32_CHARSET.find(char))
    if not bech32_verify_checksum(hrp, data, expect_bech32m=expect_bech32m):
        return None, None
    return hrp, data[:-6]


def bech32_create_checksum(hrp: str, data: list[int], bech32m: bool = False) -> list[int]:
    values = bech32_hrp_expand(hrp) + data
    constant = BECH32M_CONST if bech32m else 1
    polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ constant
    return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]


def bech32_encode(hrp: str, data: list[int], bech32m: bool = False) -> str:
    combined = data + bech32_create_checksum(hrp, data, bech32m=bech32m)
    return hrp + "1" + "".join(BECH32_CHARSET[d] for d in combined)


def convertbits(data: bytes | list[int], frombits: int, tobits: int, pad: bool = True) -> list[int] | None:
    acc = 0
    bits = 0
    ret: list[int] = []
    maxv = (1 << tobits) - 1
    for value in data:
        if value < 0 or value >> frombits:
            return None
        acc = (acc << frombits) | value
        bits += frombits
        while bits >= tobits:
            bits -= tobits
            ret.append((acc >> bits) & maxv)
    if pad:
        if bits:
            ret.append((acc << (tobits - bits)) & maxv)
    elif bits >= frombits or ((acc << (tobits - bits)) & maxv):
        return None
    return ret


def inverse_mod(a: int, modulus: int) -> int:
    return pow(a % modulus, modulus - 2, modulus)


def point_add(p1: tuple[int, int] | None, p2: tuple[int, int] | None) -> tuple[int, int] | None:
    if p1 is None:
        return p2
    if p2 is None:
        return p1

    x1, y1 = p1
    x2, y2 = p2

    if x1 == x2 and (y1 + y2) % P == 0:
        return None

    if p1 == p2:
        if y1 == 0:
            return None
        slope = ((3 * x1 * x1) * inverse_mod(2 * y1, P)) % P
    else:
        slope = ((y2 - y1) * inverse_mod(x2 - x1, P)) % P

    x3 = (slope * slope - x1 - x2) % P
    y3 = (slope * (x1 - x3) - y1) % P
    return x3, y3


def scalar_mult(k: int, point: tuple[int, int]) -> tuple[int, int] | None:
    result = None
    addend = point
    while k:
        if k & 1:
            result = point_add(result, addend)
        addend = point_add(addend, addend)
        k >>= 1
    return result


def privkey_to_compressed_pubkey(privkey_bytes: bytes) -> bytes:
    scalar = int.from_bytes(privkey_bytes, "big")
    if scalar <= 0 or scalar >= N:
        fail("private key is outside the valid secp256k1 scalar range")
    point = scalar_mult(scalar, (GX, GY))
    if point is None:
        fail("failed to derive secp256k1 public key")
    x, y = point
    prefix = 0x02 if y % 2 == 0 else 0x03
    return bytes([prefix]) + x.to_bytes(32, "big")


def compress_point(point: tuple[int, int] | None) -> bytes:
    if point is None:
        fail("invalid secp256k1 point")
    x, y = point
    prefix = 0x02 if y % 2 == 0 else 0x03
    return bytes([prefix]) + x.to_bytes(32, "big")


def decompress_compressed_pubkey(pubkey: bytes) -> tuple[int, int]:
    if len(pubkey) != 33 or pubkey[0] not in (0x02, 0x03):
        fail("compressed public key must be 33 bytes with prefix 02 or 03")
    prefix = pubkey[0]
    x = int.from_bytes(pubkey[1:], "big")
    alpha = (pow(x, 3, P) + 7) % P
    beta = pow(alpha, (P + 1) // 4, P)
    if (beta % 2 == 0 and prefix == 0x02) or (beta % 2 == 1 and prefix == 0x03):
        y = beta
    else:
        y = P - beta
    return x, y


def pubkey_point_add_scalar(pubkey: bytes, tweak_scalar: int) -> bytes:
    if tweak_scalar <= 0 or tweak_scalar >= N:
        fail("tweak scalar is outside the valid secp256k1 scalar range")
    point = decompress_compressed_pubkey(pubkey)
    tweak_point = scalar_mult(tweak_scalar, (GX, GY))
    added = point_add(point, tweak_point)
    if added is None:
        fail("tweaked public key resolved to the point at infinity")
    return compress_point(added)


def normalize_bip340_private_key(privkey_bytes: bytes) -> tuple[bytes, bytes, bool]:
    compressed_pubkey = privkey_to_compressed_pubkey(privkey_bytes)
    if compressed_pubkey[0] == 0x02:
        return privkey_bytes, compressed_pubkey, False
    scalar = int.from_bytes(privkey_bytes, "big")
    normalized_scalar = (N - scalar) % N
    if normalized_scalar == 0:
        fail("private key normalization produced zero")
    normalized_bytes = normalized_scalar.to_bytes(32, "big")
    normalized_pubkey = privkey_to_compressed_pubkey(normalized_bytes)
    if normalized_pubkey[0] != 0x02:
        fail("failed to normalize private key to even-y form")
    return normalized_bytes, normalized_pubkey, True


def nsec_to_privkey(nsec: str) -> bytes:
    hrp, data = bech32_decode(nsec)
    if hrp != "nsec" or data is None:
        fail("invalid nsec")
    decoded = convertbits(data, 5, 8, False)
    if decoded is None:
        fail("invalid nsec payload")
    raw = bytes(decoded)
    if len(raw) != 32:
        fail("expected a 32-byte nsec private key")
    return raw


def npub_to_xonly_pubkey(npub: str) -> bytes:
    hrp, data = bech32_decode(npub)
    if hrp != "npub" or data is None:
        fail("invalid npub")
    decoded = convertbits(data, 5, 8, False)
    if decoded is None:
        fail("invalid npub payload")
    raw = bytes(decoded)
    if len(raw) != 32:
        fail("expected a 32-byte npub public key payload")
    return raw


def hmac_sha512(key: bytes, data: bytes) -> bytes:
    return hmac.new(key, data, hashlib.sha512).digest()


def bip32_master_from_seed(seed_bytes: bytes) -> tuple[bytes, bytes]:
    i = hmac_sha512(b"Bitcoin seed", seed_bytes)
    master_secret = i[:32]
    master_chain_code = i[32:]
    if int.from_bytes(master_secret, "big") == 0 or int.from_bytes(master_secret, "big") >= N:
        fail("invalid master key derived from seed")
    return master_secret, master_chain_code


def bip32_ckd_priv(parent_key: bytes, parent_chain_code: bytes, index: int) -> tuple[bytes, bytes]:
    if index < 0 or index > 0xFFFFFFFF:
        fail("invalid child index")
    hardened = index >= 0x80000000
    if hardened:
        data = b"\x00" + parent_key + index.to_bytes(4, "big")
    else:
        data = privkey_to_compressed_pubkey(parent_key) + index.to_bytes(4, "big")
    i = hmac_sha512(parent_chain_code, data)
    il = int.from_bytes(i[:32], "big")
    if il >= N:
        fail("invalid BIP32 child tweak")
    child_scalar = (il + int.from_bytes(parent_key, "big")) % N
    if child_scalar == 0:
        fail("invalid zero BIP32 child key")
    return child_scalar.to_bytes(32, "big"), i[32:]


def derive_bip352_keys_from_seed(seed_bytes: bytes) -> dict[str, str]:
    key, chain = bip32_master_from_seed(seed_bytes)
    for index in (352 + 0x80000000, 0 + 0x80000000, 0 + 0x80000000):
        key, chain = bip32_ckd_priv(key, chain, index)
    spend_key, _ = bip32_ckd_priv(*bip32_ckd_priv(key, chain, 0 + 0x80000000), 0)
    scan_key, _ = bip32_ckd_priv(*bip32_ckd_priv(key, chain, 1 + 0x80000000), 0)
    return {
        "scan_private_key_hex": scan_key.hex(),
        "spend_private_key_hex": spend_key.hex(),
        "scan_public_key_hex": privkey_to_compressed_pubkey(scan_key).hex(),
        "spend_public_key_hex": privkey_to_compressed_pubkey(spend_key).hex(),
    }


def derive_nsp_keys_from_nsec(seed_bytes: bytes) -> dict[str, str]:
    base_privkey_bytes, base_pubkey, normalized = normalize_bip340_private_key(seed_bytes)
    base_scalar = int.from_bytes(base_privkey_bytes, "big")
    scan_tweak = int.from_bytes(tagged_hash(SILENT_PAYMENT_SCAN_TAG, base_pubkey), "big") % N
    spend_tweak = int.from_bytes(tagged_hash(SILENT_PAYMENT_SPEND_TAG, base_pubkey), "big") % N
    if scan_tweak == 0 or spend_tweak == 0:
        fail("silent payment derivation produced an invalid zero tweak")
    scan_scalar = (base_scalar + scan_tweak) % N
    spend_scalar = (base_scalar + spend_tweak) % N
    if scan_scalar == 0 or spend_scalar == 0:
        fail("silent payment derivation produced an invalid zero private key")
    scan_pubkey = pubkey_point_add_scalar(base_pubkey, scan_tweak)
    spend_pubkey = pubkey_point_add_scalar(base_pubkey, spend_tweak)
    return {
        "scan_private_key_hex": scan_scalar.to_bytes(32, "big").hex(),
        "spend_private_key_hex": spend_scalar.to_bytes(32, "big").hex(),
        "scan_public_key_hex": scan_pubkey.hex(),
        "spend_public_key_hex": spend_pubkey.hex(),
        "bip340_normalized": "yes" if normalized else "no",
        "base_public_key_hex": base_pubkey.hex(),
    }


def resolve_material(nostr_key: str, mode: str) -> dict[str, str]:
    normalized_mode = (mode or "nsp").strip().lower()
    if normalized_mode not in {"nsp", "bip352"}:
        fail("mode must be 'nsp' or 'bip352'")

    key_text = (nostr_key or "").strip()
    if key_text.startswith("nsec"):
        seed_bytes = nsec_to_privkey(key_text)
        if normalized_mode == "nsp":
            material = derive_nsp_keys_from_nsec(seed_bytes)
        else:
            material = derive_bip352_keys_from_seed(seed_bytes)
        material["input_kind"] = "nsec"
        material["wallet_mode"] = normalized_mode
        return material

    if key_text.startswith("npub"):
        if normalized_mode != "nsp":
            fail("bip352 validation requires an nsec because wallet-compatible private derivation is needed")
        xonly = npub_to_xonly_pubkey(key_text)
        base_pubkey = b"\x02" + xonly
        scan_tweak = int.from_bytes(tagged_hash(SILENT_PAYMENT_SCAN_TAG, base_pubkey), "big") % N
        spend_tweak = int.from_bytes(tagged_hash(SILENT_PAYMENT_SPEND_TAG, base_pubkey), "big") % N
        if scan_tweak == 0 or spend_tweak == 0:
            fail("silent payment derivation produced an invalid zero tweak")
        return {
            "scan_public_key_hex": pubkey_point_add_scalar(base_pubkey, scan_tweak).hex(),
            "spend_public_key_hex": pubkey_point_add_scalar(base_pubkey, spend_tweak).hex(),
            "input_kind": "npub",
            "wallet_mode": normalized_mode,
            "base_public_key_hex": base_pubkey.hex(),
        }

    fail("nostr_key must be an nsec or npub")


def encode_silent_payment_address(scan_pubkey: bytes, spend_pubkey: bytes, hrp: str = "sp") -> str:
    if len(scan_pubkey) != 33 or len(spend_pubkey) != 33:
        fail("silent payment scan and spend public keys must be 33-byte compressed pubkeys")
    data = convertbits(scan_pubkey + spend_pubkey, 8, 5, True)
    if data is None:
        fail("failed to convert silent payment pubkeys into bech32m data")
    return bech32_encode(hrp, [0] + data, bech32m=True)


def silent_payment_hrp(address: str) -> str:
    normalized = (address or "").strip().lower()
    if not normalized or "1" not in normalized:
        fail("silent payment address must be a valid bech32m string")
    hrp, _ = normalized.split("1", 1)
    if not hrp:
        fail("silent payment address is missing its human-readable prefix")
    return hrp


def _normalize_script_type_name(script_type: str) -> str:
    normalized = script_type.strip().lower()
    aliases = {
        "v0_p2wpkh": "p2wpkh",
        "v1_p2tr": "p2tr",
        "v0_p2wsh": "p2wsh",
        "p2sh-p2wpkh": "p2sh",
    }
    return aliases.get(normalized, normalized)


def _script_type(script_hex: str) -> str:
    if script_hex.startswith("76a914") and script_hex.endswith("88ac") and len(script_hex) == 50:
        return "p2pkh"
    if script_hex.startswith("a914") and script_hex.endswith("87") and len(script_hex) == 46:
        return "p2sh"
    if script_hex.startswith("0014") and len(script_hex) == 44:
        return "p2wpkh"
    if script_hex.startswith("5120") and len(script_hex) == 68:
        return "p2tr"
    return "unknown"


def _compressed_pub_from_xonly(xonly_hex: str) -> bytes:
    xonly = bytes.fromhex(xonly_hex)
    if len(xonly) != 32:
        fail("x-only public key must be 32 bytes")
    return b"\x02" + xonly


def _xonly_pubkey_bytes(xonly_hex: str) -> bytes:
    xonly = bytes.fromhex(xonly_hex)
    if len(xonly) != 32:
        fail("x-only public key must be 32 bytes")
    return xonly


def _outpoint_bytes(txid: str, vout: int) -> bytes:
    return bytes.fromhex(txid)[::-1] + int(vout).to_bytes(4, "little")


def _ser32(value: int) -> bytes:
    return int(value).to_bytes(4, "big")


def _extract_input_pubkey(vin: dict[str, object]) -> bytes | None:
    prevout = vin.get("prevout")
    if not isinstance(prevout, dict):
        return None
    script_hex = str(prevout.get("scriptpubkey") or "")
    script_type = _normalize_script_type_name(str(prevout.get("scriptpubkey_type") or _script_type(script_hex)))
    witness = vin.get("witness") or []
    scriptsig_hex = str(vin.get("scriptsig") or "")

    if script_type == "p2wpkh":
        if isinstance(witness, list) and witness:
            pubkey = bytes.fromhex(str(witness[-1]))
            if len(pubkey) == 33:
                return pubkey
        return None

    if script_type == "p2sh":
        redeem_hex = scriptsig_hex[2:] if scriptsig_hex.startswith("16") else scriptsig_hex
        if redeem_hex.startswith("0014") and isinstance(witness, list) and witness:
            pubkey = bytes.fromhex(str(witness[-1]))
            if len(pubkey) == 33:
                return pubkey
        return None

    if script_type == "p2tr":
        if script_hex.startswith("5120"):
            return _compressed_pub_from_xonly(script_hex[4:])
        return None

    if script_type == "p2pkh":
        script_bytes = bytes.fromhex(scriptsig_hex)
        for idx in range(len(script_bytes), 32, -1):
            candidate = script_bytes[idx - 33:idx]
            if len(candidate) == 33 and candidate[0] in (0x02, 0x03):
                return candidate
        return None

    return None


def _combine_pubkeys(pubkeys: list[bytes]) -> bytes | None:
    if not pubkeys:
        return None
    point = None
    for pubkey in pubkeys:
        point = point_add(point, decompress_compressed_pubkey(pubkey))
    return compress_point(point) if point is not None else None


def _get_input_hash(outpoint_bytes: list[bytes], summed_input_pubkey: bytes) -> bytes:
    return tagged_hash("BIP0352/Inputs", sorted(outpoint_bytes)[0] + summed_input_pubkey)


def _http_json(url: str, timeout: float) -> object:
    last_rate_limit_error = None
    for attempt in range(4):
        req = request.Request(url, headers={"Accept": "application/json", "User-Agent": "validate-silent-payment/0.1"})
        try:
            with request.urlopen(req, timeout=timeout) as response:
                return json.loads(response.read().decode("utf-8"))
        except error.HTTPError as exc:
            if exc.code == 429 and attempt < 3:
                last_rate_limit_error = exc
                retry_after = exc.headers.get("Retry-After") if exc.headers is not None else None
                try:
                    delay = float(retry_after) if retry_after else (1.5 * (2 ** attempt))
                except ValueError:
                    delay = 1.5 * (2 ** attempt)
                time.sleep(min(delay, 12.0))
                continue
            fail(f"HTTP {exc.code} for {url}")
        except error.URLError as exc:
            fail(f"request failed for {url}: {exc.reason}")
        except TimeoutError:
            fail(f"request timed out for {url}")
        except json.JSONDecodeError as exc:
            fail(f"invalid JSON from {url}: {exc}")
    fail(f"HTTP 429 after multiple retries for {url}: {last_rate_limit_error}")


def fetch_transaction(txid: str, api_base: str, timeout: float) -> dict[str, object]:
    payload = _http_json(f"{api_base.rstrip('/')}/tx/{parse.quote(txid)}", timeout)
    if not isinstance(payload, dict):
        fail(f"unexpected transaction payload for {txid}")
    return payload


def fetch_address_utxos(address: str, api_base: str, timeout: float) -> list[dict[str, object]]:
    payload = _http_json(f"{api_base.rstrip('/')}/address/{parse.quote(address)}/utxo", timeout)
    if not isinstance(payload, list):
        fail(f"unexpected UTXO payload for {address}")
    utxos = []
    for item in payload:
        if not isinstance(item, dict):
            continue
        status = item.get("status") or {}
        utxos.append(
            {
                "txid": str(item["txid"]),
                "vout": int(item["vout"]),
                "value": int(item["value"]),
                "confirmed": bool(status.get("confirmed")),
                "block_height": int(status.get("block_height", 0) or 0),
            }
        )
    return utxos


def confirmed_utxos_only(utxos: list[dict[str, object]]) -> list[dict[str, object]]:
    return [utxo for utxo in utxos if bool(utxo.get("confirmed"))]


def annotate_spendable_matches(matches: list[dict[str, object]], api_base: str, timeout: float) -> list[dict[str, object]]:
    annotated = []
    for match in matches:
        source_address = str(match.get("scriptpubkey_address") or "")
        txid = str(match.get("txid") or "")
        vout = int(match.get("vout", 0) or 0)
        spendable = False
        confirmed_unspent = False
        mempool_unspent = False
        status = "unknown"
        if source_address:
            all_utxos = fetch_address_utxos(source_address, api_base=api_base, timeout=timeout)
            confirmed_unspent = any(
                str(utxo["txid"]) == txid and int(utxo["vout"]) == vout and bool(utxo.get("confirmed"))
                for utxo in all_utxos
            )
            mempool_unspent = any(
                str(utxo["txid"]) == txid and int(utxo["vout"]) == vout and not bool(utxo.get("confirmed"))
                for utxo in all_utxos
            )
            spendable = confirmed_unspent
            if confirmed_unspent:
                status = "confirmed_unspent"
            elif mempool_unspent:
                status = "mempool_unspent"
            else:
                status = "spent"
        enriched = dict(match)
        enriched["spendable"] = spendable
        enriched["confirmed_unspent"] = confirmed_unspent
        enriched["mempool_unspent"] = mempool_unspent
        enriched["utxo_status"] = status
        annotated.append(enriched)
    return annotated


def scan_transaction(nostr_key: str, tx: dict[str, object], mode: str) -> dict[str, object]:
    material = resolve_material(nostr_key, mode)
    if "scan_private_key_hex" not in material or "spend_public_key_hex" not in material:
        fail("transaction scanning requires an nsec so the private scan key is available")

    vin = tx.get("vin")
    vout = tx.get("vout")
    if not isinstance(vin, list) or not isinstance(vout, list):
        fail("transaction payload is missing vin/vout arrays")

    input_pubkeys = []
    outpoints = []
    for txin in vin:
        if not isinstance(txin, dict):
            continue
        pubkey = _extract_input_pubkey(txin)
        txid = str(txin.get("txid") or "")
        vout_index = int(txin.get("vout", 0) or 0)
        if pubkey is None or not txid:
            continue
        input_pubkeys.append(pubkey)
        outpoints.append(_outpoint_bytes(txid, vout_index))

    if not input_pubkeys or not outpoints:
        return {"txid": str(tx.get("txid", "")), "matched_outputs": [], "input_pubkey_count": 0, "warning": "No eligible input public keys were found for Silent Payments scanning."}

    summed_input_pubkey = _combine_pubkeys(input_pubkeys)
    if summed_input_pubkey is None:
        return {"txid": str(tx.get("txid", "")), "matched_outputs": [], "input_pubkey_count": len(input_pubkeys), "warning": "Input public keys summed to an invalid point for Silent Payments scanning."}

    scan_priv_bytes = bytes.fromhex(material["scan_private_key_hex"])
    spend_pubkey = bytes.fromhex(material["spend_public_key_hex"])
    input_hash_scalar = int.from_bytes(_get_input_hash(outpoints, summed_input_pubkey), "big") % N
    if input_hash_scalar == 0:
        return {"txid": str(tx.get("txid", "")), "matched_outputs": [], "input_pubkey_count": len(input_pubkeys), "warning": "Silent Payments input hash resolved to an invalid zero scalar."}

    scan_priv_scalar = int.from_bytes(scan_priv_bytes, "big")
    shared_scalar = (input_hash_scalar * scan_priv_scalar) % N
    if shared_scalar == 0:
        return {"txid": str(tx.get("txid", "")), "matched_outputs": [], "input_pubkey_count": len(input_pubkeys), "warning": "Silent Payments shared secret resolved to an invalid zero scalar."}

    summed_input_point = decompress_compressed_pubkey(summed_input_pubkey)
    ecdh_point = scalar_mult(shared_scalar, summed_input_point)
    if ecdh_point is None:
        return {"txid": str(tx.get("txid", "")), "matched_outputs": [], "input_pubkey_count": len(input_pubkeys), "warning": "Silent Payments ECDH point resolved to infinity."}
    ecdh_compressed = compress_point(ecdh_point)

    remaining_outputs = {}
    for output in vout:
        if not isinstance(output, dict):
            continue
        script_hex = str(output.get("scriptpubkey") or "")
        script_type = _normalize_script_type_name(str(output.get("scriptpubkey_type") or _script_type(script_hex)))
        if script_type != "p2tr":
            continue
        remaining_outputs[_xonly_pubkey_bytes(script_hex[4:])] = output

    matched_outputs = []
    warning = ""
    for k in range(SCAN_OUTPUT_SEARCH_LIMIT):
        if not remaining_outputs:
            break
        tweak = tagged_hash("BIP0352/SharedSecret", ecdh_compressed + _ser32(k))
        tweak_scalar = int.from_bytes(tweak, "big") % N
        if tweak_scalar == 0:
            continue
        derived_pubkey = pubkey_point_add_scalar(spend_pubkey, tweak_scalar)
        output = remaining_outputs.pop(derived_pubkey[1:], None)
        if output is not None:
            matched_outputs.append({
                "txid": str(tx.get("txid", "")),
                "vout": int(output.get("vout", 0) or 0),
                "value": int(output.get("value", 0) or 0),
                "scriptpubkey_address": str(output.get("scriptpubkey_address") or ""),
                "output_pubkey_hex": derived_pubkey.hex(),
                "priv_key_tweak_hex": tweak.hex(),
                "shared_secret_index": k,
            })
    if remaining_outputs and not matched_outputs:
        warning = "No matching Silent Payments outputs were found within the current scan window."

    return {
        "txid": str(tx.get("txid", "")),
        "matched_outputs": matched_outputs,
        "input_pubkey_count": len(input_pubkeys),
        "warning": warning,
    }


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Derive a Silent Payments address from an nsec, and optionally validate a bitcoin transaction against it.")
    parser.add_argument("nsec", help="Receiver nsec")
    parser.add_argument("txid", nargs="?", default=None, help="Optional bitcoin transaction id to scan locally")
    parser.add_argument("--mode", choices=["nsp", "bip352"], default="nsp", help="Validation mode")
    parser.add_argument("--api-base", default="https://blockstream.info/api", help="Esplora-compatible API base URL for tx lookup")
    parser.add_argument("--timeout", type=float, default=5.0, help="HTTP timeout in seconds for tx lookup")
    return parser.parse_args()


def main() -> int:
    warning_banner = (
        "WARNING: demonstration and experimental script only. "
        "Not production-approved and not a substitute for independent security review."
    )
    args = parse_args()
    print(warning_banner)
    material = resolve_material(args.nsec, args.mode)
    derived_address = encode_silent_payment_address(
        bytes.fromhex(material['scan_public_key_hex']),
        bytes.fromhex(material['spend_public_key_hex']),
        hrp='sp',
    )

    print(f"input_kind:            {material['input_kind']}")
    print(f"wallet_mode:           {material['wallet_mode']}")
    if material.get("base_public_key_hex"):
        print(f"base_public_key_hex:   {material['base_public_key_hex']}")
    print(f"scan_public_key_hex:   {material['scan_public_key_hex']}")
    print(f"spend_public_key_hex:  {material['spend_public_key_hex']}")
    print(f"silent_payment_addr:   {derived_address}")

    if not args.txid:
        print("valid:                 yes")
        return 0

    tx = fetch_transaction(args.txid, api_base=args.api_base, timeout=args.timeout)
    scan_result = scan_transaction(args.nsec, tx, mode=args.mode)
    scan_result['matched_outputs'] = annotate_spendable_matches(scan_result['matched_outputs'], api_base=args.api_base, timeout=args.timeout)
    tx_valid = len(scan_result['matched_outputs']) > 0
    spendable_valid = any(bool(match.get('spendable')) for match in scan_result['matched_outputs'])
    print(f"txid:                  {scan_result['txid']}")
    print(f"input_pubkey_count:    {scan_result['input_pubkey_count']}")
    if scan_result['warning']:
        print(f"tx_warning:            {scan_result['warning']}")
    print(f"matched_output_count:  {len(scan_result['matched_outputs'])}")
    for index, match in enumerate(scan_result['matched_outputs'], start=1):
        print(f"match_{index}_vout:              {match['vout']}")
        print(f"match_{index}_value_sats:        {match['value']}")
        print(f"match_{index}_scriptpubkey_addr: {match['scriptpubkey_address']}")
        print(f"match_{index}_output_pubkey_hex: {match['output_pubkey_hex']}")
        print(f"match_{index}_priv_key_tweak:    {match['priv_key_tweak_hex']}")
        print(f"match_{index}_shared_index:      {match['shared_secret_index']}")
        print(f"match_{index}_confirmed_unspent: {'yes' if match['confirmed_unspent'] else 'no'}")
        print(f"match_{index}_mempool_unspent:   {'yes' if match['mempool_unspent'] else 'no'}")
        print(f"match_{index}_utxo_status:       {match['utxo_status']}")
        print(f"match_{index}_spendable:         {'yes' if match['spendable'] else 'no'}")
    print(f"tx_valid:              {'yes' if tx_valid else 'no'}")
    print(f"spendable_valid:       {'yes' if spendable_valid else 'no'}")
    if tx_valid and not spendable_valid:
        if any(match.get('mempool_unspent') for match in scan_result['matched_outputs']):
            print("spendable_note:        matched UTXO is still in the mempool and not yet confirmed as unspent")
        else:
            print("spendable_note:        matched UTXO has already been spent")
    overall_valid = tx_valid and spendable_valid
    print(f"valid:                 {'yes' if overall_valid else 'no'}")
    return 0 if overall_valid else 1


if __name__ == "__main__":
    raise SystemExit(main())


@trbouma

trbouma commented May 26, 2026

Copy link
Copy Markdown
Author

Yes, @dergigi - being able to deny that you indicated an intent to receive is an incredibly important property - in most jurisdictions that's all the authorities need. Putting you in the back of a cruiser and squeezing out your nsec and seed phrases then becomes the easy part.

In the end, the regular silents payment proposal is perfectly fine and should be implemented. Users are protected when they use a scanning server, but they've indicated the intent to receive silent payments. With this proposal, nothing would get published - for sure, there are tradeoffs on recovering the funds - you need a transaction hint, use a trusted server, or rely on herd privacy on a reputable public server, such as frigate.2140.dev. Tradeoffs worth considering, unless it is ok to have recipient assume more risk with a published silent payments address.

@trbouma

trbouma commented May 26, 2026

Copy link
Copy Markdown
Author

I added a graphic comic to show how nostr silent payment would work with NIP-05. The sweep would be possible by any nsec-enabled app or added as a method to a NIP-07 extension accepting a txid and a bc1 input. The user wouldn't even have to know about the scan_privkey and spend_pubkey.

Of course, there is the sender-receiver coordination in this scenario. The onus is on the sender to provide the txids - not the receiver, who is not really 'expecting' the payments. There is risk, no doubt, in communicating the txids, but it could be done by encrpyted messaging, or other techniques. The fallback of course, if the receiver is equipped, is to use a trusted scanning server with the caveats described above.

@trbouma

trbouma commented May 29, 2026

Copy link
Copy Markdown
Author

Sorry for the repeated cross-posts, but I now consider BIP352, on its own, a dangerous proposal

Cross-posting from the PR to here. I now consider BIP352, on its own, a dangerous proposal. Sorry if this seems like spam, but I wanted to raise the alarm.

I took very seriously, @erskingardner feedback, but just realized I was not the originator of the idea of sharing a private key - put that idea onto the BIP352 authors. As you will read below, the approach considers the private of the receiver - at the risk of the senders.


Hear me out.

It wasn't my idea to expose a private key for Nostr Silent Payments. It wasn't even in the original proposal until I began to explore using the Sparrow Frigate server and what is proposed in BIP352.

I've concluded that sharing any form of a private key (hardenened derived or otherwise) is a BAD IDEA! Telling a user to share a private key, even though it is hardened is IRRESPONSIBLE.

So please redirect your private-key-sharing rage to the authors of BIP352 who introduced the concept in the first place.

I am also coming to the conclusion that BIP352, though it has some great ideas, is a DANGEROUS PROPOSAL on its own because it encourages a user to expose a private key, if doxxed, exposes all of the donors to the silent payment address. Though the spend key is still safe and the funds are safe for the recipient, it introduces a risk for any sender/donor to that address. The receiver can issue a new silent payments address, but the DONORS REMAIN EXPOSED.

If you care about your own privacy and security, and don't really care about the security and privacy of the donors who send to you, then BIP352 os great! But otherwise, I considering BIP352, on its own, a DANGEROUS PROPOSAL.

Read all about it below:

Direct excerpt from BIP352

"Spend and Scan Key
Since Bob needs his private key b to check for incoming payments, this requires b to be exposed to an online device."

https://en.bitcoin.it/wiki/BIP_0352

@trbouma

trbouma commented May 29, 2026

Copy link
Copy Markdown
Author

Just FYI - I am being actively trolled on this proposal - so removing any comments that are indicative of trolling behaviour.

@trbouma

trbouma commented May 29, 2026

Copy link
Copy Markdown
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment