BIP-322 changes after BIP got in to the complete state

This commit is contained in:
scgbckbone 2026-05-19 17:10:43 +02:00 committed by doc-hex
parent 7e92e5162a
commit d656f371c7
15 changed files with 1087 additions and 591 deletions

View File

@ -9,56 +9,104 @@ BIP-322 specification: <https://github.com/bitcoin/bips/blob/master/bip-0322.med
COLDCARD accepts a specially crafted PSBT file to sign as BIP-322 Proof of Reserves. The PSBT
must meet all these requirements:
* COLDCARD acts as a BIP-322 PSBT signer. It validates the BIP-322 `to_sign`
transaction, shows the message from `PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE`, and
adds signatures to the PSBT. Finalizing and encoding the final BIP-322
signature string is the responsibility of the finalizer.
* PSBT MUST include `PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE = 0x09`; the value is
the exact message shown to the user and signed by BIP-322.
* 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`
* First (0th) input in `to_sign` transaction MUST have full (pre-segwit) UTXO (`PSBT_IN_NON_WITNESS_UTXO`) a.k.a `to_spend`.
* First (0th) input in `to_sign` `PSBT_IN_NON_WITNESS_UTXO` transaction (`to_spend`) is as defined
in [BIP-322](https://github.com/bitcoin/bips/blob/master/bip-0322.mediawiki#full):
* PSBT (`to_sign`) MUST have at least one input.
* First (0th) input of `to_sign` MUST spend the BIP-322 `to_spend` output.
* Input 0 MUST include one of `PSBT_IN_NON_WITNESS_UTXO` or `PSBT_IN_WITNESS_UTXO`.
* When input 0 provides `PSBT_IN_WITNESS_UTXO`, COLDCARD reconstructs the
expected `to_spend` txid from `PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE` and the
witness UTXO scriptPubKey.
* When input 0 provides `PSBT_IN_NON_WITNESS_UTXO`, it MUST be the BIP-322
`to_spend` transaction as defined in
[BIP-322](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 & first input MUST be `to_spend` full txn
* PSBT (`to_sign`) MUST only have one output with null-data `OP_RETURN`
* `to_sign` transaction version MUST be 0 or 2.
* Optionally inputs can be added to `to_sign` for Proof of Reserve signing.
* PSBT MUST be version 0.
* PSBT MUST be version 0 or 2.
* Foreign inputs not allowed in POR PSBT.
The signatures created by the BIP-322 process will never be suitable
for a on-chain Bitcoin transaction that could move funds, because
of these restrictions imposed by BIP-322.
### Output
COLDCARD always returns a signed PSBT for BIP-322 message signing and Proof of
Reserves. It never returns an extracted/finalized transaction for these PSBTs.
This is true even when finalization is requested over USB, such as with
`ckcc unsigned.psbt --finalize`.
The signed PSBT is the handoff artifact for the external finalizer/verifier. It
keeps the PSBT metadata needed to verify or finalize the BIP-322 signature,
including public keys, scripts, partial signatures, and UTXO data. This matters
because the address being proven normally commits only to a hash of the public
key or script, not the public key or script itself.
### Proof of Reserves Signing Experience
After Coldcard recognizes BIP-322 PoR PSBT it asks the user to
import a human-readable message that was used to build `to_spend`
scriptSig. This message must hash exactly the `message_hash` from
the PSBT, otherwise signing is not offered.
After Coldcard recognizes a BIP-322 PSBT it reads the message from
`PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE` and shows it to the user for approval.
COLDCARD verifies that the message hash matches the input 0 `to_spend`
commitment before offering to sign.
When the PSBT contains only input 0, COLDCARD labels the request as
`BIP-322 Message`, because it is message signing and does not prove ownership
of any additional reserve UTXOs. In that case it does not show transaction
input/output counts. When the PSBT contains additional inputs, COLDCARD labels
the request as `Proof of Reserves` and shows the reserve amount.
If the message contains non-ASCII characters, COLDCARD warns that some
characters may not be readable on screen.
Legacy PoR PSBTs without `PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE` are rejected by
this flow.
Read more [here.](https://gist.github.com/orangesurf/0c1d0a31d3ebe7e48335a34d56788d4c)
Example screen text:
Example screen text for a one-input BIP-322 message signing PSBT:
```text
BIP-322 Message
Message:
This is the signed message
Challenge Address:
bc1qzvjnhf7k70uxv6xvneaqxql7k09dd6nsr5wheq
Press ENTER to approve and sign message. Press (2) to explore transaction.
CANCEL to abort.
```
Example screen text for a Proof of Reserves PSBT:
```text
Proof of Reserves
Message:
POR
Amount 0.20000000 BTC
Message Hash:
11b5fe357842f5c368d2e3884d6a5ba577e3bc7cde132004f39b8c2a43a9cdec
Message Challenge:
00140b2537a7d6f3cc668c9e9fa0303ffb3cad6e9b81
Challenge Address:
bc1qzvjnhf7k70uxv6xvneaqxql7k09dd6nsr5wheq
21 inputs
1 output
0.00000000 BTC
- OP_RETURN -
null-data
Press ENTER to approve and sign transaction. Press (2) to explore txn
outputs. CANCEL to abort.
Press ENTER to approve and sign proof of reserves. Press (2) to explore transaction.
CANCEL to abort.
```

@ -1 +1 @@
Subproject commit 51de25089ef0154f6cc4b54a849e611e8c88a3fd
Subproject commit 3d1dfa858beb58b8dac37d8c66d7aed2909812f2

View File

@ -4,6 +4,8 @@ This lists the new changes that have not yet been published in a normal release.
# Shared Improvements - Both Mk and Q
- Change: BIP-322 Proof of Reserves & message signing PSBT requires PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE
(read more [BIP-322 Proof of Reserves documentation](../docs/proof-of-reserves-bip-322.md) )
- Bugfix: Disable Virtual Disk and NFC before activating HSM
- Bugfix: Custom address default menu position wrong
- Bugfix: Delta Mode Trick PIN was never restored from backup

View File

@ -292,56 +292,6 @@ class ApproveTransaction(UserAuthorizedAction):
self.result = None # will be (len, sha256) of the resulting PSBT
self.chain = chains.current_chain()
async def por322_msg_verify(self):
# https://gist.github.com/orangesurf/0c1d0a31d3ebe7e48335a34d56788d4c
from glob import NFC
from ux import import_export_prompt
from actions import file_picker
ch = await import_export_prompt("message", is_import=True, force_prompt=True,
intro="Import msg that hashes to 'to_spend' msg hash.",
key0="to input message manually",
title="BIP-322 Messsage" if version.has_qwerty else 'BIP-322 MSG',
no_qr=not version.has_qwerty)
# single sha256 of b'BIP0322-signed-message'
bip322_tag_hash = b'te\x84\xa1\x87/\xa1\x00AUN\xff\xa08\xd6\x12IB\xddy\xb4\xe5\x8aL\xda\x18N\x13\xdb\xe6,I'
if ch == KEY_CANCEL:
return
elif ch == "0":
msg = await ux_input_text("", confirm_exit=False)
elif ch == KEY_NFC:
msg = await NFC.read_bip322_msg()
elif ch == KEY_QR:
from ux_q1 import QRScannerInteraction
msg = await QRScannerInteraction().scan_text('Scan message from a QR code')
else:
choices = await file_picker(suffix='.txt', ux=False, **ch)
target = "%s.txt" % b2a_hex(self.psbt.por322_msg_hash).decode()
for fname, dir, _ in choices:
if target == fname:
fn = dir + "/" + fname
break
else:
fn = await file_picker(choices=choices, **ch)
if not fn: return
with CardSlot(readonly=True, **ch) as card:
with open(fn, 'rt') as fd:
msg = fd.read()
assert msg, "need msg"
msg_hash = ngu.hash.sha256t(bip322_tag_hash, msg, True)
assert msg_hash == self.psbt.por322_msg_hash, "hash verification failed"
ch = await ux_show_story(
msg+"\n\nPress %s to approve message, otherwise %s to exit." % (OK, X),
title="Message:"
)
return True if ch == "y" else False
def render_output(self, o):
# Pretty-print a transactions output.
# - expects CTxOut object
@ -477,6 +427,7 @@ class ApproveTransaction(UserAuthorizedAction):
#
try:
msg = uio.StringIO()
is_por = self.psbt.por322 and (self.psbt.num_inputs > 1)
# mention warning at top
wl= len(self.psbt.warnings)
@ -486,19 +437,14 @@ class ApproveTransaction(UserAuthorizedAction):
msg.write('(%d warnings below)\n\n' % wl)
if self.psbt.por322:
try:
if not await self.por322_msg_verify():
self.refused = True
await ux_dramatic_pause("Refused.", 1)
self.done()
return
except Exception as exc:
return await self.failure("Msg verification failed.", exc)
msg.write("Proof of Reserves\n\n")
msg.write("%s\n\n" % ("Proof of Reserves" if is_por else "BIP-322 Message"))
msg.write("Message:\n%s\n\n" % self.psbt.por322_msg)
if is_por:
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())
try:
addr = self.chain.render_address(self.psbt.por322_msg_challenge)
msg.write("Challenge Address:\n%s\n\n" % show_single_address(addr))
except ValueError:
msg.write("Message Challenge:\n%s\n\n" % b2a_hex(self.psbt.por322_msg_challenge).decode())
else:
if self.psbt.consolidation_tx:
@ -513,6 +459,7 @@ class ApproveTransaction(UserAuthorizedAction):
if fee is not None:
msg.write("Network fee %s %s\n\n" % self.chain.render_value(fee))
if not self.psbt.por322 or is_por:
msg.write(" %d %s\n %d %s\n\n" % (
self.psbt.num_inputs,
"input" if self.psbt.num_inputs == 1 else "inputs",
@ -520,8 +467,10 @@ class ApproveTransaction(UserAuthorizedAction):
"output" if self.psbt.num_outputs == 1 else "outputs",
))
if not self.psbt.por322:
# outputs + change story created here
self.output_summary_text(msg)
gc.collect()
if self.psbt.ux_notes:
@ -549,8 +498,13 @@ class ApproveTransaction(UserAuthorizedAction):
if not hsm_active:
esc = "2"
msg.write("Press %s to approve and sign transaction."
" Press (2) to explore transaction." % OK)
noun = "transaction"
if self.psbt.por322:
noun = "proof of reserves" if is_por else "message"
msg.write("Press %s to approve and sign %s."
" Press (2) to explore transaction." % (OK, noun))
if (self.input_method == "sd") and CardSlot.both_inserted():
esc += "b"
msg.write(" (B) to write to lower SD slot.")
@ -817,13 +771,19 @@ async def done_signing(psbt, tx_req, input_method=None, filename=None,
# USB case - user can choose whether to attempt finalization
is_complete = finalize
if psbt.por322:
# network txn strips PSBT BIP-32 with paths with pubkey required for verification
# overrides --finalize from USB
# disable pushTX for BIP-322
is_complete = False
with SFFile(TXN_OUTPUT_OFFSET, max_size=MAX_TXN_LEN, message="Saving...") as psram:
if is_complete:
txid = psbt.finalize(psram)
noun = "Finalized TX ready for broadcast"
else:
psbt.serialize(psram)
noun = "Partly Signed PSBT"
noun = "Signed BIP-322 PSBT" if psbt.por322 else "Partly Signed PSBT"
txid = None
data_len = psram.tell()
@ -894,7 +854,7 @@ async def done_signing(psbt, tx_req, input_method=None, filename=None,
elif ch == KEY_QR:
here = PSRAM.read_at(TXN_OUTPUT_OFFSET, data_len)
msg = txid or 'Partly Signed PSBT'
msg = txid or noun
try:
if len(here) > 920:
# too big for simple QR - use BBQr instead

View File

@ -254,7 +254,7 @@ class ChainsBase:
return ngu.codecs.b58_encode(cls.b58_script + script[2:2+20])
# segwit v0 (P2WPKH, P2WSH)
if script[0] == 0 and script[1] in (0x14, 0x20) and (ll-2) == script[1]:
if ll in (22, 34) and script[0] == 0 and script[1] in (0x14, 0x20) and (ll-2) == script[1]:
return ngu.codecs.segwit_encode(cls.bech32_hrp, script[0], script[2:])
# segwit v1 (P2TR) and later segwit version

View File

@ -5,7 +5,7 @@
# Unattended signing of transactions and messages, subject to a set of rules.
#
import ustruct, chains, sys, gc, uio, ujson, uos, utime, ckcc, ngu
from utils import problem_file_line, cleanup_deriv_path, match_deriv_path
from utils import problem_file_line, cleanup_deriv_path, match_deriv_path, keypath_to_str
from utils import cleanup_payment_address
from pincodes import AE_LONG_SECRET_LEN
from stash import blank_object
@ -882,9 +882,6 @@ class HSMPolicy:
# do this super early so always cleared even if other issues
local_ok = self.consume_local_code(psbt_sha)
if not self.rules:
raise ValueError("no txn signing allowed")
# reject anything with warning, probably
if psbt.warnings:
if self.warnings_ok:
@ -892,6 +889,32 @@ class HSMPolicy:
else:
raise ValueError("has %d warning(s)" % len(psbt.warnings))
if psbt.por322:
if not self.msg_paths:
raise ValueError("Message signing not permitted")
for inp in psbt.inputs:
if not inp.required_key:
continue
if inp.is_multisig:
paths = [
keypath_to_str(inp.subpaths[pk])
for pk in inp.required_key
if pk in inp.subpaths
]
else:
paths = [keypath_to_str(inp.subpaths[inp.required_key])]
if not any(match_deriv_path(self.msg_paths, p) for p in paths):
raise ValueError("Message signing not enabled for that path")
self.approve(log, "BIP-322 message signing allowed")
return 'y'
if not self.rules:
raise ValueError("no txn signing allowed")
# See who has entered creditials already (all must be valid).
users = []
for u, (token, counter) in auth.items():

View File

@ -5,7 +5,7 @@
import stash, gc, history, sys, ngu, ckcc, chains
from ustruct import unpack_from, unpack, pack
from ubinascii import hexlify as b2a_hex
from utils import xfp2str, B2A, keypath_to_str
from utils import xfp2str, B2A, keypath_to_str, is_ascii
from utils import seconds2human_readable, datetime_from_timestamp, datetime_to_str, node_from_privkey
from chains import NLOCK_IS_TIME
from uhashlib import sha256
@ -14,7 +14,7 @@ from sffile import SizerFile
from multisig import MultisigWallet, disassemble_multisig_mn
from exceptions import FatalPSBTIssue, FraudulentChangeOutput
from serializations import ser_compact_size, deser_compact_size, hash160
from serializations import CTxIn, CTxInWitness, CTxOut, ser_string, COutPoint
from serializations import CTransaction, CTxIn, CTxInWitness, CTxOut, ser_string, COutPoint
from serializations import ser_sig_der, uint256_from_str, ser_push_data
from serializations import SIGHASH_ALL, SIGHASH_SINGLE, SIGHASH_NONE, SIGHASH_ANYONECANPAY
from serializations import ALL_SIGHASH_FLAGS
@ -31,13 +31,23 @@ from public_constants import (
PSBT_GLOBAL_TX_MODIFIABLE, PSBT_GLOBAL_OUTPUT_COUNT, PSBT_GLOBAL_INPUT_COUNT,
PSBT_GLOBAL_FALLBACK_LOCKTIME, PSBT_GLOBAL_TX_VERSION, PSBT_IN_PREVIOUS_TXID,
PSBT_IN_OUTPUT_INDEX, PSBT_IN_SEQUENCE, PSBT_IN_REQUIRED_TIME_LOCKTIME,
PSBT_IN_REQUIRED_HEIGHT_LOCKTIME, MAX_PATH_DEPTH, MAX_SIGNERS,
PSBT_IN_REQUIRED_HEIGHT_LOCKTIME, PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE, MAX_PATH_DEPTH, MAX_SIGNERS,
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"
# single sha256 of b'BIP0322-signed-message'
BIP322_TAG_HASH = b'te\x84\xa1\x87/\xa1\x00AUN\xff\xa08\xd6\x12IB\xddy\xb4\xe5\x8aL\xda\x18N\x13\xdb\xe6,I'
def build_bip322_to_spend(msg_hash, message_challenge):
to_spend = CTransaction()
to_spend.nVersion = 0
to_spend.vin = [CTxIn(COutPoint(hash=0, n=0xffffffff), scriptSig=b'\x00\x20' + msg_hash)]
to_spend.vout = [CTxOut(0, message_challenge)]
return to_spend
# PSBT proprietary keytype
PSBT_PROPRIETARY = const(0xFC)
@ -1081,6 +1091,7 @@ class psbtObject(psbtProxy):
# Proof of Reserves
self.por322 = False
self.por322_msg = None
self.por322_msg_hash = None
self.por322_msg_challenge = None
@ -1114,12 +1125,35 @@ class psbtObject(psbtProxy):
# bytes of length 1 (tx modifiable in short_values)
assert len(val) == 1
self.txn_modifiable = val[0]
elif kt == PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE:
assert len(key) == 1
self.por322_msg = self.get(val).decode()
else:
self.unknown = self.unknown or {}
if key in self.unknown:
raise FatalPSBTIssue("Duplicate key. Key for unknown value already provided in global namespace.")
self.unknown[key] = val
def validate_bip322_input0(self, inp, txi, utxo):
msg_hash = ngu.hash.sha256t(BIP322_TAG_HASH, self.por322_msg.encode(), True)
message_challenge = utxo.scriptPubKey
to_spend = build_bip322_to_spend(msg_hash, message_challenge)
to_spend_hash = uint256_from_str(ngu.hash.sha256d(to_spend.serialize_without_witness()))
assert txi.prevout.hash == to_spend_hash, "to_spend hash"
assert txi.prevout.n == 0, "prevout n"
assert utxo.nValue == 0, "input0 value"
if inp.utxo:
old_pos = self.fd.tell()
raw_utxo = self.get(inp.utxo)
self.fd.seek(old_pos)
assert raw_utxo == to_spend.serialize_without_witness(), "utxo"
self.por322_msg_hash = msg_hash
assert message_challenge, "empty message_challenge"
self.por322_msg_challenge = message_challenge
def output_iter(self, start=0, stop=None):
# yield the txn's outputs: index, (CTxOut object) for each
if stop is None:
@ -1462,24 +1496,34 @@ class psbtObject(psbtProxy):
out = self.outputs[idx]
if self.is_v2:
# v2 requires inclusion
assert out.amount
assert out.amount is not None
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
assert self.por322_msg, "msg"
self.por322 = bool(self.por322_msg)
if self.por322:
if not is_ascii(self.por322_msg):
self.warnings.append((
"Message",
"Message contains non-ASCII characters that may not be readable on this screen."
))
if self.txn_version == 0:
# only allow txn version 0 for Proof of Reserves txn (BIP-322)
assert self.por322, TX_VER_ERR
if self.por322:
assert self.txn_version in {0, 2}, TX_VER_ERR
# time based relative locks
tb_rel_locks = []
# block height based relative locks
@ -1670,6 +1714,9 @@ class psbtObject(psbtProxy):
if input.sighash not in ALL_SIGHASH_FLAGS:
raise FatalPSBTIssue("Unsupported sighash flag 0x%x" % input.sighash)
if self.por322 and input.sighash != SIGHASH_ALL:
raise FatalPSBTIssue("POR not SIGHASH_ALL")
if input.sighash != SIGHASH_ALL:
sh_unusual = True
@ -1830,42 +1877,8 @@ class psbtObject(psbtProxy):
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)
assert inp.required_key, "not our key"
self.validate_bip322_input0(inp, txi, utxo)
except Exception as e:
raise FatalPSBTIssue("i0: invalid BIP-322 'to_spend': %s" % e)
@ -2011,6 +2024,9 @@ class psbtObject(psbtProxy):
for v, k in self.xpubs:
wr(PSBT_GLOBAL_XPUB, v, k)
if self.por322_msg:
wr(PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE, self.por322_msg.encode())
if self.unknown:
for k, v in self.unknown.items():
wr(k[0], v, k[1:])

View File

@ -379,7 +379,7 @@ class CTxOut(object):
# rare, pay to full pubkey
return AF_BARE_PK, self.scriptPubKey[2:2+33], False
if self.scriptPubKey[0] == OP_RETURN:
if self.is_op_return():
return OP_RETURN, self.scriptPubKey, False
return None, self.scriptPubKey, None
@ -410,6 +410,9 @@ class CTxOut(object):
and (self.scriptPubKey[0] == 0x21 or self.scriptPubKey[0] == 0x41) \
and self.scriptPubKey[-1] == 0xac
def is_op_return(self):
return self.scriptPubKey and (self.scriptPubKey[0] == OP_RETURN)
#def __repr__(self):
# return "CTxOut(nValue=%d scriptPubKey=%s)" \
# % (self.nValue, b2a_hex(self.scriptPubKey))

View File

@ -2,14 +2,18 @@
#
# construct Proof of Reserves transaction according to BIP-322
#
import pytest, struct, hashlib
import 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 helpers import hash160, str_to_path, taptweak
from bip32 import BIP32Node, PublicKey
from constants import simulator_fixed_tprv, AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH
from ctransaction import CTransaction, COutPoint, CTxIn, CTxOut, uint256_from_str
from sighash import legacy_sighash, segwit_v0_sighash, taproot_sighash, SIGHASH_DEFAULT, SIGHASH_ALL
from pysecp256k1 import ec_pubkey_parse, ecdsa_signature_parse_der, ecdsa_verify
from pysecp256k1.extrakeys import xonly_pubkey_parse
from pysecp256k1.schnorrsig import schnorrsig_verify
def bip322_msg_hash(msg):
@ -17,36 +21,180 @@ def bip322_msg_hash(msg):
return hashlib.sha256(tag_hash + tag_hash + msg).digest()
@pytest.fixture
def create_msg_file(sim_root_dir, garbage_collector):
def doit(msg, msg_hash):
# carelessly overwrites
fpath = f"{sim_root_dir}/MicroSD/{msg_hash.hex()}.txt"
with open(fpath, "w") as f:
f.write(msg.decode())
garbage_collector.append(fpath)
return doit
def ecdsa_verify_sig(pubkey, sig, digest):
if not sig or sig[-1] != SIGHASH_ALL:
return False
try:
parsed = ecdsa_signature_parse_der(sig[:-1])
return bool(ecdsa_verify(parsed, ec_pubkey_parse(pubkey), digest))
except Exception:
return False
@pytest.fixture
def bip322_txn(dev, pytestconfig, create_msg_file):
def bip322_verify(psbt_bytes):
"""Verify BIP-322 PSBT signatures without a full script interpreter.
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):
Enforces the BIP-322 transaction shape, SIGHASH_ALL for ECDSA,
SIGHASH_DEFAULT/SIGHASH_ALL for taproot, and direct signature checks for
p2pkh, p2wpkh, p2sh-p2wpkh, sh, wsh, and p2tr key-path.
It intentionally omits consensus-level script evaluation rules such as
CLEANSTACK, MINIMALIF, NULLFAIL beyond empty CHECKMULTISIG dummy,
CODESEPARATOR/FindAndDelete handling, and NOP-upgrade checks; unsupported
scripts raise AssertionError.
"""
psbt = BasicPSBT().parse(psbt_bytes)
assert psbt.bip322_msg is not None
msg = psbt.bip322_msg
tx = CTransaction()
if psbt.txn:
tx.deserialize(BytesIO(psbt.txn))
else:
tx.nVersion = psbt.txn_version
tx.nLockTime = psbt.fallback_locktime or 0
for inp in psbt.inputs:
tx.vin.append(CTxIn(COutPoint(uint256_from_str(inp.previous_txid), inp.prevout_idx),
nSequence=inp.sequence if inp.sequence is not None else 0xffffffff))
for out in psbt.outputs:
tx.vout.append(CTxOut(out.amount, out.script))
inp0 = psbt.inputs[0]
to_spend = None
if inp0.utxo:
to_spend = CTransaction()
to_spend.deserialize(BytesIO(inp0.utxo))
assert len(to_spend.vout) == 1
assert to_spend.vout[0].nValue == 0
script_pubkey = to_spend.vout[0].scriptPubKey
else:
assert inp0.witness_utxo
witness_utxo = CTxOut()
witness_utxo.deserialize(BytesIO(inp0.witness_utxo))
assert witness_utxo.nValue == 0
script_pubkey = witness_utxo.scriptPubKey
expected_to_spend = CTransaction()
expected_to_spend.nVersion = 0
expected_to_spend.nLockTime = 0
expected_to_spend.vin = [CTxIn(COutPoint(hash=0, n=0xffffffff),
scriptSig=b'\x00\x20' + bip322_msg_hash(msg),
nSequence=0)]
expected_to_spend.vout = [CTxOut(0, script_pubkey)]
expected_to_spend.calc_sha256()
if to_spend:
assert to_spend.serialize_without_witness() == expected_to_spend.serialize_without_witness()
to_spend = expected_to_spend
assert tx.nVersion in (0, 2)
assert len(tx.vin) >= 1
assert tx.vin[0].prevout.hash == to_spend.sha256
assert tx.vin[0].prevout.n == 0
assert not (len(tx.vin) == 1 and (tx.vin[0].nSequence != 0 or tx.nLockTime != 0))
assert len(tx.vout) == 1
assert tx.vout[0].nValue == 0
assert tx.vout[0].scriptPubKey == b'\x6a'
prevouts = []
for idx, txin in enumerate(tx.vin):
if idx == 0:
prevouts.append((0, script_pubkey))
else:
assert idx < len(psbt.inputs)
if psbt.inputs[idx].witness_utxo:
prev = CTxOut()
prev.deserialize(BytesIO(psbt.inputs[idx].witness_utxo))
else:
prev_tx = CTransaction()
prev_tx.deserialize(BytesIO(psbt.inputs[idx].utxo))
prev = prev_tx.vout[txin.prevout.n]
prevouts.append((prev.nValue, prev.scriptPubKey))
for idx, txin in enumerate(tx.vin):
amount, spk = prevouts[idx]
inp = psbt.inputs[idx]
if len(spk) == 25 and spk[:3] == b'\x76\xa9\x14' and spk[-2:] == b'\x88\xac':
assert len(inp.part_sigs) == 1
pub, sig = next(iter(inp.part_sigs.items()))
assert hash160(pub) == spk[3:23]
assert ecdsa_verify_sig(pub, sig, legacy_sighash(tx, idx, spk))
continue
if len(spk) == 22 and spk[:2] == b'\x00\x14':
assert len(inp.part_sigs) == 1
pub, sig = next(iter(inp.part_sigs.items()))
assert hash160(pub) == spk[2:22]
script_code = b'\x76\xa9\x14' + spk[2:22] + b'\x88\xac'
assert ecdsa_verify_sig(pub, sig, segwit_v0_sighash(tx, idx, script_code, amount))
continue
if len(spk) == 34 and spk[:2] == b'\x00\x20':
assert inp.witness_script
assert hashlib.sha256(inp.witness_script).digest() == spk[2:34]
assert inp.part_sigs
sighash = segwit_v0_sighash(tx, idx, inp.witness_script, amount)
for pub, sig in inp.part_sigs.items():
assert ecdsa_verify_sig(pub, sig, sighash)
continue
if len(spk) == 34 and spk[:2] == b'\x51\x20':
assert inp.taproot_key_sig
if len(inp.taproot_key_sig) == 64:
sighash = SIGHASH_DEFAULT
sig = inp.taproot_key_sig
else:
assert len(inp.taproot_key_sig) == 65
sighash = inp.taproot_key_sig[-1]
sig = inp.taproot_key_sig[:-1]
digest = taproot_sighash(tx, idx, prevouts, sighash)
assert schnorrsig_verify(sig, digest, xonly_pubkey_parse(spk[2:34]))
continue
if len(spk) == 23 and spk[:2] == b'\xa9\x14' and spk[-1:] == b'\x87':
assert inp.redeem_script
assert hash160(inp.redeem_script) == spk[2:22]
if len(inp.redeem_script) == 22 and inp.redeem_script[:2] == b'\x00\x14':
assert len(inp.part_sigs) == 1
pub, sig = next(iter(inp.part_sigs.items()))
assert hash160(pub) == inp.redeem_script[2:22]
script_code = b'\x76\xa9\x14' + inp.redeem_script[2:22] + b'\x88\xac'
assert ecdsa_verify_sig(pub, sig, segwit_v0_sighash(tx, idx, script_code, amount))
continue
if len(inp.redeem_script) == 34 and inp.redeem_script[:2] == b'\x00\x20':
assert inp.witness_script
assert inp.redeem_script == b'\x00\x20' + hashlib.sha256(inp.witness_script).digest()
assert inp.part_sigs
sighash = segwit_v0_sighash(tx, idx, inp.witness_script, amount)
for pub, sig in inp.part_sigs.items():
assert ecdsa_verify_sig(pub, sig, sighash)
continue
assert inp.part_sigs
sighash = legacy_sighash(tx, idx, inp.redeem_script)
for pub, sig in inp.part_sigs.items():
assert ecdsa_verify_sig(pub, sig, sighash)
continue
assert False, "unsupported script"
def bip322_txn(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,
psbt_v2=False, master_xpub=None):
msg_challenge = None
num_ins = len(inputs)
psbt = BasicPSBT()
psbt.bip322_msg = msg
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
master_xpub = master_xpub or simulator_fixed_tprv
# we have a key; use it to provide "plausible" value inputs
mk = BIP32Node.from_wallet_key(master_xpub)
@ -111,7 +259,6 @@ def bip322_txn(dev, pytestconfig, create_msg_file):
to_spend.nVersion = 0
out_point = COutPoint(hash=0, n=0xffffffff)
msg_hash = bip322_msg_hash(msg)
create_msg_file(msg, msg_hash)
to_spend.vin = [CTxIn(out_point, scriptSig=b'\x00\x20' + msg_hash)]
to_spend.vout = [CTxOut(0, scr)] # always zero val
msg_challenge = scr
@ -137,6 +284,10 @@ def bip322_txn(dev, pytestconfig, create_msg_file):
else:
psbt.inputs[i].utxo = to_spend.serialize_with_witness()
if len(inputs) == 1:
# basic msg sign
seq = 0
else:
if to_sign_lock_time and not i:
seq = 0xfffffffd
else:
@ -158,25 +309,27 @@ def bip322_txn(dev, pytestconfig, create_msg_file):
if psbt_hacker:
psbt_hacker(psbt)
if psbt_v2:
psbt.parsed_txn = CTransaction()
psbt.parsed_txn.deserialize(BytesIO(psbt.txn))
psbt.to_v2()
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, create_msg_file):
def bip322_ms_txn(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,
psbt_v2=False):
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()
psbt.bip322_msg = msg
txn = CTransaction()
txn.nVersion = to_sign_nVersion
@ -213,7 +366,6 @@ def bip322_ms_txn(pytestconfig, create_msg_file):
to_spend.nVersion = 0
out_point = COutPoint(hash=0, n=0xffffffff)
msg_hash = bip322_msg_hash(msg)
create_msg_file(msg, msg_hash)
to_spend.vin = [CTxIn(out_point, scriptSig=b'\x00\x20' + msg_hash)]
to_spend.vout.append(CTxOut(0, scriptPubKey))
msg_challenge = scriptPubKey
@ -235,6 +387,10 @@ def bip322_ms_txn(pytestconfig, create_msg_file):
to_spend.calc_sha256()
if num_ins == 1:
# basic msg sign
seq = 0
else:
if lock_time and not i:
seq = 0xfffffffd
else:
@ -254,11 +410,13 @@ def bip322_ms_txn(pytestconfig, create_msg_file):
hack_psbt(psbt)
psbt.txn = txn.serialize_with_witness()
if psbt_v2:
psbt.parsed_txn = CTransaction()
psbt.parsed_txn.deserialize(BytesIO(psbt.txn))
psbt.to_v2()
rv = BytesIO()
psbt.serialize(rv)
assert rv.tell() <= MAX_TXN_LEN, 'too fat'
return rv.getvalue(), msg_challenge
return doit

View File

@ -3019,6 +3019,26 @@ def import_wif_to_store(goto_home, pick_menu_item, cap_story, press_select, cap_
return doit
@pytest.fixture
def bip322_txn(dev, pytestconfig):
from bip322 import bip322_txn
return functools.partial(bip322_txn, master_xpub=dev.master_xpub,
psbt_v2=pytestconfig.getoption('psbt2'))
@pytest.fixture
def bip322_ms_txn(pytestconfig):
from bip322 import bip322_ms_txn
return functools.partial(bip322_ms_txn, psbt_v2=pytestconfig.getoption('psbt2'))
@pytest.fixture
def bip322_verify():
from bip322 import bip322_verify
return bip322_verify
# useful fixtures
from test_backup import backup_system
from test_bbqr import readback_bbqr, render_bbqr, readback_bbqr_ll, try_sign_bbqr, split_scan_bbqr
@ -3039,6 +3059,5 @@ 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, create_msg_file
# EOF

View File

@ -25,6 +25,7 @@ PSBT_GLOBAL_FALLBACK_LOCKTIME = 0x03
PSBT_GLOBAL_INPUT_COUNT = 0x04
PSBT_GLOBAL_OUTPUT_COUNT = 0x05
PSBT_GLOBAL_TX_MODIFIABLE = 0x06
PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE = 0x09
# INPUTS ===
PSBT_IN_NON_WITNESS_UTXO = 0x00
@ -352,6 +353,7 @@ class BasicPSBT:
self.outputs = []
self.txn_modifiable = None
self.fallback_locktime = None
self.bip322_msg = None
self.unknown = {}
self.parsed_txn = None
@ -360,6 +362,7 @@ class BasicPSBT:
a.input_count == b.input_count and \
a.output_count == b.output_count and \
a.fallback_locktime == b.fallback_locktime and \
a.bip322_msg == b.bip322_msg and \
a.txn_version == b.txn_version and \
a.version == b.version and \
len(a.inputs) == len(b.inputs) and \
@ -422,6 +425,8 @@ class BasicPSBT:
num_outs = self.output_count
elif kt == PSBT_GLOBAL_TX_MODIFIABLE:
self.txn_modifiable = val[0]
elif kt == PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE:
self.bip322_msg = val
else:
self.unknown[key] = val
@ -434,8 +439,8 @@ class BasicPSBT:
if self.version == 0:
assert self.txn, 'v0: missing reqd section - PSBT_GLOBAL_UNSIGNED_TX'
elif self.version == 2:
# tx version needs to be at least 2 because locktimes
assert self.txn_version in {2, 3}, 'v2: missing reqd section - PSBT_GLOBAL_TX_VERSION'
assert self.txn_version is not None, 'v2: missing reqd section - PSBT_GLOBAL_TX_VERSION'
assert self.txn_version != 0 or self.bip322_msg, 'bad txn version'
assert self.input_count is not None, 'v2: missing reqd section - PSBT_GLOBAL_INPUT_COUNT'
assert self.output_count is not None, 'v2: missing reqd section - PSBT_GLOBAL_OUTPUT_COUNT'
@ -483,6 +488,9 @@ class BasicPSBT:
if self.version is not None:
wr(PSBT_GLOBAL_VERSION, struct.pack('<I', self.version))
if self.bip322_msg is not None:
wr(PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE, self.bip322_msg)
if isinstance(self.unknown, list):
# just so I can test duplicate unknown values
# list of tuples [(key0, val0), (key1, val1)]
@ -512,7 +520,7 @@ class BasicPSBT:
def to_v2(self):
if self.version is None or self.version == 0:
self.version = 2
self.txn_version = 2
self.txn_version = self.parsed_txn.nVersion
self.txn = None
self.input_count = len(self.parsed_txn.vin)
self.output_count = len(self.parsed_txn.vout)

55
testing/sighash.py Normal file
View File

@ -0,0 +1,55 @@
# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# Bitcoin transaction signature hash helpers for tests.
#
import copy
import hashlib
import struct
from ctransaction import hash256
from serialize import ser_string
from pysecp256k1 import tagged_sha256
SIGHASH_DEFAULT = 0
SIGHASH_ALL = 1
def legacy_sighash(tx, in_idx, script_code, sighash=SIGHASH_ALL):
tmp = copy.deepcopy(tx)
for txin in tmp.vin:
txin.scriptSig = b''
tmp.vin[in_idx].scriptSig = script_code
return hash256(tmp.serialize_without_witness() + struct.pack('<I', sighash))
def segwit_v0_sighash(tx, in_idx, script_code, amount, sighash=SIGHASH_ALL):
hash_prevouts = hash256(b''.join(i.prevout.serialize() for i in tx.vin))
hash_sequence = hash256(b''.join(struct.pack('<I', i.nSequence) for i in tx.vin))
hash_outputs = hash256(b''.join(o.serialize() for o in tx.vout))
txin = tx.vin[in_idx]
preimage = struct.pack('<i', tx.nVersion)
preimage += hash_prevouts + hash_sequence
preimage += txin.prevout.serialize()
preimage += ser_string(script_code)
preimage += struct.pack('<q', amount)
preimage += struct.pack('<I', txin.nSequence)
preimage += hash_outputs
preimage += struct.pack('<I', tx.nLockTime)
preimage += struct.pack('<I', sighash)
return hash256(preimage)
def taproot_sighash(tx, in_idx, prevouts, sighash=SIGHASH_DEFAULT):
assert sighash in (SIGHASH_DEFAULT, SIGHASH_ALL)
preimage = bytes([sighash])
preimage += struct.pack('<i', tx.nVersion)
preimage += struct.pack('<I', tx.nLockTime)
preimage += hashlib.sha256(b''.join(i.prevout.serialize() for i in tx.vin)).digest()
preimage += hashlib.sha256(b''.join(struct.pack('<q', amount) for amount, spk in prevouts)).digest()
preimage += hashlib.sha256(b''.join(ser_string(spk) for amount, spk in prevouts)).digest()
preimage += hashlib.sha256(b''.join(struct.pack('<I', i.nSequence) for i in tx.vin)).digest()
preimage += hashlib.sha256(b''.join(o.serialize() for o in tx.vout)).digest()
preimage += b'\x00'
preimage += struct.pack('<I', in_idx)
return tagged_sha256(b"TapSighash", b'\x00' + preimage)

View File

@ -1,82 +1,29 @@
# (c) Copyright 2026 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# BIP-322 Message Signing and Proof of Reserves
# NOTE: Run this module with and without --psbt2 to cover both PSBT versions.
#
import pytest, time, os
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, BIP32Node
from ctransaction import CTransaction, CTxIn, COutPoint
from helpers import str_to_path
from charcodes import KEY_QR, KEY_NFC
from bbqr import split_qrs
from bip322 import bip322_txn, bip322_ms_txn, BIP32Node
from ctransaction import CTransaction, CTxIn, COutPoint, CTxOut
from helpers import addr_from_display_format, str_to_path
from txn import render_address
@pytest.fixture
def verify_msg_bip322_por(cap_story, need_keypress, press_select, press_cancel, cap_menu,
nfc_write_text, is_q1, press_nfc, scan_a_qr, split_scan_bbqr,
enter_complex, pick_menu_item):
def doit(msg, refuse=False, way="sd", fname=None):
title, story = cap_story()
assert "BIP-322" in title
# file was already created with bip322_txn fixture above
if "qr" in way and not is_q1:
raise pytest.xfail("Mk4 no QR")
if way == "input":
enter_complex(msg, b39pass=False)
elif way == "qr":
assert f"{KEY_QR} to scan QR code" in story
need_keypress(KEY_QR)
scan_a_qr(msg)
time.sleep(1)
elif way == "bbqr":
assert f"{KEY_QR} to scan QR code" in story
need_keypress(KEY_QR)
# def split_qrs(raw, type_code, encoding=None,
# min_split=1, max_split=1295, min_version=5, max_version=40
actual_vers, parts = split_qrs(msg, "U", max_version=20)
for p in parts:
scan_a_qr(p)
time.sleep(2.0 / len(parts)) # just so we can watch
time.sleep(1)
elif way == "nfc":
if f"press {KEY_NFC if is_q1 else '(3)'} to import via NFC" not in story:
pytest.xfail("NFC disabled")
else:
press_nfc()
time.sleep(0.2)
nfc_write_text(msg)
time.sleep(0.3)
else:
assert way in ["sd", "vdisk"]
if way == "vdisk":
if "(2) to import from Virtual Disk" not in story:
pytest.xfail("Vdisk disabled")
else:
need_keypress("2")
else:
need_keypress("1")
if fname:
pick_menu_item(fname)
time.sleep(.1)
def verify_msg_bip322_por(cap_story, press_select, press_cancel, cap_menu):
def doit(msg, is_por=True, refuse=False):
title, story = cap_story()
assert title == "OK TO SIGN?"
assert ("Proof of Reserves" if is_por else "BIP-322 Message") in story
assert msg in story
if refuse:
press_cancel()
time.sleep(.1)
assert "Ready To Sign" in cap_menu()
else:
press_select()
return doit
@ -91,7 +38,8 @@ def verify_msg_bip322_por(cap_story, need_keypress, press_select, press_cancel,
[["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, need_keypress,
press_select, verify_msg_bip322_por, sim_root_dir):
press_select, verify_msg_bip322_por, sim_root_dir, press_cancel,
bip322_verify):
num_ins = len(ins)
amt = sum([i[2] or 0 for i in ins])
psbt, msg_challenge = bip322_txn(ins, msg=msg)
@ -100,51 +48,202 @@ def test_bip322_por(msg, ins, bip322_txn, start_sign, end_sign, cap_story, need_
start_sign(psbt, finalize=True)
verify_msg_bip322_por(msg.decode(), way="sd")
is_por = num_ins > 1
verify_msg_bip322_por(msg.decode(), is_por=is_por)
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SIGN?"
assert "Proof of Reserves" in story
assert ("Proof of Reserves" if is_por else "BIP-322 Message") 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
assert "explore transaction" in story
if not is_por:
assert "Proof of Reserves" not in story
assert "Amount " not in story
assert "1 input" not in story
assert "1 output" not in story
assert "sign message" in story
else:
assert ("Amount %s XTN" % str(Decimal(amt/100000000).quantize(Decimal('.00000001')))) in story
assert ("%d inputs" % num_ins) in story
assert "sign proof of reserves" 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 ("Message:\n%s" % msg.decode()) in story
assert "Message Hash:" not in story
assert "Challenge Address:" in story
assert "Message Challenge:" not in story
assert ("1 output" in story) == is_por
assert "- OP_RETURN -" not in story
assert "null-data" not in story
signed = end_sign(accept=True, exit_export_loop=False)
bip322_verify(signed)
title, story = cap_story()
assert title == "PSBT Signed"
assert "Signed BIP-322 PSBT shared via USB." in story
assert "Finalized TX ready for broadcast" not in story
assert "TXID:" not in story
press_cancel()
def test_bip322_por_utf8_msg(bip322_txn, start_sign, end_sign, cap_story, press_select,
bip322_verify):
msg = "UTF-8 support: öäüéàè - test text".encode()
psbt, _ = bip322_txn([["p2wpkh", None, None]], msg=msg)
start_sign(psbt, finalize=True)
title, story = cap_story()
assert title == "OK TO SIGN?"
assert "BIP-322 Message" in story
assert "Proof of Reserves" not in story
assert msg.decode() in story
assert "WARNING" in story
assert "non-ASCII characters" in story
assert "Message Hash:" not in story
signed = end_sign(accept=True)
bip322_verify(signed)
def test_bip322_global_msg_hash_mismatch(bip322_txn, start_sign, cap_story):
def hack(psbt_in):
psbt_in.bip322_msg = b"wrong message"
psbt, _ = bip322_txn([["p2wpkh", None, None]], msg=b"right message", psbt_hacker=hack)
start_sign(psbt, finalize=True)
title, story = cap_story()
assert title == "Failure"
assert "i0: invalid BIP-322 'to_spend'" in story
assert "to_spend hash" in story
def test_bip322_missing_global_msg(bip322_txn, start_sign, cap_story):
def hack(psbt_in):
psbt_in.bip322_msg = None
psbt, _ = bip322_txn([["p2wpkh", None, None]], psbt_hacker=hack)
start_sign(psbt, finalize=True)
title, story = cap_story()
assert title == "Failure"
assert "msg" in story
def test_bip322_missing_input0_utxo(bip322_txn, start_sign, cap_story):
def hack(psbt_in):
psbt_in.inputs[0].utxo = None
psbt_in.inputs[0].witness_utxo = None
psbt, _ = bip322_txn([["p2wpkh", None, None]], psbt_hacker=hack)
start_sign(psbt, finalize=True)
title, story = cap_story()
assert title == "Failure"
assert "Missing own UTXO" in story
@pytest.mark.parametrize("ins,label", [
([["p2wpkh", None, None]], "BIP-322 Message"),
([["p2wpkh", None, None], ["p2wpkh", None, 10000000]], "Proof of Reserves"),
])
def test_bip322_psbtv2_accepted(ins, label, bip322_txn, start_sign, end_sign, cap_story,
bip322_verify):
psbt, _ = bip322_txn(ins, psbt_v2=True)
start_sign(psbt, finalize=True)
title, story = cap_story()
assert title == "OK TO SIGN?"
assert label in story
signed = end_sign(accept=True)
bip322_verify(signed)
@pytest.mark.parametrize("to_sign_nVersion", [1, 3])
def test_bip322_invalid_to_sign_version(to_sign_nVersion, bip322_txn, start_sign, cap_story):
psbt, _ = bip322_txn([["p2wpkh", None, None], ["p2wpkh", None, 10000000]],
to_sign_nVersion=to_sign_nVersion)
start_sign(psbt, finalize=True)
title, story = cap_story()
assert title == "Failure"
assert "bad txn version" in story
def test_bip322_input0_explorer(bip322_txn, start_sign, cap_story, need_keypress,
pick_menu_item, press_cancel):
psbt, msg_challenge = bip322_txn([["p2wpkh", None, None], ["p2wpkh", None, 10000000]])
start_sign(psbt, finalize=True)
title, story = cap_story()
assert title == "OK TO SIGN?"
assert "Press (2) to explore transaction" in story
need_keypress("2")
time.sleep(.1)
pick_menu_item("Inputs")
time.sleep(.1)
title, story = cap_story()
assert title == "Input 0"
sections = story.split("\n\n")
txid, n = sections[0].split(":")
assert len(txid) == 64
assert n == "0"
assert "=== UTXO ===" in sections
utxo_idx = sections.index("=== UTXO ===")
assert sections[utxo_idx + 1] == "0.00000000 XTN"
assert sections[utxo_idx + 2] == msg_challenge.hex()
assert addr_from_display_format(sections[utxo_idx + 3]) == render_address(msg_challenge)
assert sections[utxo_idx + 4] == "Address Format: p2wpkh"
assert "=== PSBT ===" in sections
assert "Our key:" in sections
assert "- OP_RETURN -" not in story
assert "null-data" not in story
press_cancel()
def test_bip322_output_explorer(bip322_txn, start_sign, cap_story, need_keypress,
pick_menu_item, press_cancel):
psbt, _ = bip322_txn([["p2wpkh", None, None], ["p2wpkh", None, 10000000]])
start_sign(psbt, finalize=True)
title, story = cap_story()
assert title == "OK TO SIGN?"
assert "Proof of Reserves" in story
assert "- OP_RETURN -" not in story
assert "null-data" not in story
assert "Press (2) to explore transaction" in story
need_keypress("2")
time.sleep(.1)
pick_menu_item("Outputs")
time.sleep(.1)
title, story = cap_story()
assert title == "0-0"
assert "Output 0:" in story
assert "0.00000000 XTN" in story
assert "- OP_RETURN -" in story
assert "null-data" in story
end_sign(accept=True, finalize=True)
press_cancel()
press_cancel()
press_cancel()
@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, verify_msg_bip322_por):
settings_set("sighshchk", 0) # disable checks
def test_bip322_por_invalid_sighash(sighash, bip322_txn, start_sign, cap_story, settings_set):
settings_set("sighshchk", 1) # BIP-322 POR still requires SIGHASH_ALL in warn-only mode.
# 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
verify_msg_bip322_por("POR", way="sd")
time.sleep(.1)
title, story = cap_story()
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
@ -152,14 +251,34 @@ def test_bip322_por_invalid_sighash(sighash, bip322_txn, start_sign, cap_story,
[["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
def test_bip322_0th_input_witness_utxo(ins, bip322_txn, start_sign, end_sign, cap_story,
verify_msg_bip322_por, bip322_verify):
# allowed when the BIP-322 message is provided in the global PSBT field
psbt, _ = bip322_txn(ins, witness_utxo=[0])
start_sign(psbt, finalize=True)
verify_msg_bip322_por("POR", is_por=len(ins) > 1)
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SIGN?"
signed = end_sign(accept=True)
bip322_verify(signed)
def test_bip322_0th_input_witness_utxo_requires_zero_value(bip322_txn, start_sign, cap_story):
def hack(psbt_in):
txo = CTxOut()
txo.deserialize(BytesIO(psbt_in.inputs[0].witness_utxo))
txo.nValue = 1
psbt_in.inputs[0].witness_utxo = txo.serialize()
psbt, _ = bip322_txn([["p2wpkh", None, None], ["p2wpkh", None, 10000000]],
witness_utxo=[0], psbt_hacker=hack)
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
assert "input0 value" in story
@pytest.mark.parametrize("ins", [
@ -168,18 +287,21 @@ def test_bip322_0th_input_witness_utxo(ins, bip322_txn, start_sign, cap_story):
[["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,
verify_msg_bip322_por):
# allowed - 0th input needs to have full pre-segwit utxo, all other can be just witness_utxo
verify_msg_bip322_por, bip322_verify):
# allowed - input 0 has full utxo here, other inputs can be witness_utxo-only
msg = b"hellow world"
psbt, msg_challenge = bip322_txn(ins, witness_utxo=[1, 2], msg=msg)
start_sign(psbt, finalize=True)
verify_msg_bip322_por(msg.decode(), way="sd")
verify_msg_bip322_por(msg.decode())
time.sleep(.1)
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)
assert ("Message:\n%s" % msg.decode()) in story
assert "Message Hash:" not in story
assert "Challenge Address:" in story
assert "Message Challenge:" not in story
signed = end_sign(accept=True)
bip322_verify(signed)
@pytest.mark.parametrize("ins", [
@ -194,8 +316,9 @@ def test_bip322_incomplete_psbt_bip32_paths(ins, bip322_txn, start_sign, cap_sto
verify_msg_bip322_por):
def hack(psbt_in):
without_paths = 0 if len(psbt_in.inputs) == 1 else 1
for i, inp in enumerate(psbt_in.inputs):
if i == 0:
if i == without_paths:
inp.bip32_paths = None
psbt, _ = bip322_txn(ins, psbt_hacker=hack)
@ -205,12 +328,26 @@ def test_bip322_incomplete_psbt_bip32_paths(ins, bip322_txn, start_sign, cap_sto
assert title == "Failure"
assert 'PSBT does not contain any key path information.' in story
else:
verify_msg_bip322_por("POR", way="sd")
verify_msg_bip322_por("POR")
time.sleep(.1)
title, story = cap_story()
assert "warning" in story
assert "Limited Signing" in story
assert "because we do not know the key: 0" in story
assert "because we do not know the key: 1" in story
def test_bip322_por_input0_bip32_paths_required(bip322_txn, start_sign, cap_story):
def hack(psbt_in):
psbt_in.inputs[0].bip32_paths = None
psbt, _ = bip322_txn([["p2wpkh", None, None], ["p2wpkh", None, 10000000]],
psbt_hacker=hack)
start_sign(psbt)
title, story = cap_story()
assert title == "Failure"
assert "i0: invalid BIP-322 'to_spend'" in story
assert "not our key" in story
@pytest.mark.parametrize("ins", [
@ -265,7 +402,7 @@ def test_bip322_invalid_to_spend_scriptSig(ins, bip322_txn, start_sign, cap_stor
title, story = cap_story()
assert title == "Failure"
assert "i0: invalid BIP-322 'to_spend'" in story
assert "scriptSig" in story
assert "to_spend hash" in story
@pytest.mark.parametrize("ins", [
@ -293,9 +430,7 @@ def test_bip322_invalid_to_spend_prevout(ins, bip322_txn, start_sign, cap_story)
to_spend_tx.calc_sha256()
spendable = CTxIn(COutPoint(to_spend_tx.sha256, 0))
to_sign_tx.vin = [spendable]
to_sign_tx.vin[0] = CTxIn(COutPoint(to_spend_tx.sha256, 0))
psbt_in.txn = to_sign_tx.serialize_with_witness()
@ -304,7 +439,7 @@ def test_bip322_invalid_to_spend_prevout(ins, bip322_txn, start_sign, cap_story)
title, story = cap_story()
assert title == "Failure"
assert "i0: invalid BIP-322 'to_spend'" in story
assert "prevout" in story
assert "to_spend hash" in story
@pytest.mark.parametrize("ins", [
@ -342,7 +477,7 @@ def test_bip322_invalid_to_spend_num_inputs(ins, bip322_txn, start_sign, cap_sto
title, story = cap_story()
assert title == "Failure"
assert "i0: invalid BIP-322 'to_spend'" in story
assert "num ins" in story
assert "to_spend hash" in story
@pytest.mark.parametrize("ins", [
@ -380,7 +515,7 @@ def test_bip322_invalid_to_spend_num_outputs(ins, bip322_txn, start_sign, cap_st
title, story = cap_story()
assert title == "Failure"
assert "i0: invalid BIP-322 'to_spend'" in story
assert "num outs" in story
assert "to_spend hash" in story
@pytest.mark.parametrize("ins", [
@ -416,7 +551,7 @@ def test_bip322_invalid_to_spend_out_nVal(ins, bip322_txn, start_sign, cap_story
title, story = cap_story()
assert title == "Failure"
assert "i0: invalid BIP-322 'to_spend'" in story
assert "nVal" in story
assert "to_spend hash" in story
@pytest.mark.parametrize("addr_fmt", [AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH])
@ -424,7 +559,8 @@ def test_bip322_invalid_to_spend_out_nVal(ins, bip322_txn, start_sign, cap_story
@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, verify_msg_bip322_por):
import_ms_wallet, clear_ms, num_ins, verify_msg_bip322_por,
bip322_verify):
clear_ms()
M, N = M_N
@ -448,29 +584,38 @@ def test_ms_bip322_por(addr_fmt, M_N, signed, bip322_ms_txn, start_sign, end_sig
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)
verify_msg_bip322_por("POR", way="sd")
is_por = num_ins > 1
verify_msg_bip322_por("POR", is_por=is_por)
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SIGN?"
assert "Proof of Reserves" in story
assert ("Proof of Reserves" if is_por else "BIP-322 Message") 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
if not is_por:
assert "Proof of Reserves" not in story
assert "Amount " not in story
assert "1 input" not in story
assert "1 output" not 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
assert "Message:\nPOR" in story
assert "Message Hash:" not in story
assert "Challenge Address:" in story
assert "Message Challenge:" not in story
assert ("1 output" in story) == is_por
assert "- OP_RETURN -" not in story
assert "null-data" not in story
end_sign(accept=True, finalize=signed)
signed_psbt = end_sign(accept=True)
if signed:
# with_sigs=True preloads placeholder cosigner signatures; the device
# can accept the PSBT shape, but a real signature verifier must reject it.
with pytest.raises(AssertionError):
bip322_verify(signed_psbt)
@pytest.mark.parametrize("addr_fmt", [AF_P2WSH, AF_P2SH])
@ -504,82 +649,12 @@ def test_bip322_invalid_ms_psbt(addr_fmt, bip322_ms_txn, start_sign, cap_story,
assert "Missing redeem/witness script" in story
@pytest.mark.parametrize("msg", [b"COLDCARD\n\nTHE\n\nBEST\n\nSIGNER", b"X" * 512])
@pytest.mark.parametrize("ins", [
[["p2sh-p2wpkh", None, None]],
[["p2pkh", None, None]] + ([["p2wpkh", None, 1000000]] * 5) + ([["p2sh-p2wpkh", None, 10000000]] * 5),
])
@pytest.mark.parametrize("way", ["sd", "qr", "nfc", "vdisk", "bbqr"])
def test_bip322_msg_import(msg, ins, way, bip322_txn, start_sign, end_sign, cap_story, need_keypress,
press_select, verify_msg_bip322_por):
if b"\n" in msg and way == "qr":
raise pytest.skip("QR code with newlines not supported")
psbt, msg_challenge = bip322_txn(ins, msg=msg)
start_sign(psbt, finalize=True)
verify_msg_bip322_por(msg.decode(), way=way)
time.sleep(.1)
title, story = cap_story()
assert title == "OK TO SIGN?"
assert "Proof of Reserves" in story
def test_bip322_msg_import_fail(bip322_txn, start_sign, end_sign, cap_story, need_keypress,
press_select, OK, press_cancel, cap_menu, microsd_path, enter_complex):
msg = b"it's me!"
psbt, msg_challenge = bip322_txn([["p2wpkh", None, None]], msg=msg)
start_sign(psbt, finalize=True)
time.sleep(.1)
title, story = cap_story()
assert 'BIP-322' in title
need_keypress("1") # SD
time.sleep(.1)
title, story = cap_story()
assert f"Press {OK} to approve message" in story
press_cancel() # refuse
time.sleep(.1)
assert "Ready To Sign" in cap_menu()
start_sign(psbt, finalize=True)
time.sleep(.1)
title, story = cap_story()
assert 'BIP-322' in title
need_keypress("0") # manual input
# leave empty
press_cancel()
time.sleep(.1)
title, story = cap_story()
assert title == "Failure"
assert "need msg" in story
assert "Msg verification failed" in story
press_cancel()
start_sign(psbt, finalize=True)
time.sleep(.1)
title, story = cap_story()
assert 'BIP-322' in title
need_keypress("0") # manual input
enter_complex("AAA", apply=False, b39pass=False) # msg wrong
time.sleep(.1)
title, story = cap_story()
assert title == "Failure"
assert "Msg verification failed" in story
assert "hash verification failed" in story
press_cancel()
@pytest.mark.parametrize("num_ins", [1, 12])
@pytest.mark.parametrize("addr_fmt", ["p2pkh", "p2wpkh", "p2sh-p2wpkh"])
def test_wif_store_sign_bip322_por(num_ins, addr_fmt, bip322_txn, goto_home, pick_menu_item,
need_keypress, start_sign, end_sign, cap_menu, cap_story,
press_cancel, settings_remove, press_select, import_wif_to_store):
press_cancel, settings_remove, press_select, import_wif_to_store,
bip322_verify):
settings_remove("wifs")
@ -600,7 +675,7 @@ def test_wif_store_sign_bip322_por(num_ins, addr_fmt, bip322_txn, goto_home, pic
ins.append([addr_fmt, None, amt , n.node.private_key.K.sec()])
msg = b"Coinkite"
psbt, msg_challenge = bip322_txn(ins, msg=b"Coinkite")
psbt, msg_challenge = bip322_txn(ins, msg=msg)
import_wif_to_store(wifs)
@ -610,22 +685,52 @@ def test_wif_store_sign_bip322_por(num_ins, addr_fmt, bip322_txn, goto_home, pic
start_sign(psbt, finalize=True)
time.sleep(.1)
title, story = cap_story()
assert "import message" in story
# msg file was auto-gened on SD card
need_keypress("1")
time.sleep(.1)
title, story = cap_story()
assert title == "Message:"
assert title == "OK TO SIGN?"
assert ("Proof of Reserves" if num_ins > 1 else "BIP-322 Message") in story
if num_ins == 1:
assert "Proof of Reserves" not in story
assert "Amount " not in story
assert "1 input" not in story
assert "1 output" not in story
assert msg.decode() in story
press_select()
time.sleep(.1)
title, story = cap_story()
assert "Proof of Reserves" in story
assert "Message Hash:" not in story
assert "warning" in story
if num_ins == 1:
assert "WIF store: 0" in story
else:
assert f"WIF store: {', '.join([str(i) for i in range(num_ins)])}" in story
end_sign(finalize=True)
signed = end_sign()
bip322_verify(signed)
@pytest.mark.parametrize("bip32_paths", [True, False])
@pytest.mark.parametrize("por", [True, False])
def test_bip322_empty_message_challenge_rejected(bip32_paths, por, bip322_txn,
start_sign, cap_story):
def hack(psbt_in):
to_spend_tx = CTransaction()
to_sign_tx = CTransaction()
to_spend_tx.deserialize(BytesIO(psbt_in.inputs[0].utxo))
to_sign_tx.deserialize(BytesIO(psbt_in.txn))
if not bip32_paths:
psbt_in.inputs[0].bip32_paths = None
to_spend_tx.vout[0].scriptPubKey = b""
psbt_in.inputs[0].utxo = to_spend_tx.serialize_with_witness()
to_spend_tx.calc_sha256()
to_sign_tx.vin[0] = CTxIn(COutPoint(to_spend_tx.sha256, 0),
nSequence=to_sign_tx.vin[0].nSequence)
psbt_in.txn = to_sign_tx.serialize_with_witness()
ins = [["p2wpkh", None, None]]
if por:
ins.append(["p2wpkh", None, 10000000])
psbt, _ = bip322_txn(ins, psbt_hacker=hack)
start_sign(psbt)
title, story = cap_story()
assert title == "Failure"
# EOF

View File

@ -937,6 +937,72 @@ def test_sign_msg_any(quick_start_hsm, attempt_msg_sign, addr_fmt=AF_CLASSIC):
for p in permit+block:
attempt_msg_sign(None, msg, p, addr_fmt=addr_fmt)
def test_bip322_psbt_uses_msg_sign_policy(quick_start_hsm, change_hsm, attempt_psbt,
bip322_txn):
psbt, _ = bip322_txn([["p2wpkh", "0/0", None]], msg=b"HSM BIP-322 message")
quick_start_hsm(DICT(msg_paths=["m/0/0"]))
attempt_psbt(psbt)
change_hsm(DICT(msg_paths=["any"]))
attempt_psbt(psbt)
change_hsm(DICT(msg_paths=["m/9"]))
attempt_psbt(psbt, "Message signing not enabled for that path")
change_hsm(DICT(rules=[{}]))
attempt_psbt(psbt, "Message signing not permitted")
def test_bip322_por_psbt_uses_msg_sign_policy(quick_start_hsm, change_hsm, attempt_psbt,
bip322_txn):
psbt, _ = bip322_txn([
["p2wpkh", "0/0", None],
["p2wpkh", "0/1", 1000000],
["p2sh-p2wpkh", "0/2", 2000000],
["p2pkh", "0/3", 3000000],
], msg=b"HSM BIP-322 proof of reserves")
quick_start_hsm(DICT(msg_paths=["m/0/*"]))
attempt_psbt(psbt)
change_hsm(DICT(msg_paths=["any"]))
attempt_psbt(psbt)
change_hsm(DICT(msg_paths=["m/0/0", "m/0/1", "m/0/2"]))
attempt_psbt(psbt, "Message signing not enabled for that path")
change_hsm(DICT(msg_paths=["m/0/0", "m/0/1", "m/0/2", "m/0/3"]))
attempt_psbt(psbt)
change_hsm(DICT(rules=[{}]))
attempt_psbt(psbt, "Message signing not permitted")
def test_bip322_ms_psbt_uses_msg_sign_policy(quick_start_hsm, change_hsm, attempt_psbt,
bip322_ms_txn, import_ms_wallet, clear_ms):
clear_ms()
deriv = "m/48h/1h/0h/2h"
def path_mapper(idx):
return [0x80000030, 0x80000001, 0x80000000, 0x80000002, 0, 0]
keys = import_ms_wallet(1, 1, name="hsm_bip322_msg", accept=True, addr_fmt=AF_P2WSH,
common=deriv, do_import=True, descriptor=True)
psbt, _ = bip322_ms_txn(1, 1, keys, path_mapper=path_mapper, inp_af=AF_P2WSH,
msg=b"HSM multisig BIP-322 message")
quick_start_hsm(DICT(msg_paths=[deriv + "/0/0"]))
attempt_psbt(psbt)
change_hsm(DICT(msg_paths=["any"]))
attempt_psbt(psbt)
change_hsm(DICT(msg_paths=["m/48h/1h/0h/2h/0/9"]))
attempt_psbt(psbt, "Message signing not enabled for that path")
def test_must_log(dev, start_hsm, sd_cards_eject, attempt_msg_sign, fake_txn, attempt_psbt, is_simulator):
# stop everything if can't log
policy = DICT(must_log=True, msg_paths=['m'], rules=[{}])

View File

@ -3779,4 +3779,37 @@ def test_txid_qr(fake_txn, start_sign, cap_story, press_cancel, press_select, go
assert "(6) for QR Code of TXID" in story
press_cancel()
@pytest.mark.parametrize("segwit_in", [True, False])
def test_empty_input_scriptPubKey(segwit_in, dev, fake_txn, start_sign, cap_story):
def hack(psbt):
target_idx = 0
if segwit_in:
txo = CTxOut()
txo.deserialize(BytesIO(psbt.inputs[target_idx].witness_utxo))
txo.scriptPubKey = b""
psbt.inputs[target_idx].witness_utxo = txo.serialize()
else:
supply_tx = CTransaction()
supply_tx.deserialize(BytesIO(psbt.inputs[target_idx].utxo))
supply_tx.vout[0].scriptPubKey = b""
psbt.inputs[target_idx].utxo = supply_tx.serialize_with_witness()
supply_tx.calc_sha256()
spend_tx = CTransaction()
spend_tx.deserialize(BytesIO(psbt.txn))
spend_tx.vin[target_idx] = CTxIn(
COutPoint(supply_tx.sha256, 0),
nSequence=spend_tx.vin[target_idx].nSequence,
)
psbt.txn = spend_tx.serialize_with_witness()
psbt = fake_txn(2, 1, dev.master_xpub, psbt_hacker=hack, segwit_in=segwit_in)
start_sign(psbt)
title, _ = cap_story()
assert title == "Failure"
# EOF