diff --git a/elip-silent-payments-liquid.mediawiki b/elip-silent-payments-liquid.mediawiki
new file mode 100644
index 0000000..813e093
--- /dev/null
+++ b/elip-silent-payments-liquid.mediawiki
@@ -0,0 +1,180 @@
+
+ELIP: ?
+Layer: Applications
+Title: Silent Payments for the Liquid Network
+Author: 42pupusas
+Comments-Summary: No comments yet.
+Comments-URI: https://github.com/ElementsProject/elips/wiki/Comments:ELIP-????
+Status: Draft
+Type: Standards Track
+Created: 2026-06-01
+License: BSD-3-Clause
+
+
+==Abstract==
+
+This document specifies Silent Payments for the Liquid Network. The key derivation,
+address format, scanning, and spending follow BIP-352[BIP-352: Silent Payments. https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki]
+exactly. This specification only adds Liquid-specific differences.
+
+==Copyright==
+
+This document is licensed under the 3-clause BSD license.
+
+==Motivation==
+
+Confidential Transactions (CT)[Confidential Transactions. https://elementsproject.org/features/confidential-transactions]
+hide a Liquid output's asset and amount but not its
+script. A receiver with one published address has to either reuse it, (linking
+all their payments) or run an interactive protocol to hand out fresh ones. Silent
+Payments remove that trade-off: one static address, a distinct unlinkable output per payment.
+
+==Specification==
+
+We reuse the notation, functions, and conventions of BIP-352. In particular,
+serP(P) is the SEC1 compressed encoding of a point, ser32(i)
+serializes a 32-bit unsigned integer most-significant-byte first, n is the
+secp256k1 curve order, and hashtag(x) denotes
+SHA256(SHA256(tag) || SHA256(tag) || x) as defined in
+BIP-340[BIP-340: Schnorr Signatures for secp256k1. https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki].
+
+We additionally define:
+
+* Let blind_tag be the exact 26-byte ASCII string LiquidSilentPayments/Blind.
+
+A mismatch in these bytes breaks unblinding across implementations. The tag is disjoint
+from BIP-352's BIP0352/SharedSecret, so the blinding key and the BIP-352
+output tweak, though both derived from S, do not reveal each other.
+
+A silent payment on Liquid is a BIP-352 output that is additionally made a confidential
+Liquid output, blinded to a per-output blinding key derived from the same shared secret.
+The sender, for each output index k:
+
+* Let S be the shared secret and P_k the output public key, derived as in BIP-352
+* Let scriptPubKey = OP_1 , a BIP-341[BIP-341: Taproot: SegWit version 1 spending rules. https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki] Taproot output
+* Let bk_k = int(hashblind_tag(serP(S) || ser32(k)))
+** If bk_k = 0 or bk_k ≥ n, bk_k is not a valid secp256k1 secret key: as with an out-of-range t_k in BIP-352, this MUST be treated as a failure, not skipped — the sender fails to construct this output and the receiver fails to scan past it. (The probability is about 2-128 and cannot be triggered by an attacker without solving ECDH.)
+* Let BK_k = bk_k·G
+* Blind the output's asset and amount to BK_k, as for any confidential Liquid output. This includes all witness data required by Liquid consensus — in particular the asset ''surjection proof'', which is built from transaction-level input data as usual and is independent of the silent-payments derivation (it does not involve bk_k or S)
+
+The receiver, having recomputed S as in BIP-352, recomputes
+bk_k and unblinds the output with no out-of-band exchange. Deriving the
+blinding key from S avoids both a fixed address-level blinding key (which
+would link a receiver's outputs) and an interactive per-output exchange.
+
+===Differences from BIP-352===
+
+* '''Output format.''' The output is a ''confidential'' Liquid Taproot output, with its asset and amount blinded to BK_k, rather than a bare Taproot output.
+* '''outpoint_L encoding.''' Outpoints use the Elements consensus encoding: the 36-byte concatenation of the 32-byte txid followed by the 4-byte little-endian vout. The txid is in ''internal byte order'', i.e. the raw 32 bytes output by the double-SHA256 of the serialized transaction, exactly as they appear inside a serialized transaction input — the ''reverse'' of the big-endian hex string displayed by RPC interfaces and block explorers. For example, output vout = 1 of the transaction displayed as
+
+ 112233445566778899aabbccddeeff00112233445566778899aabbccddeeff00
+
+is encoded as the 36 bytes
+
+ 00ffeeddccbbaa99887766554433221100ffeeddccbbaa998877665544332211 01000000
+
+The test-vector txids below are given in internal byte order. Elements also overloads the high bits of vout as issuance (bit 31) and peg-in (bit 30) flags, cleared when the input is parsed[Elements ]COutPoint::OUTPOINT_ISSUANCE_FLAG / OUTPOINT_PEGIN_FLAG. https://github.com/ElementsProject/elements/blob/master/src/primitives/transaction.h; vout here is always the parsed index with both flags cleared (equivalently, vout & 0x3fffffff), matching what every implementation already has after deserializing the transaction. A coinbase input's vout = 0xffffffff carries no flags and is unaffected.
+* '''Input eligibility for peg-ins and issuances.''' A peg-in input spends a Bitcoin-chain output that a Liquid-only client cannot inspect, so it MUST be excluded from the eligible-input list in [[#inputs-for-shared-secret-derivation|Inputs For Shared Secret Derivation]] — it contributes no public key to a/A — but its outpoint still participates in outpoint_L, since that is read from the Liquid transaction itself. An input carrying an asset issuance or reissuance is eligible under BIP-352's normal rules unchanged; an issuance does not alter how the input is spent or its scriptPubKey.
+* '''Key derivation path.''' Scan and spend keys use the BIP-352 derivation scheme with the coin type replaced by SLIP-44[SLIP-0044: Registered coin types for BIP-0044. https://github.com/satoshilabs/slips/blob/master/slip-0044.md] code 1776 (Liquid): scan_key: m/352'/1776'/account'/1'/0 and spend_key: m/352'/1776'/account'/0'/0 for mainnet, with coin type 1' for testnet and regtest. This keeps Liquid silent-payment keys disjoint from a wallet's Bitcoin silent-payment keys (m/352'/0'/...).
+* '''Address HRP.''' The human-readable part is lqsp (mainnet) or tlqsp (testnet and regtest) — distinct from ex/tex, lq/tlq, and Bitcoin's sp/tsp. The address payload, version symbol (q), and relaxed Bech32 length limit are unchanged from BIP-352.
+* '''Light-client scanning.''' BIP-158[BIP-158: Compact Block Filters for Light Clients. https://github.com/bitcoin/bips/blob/master/bip-0158.mediawiki] compact filters are unchanged: Elements implements BIP-157/158 identically to Bitcoin Core (NODE_COMPACT_FILTERS, getcfilters/getcfheaders/getcfcheckpt, and the getblockfilter RPC), and filters are built from scriptPubKeys, which CT never blinds. A light client scans for candidate P_k the same way as on Bitcoin, downloading a full block only on a filter match.
+* '''Tweak server.''' The Silent Payments index server specification[Silent Payments Index Server Specification. https://github.com/silent-payments/BIP0352-index-server-specification], unchanged: publishes T = input_hash·A per transaction; a client computes S = b_scan·T. Its optional dust_limit filtering does not apply on Liquid — output amounts are confidential, so a server cannot tell a dust output from any other; a Liquid tweak server MUST report dust_limit = 0 (disabled).
+
+Everything else follows BIP-352 exactly: eligible-input extraction for the four
+BIP-352 input types, the even-Y rule, input_hash,
+S = input_hash·a·B_scan, the output tweak t_k, labels,
+gap-limit scanning, the per-group recipient limit K_max = 2323 (a
+sender MUST fail if a recipient group exceeds it; a receiver MUST NOT scan past
+k = K_max), and the Taproot key-path spend with
+d = b_spend + t_k.
+
+===Privacy===
+
+Detecting or unblinding a silent payment requires the Diffie-Hellman shared secret
+S = input_hash·a·B_scan = b_scan·(input_hash·A). Computing it needs either the
+sender's input secret a or the receiver's secret scan key b_scan,
+so this inherits the BIP-352 privacy model. CT additionally hides the output's amount
+and asset from anyone who holds neither key.
+
+Since bk_k is derived from S, a party holding
+b_scan can unblind the output, not merely detect it. A wallet that
+delegates b_scan to a scanning server therefore reveals the amounts and
+assets of all its incoming silent payments to that server (which still cannot spend
+them without b_spend). Wallets requiring confidentiality against the
+scanning server must scan locally.
+
+==Test Vectors==
+
+All byte strings are hex. Receiver keys are given as raw scalars for reproducibility
+(a wallet would derive them per the key derivation path above); txids are in internal
+byte order:
+
+
+b_scan = 1111111111111111111111111111111111111111111111111111111111111111
+b_spend = 2222222222222222222222222222222222222222222222222222222222222222
+
+
+Two eligible inputs:
+
+
+input 0: priv = 3131...31 (0x31 x32), outpoint txid = 1010...10 (0x10 x32), vout = 0
+input 1: priv = 3232...32 (0x32 x32), outpoint txid = 2020...20 (0x20 x32), vout = 1
+
+
+Aggregated values (outpoint_L = input 0, the lexicographically smaller):
+
+
+A = 031195a8046dcbb8e17034bca630065e7a0982e4e36f6f7e5a8d4554e4846fcd99
+input_hash = d392922c00280a7e8d282182f5026f2fddbc74c1e1de18b4822128b2b77ec641
+
+
+Per-output values:
+
+
+k = 0:
+ P_k = 02a29d9716417c964ca9e477343e71ffe730a4991a3eaad668eabec84e9feb7931
+ BK_k = 0344e1289497e6da66fde710d2f38de053fc07355e405524401d7d609df5a1a8cc
+ bk_k = 70ab8897b64bd21b427339ff4d014b883191ef6425862246c53bfc27a59aa3f0
+ spend priv = f03c436d2cd67ae1fecf7d88a38aa3a03c0abea43feaf6da8eb71e2e3a866bda
+ scriptPubKey = 5120a29d9716417c964ca9e477343e71ffe730a4991a3eaad668eabec84e9feb7931
+
+k = 1:
+ P_k = 0229d77654023af267dbe9cb7ff1956f947c816f203494381308387168fb010c92
+ BK_k = 03efdeda770ccdbe8bf466fba48bfd2b2c436ab0c04658fc6d6c277de5078129fa
+ bk_k = 945ba73a9804f62089c7d2ffdc079031031f0aebab372cec17ef9c110ebceb10
+ spend priv = 9eff3472230fc83ef5ea8f8c80401c4eecd595a048bd2482a107d3a49baa5a58
+ scriptPubKey = 512029d77654023af267dbe9cb7ff1956f947c816f203494381308387168fb010c92
+
+
+Mainnet address (HRP lqsp):
+
+
+lqsp1qqd8n2k7uklxq4aegau7vawtptkgxsja4kt99lpv6krctwpq8tpc65qjxd4lu4etruh9sngx3su9mtqp5fqzxz7re59y5nnez9p03ht3lyudcfhfe
+
+
+Because blinding factors are randomized per output, the blinded output is not
+byte-reproducible. What matters is that an output blinded to BK_k
+unblinds under bk_k and under no other key.
+
+==Backwards Compatibility==
+
+No consensus change. A silent-payment output is an ordinary confidential Taproot output,
+spent by an ordinary key-path signature, so existing relay and validation rules apply.
+Wallets that don't implement this are unaffected.
+
+==Reference Implementation==
+
+A Python reference implementation, in the style of the canonical BIP-352
+reference, reproduces the test vectors below byte-for-byte and demonstrates
+non-interactive unblinding of the shared-secret-blinded output: [[elip-silent-payments-liquid/reference.py|elip-silent-payments-liquid/reference.py]]. Wallet integration — scanning, signing, and transaction building — is left to implementations.
+
+==Acknowledgements==
+
+This specification builds on BIP-352, the BIP-352 index server specification,
+and the Confidential Transactions work of the Elements Project.
+
+The authors thank JAN3 for sponsoring and supporting this work.
+
+==References==
+
+
diff --git a/elip-silent-payments-liquid/bech32m.py b/elip-silent-payments-liquid/bech32m.py
new file mode 100644
index 0000000..5f87cdb
--- /dev/null
+++ b/elip-silent-payments-liquid/bech32m.py
@@ -0,0 +1,139 @@
+# Copyright (c) 2017, 2020 Pieter Wuille
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+"""Reference implementation for Bech32/Bech32m and segwit addresses.
+
+Verbatim from BIP-350 / the BIP-352 reference, so silent-payment address
+encoding matches the canonical implementation exactly.
+"""
+
+
+from enum import Enum
+
+class Encoding(Enum):
+ """Enumeration type to list the various supported encodings."""
+ BECH32 = 1
+ BECH32M = 2
+
+CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
+BECH32M_CONST = 0x2bc830a3
+
+def bech32_polymod(values):
+ """Internal function that computes the Bech32 checksum."""
+ generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
+ chk = 1
+ for value in values:
+ top = chk >> 25
+ chk = (chk & 0x1ffffff) << 5 ^ value
+ for i in range(5):
+ chk ^= generator[i] if ((top >> i) & 1) else 0
+ return chk
+
+
+def bech32_hrp_expand(hrp):
+ """Expand the HRP into values for checksum computation."""
+ return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]
+
+
+def bech32_verify_checksum(hrp, data):
+ """Verify a checksum given HRP and converted data characters."""
+ const = bech32_polymod(bech32_hrp_expand(hrp) + data)
+ if const == 1:
+ return Encoding.BECH32
+ if const == BECH32M_CONST:
+ return Encoding.BECH32M
+ return None
+
+def bech32_create_checksum(hrp, data, spec):
+ """Compute the checksum values given HRP and data."""
+ values = bech32_hrp_expand(hrp) + data
+ const = BECH32M_CONST if spec == Encoding.BECH32M else 1
+ polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ const
+ return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
+
+
+def bech32_encode(hrp, data, spec):
+ """Compute a Bech32 string given HRP and data values."""
+ combined = data + bech32_create_checksum(hrp, data, spec)
+ return hrp + '1' + ''.join([CHARSET[d] for d in combined])
+
+def bech32_decode(bech):
+ """Validate a Bech32/Bech32m string, and determine HRP and data."""
+ if ((any(ord(x) < 33 or ord(x) > 126 for x in bech)) or
+ (bech.lower() != bech and bech.upper() != bech)):
+ return (None, None, None)
+ bech = bech.lower()
+ pos = bech.rfind('1')
+
+ # remove the requirement that bech32m be less than 90 chars
+ if pos < 1 or pos + 7 > len(bech):
+ return (None, None, None)
+ if not all(x in CHARSET for x in bech[pos+1:]):
+ return (None, None, None)
+ hrp = bech[:pos]
+ data = [CHARSET.find(x) for x in bech[pos+1:]]
+ spec = bech32_verify_checksum(hrp, data)
+ if spec is None:
+ return (None, None, None)
+ return (hrp, data[:-6], spec)
+
+def convertbits(data, frombits, tobits, pad=True):
+ """General power-of-2 base conversion."""
+ acc = 0
+ bits = 0
+ ret = []
+ maxv = (1 << tobits) - 1
+ max_acc = (1 << (frombits + tobits - 1)) - 1
+ for value in data:
+ if value < 0 or (value >> frombits):
+ return None
+ acc = ((acc << frombits) | value) & max_acc
+ 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 decode(hrp, addr):
+ """Decode a segwit address."""
+ hrpgot, data, spec = bech32_decode(addr)
+ if hrpgot != hrp:
+ return (None, None)
+ decoded = convertbits(data[1:], 5, 8, False)
+ if decoded is None or len(decoded) < 2:
+ return (None, None)
+ if data[0] > 16:
+ return (None, None)
+ return (data[0], decoded)
+
+
+def encode(hrp, witver, witprog):
+ """Encode a segwit address."""
+ spec = Encoding.BECH32 if witver == 0 else Encoding.BECH32M
+ ret = bech32_encode(hrp, [witver] + convertbits(witprog, 8, 5), spec)
+ if decode(hrp, ret) == (None, None):
+ return None
+ return ret
diff --git a/elip-silent-payments-liquid/reference.py b/elip-silent-payments-liquid/reference.py
new file mode 100644
index 0000000..0312639
--- /dev/null
+++ b/elip-silent-payments-liquid/reference.py
@@ -0,0 +1,232 @@
+"""Silent Payments for the Liquid Network — reference implementation.
+
+Same style as the BIP-352 reference (secp256k1lab for the curve algebra, so the
+spec math reads literally). INSECURE — for test vectors only.
+
+Everything up to and including the output public key P_k is BIP-352, unchanged.
+The two Liquid-specific additions are the per-output blinding key bk_k (tag
+LiquidSilentPayments/Blind) and the Confidential Transactions plumbing
+(build_confidential_sp_txout / unblind_output), which is the only part that
+needs Liquid primitives and so uses wallycore rather than secp256k1lab.
+"""
+
+from typing import Dict, List, Tuple
+
+from secp256k1lab.secp256k1 import G, GE, Scalar
+from secp256k1lab.util import tagged_hash
+
+import bech32m
+
+TAG_INPUTS = "BIP0352/Inputs"
+TAG_SHARED_SECRET = "BIP0352/SharedSecret"
+# Disjoint from BIP0352/SharedSecret, so bk_k and t_k are independent even
+# though both are derived from the same shared secret S.
+TAG_BLIND = "LiquidSilentPayments/Blind"
+
+SP_ADDRESS_VERSION = 0 # Bech32 character `q`.
+
+# HRP per network. Distinct from Liquid's ex/lq (and testnet tex/tlq) prefixes.
+_HRP = {"liquid": "lqsp", "liquid-testnet": "tlqsp", "liquid-regtest": "tlqsp"}
+
+
+def ser_uint32(n: int) -> bytes:
+ return n.to_bytes(4, "big")
+
+
+def hrp_for(network: str) -> str:
+ return _HRP["liquid"] if network == "liquid" else _HRP["liquid-testnet"]
+
+
+def encode_silent_payment_address(B_scan: GE, B_spend: GE, network: str = "liquid") -> str:
+ """Bech32m(HRP, version || serP(B_scan) || serP(B_spend))."""
+ data = bech32m.convertbits(
+ B_scan.to_bytes_compressed() + B_spend.to_bytes_compressed(), 8, 5
+ )
+ return bech32m.bech32_encode(
+ hrp_for(network), [SP_ADDRESS_VERSION] + data, bech32m.Encoding.BECH32M
+ )
+
+
+def decode_silent_payment_address(address: str, network: str = "liquid") -> Tuple[GE, GE]:
+ version, data = bech32m.decode(hrp_for(network), address)
+ if data is None:
+ raise ValueError("bad HRP or checksum")
+ if version != SP_ADDRESS_VERSION:
+ raise ValueError(f"unknown address version {version}")
+ if len(data) != 66:
+ raise ValueError(f"wrong payload length {len(data)}")
+ B_scan = GE.from_bytes_compressed(bytes(data[:33]))
+ B_spend = GE.from_bytes_compressed(bytes(data[33:]))
+ return B_scan, B_spend
+
+
+def sum_input_privkeys(input_priv_keys: List[Tuple[Scalar, bool]]) -> Scalar:
+ """Sum eligible input private keys, applying BIP-352 even-Y normalization.
+
+ Each entry is (private_key, is_taproot). A taproot (BIP-341) prevout commits
+ only to the x-only key, so if a*G has odd Y, negate a first.
+ """
+ negated = []
+ for a, is_taproot in input_priv_keys:
+ if is_taproot and not (a * G).has_even_y():
+ a = -a
+ negated.append(a)
+ return Scalar.sum(*negated)
+
+
+def get_input_hash(outpoints: List[bytes], A: GE) -> Scalar:
+ """input_hash = tagged_hash("BIP0352/Inputs", lowest_outpoint || serP(A)).
+
+ Each outpoint is 36 bytes: txid (32) || vout (4, little-endian).
+ """
+ lowest = sorted(outpoints)[0]
+ h = tagged_hash(TAG_INPUTS, lowest + A.to_bytes_compressed())
+ return Scalar.from_bytes_checked(h)
+
+
+def sender_shared_secret(input_hash: Scalar, a_sum: Scalar, B_scan: GE) -> GE:
+ return input_hash * a_sum * B_scan
+
+
+def receiver_shared_secret(input_hash: Scalar, b_scan: Scalar, A_sum: GE) -> GE:
+ return input_hash * b_scan * A_sum
+
+
+def output_tweak(S: GE, k: int) -> Scalar:
+ """t_k = tagged_hash("BIP0352/SharedSecret", serP(S) || ser32(k))."""
+ return Scalar.from_bytes_checked(
+ tagged_hash(TAG_SHARED_SECRET, S.to_bytes_compressed() + ser_uint32(k))
+ )
+
+
+def output_pubkey(B_spend: GE, S: GE, k: int) -> GE:
+ return B_spend + output_tweak(S, k) * G
+
+
+def output_spend_privkey(b_spend: Scalar, S: GE, k: int) -> Scalar:
+ return b_spend + output_tweak(S, k)
+
+
+# Labels (BIP-352, unchanged on Liquid). A label lets the receiver tag a spend
+# key so incoming payments can be attributed to a source without publishing
+# multiple addresses. m=0 is reserved for change and MUST never be handed out.
+
+
+def label_tweak(b_scan: Scalar, m: int) -> Scalar:
+ """hash_{BIP0352/Label}(ser256(b_scan) || ser32(m))."""
+ return Scalar.from_bytes_checked(
+ tagged_hash("BIP0352/Label", b_scan.to_bytes() + ser_uint32(m))
+ )
+
+
+def labeled_spend_pubkey(B_spend: GE, b_scan: Scalar, m: int) -> GE:
+ """B_m = B_spend + label_tweak(b_scan, m)*G, published in place of B_spend."""
+ return B_spend + label_tweak(b_scan, m) * G
+
+
+def labeled_output_spend_privkey(
+ b_spend: Scalar, label: Scalar, S: GE, k: int
+) -> Scalar:
+ """Private key for an output built against a labeled B_m: b_spend + label + t_k."""
+ return b_spend + label + output_tweak(S, k)
+
+
+def blinding_privkey(S: GE, k: int) -> Scalar:
+ """bk_k = tagged_hash("LiquidSilentPayments/Blind", serP(S) || ser32(k)).
+
+ Per the ELIP, if the hash is 0 or >= n this MUST be treated as a failure,
+ matching BIP-352's handling of an out-of-range t_k (which also fails, not
+ skips). from_bytes_checked raises on 0 or >= n.
+ """
+ h = tagged_hash(TAG_BLIND, S.to_bytes_compressed() + ser_uint32(k))
+ return Scalar.from_bytes_checked(h)
+
+
+def script_pubkey(P_k: GE) -> bytes:
+ """OP_1 — a P2TR output, no taptweak per BIP-352."""
+ return bytes([0x51, 0x20]) + P_k.to_bytes_xonly()
+
+
+# Tweak server: publishes T = input_hash * A per transaction; a client holding
+# b_scan computes S = b_scan * T without learning any private key.
+
+
+def compute_tweak(input_pubkeys: List[GE], outpoints: List[bytes]) -> Tuple[GE, Scalar, GE]:
+ """(T = input_hash * A, input_hash, A) from the eligible input pubkeys."""
+ A = GE.sum(*input_pubkeys)
+ input_hash = get_input_hash(outpoints, A)
+ T = input_hash * A
+ return T, input_hash, A
+
+
+def shared_secret_from_tweak(b_scan: Scalar, T: GE) -> GE:
+ return b_scan * T
+
+
+# Confidential Transactions blinding / unblinding — the only part needing
+# Liquid primitives, hence wallycore.
+
+
+def build_confidential_sp_txout(
+ BK_k: GE,
+ P_k: GE,
+ asset_id: bytes,
+ value: int,
+ abf: bytes,
+ vbf: bytes,
+ ephemeral_sk: Scalar,
+ input_assets: List[Tuple[bytes, bytes, bytes]],
+) -> Dict[str, bytes]:
+ """Build a confidential output blinded to BK_k.
+
+ input_assets lists, per transaction input, its (asset_id, abf, generator),
+ feeding the asset surjection proof Liquid consensus requires (independent
+ of the silent-payments derivation).
+ """
+ import os
+
+ import wallycore as wally
+
+ script = script_pubkey(P_k)
+ nonce_hash = wally.ecdh_nonce_hash(BK_k.to_bytes_compressed(), ephemeral_sk.to_bytes())
+ nonce_pubkey = (ephemeral_sk * G).to_bytes_compressed()
+
+ asset_generator = wally.asset_generator_from_bytes(asset_id, abf)
+ value_commitment = wally.asset_value_commitment(value, vbf, asset_generator)
+ rangeproof = wally.asset_rangeproof_with_nonce(
+ value, nonce_hash, asset_id, abf, vbf,
+ value_commitment, script, asset_generator,
+ 1, 0, 52,
+ )
+
+ in_assets = b"".join(a for a, _, _ in input_assets)
+ in_abfs = b"".join(f for _, f, _ in input_assets)
+ in_gens = b"".join(g for _, _, g in input_assets)
+ seed = os.urandom(32)
+ surjectionproof = wally.asset_surjectionproof(
+ asset_id, abf, asset_generator, seed, in_assets, in_abfs, in_gens,
+ )
+
+ return {
+ "asset_generator": asset_generator,
+ "value_commitment": value_commitment,
+ "nonce_pubkey": nonce_pubkey,
+ "rangeproof": rangeproof,
+ "surjectionproof": bytes(surjectionproof),
+ "script_pubkey": script,
+ }
+
+
+def unblind_output(txout: Dict[str, bytes], bk_k: Scalar) -> Dict[str, object]:
+ """Unblind with bk_k. Raises if bk_k is wrong (rangeproof fails)."""
+ import wallycore as wally
+
+ nonce_hash = wally.ecdh_nonce_hash(txout["nonce_pubkey"], bk_k.to_bytes())
+ value, asset, abf, vbf = wally.asset_unblind_with_nonce(
+ nonce_hash,
+ txout["rangeproof"],
+ txout["value_commitment"],
+ txout["script_pubkey"],
+ txout["asset_generator"],
+ )
+ return {"asset": bytes(asset), "value": value, "abf": bytes(abf), "vbf": bytes(vbf)}
diff --git a/elip-silent-payments-liquid/test_vectors.py b/elip-silent-payments-liquid/test_vectors.py
new file mode 100644
index 0000000..d8e11ed
--- /dev/null
+++ b/elip-silent-payments-liquid/test_vectors.py
@@ -0,0 +1,301 @@
+"""Reference test vectors, in the style of the BIP-352 reference tests."""
+
+import os
+import sys
+
+import pytest
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from secp256k1lab.secp256k1 import G, GE, Scalar # noqa: E402
+
+from reference import ( # noqa: E402
+ blinding_privkey,
+ compute_tweak,
+ decode_silent_payment_address,
+ encode_silent_payment_address,
+ get_input_hash,
+ label_tweak,
+ labeled_output_spend_privkey,
+ labeled_spend_pubkey,
+ output_pubkey,
+ output_spend_privkey,
+ receiver_shared_secret,
+ script_pubkey,
+ sender_shared_secret,
+ shared_secret_from_tweak,
+ sum_input_privkeys,
+)
+
+
+def sk(b: int) -> Scalar:
+ return Scalar.from_bytes_checked(bytes([b] * 32))
+
+
+def outpoint(txid_byte: int, vout: int) -> bytes:
+ return bytes([txid_byte] * 32) + vout.to_bytes(4, "little")
+
+
+def test_vectors():
+ """b_scan = 0x11*32, b_spend = 0x22*32; inputs 0x31 @ 0x10:0, 0x32 @ 0x20:1."""
+ b_scan, b_spend = sk(0x11), sk(0x22)
+ B_scan, B_spend = b_scan * G, b_spend * G
+
+ input_priv_keys = [(sk(0x31), False), (sk(0x32), False)]
+ outpoints = [outpoint(0x10, 0), outpoint(0x20, 1)]
+
+ a_sum = sum_input_privkeys(input_priv_keys)
+ A = a_sum * G
+ input_hash = get_input_hash(outpoints, A)
+
+ assert (
+ A.to_bytes_compressed().hex()
+ == "031195a8046dcbb8e17034bca630065e7a0982e4e36f6f7e5a8d4554e4846fcd99"
+ ), "A"
+ assert (
+ input_hash.to_bytes().hex()
+ == "d392922c00280a7e8d282182f5026f2fddbc74c1e1de18b4822128b2b77ec641"
+ ), "input_hash"
+
+ expected = [
+ (
+ 0,
+ "02a29d9716417c964ca9e477343e71ffe730a4991a3eaad668eabec84e9feb7931",
+ "0344e1289497e6da66fde710d2f38de053fc07355e405524401d7d609df5a1a8cc",
+ "70ab8897b64bd21b427339ff4d014b883191ef6425862246c53bfc27a59aa3f0",
+ "f03c436d2cd67ae1fecf7d88a38aa3a03c0abea43feaf6da8eb71e2e3a866bda",
+ "5120a29d9716417c964ca9e477343e71ffe730a4991a3eaad668eabec84e9feb7931",
+ ),
+ (
+ 1,
+ "0229d77654023af267dbe9cb7ff1956f947c816f203494381308387168fb010c92",
+ "03efdeda770ccdbe8bf466fba48bfd2b2c436ab0c04658fc6d6c277de5078129fa",
+ "945ba73a9804f62089c7d2ffdc079031031f0aebab372cec17ef9c110ebceb10",
+ "9eff3472230fc83ef5ea8f8c80401c4eecd595a048bd2482a107d3a49baa5a58",
+ "512029d77654023af267dbe9cb7ff1956f947c816f203494381308387168fb010c92",
+ ),
+ ]
+
+ S_send = sender_shared_secret(input_hash, a_sum, B_scan)
+ S_recv = receiver_shared_secret(input_hash, b_scan, A)
+ assert S_send == S_recv, "sender and receiver shared secrets agree"
+
+ for k, pk, bk_pub, bk_sk, spk_sk, script in expected:
+ P_k = output_pubkey(B_spend, S_send, k)
+ bk = blinding_privkey(S_send, k)
+ BK_k = bk * G
+ spend_sk = output_spend_privkey(b_spend, S_send, k)
+
+ # The receiver recomputes the same P_k from b_spend and the shared secret.
+ assert output_pubkey(B_spend, S_recv, k) == P_k, f"P_k receiver agrees k={k}"
+ assert spend_sk * G == P_k, f"spend_sk * G == P_k k={k}"
+
+ assert P_k.to_bytes_compressed().hex() == pk, f"P_k k={k}"
+ assert BK_k.to_bytes_compressed().hex() == bk_pub, f"BK_k k={k}"
+ assert bk.to_bytes().hex() == bk_sk, f"bk_k k={k}"
+ assert spend_sk.to_bytes().hex() == spk_sk, f"spend_sk k={k}"
+ assert script_pubkey(P_k).hex() == script, f"scriptPubKey k={k}"
+
+ assert (
+ encode_silent_payment_address(B_scan, B_spend, "liquid")
+ == "lqsp1qqd8n2k7uklxq4aegau7vawtptkgxsja4kt99lpv6krctwpq8tpc65qjxd4lu4etruh9sngx3su9mtqp5fqzxz7re59y5nnez9p03ht3lyudcfhfe"
+ ), "mainnet address"
+
+
+def test_taproot_even_y_negation():
+ """BIP-352 even-Y normalization for taproot inputs."""
+ odd = None
+ for b in range(1, 0x100):
+ if not (sk(b) * G).has_even_y():
+ odd = sk(b)
+ break
+ assert odd is not None
+
+ a_tr = sum_input_privkeys([(odd, True)])
+ a_legacy = sum_input_privkeys([(odd, False)])
+ assert a_tr != a_legacy, "odd-Y taproot key is negated"
+ assert a_tr == -a_legacy, "negation is exactly n - a"
+
+ A_tr = a_tr * G
+ assert A_tr.to_bytes_xonly() == (odd * G).to_bytes_xonly(), "x-only A unchanged"
+
+ even = None
+ for b in range(1, 0x100):
+ if (sk(b) * G).has_even_y():
+ even = sk(b)
+ break
+ assert even is not None
+ assert sum_input_privkeys([(even, True)]) == sum_input_privkeys([(even, False)])
+
+
+def test_tweak_server_agreement():
+ """A tweak server publishes T = input_hash * A; client computes S = b_scan * T."""
+ b_scan, b_spend = sk(0x11), sk(0x22)
+ B_scan, B_spend = b_scan * G, b_spend * G
+
+ input_priv_keys = [(sk(0x55), False)]
+ op = outpoint(0xAA, 0)
+
+ a_sum = sum_input_privkeys(input_priv_keys)
+ A = a_sum * G
+ input_hash = get_input_hash([op], A)
+
+ S_sender = sender_shared_secret(input_hash, a_sum, B_scan)
+
+ T, ih_server, A_server = compute_tweak([sk(0x55) * G], [op])
+ assert A_server == A and ih_server == input_hash
+ S_client = shared_secret_from_tweak(b_scan, T)
+
+ assert S_client == S_sender, "client shared secret matches sender"
+ assert output_pubkey(B_spend, S_client, 0) == output_pubkey(B_spend, S_sender, 0)
+
+
+def test_address_round_trip_and_network_separation():
+ b_scan, b_spend = sk(0x11), sk(0x22)
+ B_scan, B_spend = b_scan * G, b_spend * G
+
+ for network in ("liquid", "liquid-testnet"):
+ enc = encode_silent_payment_address(B_scan, B_spend, network)
+ d_scan, d_spend = decode_silent_payment_address(enc, network)
+ assert d_scan == B_scan and d_spend == B_spend
+
+ mainnet = encode_silent_payment_address(B_scan, B_spend, "liquid")
+ with pytest.raises(ValueError):
+ decode_silent_payment_address(mainnet, "liquid-testnet")
+
+
+def test_ct_round_trip_unblind_with_bk():
+ """CT round-trip: sender blinds to BK_k, receiver unblinds with bk_k."""
+ pytest.importorskip("wallycore")
+ from reference import build_confidential_sp_txout, unblind_output
+
+ b_scan, b_spend = sk(0x11), sk(0x22)
+ B_scan, B_spend = b_scan * G, b_spend * G
+
+ input_priv_keys = [(sk(0x33), False)]
+ op = outpoint(0xAB, 0)
+ a_sum = sum_input_privkeys(input_priv_keys)
+ A = a_sum * G
+ input_hash = get_input_hash([op], A)
+
+ k = 0
+ S = sender_shared_secret(input_hash, a_sum, B_scan)
+ P_k = output_pubkey(B_spend, S, k)
+ bk = blinding_privkey(S, k)
+ BK_k = bk * G
+
+ asset_id = bytes([0x42] * 32)
+ value = 123_456
+ abf = bytes([0x01] * 32)
+ vbf = bytes([0x02] * 32)
+ ephemeral_sk = sk(0x07)
+
+ import wallycore as wally
+
+ input_abf = bytes([0x03] * 32)
+ input_gen = wally.asset_generator_from_bytes(asset_id, input_abf)
+ input_assets = [(asset_id, input_abf, input_gen)]
+
+ txout = build_confidential_sp_txout(
+ BK_k, P_k, asset_id, value, abf, vbf, ephemeral_sk, input_assets
+ )
+ assert len(txout["surjectionproof"]) > 0
+
+ S_recv = receiver_shared_secret(input_hash, b_scan, A)
+ bk_recv = blinding_privkey(S_recv, k)
+ assert bk_recv == bk
+
+ recovered = unblind_output(txout, bk_recv)
+ assert recovered["value"] == value
+ assert recovered["abf"] == abf
+ assert recovered["vbf"] == vbf
+
+ wrong_bk = blinding_privkey(
+ receiver_shared_secret(input_hash, sk(0x99), A), k
+ )
+ assert wrong_bk != bk
+ with pytest.raises(Exception):
+ unblind_output(txout, wrong_bk)
+
+
+def test_labels():
+ # BIP-352 labels, unchanged on Liquid: B_m = B_spend + label_tweak(b_scan, m)*G.
+ # m=0 is reserved for change. We verify the sender/receiver key-derivation
+ # agreement for a labeled address: sender uses the published B_m in place
+ # of B_spend, receiver reconstructs the same spend key offline.
+ b_scan, b_spend = sk(0x11), sk(0x22)
+ B_scan, B_spend = b_scan * G, b_spend * G
+
+ input_priv_keys = [(sk(0x44), False)]
+ op = outpoint(0xcd, 0)
+ a_sum = sum_input_privkeys(input_priv_keys)
+ A = a_sum * G
+ input_hash = get_input_hash([op], A)
+
+ S_send = sender_shared_secret(input_hash, a_sum, B_scan)
+ S_recv = receiver_shared_secret(input_hash, b_scan, A)
+ assert S_send == S_recv
+
+ for m in (1, 2, 7):
+ label = label_tweak(b_scan, m)
+ B_m = labeled_spend_pubkey(B_spend, b_scan, m)
+ assert B_m == B_spend + label * G
+
+ P_k = output_pubkey(B_m, S_send, 0)
+ spend_sk = labeled_output_spend_privkey(b_spend, label, S_recv, 0)
+ assert spend_sk * G == P_k, f"labeled spend key matches P_k, m={m}"
+
+ assert label_tweak(b_scan, 0) != label_tweak(b_scan, 1)
+
+
+def test_pegin_input_excluded_from_shared_secret():
+ # A peg-in input contributes no pubkey to the shared secret (per the
+ # ELIP's 'Input eligibility for peg-ins and issuances' bullet), but its
+ # outpoint still participates in outpoint_L since that's read from the
+ # Liquid transaction itself, not the Bitcoin-chain prevout it spends.
+ b_scan, b_spend = sk(0x11), sk(0x22)
+ B_scan = b_scan * G
+
+ eligible_priv_keys = [(sk(0x51), False), (sk(0x52), False)]
+ pegin_outpoint = outpoint(0xfe, 0) # peg-in's own outpoint on Liquid
+ eligible_outpoints = [outpoint(0x61, 0), outpoint(0x62, 1)]
+ all_outpoints = eligible_outpoints + [pegin_outpoint]
+
+ a_sum = sum_input_privkeys(eligible_priv_keys)
+ A = a_sum * G
+
+ input_hash_with_pegin = get_input_hash(all_outpoints, A)
+ input_hash_without_pegin = get_input_hash(eligible_outpoints, A)
+
+ lowest_with = sorted(all_outpoints)[0]
+ if lowest_with == pegin_outpoint:
+ assert input_hash_with_pegin != input_hash_without_pegin
+ else:
+ assert input_hash_with_pegin == input_hash_without_pegin
+
+ S = sender_shared_secret(input_hash_with_pegin, a_sum, B_scan)
+ S_from_eligible_only = sender_shared_secret(
+ input_hash_with_pegin, sum_input_privkeys(eligible_priv_keys), B_scan
+ )
+ assert S == S_from_eligible_only
+
+
+def test_testnet_and_regtest_addresses():
+ # tlqsp is shared by testnet and regtest per the ELIP (mirrors BIP-352's
+ # single non-mainnet HRP, which itself only names signet/testnet but is
+ # conventionally extended to regtest by implementations).
+ b_scan, b_spend = sk(0x11), sk(0x22)
+ B_scan, B_spend = b_scan * G, b_spend * G
+
+ testnet_addr = encode_silent_payment_address(B_scan, B_spend, "liquid-testnet")
+ regtest_addr = encode_silent_payment_address(B_scan, B_spend, "liquid-regtest")
+ assert testnet_addr == regtest_addr, "testnet and regtest share tlqsp"
+ assert testnet_addr.startswith("tlqsp1")
+
+ for network in ("liquid-testnet", "liquid-regtest"):
+ d_scan, d_spend = decode_silent_payment_address(testnet_addr, network)
+ assert d_scan == B_scan and d_spend == B_spend
+
+ mainnet_addr = encode_silent_payment_address(B_scan, B_spend, "liquid")
+ assert mainnet_addr != testnet_addr
+ assert mainnet_addr.startswith("lqsp1")