Test framework update for transaction send: unstreaming, warning unification, status diversification
What changed, and why it matters
This commit only updates the automated test framework for the Ledger Bitcoin app. It changes how tests simulate button presses on the device screen, renames some test helper functions, and adds new test cases for transactions with many outputs and for navigating back and forth during signing. There is no change to the actual app code that runs on the Ledger device, so this commit does not introduce or fix a security vulnerability in the product itself.
No security action required; this is a test-only maintenance commit. Reviewers may optionally verify that the new test cases adequately cover the large-output and navigation-back flows they are intended to exercise.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The diff is confined to test infrastructure files: ragger_bitcoin/ragger_instructions.py, test_utils/txmaker.py, tests/instructions.py, and tests/test_sign_psbt.py. It refactors Ragger instruction sequences to match updated UI strings (e.g., ‘Security risk detected’ instead of ‘Warning’, ‘Continue anyway’ for warnings), introduces MAX_EXT_OUTPUT_SIMPLIFIED_NUMBER = 16 to generalize cached-output handling, replaces a deterministic P2TR key generator in txmaker for test reproducibility, and adds parameterized tests for 17- and 18-output transactions plus a go-back navigation test. No firmware or signing logic is modified.
Changed components
tests/instructions.pytests/test_sign_psbt.pyragger_bitcoin/ragger_instructions.pytest_utils/txmaker.pyInspect captured patch +168 / −53
diff --git a/ragger_bitcoin/ragger_instructions.py b/ragger_bitcoin/ragger_instructions.py
index 43999ad..029fc42 100644
--- a/ragger_bitcoin/ragger_instructions.py
+++ b/ragger_bitcoin/ragger_instructions.py
@@ -1,5 +1,6 @@
from ragger.navigator import NavInsID
+MAX_EXT_OUTPUT_SIMPLIFIED_NUMBER = 16
class Instructions:
def __init__(self, model):
@@ -43,12 +44,12 @@ class Instructions:
save_screenshot=save_screenshot)
if has_warning:
- self.same_request("Warning", NavInsID.USE_CASE_REVIEW_TAP, NavInsID.USE_CASE_CHOICE_CONFIRM,
+ self.same_request("Security risk detected", NavInsID.USE_CASE_REVIEW_TAP, NavInsID.USE_CASE_REVIEW_TAP,
save_screenshot=save_screenshot)
for output_index in range(0, output_count):
- # the initial 2 outputs are cached; that depends on the N_CACHED_EXTERNAL_OUTPUTS constant
- if output_index < 2:
+ # the initial N_CACHED_EXTERNAL_OUTPUTS outputs are cached, so it is the same request
+ if output_index < MAX_EXT_OUTPUT_SIMPLIFIED_NUMBER:
self.same_request("Amount", NavInsID.USE_CASE_REVIEW_TAP, NavInsID.USE_CASE_REVIEW_TAP,
save_screenshot=save_screenshot)
else:
@@ -107,12 +108,12 @@ class Instructions:
self.same_request("Address verified", NavInsID.USE_CASE_REVIEW_TAP, NavInsID.CANCEL_FOOTER_TAP,
save_screenshot=save_screenshot)
- def choice_confirm(self, save_screenshot=True):
- self.new_request("Approve", NavInsID.USE_CASE_REVIEW_TAP, NavInsID.USE_CASE_CHOICE_CONFIRM,
+ def choice_confirm(self, confirm_text = "Approve", save_screenshot=True):
+ self.new_request(confirm_text, NavInsID.USE_CASE_REVIEW_TAP, NavInsID.USE_CASE_CHOICE_CONFIRM,
save_screenshot=save_screenshot)
- def choice_reject(self, save_screenshot=True):
- self.new_request("Approve", NavInsID.USE_CASE_REVIEW_TAP, NavInsID.USE_CASE_CHOICE_REJECT,
+ def choice_reject(self, reject_text = "Approve", save_screenshot=True):
+ self.new_request(reject_text, NavInsID.USE_CASE_REVIEW_TAP, NavInsID.USE_CASE_CHOICE_REJECT,
save_screenshot=save_screenshot)
def footer_cancel(self, save_screenshot=True):
diff --git a/test_utils/txmaker.py b/test_utils/txmaker.py
index dd3b9b3..279ac4e 100644
--- a/test_utils/txmaker.py
+++ b/test_utils/txmaker.py
@@ -20,6 +20,8 @@ from embit.script import Script
from embit.bip32 import HDKey
from embit.bip39 import mnemonic_to_seed
+from hashlib import sha256
+
from ledger_bitcoin.embit.descriptor.miniscript import Miniscript
from test_utils import bip0340
from test_utils.wallet_policy import DescriptorTemplate, KeyPlaceholder, PlainKeyPlaceholder, TrDescriptorTemplate, WshDescriptorTemplate, derive_plain_descriptor, tapleaf_hash
@@ -28,6 +30,7 @@ from test_utils.wallet_policy import DescriptorTemplate, KeyPlaceholder, PlainKe
SPECULOS_SEED = "glory promote mansion idle axis finger extra february uncover one trip resource lawn turtle enact monster seven myth punch hobby comfort wild raise skin"
master_key = HDKey.from_seed(mnemonic_to_seed(SPECULOS_SEED))
master_key_fpr = master_key.derive("m/0'").fingerprint
+privkey_initial = bytearray(32)
def random_numbers_with_sum(n: int, s: int) -> List[int]:
@@ -52,11 +55,12 @@ def random_txid() -> bytes:
"""Returns 32 random bytes. Not cryptographically secure."""
return random_bytes(32)
-
def random_p2tr() -> bytes:
"""Returns 32 random bytes. Not cryptographically secure."""
- privkey = random_bytes(32)
- pubkey = bip0340.point_mul(bip0340.G, int.from_bytes(privkey, 'big'))
+ global privkey_initial
+ # Using non-random sequence for the sake of tests
+ privkey_initial = sha256(privkey_initial).digest()
+ pubkey = bip0340.point_mul(bip0340.G, int.from_bytes(privkey_initial, 'big'))
return b'\x51\x20' + (pubkey[0]).to_bytes(32, 'big')
@@ -274,6 +278,8 @@ def createPsbt(wallet_policy: WalletPolicy, input_amounts: List[int], output_amo
tx.wit = CTxWitness()
change_address_index = randint(0, 10_000)
+ global privkey_initial
+ privkey_initial = bytearray([0xB6] * 32)
for i, output_amount in enumerate(output_amounts):
tx.vout[i].nValue = output_amount
if output_is_change[i]:
diff --git a/tests/instructions.py b/tests/instructions.py
index 2514820..832dd7b 100644
--- a/tests/instructions.py
+++ b/tests/instructions.py
@@ -3,7 +3,7 @@ import pytest
from ragger.navigator import NavInsID
from ragger.firmware import Firmware
-from ragger_bitcoin.ragger_instructions import Instructions
+from ragger_bitcoin.ragger_instructions import Instructions, MAX_EXT_OUTPUT_SIMPLIFIED_NUMBER
def message_instruction_approve(model: Firmware, save_screenshot=True) -> Instructions:
@@ -162,35 +162,94 @@ def sign_psbt_instruction_tap(model: Firmware) -> Instructions:
return instructions
-def sign_psbt_instruction_approve(model: Firmware, save_screenshot: bool = True, *, has_spend_from_wallet: bool = False, to_on_next_page: bool = False, fees_on_next_page: bool = False, has_unverifiedwarning: bool = False, has_sighashwarning: bool = False, has_feewarning: bool = False) -> Instructions:
+def sign_psbt_instruction_approve(model: Firmware, save_screenshot: bool = True, *, has_spend_from_wallet: bool = False, to_on_next_page: bool = False, fees_on_next_page: bool = False, has_unverifiedwarning: bool = False, has_sighashwarning: bool = False, has_feewarning: bool = False, has_external_inputs: bool = False, go_back: bool = False) -> Instructions:
instructions = Instructions(model)
+ funcdict = {
+ 'new_request': instructions.new_request,
+ 'same_request': instructions.same_request
+ }
+ which_func = 'new_request'
+
+ # It is probably possibile to factorize between Nano and touch screen devices
if model.name.startswith("nano"):
- instructions.new_request("Sign transaction", save_screenshot=save_screenshot)
+ if has_sighashwarning:
+ # This transaction uses non-standard signing rules- actually clicking "Continue anyway"
+ funcdict[which_func]("Continue anyway", save_screenshot=save_screenshot)
+ which_func = 'same_request'
+
+ if has_external_inputs:
+ # This transaction has external inputs- actually clicking "Continue anyway"
+ funcdict[which_func]("Continue anyway", save_screenshot=save_screenshot)
+ which_func = 'same_request'
+
+ if has_unverifiedwarning:
+ # Non-default sighash - actually clicking "Continue anyway"
+ funcdict[which_func]("Continue anyway", save_screenshot=save_screenshot)
+ which_func = 'same_request'
+
+ run_num = 1
+ # if go_back is True then
+ # 1. we go forward once until "Sign transaction" screen
+ # 2. then back until "Review" screen
+ # 3. then again forwar signing finaly the transaction
+ while True:
+ if not go_back or run_num >= 2:
+ funcdict[which_func]("Sign transaction", save_screenshot=save_screenshot)
+ break
+ else:
+ funcdict[which_func]("Sign transaction", NavInsID.RIGHT_CLICK, NavInsID.LEFT_CLICK,
+ save_screenshot=save_screenshot)
+ which_func = 'same_request'
+ funcdict[which_func]("Review", NavInsID.LEFT_CLICK, NavInsID.RIGHT_CLICK,
+ save_screenshot=save_screenshot)
+ run_num = run_num + 1
+
else:
- instructions.new_request("Review", NavInsID.USE_CASE_REVIEW_TAP, NavInsID.USE_CASE_REVIEW_TAP,
- save_screenshot=save_screenshot)
if has_sighashwarning:
- instructions.same_request(
- "Non-default sighash", NavInsID.USE_CASE_REVIEW_TAP, NavInsID.USE_CASE_REVIEW_TAP, save_screenshot=save_screenshot)
+ # This transaction uses non-standard signing rules- actually clicking "Continue anyway"
+ instructions.choice_reject("Continue anyway")
+ which_func = 'same_request'
+
+ if has_external_inputs:
+ # This transaction has external inputs- actually clicking "Continue anyway"
+ instructions.choice_reject("Continue anyway")
+ which_func = 'same_request'
if has_unverifiedwarning:
- instructions.same_request(
- "Unverified inputs", NavInsID.USE_CASE_REVIEW_TAP, NavInsID.USE_CASE_REVIEW_TAP, save_screenshot=save_screenshot)
+ # Non-default sighash - actually clicking "Continue anyway"
+ instructions.choice_reject("Continue anyway")
+ which_func = 'same_request'
- instructions.same_request("Amount", NavInsID.USE_CASE_REVIEW_TAP, NavInsID.USE_CASE_REVIEW_TAP,
- save_screenshot=save_screenshot)
- if to_on_next_page:
- instructions.same_request("To", NavInsID.USE_CASE_REVIEW_TAP, NavInsID.USE_CASE_REVIEW_TAP,
- save_screenshot=save_screenshot)
- if fees_on_next_page:
- instructions.same_request("Fees", NavInsID.USE_CASE_REVIEW_TAP, NavInsID.USE_CASE_REVIEW_TAP,
+ funcdict[which_func]("Review", NavInsID.USE_CASE_REVIEW_TAP, NavInsID.USE_CASE_REVIEW_TAP,
+ save_screenshot=save_screenshot)
+ which_func = 'same_request'
+
+ run_num = 1
+ while True:
+ funcdict[which_func]("Amount", NavInsID.USE_CASE_REVIEW_TAP, NavInsID.USE_CASE_REVIEW_TAP,
save_screenshot=save_screenshot)
+ if to_on_next_page:
+ funcdict[which_func]("To", NavInsID.USE_CASE_REVIEW_TAP, NavInsID.USE_CASE_REVIEW_TAP,
+ save_screenshot=save_screenshot)
+ if fees_on_next_page:
+ funcdict[which_func]("Fees", NavInsID.USE_CASE_REVIEW_TAP, NavInsID.USE_CASE_REVIEW_TAP,
+ save_screenshot=save_screenshot)
+ if has_feewarning:
+ funcdict[which_func](
+ "High fees warning", NavInsID.USE_CASE_REVIEW_TAP, NavInsID.USE_CASE_REVIEW_TAP, save_screenshot=save_screenshot)
+
+ if not go_back or run_num >= 2:
+ instructions.confirm_transaction(save_screenshot=save_screenshot)
+ break
+ else:
+ funcdict[which_func]("Sign", NavInsID.USE_CASE_REVIEW_TAP, NavInsID.USE_CASE_REVIEW_PREVIOUS,
+ save_screenshot=save_screenshot)
+ which_func = 'same_request'
+ funcdict[which_func]("Review", NavInsID.USE_CASE_REVIEW_PREVIOUS, NavInsID.USE_CASE_REVIEW_TAP,
+ save_screenshot=save_screenshot)
+ run_num = run_num + 1
- if has_feewarning:
- instructions.same_request(
- "Fees are above", NavInsID.USE_CASE_REVIEW_TAP, NavInsID.USE_CASE_REVIEW_TAP, save_screenshot=save_screenshot)
- instructions.confirm_transaction(save_screenshot=save_screenshot)
return instructions
@@ -208,10 +267,15 @@ def sign_psbt_instruction_approve_selftransfer(model: Firmware) -> Instructions:
return instructions
-def sign_psbt_instruction_approve_streaming(model: Firmware, output_count: int, save_screenshot: bool = True) -> Instructions:
+def sign_psbt_instruction_approve_generic(model: Firmware, output_count: int, save_screenshot: bool = True, go_back: bool = False) -> Instructions:
instructions = Instructions(model)
+ if (output_count <= MAX_EXT_OUTPUT_SIMPLIFIED_NUMBER):
+ # Classical case
+ return sign_psbt_instruction_approve(model, save_screenshot, has_feewarning = True, go_back = go_back);
+ # Streaming case
if model.name.startswith("nano"):
+ instructions.new_request("Loading transaction")
instructions.new_request("Sign transaction", save_screenshot=save_screenshot)
else:
instructions.review_start(
@@ -221,24 +285,6 @@ def sign_psbt_instruction_approve_streaming(model: Firmware, output_count: int,
return instructions
-def sign_psbt_instruction_approve_external_inputs(model: Firmware, output_count) -> Instructions:
- instructions = Instructions(model)
-
- if model.name.startswith("nano"):
- instructions.new_request("Continue")
- for output_index in range(output_count - 2):
- if output_index < 1:
- instructions.same_request("Loading transaction")
- else:
- instructions.new_request("Loading transaction")
- instructions.new_request("Sign transaction")
- else:
- instructions.review_start(output_count=output_count, has_warning=True)
- instructions.review_fees(fees_on_same_request=True)
- instructions.confirm_transaction()
- return instructions
-
-
def e2e_register_wallet_instruction(model: Firmware, n_keys) -> Instructions:
instructions = Instructions(model)
diff --git a/tests/test_sign_psbt.py b/tests/test_sign_psbt.py
index f3f9f31..eca2c7f 100644
--- a/tests/test_sign_psbt.py
+++ b/tests/test_sign_psbt.py
@@ -16,6 +16,7 @@ from ragger.firmware import Firmware
from test_utils import bip0340, txmaker
from ragger_bitcoin import RaggerClient
+from ragger_bitcoin.ragger_instructions import MAX_EXT_OUTPUT_SIMPLIFIED_NUMBER
from .instructions import *
tests_root: Path = Path(__file__).parent
@@ -543,11 +544,72 @@ def test_sign_psbt_singlesig_wpkh_4to3(navigator: Navigator, firmware: Firmware,
assert sum_out < sum_in
result = client.sign_psbt(psbt, wallet, None, navigator,
- instructions=sign_psbt_instruction_approve_streaming(firmware, output_count=2, save_screenshot=False),
+ instructions=sign_psbt_instruction_approve(firmware, save_screenshot=False),
+ testname=test_name)
+
+ assert len(result) == n_ins
+
+def singlesig_wpkh_4toN(navigator: Navigator, firmware: Firmware, client: RaggerClient, test_name, n_outs: int, go_back: bool = False):
+ # PSBT for a segwit 4-input N-outputs spend (including 1 change address)
+ # this test also checks that addresses, amounts and fees shown on screen are correct
+
+ # Define account
+ wallet = WalletPolicy(
+ "Me and Bob or me and Carl",
+ "wpkh(@0/**)",
+ [
+ "[f5acc2fd/84'/1'/0']tpubDCtKfsNyRhULjZ9XMS4VKKtVcPdVDi8MKUbcSD9MJDyjRu1A2ND5MiipozyyspBT9bg8upEp7a8EAgFxNxXn1d7QkdbL52Ty5jiSLcxPt1P"
+ ],
+ )
+
+ n_ins = 4
+
+ in_amounts = [100000 + 10000 * i for i in range(n_ins)]
+ total_in = sum(in_amounts)
+ # Make sure that the fees are at least 10% of the total amount
+ out_amounts = [int(total_in * 0.89 // n_outs) - i for i in range(n_outs)]
+
+ print(f"total_in = {total_in}")
+ print(f"out_amounts = {out_amounts}")
+
+ change_index = 1
+
+ psbt = txmaker.createPsbt(
+ wallet,
+ in_amounts,
+ out_amounts,
+ [i == change_index for i in range(n_outs)]
+ )
+
+ print(f"psbt={psbt.serialize()}")
+
+ sum_in = sum(in_amounts)
+ sum_out = sum(out_amounts)
+
+ assert sum_out < sum_in
+
+ wallet_hmac = bytes.fromhex(
+ "297a8fb8516307dfe24649ce8940b014966cc3d1173da985ee92fba062785125"
+ )
+
+ result = client.sign_psbt(psbt, wallet, wallet_hmac, navigator,
+ instructions=sign_psbt_instruction_approve_generic(firmware, output_count=n_outs-1, save_screenshot=True, go_back=go_back),
testname=test_name)
assert len(result) == n_ins
+def test_sign_psbt_singlesig_wpkh_4to17(navigator: Navigator, firmware: Firmware, client:
+ RaggerClient, test_name: str):
+ singlesig_wpkh_4toN(navigator, firmware, client, test_name, MAX_EXT_OUTPUT_SIMPLIFIED_NUMBER + 1)
+
+def test_sign_psbt_singlesig_wpkh_4to17_go_back(navigator: Navigator, firmware: Firmware, client:
+ RaggerClient, test_name: str):
+ singlesig_wpkh_4toN(navigator, firmware, client, test_name, MAX_EXT_OUTPUT_SIMPLIFIED_NUMBER + 1, True)
+
+def test_sign_psbt_singlesig_wpkh_4to18(navigator: Navigator, firmware: Firmware, client:
+ RaggerClient, test_name: str):
+ singlesig_wpkh_4toN(navigator, firmware, client, test_name, MAX_EXT_OUTPUT_SIMPLIFIED_NUMBER + 2)
+
def test_sign_psbt_singlesig_large_amount(navigator: Navigator, firmware: Firmware, client:
RaggerClient, test_name: str):
@@ -760,9 +822,9 @@ def test_sign_psbt_with_segwit_v16(navigator: Navigator, firmware: Firmware, cli
def test_sign_psbt_with_external_inputs(navigator: Navigator, firmware: Firmware, client:
RaggerClient, test_name: str):
- instructions = [sign_psbt_instruction_approve_external_inputs(firmware, output_count=5),
- sign_psbt_instruction_approve_external_inputs(firmware, output_count=4),
- sign_psbt_instruction_approve_external_inputs(firmware, output_count=4)]
+ instructions = [sign_psbt_instruction_approve(firmware, has_external_inputs=True),
+ sign_psbt_instruction_approve(firmware, has_external_inputs=True),
+ sign_psbt_instruction_approve(firmware, has_external_inputs=True)]
# PSBT obtained by joining pkh-1to1.psbt, tr-1to2.psbt, wpkh-1to2.psbt.
# We sign it with each of the respective wallets; therefore it must show the "external inputs" warning each time.
psbt_b64 = "cHNidP8BAP0yAQIAAAADobgj0jNtaUtJNO+bblt94XoFUT2oop2wKi7Lx6mm/m0BAAAAAP3///9RIsLN5oI+VXVBdbksnFegqOGsg8OOF4f9Oh/zNI6VEwEAAAAA/f///3oqmXlWwJ+Op/0oGcGph7sU4iv5rc2vIKiXY3Is7uJkAQAAAAD9////BaCGAQAAAAAAFgAUE5m4oJhHoDmwNS9Y0hLBgLqxf3dV/6cAAAAAACJRIAuOdIa8MGoK77enwArwQFVC2xrNc+7MqCdxzPX+XrYPeEEPAAAAAAAZdqkUE9fVgWaUbD7AIpNAZtjA0RHRu0GIrHQ4IwAAAAAAFgAU6zj6m4Eo+B8m6V7bDF/66oNpD+Sguw0AAAAAABl2qRQ0Sg9IyhUOwrkDgXZgubaLE6ZwJoisAAAAAAABASunhqkAAAAAACJRINj08dGJltthuxyvVCPeJdih7unJUNN+b/oCMBLV5i4NIRYhLqKFalzxEOZqK+nXNTFHk/28s4iyuPE/K2remC569RkA9azC/VYAAIABAACAAAAAgAEAAAAAAAAAARcgIS6ihWpc8RDmaivp1zUxR5P9vLOIsrjxPytq3pguevUAAQCMAgAAAAHsIw5TCVJWBSokKCcO7ASYlEsQ9vHFePQxwj0AmLSuWgEAAAAXFgAUKBU5gg4t6XOuQbpgBLQxySHE2G3+////AnJydQAAAAAAF6kUyLkGrymMcOYDoow+/C+uGearKA+HQEIPAAAAAAAZdqkUy65bUM+Tnm9TG4prer14j+FLApeIrITyHAAiBgLuhgggfiEChCb2nnZEfX49XgdwSfXmg8MTbCMUdipHGBj1rML9LAAAgAEAAIAAAACAAAAAAAAAAAAAAQB9AgAAAAGvv64GWQ90H/GvWbasRhEmM2pMSoLbVT32/vq3N6wz8wEAAAAA/f///wJwEQEAAAAAACIAIP3uRBxW5bBtDfgsEkxwcBSlyhlli+C5hWvKFvHtMln3pfQwAAAAAAAWABQ6+EKa1ZVKpe6KM8mD/YoehnmSSwAAAAABAR+l9DAAAAAAABYAFDr4QprVlUql7oozyYP9ih6GeZJLIgYD7iw9mOsfk8Chqo5aQAm3Dre0Tq0V8WZvE2sBKtWNMGgY9azC/VQAAIABAACAAAAAgAEAAAAIAAAAAAABBSACkIHs5WFqocuZMZ/Eh07+5H8IzrpfYARjbIxDQJpfCiEHApCB7OVhaqHLmTGfxIdO/uR/CM66X2AEY2yMQ0CaXwoZAPWswv1WAACAAQAAgAAAAIABAAAAAgAAAAAAIgICKexHcnEx7SWIogxG7amrt9qm9J/VC6/nC5xappYcTswY9azC/VQAAIABAACAAAAAgAEAAAAKAAAAAAA="
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.