BIP-322 Proof of Reserves
This commit is contained in:
parent
5047512bae
commit
5d7d5d881d
48
docs/proof-of-reserves-bip-322.md
Normal file
48
docs/proof-of-reserves-bip-322.md
Normal file
@ -0,0 +1,48 @@
|
||||
# [BIP-322](https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki) Generic Signed Message Format
|
||||
|
||||
BIP link https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki
|
||||
|
||||
## Proof of Reserves (POR)
|
||||
|
||||
### POR PSBT
|
||||
COLDCARD accepts specially crafted PSBT to sign BIP-322 Proof of Reserves
|
||||
* PSBT requires PSBT_IN_BIP32_DERIVATION for each input
|
||||
* p2sh wrapped segwit addresses MUST have proper redeem script in PSBT (PSBT_IN_REDEEM_SCRIPT)
|
||||
* p2wsh segwit addresses MUST have proper witness script in PSBT (PSBT_IN_WITNESS_SCRIPT)
|
||||
* 0th input in `to_sign` transaction MUST have full (pre-segwit) UTXO (PSBT_IN_NON_WITNESS_UTXO) a.k.a `to_spend`.
|
||||
* 0th input in `to_sign` PSBT_IN_NON_WITNESS_UTXO transaction (`to_spend`) is as defined in https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki#full:
|
||||
* 1 input, 1 output
|
||||
* output nValue is 0
|
||||
* input prevout hash is 0
|
||||
* input prevout n is 0xffffffff
|
||||
* input scriptSig is OP_0 PUSH32 message_hash
|
||||
|
||||
* PSBT (`to_sign`) MUST have at least one input & 0th input is MUST be `to_spend` full txn
|
||||
* PSBT (`to_sign`) MUST only have one output with null-data OP_RETURN
|
||||
* optionally inputs can be added to `to_sign` for Proof of Reserve signing
|
||||
* PSBT MUST be version 0
|
||||
* foreign inputs not allowed in POR PSBT
|
||||
|
||||
### POR Signing UX
|
||||
|
||||
```text
|
||||
Proof of Reserves
|
||||
|
||||
Amount 0.20000000 XTN
|
||||
|
||||
Message Hash:
|
||||
11b5fe357842f5c368d2e3884d6a5ba577e3bc7cde132004f39b8c2a43a9cdec
|
||||
|
||||
Message Challenge:
|
||||
00140b2537a7d6f3cc668c9e9fa0303ffb3cad6e9b81
|
||||
|
||||
21 inputs
|
||||
1 output
|
||||
|
||||
0.00000000 XTN
|
||||
- OP_RETURN -
|
||||
null-data
|
||||
|
||||
Press ENTER to approve and sign transaction. Press (2) to explore txn
|
||||
outputs. CANCEL to abort.
|
||||
```
|
||||
@ -429,17 +429,23 @@ class ApproveTransaction(UserAuthorizedAction):
|
||||
elif wl >= 2:
|
||||
msg.write('(%d warnings below)\n\n' % wl)
|
||||
|
||||
if self.psbt.consolidation_tx:
|
||||
# consolidating txn that doesn't change balance of account.
|
||||
msg.write("Consolidating %s %s\nwithin wallet.\n\n" %
|
||||
self.chain.render_value(self.psbt.total_value_out))
|
||||
if self.psbt.por322:
|
||||
msg.write("Proof of Reserves\n\n")
|
||||
msg.write("Amount %s %s\n\n" % self.chain.render_value(self.psbt.total_value_in))
|
||||
msg.write("Message Hash:\n%s\n\n" % b2a_hex(self.psbt.por322_msg_hash).decode())
|
||||
msg.write("Message Challenge:\n%s\n\n" % b2a_hex(self.psbt.por322_msg_challenge).decode())
|
||||
else:
|
||||
msg.write("Sending %s %s\n" % self.chain.render_value(
|
||||
self.psbt.total_value_out - self.psbt.total_change_value))
|
||||
if self.psbt.consolidation_tx:
|
||||
# consolidating txn that doesn't change balance of account.
|
||||
msg.write("Consolidating %s %s\nwithin wallet.\n\n" %
|
||||
self.chain.render_value(self.psbt.total_value_out))
|
||||
else:
|
||||
msg.write("Sending %s %s\n" % self.chain.render_value(
|
||||
self.psbt.total_value_out - self.psbt.total_change_value))
|
||||
|
||||
fee = self.psbt.calculate_fee()
|
||||
if fee is not None:
|
||||
msg.write("Network fee %s %s\n\n" % self.chain.render_value(fee))
|
||||
fee = self.psbt.calculate_fee()
|
||||
if fee is not None:
|
||||
msg.write("Network fee %s %s\n\n" % self.chain.render_value(fee))
|
||||
|
||||
msg.write(" %d %s\n %d %s\n\n" % (
|
||||
self.psbt.num_inputs,
|
||||
@ -484,8 +490,9 @@ class ApproveTransaction(UserAuthorizedAction):
|
||||
msg.write(" (B) to write to lower SD slot.")
|
||||
msg.write(" %s to abort." % X)
|
||||
|
||||
title = "OK TO %s?" % ("SIGN" if self.psbt.por322 else "SEND")
|
||||
while True:
|
||||
ch = await ux_show_story(msg, title="OK TO SEND?", escape=esc)
|
||||
ch = await ux_show_story(msg, title=title, escape=esc)
|
||||
if ch == "2":
|
||||
await TXExplorer.start(self)
|
||||
continue
|
||||
|
||||
@ -34,6 +34,9 @@ from public_constants import (
|
||||
AF_P2WSH_P2SH, AF_P2TR, AF_P2WSH, AF_P2SH, AF_CLASSIC, AF_P2WPKH_P2SH, AF_P2WPKH, AF_BARE_PK
|
||||
)
|
||||
|
||||
# transaction version error
|
||||
TX_VER_ERR = "bad txn version"
|
||||
|
||||
# PSBT proprietary keytype
|
||||
PSBT_PROPRIETARY = const(0xFC)
|
||||
|
||||
@ -1063,6 +1066,11 @@ class psbtObject(psbtProxy):
|
||||
self.has_goc = False # global output count
|
||||
self.has_gtv = False # global txn version
|
||||
|
||||
# Proof of Reserves
|
||||
self.por322 = False
|
||||
self.por322_msg_hash = None
|
||||
self.por322_msg_challenge = None
|
||||
|
||||
@property
|
||||
def lock_time(self):
|
||||
return (self._lock_time or self.fallback_locktime) or 0
|
||||
@ -1144,7 +1152,7 @@ class psbtObject(psbtProxy):
|
||||
self.txn_version, marker, flags = unpack("<iBB", fd.read(6))
|
||||
self.had_witness = (marker == 0 and flags != 0x0)
|
||||
|
||||
assert self.txn_version in {1,2,3}, "bad txn version"
|
||||
assert self.txn_version in {0,1,2,3}, TX_VER_ERR
|
||||
|
||||
if not self.had_witness:
|
||||
# rewind back over marker+flags
|
||||
@ -1429,20 +1437,35 @@ class psbtObject(psbtProxy):
|
||||
assert not self.has_goc, "v0 requires exclusion of global output count"
|
||||
assert not self.has_gtv, "v0 requires exclusion of global txn version"
|
||||
assert self.txn, "v0 requires inclusion of global unsigned tx"
|
||||
assert self.txn[1] > 61, 'txn too short'
|
||||
# smallest possible Proof of Reserves transaction has 61 bytes
|
||||
assert self.txn[1] > 60, 'txn too short'
|
||||
assert self.fallback_locktime is None, "v0 requires exclusion of global fallback locktime"
|
||||
assert self.txn_modifiable is None, "v0 requires exclusion of global txn modifiable"
|
||||
|
||||
num_outs = 0
|
||||
null_data_op_return = False
|
||||
for idx, txo in self.output_iter():
|
||||
num_outs += 1
|
||||
out = self.outputs[idx]
|
||||
if self.is_v2:
|
||||
# v2 requires inclusion
|
||||
assert out.amount
|
||||
assert out.script
|
||||
if out.amount == 0 and out.script == b'\x6a':
|
||||
null_data_op_return = True
|
||||
else:
|
||||
# v0 requires exclusion
|
||||
assert out.amount is None
|
||||
assert out.script is None
|
||||
if txo.nValue == 0 and txo.scriptPubKey == b'\x6a':
|
||||
null_data_op_return = True
|
||||
|
||||
if null_data_op_return and (num_outs == 1):
|
||||
self.por322 = True
|
||||
|
||||
if self.txn_version == 0:
|
||||
# only allow txn version 0 for Proof of Reserves txn (BIP-322)
|
||||
assert self.por322, TX_VER_ERR
|
||||
|
||||
# time based relative locks
|
||||
tb_rel_locks = []
|
||||
@ -1568,6 +1591,11 @@ class psbtObject(psbtProxy):
|
||||
|
||||
# check fee is reasonable
|
||||
the_fee = self.calculate_fee()
|
||||
|
||||
if self.por322:
|
||||
# Proof of Reserves - nothing more to check - txn is invalid anyways
|
||||
return
|
||||
|
||||
if the_fee is None:
|
||||
return
|
||||
if the_fee < 0:
|
||||
@ -1761,20 +1789,64 @@ class psbtObject(psbtProxy):
|
||||
|
||||
# iff to UTXO is segwit, then check it's value, and also
|
||||
# capture that value, since it's supposed to be immutable
|
||||
if inp.is_segwit:
|
||||
# Proof of Reserves PSBT must not modify history
|
||||
if inp.is_segwit and not self.por322:
|
||||
history.verify_amount(txi.prevout, inp.amount, i)
|
||||
|
||||
if self.por322 and (i == 0):
|
||||
# Proof of Reserves 'to_spend' validation
|
||||
try:
|
||||
assert inp.utxo, "utxo"
|
||||
fd = self.fd
|
||||
old_pos = fd.tell()
|
||||
fd.seek(inp.utxo[0])
|
||||
|
||||
txn_version, marker, flags = unpack("<iBB", fd.read(6))
|
||||
assert txn_version == 0, TX_VER_ERR
|
||||
wit_format = (marker == 0 and flags != 0x0)
|
||||
if not wit_format:
|
||||
fd.seek(-2, 1)
|
||||
|
||||
num_in = deser_compact_size(fd)
|
||||
assert num_in == 1, "num ins"
|
||||
tx_inp = CTxIn()
|
||||
tx_inp.deserialize(fd)
|
||||
try:
|
||||
assert len(tx_inp.scriptSig) == 34
|
||||
assert tx_inp.scriptSig[0] == 0
|
||||
assert tx_inp.scriptSig[1] == 32
|
||||
except:
|
||||
assert False, "scriptSig"
|
||||
self.por322_msg_hash = tx_inp.scriptSig[2:]
|
||||
try:
|
||||
assert tx_inp.prevout.hash == 0
|
||||
assert tx_inp.prevout.n == 0xffffffff
|
||||
except:
|
||||
assert False, "prevout"
|
||||
|
||||
num_out = deser_compact_size(fd)
|
||||
assert num_out == 1, "num outs"
|
||||
tx_out = CTxOut()
|
||||
tx_out.deserialize(fd)
|
||||
self.por322_msg_challenge = tx_out.scriptPubKey
|
||||
assert tx_out.nValue == 0, "nVal"
|
||||
|
||||
fd.seek(old_pos)
|
||||
except Exception as e:
|
||||
raise FatalPSBTIssue("i0: invalid BIP-322 'to_spend': %s" % e)
|
||||
|
||||
del utxo
|
||||
|
||||
# XXX scan witness data provided, and consider those ins signed if not multisig?
|
||||
|
||||
if not foreign:
|
||||
# no foreign inputs, we can calculate the total input value
|
||||
assert total_in > 0, "zero value txn"
|
||||
self.total_value_in = total_in
|
||||
assert total_in > 0 or self.por322, "zero value txn"
|
||||
else:
|
||||
# 1+ inputs don't belong to us, we can't calculate the total input value
|
||||
# OK for multi-party transactions (coinjoin etc.)
|
||||
assert not self.por322 # cannot have foreign inputs in POR txn
|
||||
self.total_value_in = None
|
||||
self.warnings.append(
|
||||
("Unable to calculate fee", "Some input(s) haven't provided UTXO(s): " + seq_to_str(foreign))
|
||||
@ -2028,6 +2100,9 @@ class psbtObject(psbtProxy):
|
||||
assert txi.scriptSig, "no scriptsig?"
|
||||
|
||||
inp.handle_none_sighash()
|
||||
if self.por322:
|
||||
assert inp.sighash in [SIGHASH_ALL], "POR not SIGHASH_ALL" # add DEFAULT for taproot
|
||||
|
||||
if inp.is_multisig:
|
||||
# need to consider a set of possible keys, since xfp may not be unique
|
||||
for which_key in inp.required_key:
|
||||
|
||||
238
testing/bip322.py
Normal file
238
testing/bip322.py
Normal file
@ -0,0 +1,238 @@
|
||||
# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
#
|
||||
# construct Proof of Reserves transaction according to BIP-322
|
||||
#
|
||||
import pytest, struct, hashlib
|
||||
from ckcc_protocol.protocol import MAX_TXN_LEN
|
||||
from psbt import BasicPSBT, BasicPSBTInput, BasicPSBTOutput
|
||||
from io import BytesIO
|
||||
from helpers import hash160, taptweak, str_to_path
|
||||
from bip32 import BIP32Node
|
||||
from constants import simulator_fixed_tprv, AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH
|
||||
from ctransaction import CTransaction, COutPoint, CTxIn, CTxOut, uint256_from_str
|
||||
|
||||
|
||||
def bip322_msg_hash(msg):
|
||||
tag_hash = hashlib.sha256(b'BIP0322-signed-message').digest()
|
||||
return hashlib.sha256(tag_hash + tag_hash + msg).digest()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bip322_txn(dev, pytestconfig):
|
||||
|
||||
def doit(inputs, msg=b"POR", addr_fmt="p2wpkh", input_amount=1E8, to_sign_lock_time=0,
|
||||
sighash=None, psbt_hacker=None, witness_utxo=[], to_sign_nVersion=0):
|
||||
|
||||
msg_challenge = None
|
||||
|
||||
num_ins = len(inputs)
|
||||
|
||||
psbt = BasicPSBT()
|
||||
|
||||
to_sign = CTransaction()
|
||||
to_sign.nLockTime = to_sign_lock_time
|
||||
# must be set to 2 if BIP-68 is used (relative tx level lock)
|
||||
to_sign.nVersion = to_sign_nVersion
|
||||
master_xpub = dev.master_xpub or simulator_fixed_tprv
|
||||
|
||||
# we have a key; use it to provide "plausible" value inputs
|
||||
mk = BIP32Node.from_wallet_key(master_xpub)
|
||||
mfp = mk.fingerprint()
|
||||
|
||||
psbt.inputs = [BasicPSBTInput(idx=i) for i in range(num_ins)]
|
||||
psbt.outputs = []
|
||||
|
||||
for i, inp in enumerate(inputs):
|
||||
sp = f"0/{i}"
|
||||
af = addr_fmt
|
||||
ia = input_amount
|
||||
try:
|
||||
if inp[0] is not None:
|
||||
af = inp[0]
|
||||
if inp[1] is not None:
|
||||
sp = inp[1]
|
||||
if inp[2] is not None:
|
||||
ia = inp[2]
|
||||
except:
|
||||
pass
|
||||
|
||||
int_path = str_to_path(sp)
|
||||
subkey = mk.subkey_for_path(sp)
|
||||
sec = subkey.sec()
|
||||
assert len(sec) == 33, "expect compressed"
|
||||
|
||||
if af == "p2tr":
|
||||
tweaked_xonly = taptweak(sec[1:])
|
||||
psbt.inputs[i].taproot_bip32_paths[sec[1:]] = b"\x00" + mfp + struct.pack(f'<{"I" * len(int_path)}',
|
||||
*int_path)
|
||||
scr = bytes([81, 32]) + tweaked_xonly
|
||||
|
||||
elif af in ("p2wpkh", "p2sh-p2wpkh", "p2wpkh-p2sh"):
|
||||
psbt.inputs[i].bip32_paths[sec] = mfp + struct.pack(f'<{"I" * len(int_path)}', *int_path)
|
||||
scr = bytes([0x00, 0x14]) + subkey.hash160()
|
||||
|
||||
if af != "p2wpkh":
|
||||
# use classic p2wpkh (from above) as redeem script
|
||||
psbt.inputs[i].redeem_script = scr
|
||||
scr = bytes([0xa9, 0x14]) + hash160(scr) + bytes([0x87])
|
||||
|
||||
elif af == "p2pkh":
|
||||
psbt.inputs[i].bip32_paths[sec] = mfp + struct.pack('<II', 0, i)
|
||||
scr = bytes([0x76, 0xa9, 0x14]) + subkey.hash160() + bytes([0x88, 0xac])
|
||||
|
||||
else:
|
||||
raise ValueError("unknown addr_fmt %s" % af)
|
||||
|
||||
if i == 0:
|
||||
# first input always spends to_spend
|
||||
to_spend = CTransaction()
|
||||
to_spend.nVersion = 0
|
||||
out_point = COutPoint(hash=0, n=0xffffffff)
|
||||
to_spend.vin = [CTxIn(out_point, scriptSig=b'\x00\x20' + bip322_msg_hash(msg))]
|
||||
to_spend.vout = [CTxOut(0, scr)] # always zero val
|
||||
msg_challenge = scr
|
||||
else:
|
||||
# other outputs that we want to prove ownership
|
||||
to_spend = CTransaction()
|
||||
to_spend.nVersion = 0
|
||||
out_point = COutPoint(
|
||||
uint256_from_str(struct.pack('4Q', 0xdead, 0xbeef, 0, 0)),
|
||||
73
|
||||
)
|
||||
to_spend.vin = [CTxIn(out_point, nSequence=0xffffffff)]
|
||||
to_spend.vout.append(CTxOut(int(ia), scr))
|
||||
|
||||
|
||||
if sighash is not None:
|
||||
psbt.inputs[i].sighash = sighash
|
||||
|
||||
to_spend.calc_sha256()
|
||||
|
||||
if i in witness_utxo:
|
||||
psbt.inputs[i].witness_utxo = to_spend.vout[-1].serialize()
|
||||
else:
|
||||
psbt.inputs[i].utxo = to_spend.serialize_with_witness()
|
||||
|
||||
if to_sign_lock_time and not i:
|
||||
seq = 0xfffffffd
|
||||
else:
|
||||
seq = 0xffffffff
|
||||
|
||||
spendable = CTxIn(COutPoint(to_spend.sha256, 0), nSequence=seq)
|
||||
to_sign.vin.append(spendable)
|
||||
|
||||
# just one zero amount output with script null data OP_RETURN
|
||||
op_ret_o = BasicPSBTOutput(idx=0)
|
||||
op_return_out = CTxOut(0, b'\x6a')
|
||||
to_sign.vout.append(op_return_out)
|
||||
|
||||
psbt.outputs.append(op_ret_o)
|
||||
|
||||
psbt.txn = to_sign.serialize_with_witness()
|
||||
|
||||
# last minute chance to mod PSBT object
|
||||
if psbt_hacker:
|
||||
psbt_hacker(psbt)
|
||||
|
||||
rv = BytesIO()
|
||||
psbt.serialize(rv)
|
||||
assert rv.tell() <= MAX_TXN_LEN, 'too fat'
|
||||
|
||||
return rv.getvalue(), msg_challenge
|
||||
|
||||
return doit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bip322_ms_txn(pytestconfig):
|
||||
from test_multisig import make_ms_address
|
||||
|
||||
def doit(num_ins, M, keys, msg=b"POR", inp_af=AF_P2WSH, input_amount=1E8, path_mapper=None,
|
||||
lock_time=0, with_sigs=False, sighash=None, hack_psbt=None, to_sign_nVersion=0):
|
||||
|
||||
msg_challenge = None
|
||||
|
||||
psbt = BasicPSBT()
|
||||
|
||||
txn = CTransaction()
|
||||
txn.nVersion = to_sign_nVersion
|
||||
txn.nLockTime = lock_time
|
||||
|
||||
psbt.inputs = [BasicPSBTInput(idx=i) for i in range(num_ins)]
|
||||
psbt.outputs = []
|
||||
|
||||
for i in range(num_ins):
|
||||
# make a fake txn to supply each of the inputs
|
||||
# - each input is 1BTC
|
||||
|
||||
# addr where the fake money will be stored.
|
||||
addr, scriptPubKey, script, details = make_ms_address(
|
||||
M, keys, idx=i, addr_fmt=inp_af, path_mapper=path_mapper
|
||||
)
|
||||
|
||||
if inp_af == AF_P2WSH:
|
||||
psbt.inputs[i].witness_script = script
|
||||
elif inp_af == AF_P2SH:
|
||||
psbt.inputs[i].redeem_script = script
|
||||
else:
|
||||
assert inp_af == AF_P2WSH_P2SH
|
||||
psbt.inputs[i].witness_script = script
|
||||
psbt.inputs[i].redeem_script = b'\0\x20' + hashlib.sha256(script).digest()
|
||||
|
||||
for pubkey, xfp_path in details:
|
||||
psbt.inputs[i].bip32_paths[pubkey] = b''.join(struct.pack('<I', j) for j in xfp_path)
|
||||
if with_sigs and (xfp_path[0] != keys[-1][0]): # only cosigner signatures are added
|
||||
psbt.inputs[i].part_sigs[pubkey] = b"\x30" + 70*b"a"
|
||||
|
||||
if i == 0:
|
||||
to_spend = CTransaction()
|
||||
to_spend.nVersion = 0
|
||||
out_point = COutPoint(hash=0, n=0xffffffff)
|
||||
to_spend.vin = [CTxIn(out_point, scriptSig=b'\x00\x20' + bip322_msg_hash(msg))]
|
||||
to_spend.vout.append(CTxOut(0, scriptPubKey))
|
||||
msg_challenge = scriptPubKey
|
||||
else:
|
||||
# other outputs that we want to prove ownership
|
||||
to_spend = CTransaction()
|
||||
to_spend.nVersion = 0
|
||||
out_point = COutPoint(
|
||||
uint256_from_str(struct.pack('4Q', 0xdead, 0xbeef, 0, 0)),
|
||||
73
|
||||
)
|
||||
to_spend.vin = [CTxIn(out_point, nSequence=0xffffffff)]
|
||||
to_spend.vout.append(CTxOut(int(input_amount), scriptPubKey))
|
||||
|
||||
# always add whole txn as utxo
|
||||
psbt.inputs[i].utxo = to_spend.serialize_with_witness()
|
||||
if sighash is not None and (i != 0):
|
||||
psbt.inputs[i].sighash = sighash
|
||||
|
||||
to_spend.calc_sha256()
|
||||
|
||||
if lock_time and not i:
|
||||
seq = 0xfffffffd
|
||||
else:
|
||||
seq = 0xffffffff
|
||||
|
||||
spendable = CTxIn(COutPoint(to_spend.sha256, 0), nSequence=seq)
|
||||
txn.vin.append(spendable)
|
||||
|
||||
# just one zero amount output with script null data OP_RETURN
|
||||
op_ret_o = BasicPSBTOutput(idx=0)
|
||||
op_return_out = CTxOut(0, b'\x6a')
|
||||
txn.vout.append(op_return_out)
|
||||
|
||||
psbt.outputs.append(op_ret_o)
|
||||
|
||||
if hack_psbt:
|
||||
hack_psbt(psbt)
|
||||
|
||||
psbt.txn = txn.serialize_with_witness()
|
||||
|
||||
rv = BytesIO()
|
||||
psbt.serialize(rv)
|
||||
assert rv.tell() <= MAX_TXN_LEN, 'too fat'
|
||||
|
||||
return rv.getvalue(), msg_challenge
|
||||
|
||||
return doit
|
||||
@ -1481,7 +1481,7 @@ def end_sign(dev, need_keypress, press_cancel):
|
||||
assert res[0:4] != b'psbt', 'still a PSBT, but asked for finalize'
|
||||
t = CTransaction()
|
||||
t.deserialize(io.BytesIO(res))
|
||||
assert t.nVersion in [1, 2, 3]
|
||||
assert t.nVersion in [0, 1, 2, 3]
|
||||
|
||||
# TODO: pull out signatures from signed txn
|
||||
|
||||
@ -2927,5 +2927,6 @@ from test_seed_xor import restore_seed_xor
|
||||
from test_sign import txid_from_export_prompt
|
||||
from test_ux import pass_word_quiz, word_menu_entry, enable_hw_ux
|
||||
from txn import fake_txn
|
||||
from bip322 import bip322_txn, bip322_ms_txn
|
||||
|
||||
# EOF
|
||||
|
||||
414
testing/test_bip322.py
Normal file
414
testing/test_bip322.py
Normal file
@ -0,0 +1,414 @@
|
||||
# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC.
|
||||
#
|
||||
# BIP-322 message signing & Proof of Reserves
|
||||
#
|
||||
import pytest
|
||||
from io import BytesIO
|
||||
from decimal import Decimal
|
||||
from constants import SIGHASH_MAP, AF_P2SH, AF_P2WSH, AF_P2WSH_P2SH
|
||||
from bip322 import bip322_txn, bip322_ms_txn, bip322_msg_hash
|
||||
from ctransaction import CTransaction, CTxIn, COutPoint
|
||||
from helpers import str_to_path
|
||||
|
||||
|
||||
@pytest.mark.parametrize("msg", [b"POR", b"This is the signed message"])
|
||||
@pytest.mark.parametrize("ins", [
|
||||
[["p2wpkh", None, None]],
|
||||
[["p2pkh", None, None]],
|
||||
[["p2sh-p2wpkh", None, None]],
|
||||
[["p2sh-p2wpkh", None, None], ["p2wpkh", None, 100000], ["p2pkh", None, 1000000]],
|
||||
[["p2wpkh", None, None]] + ([["p2wpkh", None, 1000000]] * 20),
|
||||
[["p2pkh", None, None]] + ([["p2wpkh", None, 1000000]] * 5) + ([["p2sh-p2wpkh", None, 10000000]] * 5),
|
||||
])
|
||||
def test_bip322_por(msg, ins, bip322_txn, start_sign, end_sign, cap_story):
|
||||
num_ins = len(ins)
|
||||
amt = sum([i[2] or 0 for i in ins])
|
||||
psbt, msg_challenge = bip322_txn(ins, msg=msg)
|
||||
start_sign(psbt, finalize=True)
|
||||
title, story = cap_story()
|
||||
assert title == "OK TO SIGN?"
|
||||
assert "Proof of Reserves" in story
|
||||
assert 'Network fee' not in story # different story for POR
|
||||
if len(ins) == 1:
|
||||
# only the message signed input - amount is zero
|
||||
assert "Amount 0.00000000 XTN" in story
|
||||
assert "1 input" in story
|
||||
else:
|
||||
assert ("Amount %s XTN" % str(Decimal(amt/100000000).quantize(Decimal('.00000001')))) in story
|
||||
assert ("%d inputs" % num_ins) in story
|
||||
|
||||
assert ("Message Hash:\n%s" % bip322_msg_hash(msg).hex()) in story
|
||||
assert ("Message Challenge:\n%s" % msg_challenge.hex()) in story
|
||||
assert "1 output" in story
|
||||
assert "- OP_RETURN -" in story
|
||||
assert "null-data" in story
|
||||
end_sign(accept=True, finalize=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sighash", [sh for sh in SIGHASH_MAP if sh != 'ALL'])
|
||||
def test_bip322_por_invalid_sighash(sighash, bip322_txn, start_sign, cap_story, settings_set, end_sign):
|
||||
settings_set("sighshchk", 0) # disable checks
|
||||
# all POR txns must have only SIGHASH_ALL
|
||||
psbt, _ = bip322_txn([["p2sh-p2wpkh", None, None], ["p2wpkh", None, 100000], ["p2pkh", None, 1000000]],
|
||||
sighash=SIGHASH_MAP[sighash])
|
||||
start_sign(psbt, finalize=True)
|
||||
title, story = cap_story()
|
||||
if "NONE" in sighash:
|
||||
assert title == "Failure"
|
||||
return
|
||||
|
||||
assert "warning" in story
|
||||
with pytest.raises(Exception):
|
||||
end_sign(accept=True, finalize=True)
|
||||
|
||||
title, story = cap_story()
|
||||
assert "POR not SIGHASH_ALL" in story
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ins", [
|
||||
[["p2wpkh", None, None]],
|
||||
[["p2wpkh", None, None], ["p2wpkh", None, 10000000]],
|
||||
])
|
||||
def test_bip322_0th_input_witness_utxo(ins, bip322_txn, start_sign, cap_story):
|
||||
# not allowed - 0th input needs to have full pre-segwit utxo
|
||||
psbt, _ = bip322_txn(ins, witness_utxo=[0])
|
||||
start_sign(psbt, finalize=True)
|
||||
title, story = cap_story()
|
||||
assert title == "Failure"
|
||||
assert "i0: invalid BIP-322 'to_spend'" in story
|
||||
assert "utxo" in story
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ins", [
|
||||
[["p2wpkh", None, None], ["p2wpkh", None, 10000000], ["p2wpkh", None, 10000000]],
|
||||
[["p2wpkh", None, None], ["p2sh-p2wpkh", None, 10000000], ["p2pkh", None, 10000000]],
|
||||
[["p2pkh", None, None], ["p2wpkh", None, 10000000], ["p2sh-p2wpkh", None, 10000000]],
|
||||
])
|
||||
def test_bip322_Xth_input_witness_utxo(ins, bip322_txn, start_sign, cap_story, end_sign):
|
||||
# allowed - 0th input needs to have full pre-segwit utxo, all other can be just witness_utxo
|
||||
msg = b"hellow world"
|
||||
psbt, msg_challenge = bip322_txn(ins, witness_utxo=[1, 2], msg=msg)
|
||||
start_sign(psbt, finalize=True)
|
||||
title, story = cap_story()
|
||||
assert title == "OK TO SIGN?"
|
||||
assert bip322_msg_hash(msg).hex() in story
|
||||
assert msg_challenge.hex() in story
|
||||
end_sign(accept=True, finalize=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ins", [
|
||||
[["p2wpkh", None, None]],
|
||||
[["p2pkh", None, None]],
|
||||
[["p2sh-p2wpkh", None, None]],
|
||||
[["p2pkh", None, None], ["p2wpkh", None, 10000000], ["p2wpkh", None, 10000000]],
|
||||
[["p2wpkh", None, None], ["p2pkh", None, 10000000], ["p2pkh", None, 10000000]],
|
||||
[["p2sh-p2wpkh", None, None], ["p2sh-p2wpkh", None, 10000000], ["p2sh-p2wpkh", None, 10000000]],
|
||||
])
|
||||
def test__bip322_incomplete_psbt_bip32_paths(ins, bip322_txn, start_sign, cap_story):
|
||||
|
||||
def hack(psbt_in):
|
||||
for i, inp in enumerate(psbt_in.inputs):
|
||||
if i == 0:
|
||||
inp.bip32_paths = None
|
||||
|
||||
psbt, _ = bip322_txn(ins, psbt_hacker=hack)
|
||||
start_sign(psbt)
|
||||
title, story = cap_story()
|
||||
if len(ins) == 1:
|
||||
assert title == "Failure"
|
||||
assert 'PSBT does not contain any key path information.' in story
|
||||
else:
|
||||
assert "warning" in story
|
||||
assert "Limited Signing" in story
|
||||
assert "because we do not know the key: 0" in story
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ins", [
|
||||
[["p2sh-p2wpkh", None, None]],
|
||||
[["p2pkh", None, None], ["p2sh-p2wpkh", None, 10000000], ["p2wpkh", None, 100000000]],
|
||||
[["p2sh-p2wpkh", None, None], ["p2sh-p2wpkh", None, 10000000], ["p2pkh", None, 10000000]],
|
||||
])
|
||||
def test__bip322_incomplete_psbt_wrapped_redeem(ins, bip322_txn, start_sign, cap_story):
|
||||
|
||||
def hack(psbt_in):
|
||||
for i, inp in enumerate(psbt_in.inputs):
|
||||
inp.redeem_script = None
|
||||
|
||||
psbt, _ = bip322_txn(ins, psbt_hacker=hack)
|
||||
start_sign(psbt)
|
||||
title, story = cap_story()
|
||||
|
||||
assert title == "Failure"
|
||||
assert "Missing redeem/witness script" in story
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ins", [
|
||||
[["p2wpkh", None, None]],
|
||||
[["p2pkh", None, None]],
|
||||
[["p2sh-p2wpkh", None, None]],
|
||||
])
|
||||
def test_bip322_invalid_to_spend_scriptSig(ins, bip322_txn, start_sign, cap_story):
|
||||
def hack(psbt_in):
|
||||
to_spend = psbt_in.inputs[0].utxo
|
||||
|
||||
to_sign = psbt_in.txn
|
||||
|
||||
to_spend_tx = CTransaction()
|
||||
to_sign_tx = CTransaction()
|
||||
to_spend_tx.deserialize(BytesIO(to_spend))
|
||||
to_sign_tx.deserialize(BytesIO(to_sign))
|
||||
|
||||
for i in to_spend_tx.vin:
|
||||
i.scriptSig = b"a" * 34
|
||||
|
||||
psbt_in.inputs[0].utxo = to_spend_tx.serialize_with_witness()
|
||||
|
||||
to_spend_tx.calc_sha256()
|
||||
|
||||
spendable = CTxIn(COutPoint(to_spend_tx.sha256, 0))
|
||||
to_sign_tx.vin= [spendable]
|
||||
|
||||
psbt_in.txn = to_sign_tx.serialize_with_witness()
|
||||
|
||||
psbt, _ = bip322_txn(ins, psbt_hacker=hack)
|
||||
start_sign(psbt)
|
||||
title, story = cap_story()
|
||||
assert title == "Failure"
|
||||
assert "i0: invalid BIP-322 'to_spend'" in story
|
||||
assert "scriptSig" in story
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ins", [
|
||||
[["p2wpkh", None, None]],
|
||||
[["p2pkh", None, None], ["p2wpkh", None, None]],
|
||||
[["p2sh-p2wpkh", None]],
|
||||
])
|
||||
def test_bip322_invalid_to_spend_prevout(ins, bip322_txn, start_sign, cap_story):
|
||||
def hack(psbt_in):
|
||||
to_spend = psbt_in.inputs[0].utxo
|
||||
|
||||
to_sign = psbt_in.txn
|
||||
|
||||
to_spend_tx = CTransaction()
|
||||
to_sign_tx = CTransaction()
|
||||
to_spend_tx.deserialize(BytesIO(to_spend))
|
||||
to_sign_tx.deserialize(BytesIO(to_sign))
|
||||
|
||||
if len(ins) == 2:
|
||||
to_spend_tx.vin[0].prevout.n = 0xfffffffe
|
||||
else:
|
||||
to_spend_tx.vin[0].prevout.hash = 1
|
||||
|
||||
psbt_in.inputs[0].utxo = to_spend_tx.serialize_with_witness()
|
||||
|
||||
to_spend_tx.calc_sha256()
|
||||
|
||||
spendable = CTxIn(COutPoint(to_spend_tx.sha256, 0))
|
||||
|
||||
to_sign_tx.vin = [spendable]
|
||||
|
||||
psbt_in.txn = to_sign_tx.serialize_with_witness()
|
||||
|
||||
psbt, _ = bip322_txn(ins, psbt_hacker=hack)
|
||||
start_sign(psbt)
|
||||
title, story = cap_story()
|
||||
assert title == "Failure"
|
||||
assert "i0: invalid BIP-322 'to_spend'" in story
|
||||
assert "prevout" in story
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ins", [
|
||||
[["p2wpkh", None, None]],
|
||||
[["p2pkh", None, None]],
|
||||
[["p2sh-p2wpkh", None]],
|
||||
])
|
||||
def test_bip322_invalid_to_spend_num_inputs(ins, bip322_txn, start_sign, cap_story):
|
||||
def hack(psbt_in):
|
||||
to_spend = psbt_in.inputs[0].utxo
|
||||
|
||||
to_sign = psbt_in.txn
|
||||
|
||||
to_spend_tx = CTransaction()
|
||||
to_sign_tx = CTransaction()
|
||||
to_spend_tx.deserialize(BytesIO(to_spend))
|
||||
to_sign_tx.deserialize(BytesIO(to_sign))
|
||||
|
||||
to_spend_tx.vin.append(to_sign_tx.vin[0]) # two inputs
|
||||
|
||||
assert len(to_spend_tx.vin) == 2
|
||||
|
||||
psbt_in.inputs[0].utxo = to_spend_tx.serialize_with_witness()
|
||||
|
||||
to_spend_tx.calc_sha256()
|
||||
|
||||
spendable = CTxIn(COutPoint(to_spend_tx.sha256, 0))
|
||||
|
||||
to_sign_tx.vin = [spendable]
|
||||
|
||||
psbt_in.txn = to_sign_tx.serialize_with_witness()
|
||||
|
||||
psbt, _ = bip322_txn(ins, psbt_hacker=hack)
|
||||
start_sign(psbt)
|
||||
title, story = cap_story()
|
||||
assert title == "Failure"
|
||||
assert "i0: invalid BIP-322 'to_spend'" in story
|
||||
assert "num ins" in story
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ins", [
|
||||
[["p2wpkh", None, None]],
|
||||
[["p2pkh", None, None]],
|
||||
[["p2sh-p2wpkh", None]],
|
||||
])
|
||||
def test_bip322_invalid_to_spend_num_outputs(ins, bip322_txn, start_sign, cap_story):
|
||||
def hack(psbt_in):
|
||||
to_spend = psbt_in.inputs[0].utxo
|
||||
|
||||
to_sign = psbt_in.txn
|
||||
|
||||
to_spend_tx = CTransaction()
|
||||
to_sign_tx = CTransaction()
|
||||
to_spend_tx.deserialize(BytesIO(to_spend))
|
||||
to_sign_tx.deserialize(BytesIO(to_sign))
|
||||
|
||||
to_spend_tx.vout.append(to_sign_tx.vout[0]) # two inputs
|
||||
|
||||
assert len(to_spend_tx.vout) == 2
|
||||
|
||||
psbt_in.inputs[0].utxo = to_spend_tx.serialize_with_witness()
|
||||
|
||||
to_spend_tx.calc_sha256()
|
||||
|
||||
spendable = CTxIn(COutPoint(to_spend_tx.sha256, 0))
|
||||
|
||||
to_sign_tx.vin = [spendable]
|
||||
|
||||
psbt_in.txn = to_sign_tx.serialize_with_witness()
|
||||
|
||||
psbt, _ = bip322_txn(ins, psbt_hacker=hack)
|
||||
start_sign(psbt)
|
||||
title, story = cap_story()
|
||||
assert title == "Failure"
|
||||
assert "i0: invalid BIP-322 'to_spend'" in story
|
||||
assert "num outs" in story
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ins", [
|
||||
[["p2wpkh", None, None]],
|
||||
[["p2pkh", None, None]],
|
||||
[["p2sh-p2wpkh", None]],
|
||||
])
|
||||
def test_bip322_invalid_to_spend_out_nVal(ins, bip322_txn, start_sign, cap_story):
|
||||
def hack(psbt_in):
|
||||
to_spend = psbt_in.inputs[0].utxo
|
||||
|
||||
to_sign = psbt_in.txn
|
||||
|
||||
to_spend_tx = CTransaction()
|
||||
to_sign_tx = CTransaction()
|
||||
to_spend_tx.deserialize(BytesIO(to_spend))
|
||||
to_sign_tx.deserialize(BytesIO(to_sign))
|
||||
|
||||
to_spend_tx.vout[0].nValue = 1
|
||||
|
||||
psbt_in.inputs[0].utxo = to_spend_tx.serialize_with_witness()
|
||||
|
||||
to_spend_tx.calc_sha256()
|
||||
|
||||
spendable = CTxIn(COutPoint(to_spend_tx.sha256, 0))
|
||||
|
||||
to_sign_tx.vin = [spendable]
|
||||
|
||||
psbt_in.txn = to_sign_tx.serialize_with_witness()
|
||||
|
||||
psbt, _ = bip322_txn(ins, psbt_hacker=hack)
|
||||
start_sign(psbt)
|
||||
title, story = cap_story()
|
||||
assert title == "Failure"
|
||||
assert "i0: invalid BIP-322 'to_spend'" in story
|
||||
assert "nVal" in story
|
||||
|
||||
|
||||
@pytest.mark.parametrize("addr_fmt", [AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH])
|
||||
@pytest.mark.parametrize("M_N", [(2,3), (13,15)])
|
||||
@pytest.mark.parametrize("signed", [True, False])
|
||||
@pytest.mark.parametrize("num_ins", [1, 7])
|
||||
def test_ms_bip322_por(addr_fmt, M_N, signed, bip322_ms_txn, start_sign, end_sign, cap_story,
|
||||
import_ms_wallet, clear_ms, num_ins):
|
||||
clear_ms()
|
||||
|
||||
M, N = M_N
|
||||
inp_amount = 10000000
|
||||
|
||||
if addr_fmt == AF_P2SH:
|
||||
dd = "m/45h"
|
||||
elif addr_fmt == AF_P2WSH:
|
||||
dd = "m/48h/1h/0h/2h"
|
||||
else:
|
||||
dd = "m/48h/1h/0h/1h"
|
||||
|
||||
def path_mapper(idx):
|
||||
kk = str_to_path(dd)
|
||||
return kk + [0,0]
|
||||
|
||||
keys = import_ms_wallet(M, N, name='bip322_por', accept=True, addr_fmt=addr_fmt, common=dd,
|
||||
do_import=True, descriptor=True)
|
||||
|
||||
|
||||
psbt, msg_challenge = bip322_ms_txn(num_ins, M, keys, path_mapper=path_mapper, inp_af=addr_fmt,
|
||||
with_sigs=signed, input_amount=inp_amount)
|
||||
start_sign(psbt, finalize=signed)
|
||||
title, story = cap_story()
|
||||
assert title == "OK TO SIGN?"
|
||||
assert "Proof of Reserves" in story
|
||||
assert 'Network fee' not in story
|
||||
if num_ins == 1:
|
||||
# only the message signed input - amount is zero
|
||||
assert "Amount 0.00000000 XTN" in story
|
||||
assert "1 input" in story
|
||||
else:
|
||||
amt = (num_ins - 1) * inp_amount
|
||||
str_amt = str(Decimal(amt / 100000000).quantize(Decimal('.00000001')))
|
||||
assert ("Amount %s XTN" % str_amt) in story
|
||||
assert ("%d inputs" % num_ins) in story
|
||||
|
||||
assert ("Message Hash:\n%s" % bip322_msg_hash(b"POR").hex()) in story
|
||||
assert ("Message Challenge:\n%s" % msg_challenge.hex()) in story
|
||||
assert "1 output" in story
|
||||
assert "- OP_RETURN -" in story
|
||||
assert "null-data" in story
|
||||
|
||||
end_sign(accept=True, finalize=signed)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("addr_fmt", [AF_P2WSH, AF_P2SH])
|
||||
def test_bip322_invalid_ms_psbt(addr_fmt, bip322_ms_txn, start_sign, cap_story, import_ms_wallet):
|
||||
def hack(psbt_in):
|
||||
if addr_fmt in [AF_P2WSH]:
|
||||
psbt_in.inputs[0].witness_script = None
|
||||
else:
|
||||
psbt_in.inputs[0].redeem_script = None
|
||||
|
||||
if addr_fmt == AF_P2SH:
|
||||
dd = "m/45h"
|
||||
elif addr_fmt == AF_P2WSH:
|
||||
dd = "m/48h/1h/0h/2h"
|
||||
else:
|
||||
dd = "m/48h/1h/0h/1h"
|
||||
|
||||
def path_mapper(idx):
|
||||
kk = str_to_path(dd)
|
||||
return kk + [0,0]
|
||||
|
||||
keys = import_ms_wallet(2, 3, name='fail_b322', accept=True, addr_fmt=addr_fmt, common=dd,
|
||||
do_import=True, descriptor=True)
|
||||
|
||||
psbt, msg_challenge = bip322_ms_txn(1, 2, keys, path_mapper=path_mapper, inp_af=addr_fmt,
|
||||
hack_psbt=hack)
|
||||
|
||||
start_sign(psbt)
|
||||
title, story = cap_story()
|
||||
assert title == "Failure"
|
||||
assert "Missing redeem/witness script" in story
|
||||
|
||||
# EOF
|
||||
@ -21,6 +21,7 @@ from txn import *
|
||||
from ctransaction import CTransaction, CTxOut, CTxIn, COutPoint
|
||||
from ckcc_protocol.constants import STXN_VISUALIZE, STXN_SIGNED
|
||||
from charcodes import KEY_QR, KEY_RIGHT, KEY_LEFT
|
||||
from bip322 import bip322_msg_hash
|
||||
|
||||
|
||||
SEQUENCE_LOCKTIME_TYPE_FLAG = (1 << 22)
|
||||
@ -3625,4 +3626,21 @@ def test_tx_explorer_goto_idx(fake_txn, start_sign, cap_story, use_testnet, need
|
||||
assert title == f"{num}-{num}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("segwit", [True, False])
|
||||
def test_txn_nVersion_zero(segwit, fake_txn, start_sign, cap_story, goto_home):
|
||||
goto_home()
|
||||
|
||||
def hack(psbt):
|
||||
t = CTransaction()
|
||||
t.deserialize(BytesIO(psbt.txn))
|
||||
t.nVersion = 0
|
||||
psbt.txn = t.serialize()
|
||||
|
||||
psbt = fake_txn(1, 2, segwit_in=segwit, change_outputs=[0], psbt_hacker=hack)
|
||||
start_sign(psbt)
|
||||
time.sleep(.1)
|
||||
title, story = cap_story()
|
||||
assert title == "Failure"
|
||||
assert "txn version" in story
|
||||
|
||||
# EOF
|
||||
|
||||
Loading…
Reference in New Issue
Block a user