2024-01-15 12:05:08 +01:00
#!/usr/bin/env python3
# For running the test vectors, run this script:
# ./reference.py send_and_receive_test_vectors.json
import json
2026-01-16 16:26:23 +01:00
from pathlib import Path
import sys
2026-05-07 20:55:56 -06:00
from typing import Any , List , Tuple , Dict , cast
2024-01-15 12:05:08 +01:00
2026-01-16 16:26:23 +01:00
# import the vendored copy of secp256k1lab
sys . path . insert ( 0 , str ( Path ( __file__ ) . parent / "secp256k1lab/src" ))
from secp256k1lab.bip340 import schnorr_sign , schnorr_verify
from secp256k1lab.secp256k1 import G , GE , Scalar
from secp256k1lab.util import tagged_hash , hash_sha256
2024-01-15 12:05:08 +01:00
from bech32m import convertbits , bech32_encode , decode , Encoding
from bitcoin_utils import (
deser_txid ,
from_hex ,
hash160 ,
is_p2pkh ,
is_p2sh ,
is_p2wpkh ,
is_p2tr ,
ser_uint32 ,
COutPoint ,
CTxInWitness ,
VinInfo ,
)
2026-01-30 01:46:35 +01:00
K_max = 2323 # per-group recipient limit
2026-01-16 16:26:23 +01:00
NUMS_H = 0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0
2026-01-30 01:46:35 +01:00
2026-01-16 16:26:23 +01:00
def get_pubkey_from_input ( vin : VinInfo ) -> GE :
2024-01-15 12:05:08 +01:00
if is_p2pkh ( vin . prevout ):
# skip the first 3 op_codes and grab the 20 byte hash
# from the scriptPubKey
spk_hash = vin . prevout [ 3 : 3 + 20 ]
for i in range ( len ( vin . scriptSig ), 0 , - 1 ):
if i - 33 >= 0 :
# starting from the back, we move over the scriptSig with a 33 byte
# window (to match a compressed pubkey). we hash this and check if it matches
2025-04-03 09:10:20 +03:00
# the 20 byte hash from the scriptPubKey. for standard scriptSigs, this will match
2024-01-15 12:05:08 +01:00
# right away because the pubkey is the last item in the scriptSig.
# if its a non-standard (malleated) scriptSig, we will still find the pubkey if its
# a compressed pubkey.
#
# note: this is an incredibly inefficient implementation, for demonstration purposes only.
pubkey_bytes = vin . scriptSig [ i - 33 : i ]
pubkey_hash = hash160 ( pubkey_bytes )
if pubkey_hash == spk_hash :
2026-01-16 16:26:23 +01:00
try :
return GE . from_bytes_compressed ( pubkey_bytes )
except ValueError :
pass
2024-01-15 12:05:08 +01:00
if is_p2sh ( vin . prevout ):
redeem_script = vin . scriptSig [ 1 :]
if is_p2wpkh ( redeem_script ):
2026-01-16 16:26:23 +01:00
try :
return GE . from_bytes_compressed ( vin . txinwitness . scriptWitness . stack [ - 1 ])
except ( ValueError , AssertionError ):
pass
2024-01-15 12:05:08 +01:00
if is_p2wpkh ( vin . prevout ):
2026-01-16 16:26:23 +01:00
try :
return GE . from_bytes_compressed ( vin . txinwitness . scriptWitness . stack [ - 1 ])
except ( ValueError , AssertionError ):
pass
2024-01-15 12:05:08 +01:00
if is_p2tr ( vin . prevout ):
witnessStack = vin . txinwitness . scriptWitness . stack
if ( len ( witnessStack ) >= 1 ):
if ( len ( witnessStack ) > 1 and witnessStack [ - 1 ][ 0 ] == 0x50 ):
# Last item is annex
witnessStack . pop ()
if ( len ( witnessStack ) > 1 ):
# Script-path spend
control_block = witnessStack [ - 1 ]
# control block is <control byte> <32 byte internal key> and 0 or more <32 byte hash>
internal_key = control_block [ 1 : 33 ]
if ( internal_key == NUMS_H . to_bytes ( 32 , 'big' )):
# Skip if NUMS_H
2026-01-16 16:26:23 +01:00
return GE ()
2024-01-15 12:05:08 +01:00
2026-01-16 16:26:23 +01:00
try :
return GE . from_bytes_xonly ( vin . prevout [ 2 :])
except ValueError :
pass
2024-01-15 12:05:08 +01:00
2026-01-16 16:26:23 +01:00
return GE ()
2024-01-15 12:05:08 +01:00
2026-01-16 16:26:23 +01:00
def get_input_hash ( outpoints : List [ COutPoint ], sum_input_pubkeys : GE ) -> bytes :
2024-01-15 12:05:08 +01:00
lowest_outpoint = sorted ( outpoints , key = lambda outpoint : outpoint . serialize ())[ 0 ]
2026-01-16 16:26:23 +01:00
return tagged_hash ( "BIP0352/Inputs" , lowest_outpoint . serialize () + sum_input_pubkeys . to_bytes_compressed ())
2024-01-15 12:05:08 +01:00
2026-01-16 16:26:23 +01:00
def encode_silent_payment_address ( B_scan : GE , B_m : GE , hrp : str = "tsp" , version : int = 0 ) -> str :
data = convertbits ( B_scan . to_bytes_compressed () + B_m . to_bytes_compressed (), 8 , 5 )
2024-01-15 12:05:08 +01:00
return bech32_encode ( hrp , [ version ] + cast ( List [ int ], data ), Encoding . BECH32M )
2026-01-16 16:26:23 +01:00
def generate_label ( b_scan : Scalar , m : int ) -> Scalar :
return Scalar . from_bytes_checked ( tagged_hash ( "BIP0352/Label" , b_scan . to_bytes () + ser_uint32 ( m )))
2024-01-15 12:05:08 +01:00
2026-01-16 16:26:23 +01:00
def create_labeled_silent_payment_address ( b_scan : Scalar , B_spend : GE , m : int , hrp : str = "tsp" , version : int = 0 ) -> str :
B_scan = b_scan * G
2024-01-15 12:05:08 +01:00
B_m = B_spend + generate_label ( b_scan , m ) * G
labeled_address = encode_silent_payment_address ( B_scan , B_m , hrp , version )
return labeled_address
2026-01-16 16:26:23 +01:00
def decode_silent_payment_address ( address : str , hrp : str = "tsp" ) -> Tuple [ GE , GE ]:
2024-01-15 12:05:08 +01:00
_ , data = decode ( hrp , address )
if data is None :
2026-01-16 16:26:23 +01:00
return GE (), GE ()
B_scan = GE . from_bytes_compressed ( data [: 33 ])
B_spend = GE . from_bytes_compressed ( data [ 33 :])
2024-01-15 12:05:08 +01:00
return B_scan , B_spend
2026-05-07 20:55:56 -06:00
def create_outputs ( input_priv_keys : List [ Tuple [ Scalar , bool ]], outpoints : List [ COutPoint ], recipients : List [ Dict [ str , str ]], expected : Dict [ str , Any ] = None , hrp = "tsp" ) -> List [ str ]:
2024-01-15 12:05:08 +01:00
negated_keys = []
for key , is_xonly in input_priv_keys :
2026-01-16 16:26:23 +01:00
k = Scalar . from_bytes_checked ( key . to_bytes ())
if is_xonly and not ( k * G ) . has_even_y ():
k = - k
2024-01-15 12:05:08 +01:00
negated_keys . append ( k )
2026-01-16 16:26:23 +01:00
a_sum = Scalar . sum ( * negated_keys )
if a_sum == 0 :
2024-06-22 01:18:37 +02:00
# Input privkeys sum is zero -> fail
return []
2026-01-16 16:26:23 +01:00
assert Scalar . from_bytes_checked ( bytes . fromhex ( expected . get ( "input_private_key_sum" ))) == a_sum , "a_sum did not match expected input_private_key_sum"
input_hash_scalar = Scalar . from_bytes_checked ( get_input_hash ( outpoints , a_sum * G ))
silent_payment_groups : Dict [ GE , List [ GE ]] = {}
2024-01-15 12:05:08 +01:00
for recipient in recipients :
2025-09-04 10:48:59 -04:00
B_scan , B_m = decode_silent_payment_address ( recipient [ "address" ], hrp = hrp )
# Verify decoded intermediate keys for recipient
2026-01-16 16:26:23 +01:00
expected_B_scan = GE . from_bytes_compressed ( bytes . fromhex ( recipient [ "scan_pub_key" ]))
expected_B_m = GE . from_bytes_compressed ( bytes . fromhex ( recipient [ "spend_pub_key" ]))
2025-09-04 10:48:59 -04:00
assert expected_B_scan == B_scan , "B_scan did not match expected recipient.scan_pub_key"
assert expected_B_m == B_m , "B_m did not match expected recipient.spend_pub_key"
2024-01-15 12:05:08 +01:00
if B_scan in silent_payment_groups :
silent_payment_groups [ B_scan ] . append ( B_m )
else :
silent_payment_groups [ B_scan ] = [ B_m ]
2026-01-30 01:46:35 +01:00
# Fail if per-group recipient limit (K_max) is exceeded
if any ([ len ( group ) > K_max for group in silent_payment_groups . values ()]):
return []
2024-01-15 12:05:08 +01:00
outputs = []
for B_scan , B_m_values in silent_payment_groups . items ():
2026-01-16 16:26:23 +01:00
ecdh_shared_secret = input_hash_scalar * a_sum * B_scan
2025-09-04 10:48:59 -04:00
expected_shared_secrets = expected . get ( "shared_secrets" , {})
# Find the recipient address that corresponds to this B_scan and get its index
for recipient_idx , recipient in enumerate ( recipients ):
2026-01-16 16:26:23 +01:00
recipient_B_scan = GE . from_bytes_compressed ( bytes . fromhex ( recipient [ "scan_pub_key" ]))
2025-09-04 10:48:59 -04:00
if recipient_B_scan == B_scan :
expected_shared_secret_hex = expected_shared_secrets [ recipient_idx ]
2026-01-16 16:26:23 +01:00
assert ecdh_shared_secret . to_bytes_compressed () . hex () == expected_shared_secret_hex , f "ecdh_shared_secret did not match expected, recipient { recipient_idx } ( { recipient [ 'address' ] } ): expected= { expected_shared_secret_hex } "
2025-09-04 10:48:59 -04:00
break
2024-01-15 12:05:08 +01:00
k = 0
for B_m in B_m_values :
2026-01-16 16:26:23 +01:00
t_k = Scalar . from_bytes_checked ( tagged_hash ( "BIP0352/SharedSecret" , ecdh_shared_secret . to_bytes_compressed () + ser_uint32 ( k )))
2024-01-15 12:05:08 +01:00
P_km = B_m + t_k * G
2026-01-16 16:26:23 +01:00
outputs . append ( P_km . to_bytes_xonly () . hex ())
2024-01-15 12:05:08 +01:00
k += 1
return list ( set ( outputs ))
2026-05-07 20:55:56 -06:00
def scanning ( b_scan : Scalar , B_spend : GE , A_sum : GE , input_hash : bytes , outputs_to_check : List [ bytes ], labels : Dict [ str , str ] = None , expected : Dict [ str , Any ] = None ) -> List [ Dict [ str , str ]]:
2026-01-16 16:26:23 +01:00
input_hash_scalar = Scalar . from_bytes_checked ( input_hash )
computed_tweak_point = input_hash_scalar * A_sum
assert computed_tweak_point . to_bytes_compressed () . hex () == expected . get ( "tweak" ), "tweak did not match expected"
ecdh_shared_secret = input_hash_scalar * b_scan * A_sum
assert ecdh_shared_secret . to_bytes_compressed () . hex () == expected . get ( "shared_secret" ), "ecdh_shared_secret did not match expected shared_secret"
2024-01-15 12:05:08 +01:00
k = 0
wallet = []
while True :
2026-01-30 01:46:35 +01:00
if k == K_max : # Don't look further than the per-group recipient limit (K_max)
break
2026-01-16 16:26:23 +01:00
t_k = Scalar . from_bytes_checked ( tagged_hash ( "BIP0352/SharedSecret" , ecdh_shared_secret . to_bytes_compressed () + ser_uint32 ( k )))
2024-01-15 12:05:08 +01:00
P_k = B_spend + t_k * G
for output in outputs_to_check :
2026-01-16 16:26:23 +01:00
output_ge = GE . from_bytes_xonly ( output )
if P_k . to_bytes_xonly () == output :
wallet . append ({ "pub_key" : P_k . to_bytes_xonly () . hex (), "priv_key_tweak" : t_k . to_bytes () . hex ()})
2024-01-15 12:05:08 +01:00
outputs_to_check . remove ( output )
k += 1
break
elif labels :
2026-01-16 16:26:23 +01:00
m_G_sub = output_ge - P_k
if m_G_sub . to_bytes_compressed () . hex () in labels :
2024-01-15 12:05:08 +01:00
P_km = P_k + m_G_sub
wallet . append ({
2026-01-16 16:26:23 +01:00
"pub_key" : P_km . to_bytes_xonly () . hex (),
"priv_key_tweak" : ( t_k + Scalar . from_bytes_checked (
bytes . fromhex ( labels [ m_G_sub . to_bytes_compressed () . hex ()])
)) . to_bytes () . hex (),
2024-01-15 12:05:08 +01:00
})
outputs_to_check . remove ( output )
k += 1
break
else :
2026-01-16 16:26:23 +01:00
m_G_sub = - output_ge - P_k
if m_G_sub . to_bytes_compressed () . hex () in labels :
2024-01-15 12:05:08 +01:00
P_km = P_k + m_G_sub
wallet . append ({
2026-01-16 16:26:23 +01:00
"pub_key" : P_km . to_bytes_xonly () . hex (),
"priv_key_tweak" : ( t_k + Scalar . from_bytes_checked (
bytes . fromhex ( labels [ m_G_sub . to_bytes_compressed () . hex ()])
)) . to_bytes () . hex (),
2024-01-15 12:05:08 +01:00
})
outputs_to_check . remove ( output )
k += 1
break
else :
break
return wallet
if __name__ == "__main__" :
2026-01-16 16:26:23 +01:00
if len ( sys . argv ) != 2 or sys . argv [ 1 ] in ( '-h' , '--help' ):
2024-01-15 12:05:08 +01:00
print ( "Usage: ./reference.py send_and_receive_test_vectors.json" )
2026-01-16 16:26:23 +01:00
sys . exit ( 0 )
2024-01-15 12:05:08 +01:00
2026-01-16 16:26:23 +01:00
with open ( sys . argv [ 1 ], "r" ) as f :
2024-01-15 12:05:08 +01:00
test_data = json . loads ( f . read ())
for case in test_data :
print ( case [ "comment" ])
# Test sending
for sending_test in case [ "sending" ]:
given = sending_test [ "given" ]
expected = sending_test [ "expected" ]
vins = [
VinInfo (
outpoint = COutPoint ( hash = deser_txid ( input [ "txid" ]), n = input [ "vout" ]),
scriptSig = bytes . fromhex ( input [ "scriptSig" ]),
txinwitness = CTxInWitness () . deserialize ( from_hex ( input [ "txinwitness" ])),
prevout = bytes . fromhex ( input [ "prevout" ][ "scriptPubKey" ][ "hex" ]),
2026-01-16 16:26:23 +01:00
private_key = Scalar . from_bytes_checked ( bytes . fromhex ( input [ "private_key" ])),
2024-01-15 12:05:08 +01:00
)
for input in given [ "vin" ]
]
2024-05-28 19:25:46 +02:00
# Convert the tuples to lists so they can be easily compared to the json list of lists from the given test vectors
2024-01-15 12:05:08 +01:00
input_priv_keys = []
input_pub_keys = []
for vin in vins :
pubkey = get_pubkey_from_input ( vin )
2026-01-16 16:26:23 +01:00
if pubkey . infinity :
2024-01-15 12:05:08 +01:00
continue
input_priv_keys . append ((
vin . private_key ,
is_p2tr ( vin . prevout ),
))
input_pub_keys . append ( pubkey )
2026-01-16 16:26:23 +01:00
assert [ pk . to_bytes_compressed () . hex () for pk in input_pub_keys ] == expected . get ( "input_pub_keys" ), "input_pub_keys did not match expected"
2024-01-15 12:05:08 +01:00
sending_outputs = []
if ( len ( input_pub_keys ) > 0 ):
2024-06-19 23:56:42 +02:00
outpoints = [ vin . outpoint for vin in vins ]
2026-02-26 20:14:59 +01:00
recipients = [] # expand given recipient entries to full list
for recipient_entry in given [ "recipients" ]:
count = recipient_entry . get ( "count" , 1 )
recipients . extend ([ recipient_entry ] * count )
sending_outputs = create_outputs ( input_priv_keys , outpoints , recipients , expected = expected , hrp = "sp" )
2024-01-15 12:05:08 +01:00
# Note: order doesn't matter for creating/finding the outputs. However, different orderings of the recipient addresses
# will produce different generated outputs if sending to multiple silent payment addresses belonging to the
# same sender but with different labels. Because of this, expected["outputs"] contains all possible valid output sets,
# based on all possible permutations of recipient address orderings. Must match exactly one of the possible output sets.
assert ( any ( set ( sending_outputs ) == set ( lst ) for lst in expected [ "outputs" ])), "Sending test failed"
else :
assert ( sending_outputs == expected [ "outputs" ][ 0 ] == []), "Sending test failed"
# Test receiving
2026-01-16 16:26:23 +01:00
msg = hash_sha256 ( b "message" )
aux = hash_sha256 ( b "random auxiliary data" )
2024-01-15 12:05:08 +01:00
for receiving_test in case [ "receiving" ]:
given = receiving_test [ "given" ]
expected = receiving_test [ "expected" ]
outputs_to_check = [
2026-01-16 16:26:23 +01:00
bytes . fromhex ( p ) for p in given [ "outputs" ]
2024-01-15 12:05:08 +01:00
]
vins = [
VinInfo (
outpoint = COutPoint ( hash = deser_txid ( input [ "txid" ]), n = input [ "vout" ]),
scriptSig = bytes . fromhex ( input [ "scriptSig" ]),
txinwitness = CTxInWitness () . deserialize ( from_hex ( input [ "txinwitness" ])),
prevout = bytes . fromhex ( input [ "prevout" ][ "scriptPubKey" ][ "hex" ]),
)
for input in given [ "vin" ]
]
# Check that the given inputs for the receiving test match what was generated during the sending test
receiving_addresses = []
2026-01-16 16:26:23 +01:00
b_scan = Scalar . from_bytes_checked ( bytes . fromhex ( given [ "key_material" ][ "scan_priv_key" ]))
b_spend = Scalar . from_bytes_checked ( bytes . fromhex ( given [ "key_material" ][ "spend_priv_key" ]))
B_scan = b_scan * G
B_spend = b_spend * G
2024-01-15 12:05:08 +01:00
receiving_addresses . append (
encode_silent_payment_address ( B_scan , B_spend , hrp = "sp" )
)
if given [ "labels" ]:
for label in given [ "labels" ]:
receiving_addresses . append (
create_labeled_silent_payment_address (
b_scan , B_spend , m = label , hrp = "sp"
)
)
# Check that the silent payment addresses match for the given BIP32 seed and labels dictionary
assert ( receiving_addresses == expected [ "addresses" ]), "Receiving addresses don't match"
input_pub_keys = []
for vin in vins :
pubkey = get_pubkey_from_input ( vin )
2026-01-16 16:26:23 +01:00
if pubkey . infinity :
2024-01-15 12:05:08 +01:00
continue
input_pub_keys . append ( pubkey )
add_to_wallet = []
if ( len ( input_pub_keys ) > 0 ):
2026-01-16 16:26:23 +01:00
A_sum = GE . sum ( * input_pub_keys )
if A_sum . infinity :
2024-06-14 14:33:40 +02:00
# Input pubkeys sum is point at infinity -> skip tx
assert expected [ "outputs" ] == []
continue
2026-01-16 16:26:23 +01:00
assert A_sum . to_bytes_compressed () . hex () == expected . get ( "input_pub_key_sum" ), "A_sum did not match expected input_pub_key_sum"
2024-01-15 12:05:08 +01:00
input_hash = get_input_hash ([ vin . outpoint for vin in vins ], A_sum )
pre_computed_labels = {
2026-01-16 16:26:23 +01:00
( generate_label ( b_scan , label ) * G ) . to_bytes_compressed () . hex (): generate_label ( b_scan , label ) . to_bytes () . hex ()
2024-01-15 12:05:08 +01:00
for label in given [ "labels" ]
}
add_to_wallet = scanning (
b_scan = b_scan ,
B_spend = B_spend ,
A_sum = A_sum ,
input_hash = input_hash ,
outputs_to_check = outputs_to_check ,
labels = pre_computed_labels ,
2025-09-04 10:48:59 -04:00
expected = expected ,
2024-01-15 12:05:08 +01:00
)
# Check that the private key is correct for the found output public key
for output in add_to_wallet :
2026-01-16 16:26:23 +01:00
pub_key = GE . from_bytes_xonly ( bytes . fromhex ( output [ "pub_key" ]))
full_private_key = b_spend + Scalar . from_bytes_checked ( bytes . fromhex ( output [ "priv_key_tweak" ]))
if not ( full_private_key * G ) . has_even_y ():
full_private_key = - full_private_key
2024-01-15 12:05:08 +01:00
2026-01-16 16:26:23 +01:00
sig = schnorr_sign ( msg , full_private_key . to_bytes (), aux )
assert schnorr_verify ( msg , pub_key . to_bytes_xonly (), sig ), f "Invalid signature for { pub_key } "
2024-01-15 12:05:08 +01:00
output [ "signature" ] = sig . hex ()
# Note: order doesn't matter for creating/finding the outputs. However, different orderings of the recipient addresses
# will produce different generated outputs if sending to multiple silent payment addresses belonging to the
# same sender but with different labels. Because of this, expected["outputs"] contains all possible valid output sets,
# based on all possible permutations of recipient address orderings. Must match exactly one of the possible found output
# sets in expected["outputs"]
2026-02-26 20:40:33 +01:00
if "outputs" in expected : # detailed check against expected outputs
generated_set = { frozenset ( d . items ()) for d in add_to_wallet }
expected_set = { frozenset ( d . items ()) for d in expected [ "outputs" ]}
assert generated_set == expected_set , "Receive test failed"
elif "n_outputs" in expected : # only check the number of found outputs
assert len ( add_to_wallet ) == expected [ "n_outputs" ], "Receive test failed"
else :
assert False , "either 'outputs' or 'n_outputs' must be specified in 'expected' field of receiving test vector"
2024-01-15 12:05:08 +01:00
print ( "All tests passed" )