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-352BIP-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-340BIP-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-341BIP-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 parsedElements 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-44SLIP-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-158BIP-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 specificationSilent 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")