Trustworthy sign_psbt amount/fee display for non-default sighash: implementation
What changed, and why it matters
This commit changes how the Ledger Bitcoin app shows transaction amounts and fees on the device screen when a user signs a transaction that does not use the normal 'sign everything' rule (a so-called non-default sighash). Previously the app might have displayed a fee or total that could actually change later, because some sighash types let other people add inputs or change outputs after the signature. Now the app detects those cases and either shows only the net amount the user is spending/receiving, or shows a warning that amounts cannot be verified. It also adds a 'Signing rule' line so the user knows the transaction is not fully committed.
Treat this as a security-hardening change and include it in release notes. Review the new display flows on both Nano and Stax/Flex form factors to ensure users cannot be confused into approving transactions with untrustworthy amounts. Verify that the UNAVAILABLE path cannot be bypassed and that NET_ONLY correctly omits fee information for open-output sighashes.
Security signals we found
UI trust reduction for non-default sighash prevents misleading fee/amount display
Sighash classification helpers centralize ANYONECANPAY/NONE/SINGLE semantics
Mixed-sighash detection disables coherent amount display
Negative-fee rejection scoped to fully-committed transactions only
Default wallet label now derived from captured BIP-44 purpose/account
Evidence from the diff
The patch implements trustworthy transaction review for non-default Bitcoin sighash types in the sign_psbt flow. It adds helper functions in sighash.h to determine whether the input set (ANYONECANPAY) and output set (NONE/SINGLE) are closed, and a tx_display_mode_t enum (FULL, NET_ONLY, UNAVAILABLE) computed via decide_tx_display_mode(). During input preprocessing, the app tracks internal input totals, whether any signed input opens inputs/outputs, and whether mixed sighashes are present. Output preprocessing now only rejects negative fees when the whole transaction is committed. The transaction display logic then selects a display mode: FULL for default/ALL, NET_ONLY when outputs are committed but the fee is not trustworthy, and UNAVAILABLE when outputs are not fixed. UI code is updated to show account direction, the signing rule, net amount, or an ‘Amounts & fees cannot be verified’ warning accordingly. is_wallet_policy_standard() is extended to return BIP-44 purpose/account for default-wallet labels.
Changed components
src/common/sighash.hsrc/handler/sign_psbt/preprocess_inputs.csrc/handler/sign_psbt/preprocess_outputs.csrc/handler/sign_psbt/transaction_display.csrc/handler/sign_psbt/init_global_state.csrc/handler/sign_psbt.hsrc/handler/lib/policy.csrc/handler/lib/policy.hsrc/handler/get_wallet_address.csrc/ui/display.csrc/ui/display.hsrc/ui/display_nbgl.cInspect captured patch +451 / −66
diff --git a/src/common/sighash.h b/src/common/sighash.h
index f83f44c..4a7abc7 100644
--- a/src/common/sighash.h
+++ b/src/common/sighash.h
@@ -1,6 +1,7 @@
#pragma once
#include <stdint.h>
+#include <stdbool.h>
/* Local headers */
#include "constants.h"
@@ -51,3 +52,48 @@ static inline sighash_class_t classify_sighash(uint32_t sighash_type, int segwit
return SIGHASH_CLASS_UNSUPPORTED;
}
+
+// Whether the input set is closed (no one can add inputs): true iff not ANYONECANPAY.
+static inline bool sighash_input_set_closed(uint32_t sighash_type) {
+ return (sighash_type & SIGHASH_ANYONECANPAY) == 0;
+}
+
+// Output set closed (no one can add/replace outputs): only base ALL/DEFAULT.
+static inline bool sighash_output_set_closed(uint32_t sighash_type) {
+ uint32_t base = sighash_type & ~(uint32_t) SIGHASH_ANYONECANPAY;
+ return base == SIGHASH_ALL || base == SIGHASH_DEFAULT;
+}
+
+// Whether the signature commits every output in this PSBT: base ALL/DEFAULT, or SINGLE with
+// exactly one output. (Unlike output_set_closed: SINGLE+1out commits it but the set stays open.)
+static inline bool sighash_commits_provided_outputs(uint32_t sighash_type, unsigned int n_outputs) {
+ uint32_t base = sighash_type & ~(uint32_t) SIGHASH_ANYONECANPAY;
+ if (base == SIGHASH_ALL || base == SIGHASH_DEFAULT) {
+ return true;
+ }
+ return base == SIGHASH_SINGLE && n_outputs == 1;
+}
+
+// What the review can trust, given what the signed inputs commit to.
+typedef enum {
+ TX_DISPLAY_FULL, // all outputs and the fee
+ TX_DISPLAY_NET_ONLY, // only the net amount leaving our account; fee unknown
+ TX_DISPLAY_UNAVAILABLE, // outputs not even fixed; show a warning only
+} tx_display_mode_t;
+
+/**
+ * Decides what the review can trustworthily show.
+ *
+ * @param fee_trustworthy true iff all inputs and outputs are committed, so the fee can't
+ * change after signing; false for any non-default sighash
+ * @param commits_all_outputs true iff every output in this PSBT is committed (ALL/DEFAULT,
+ * or SINGLE with one output), so the net amount is exact
+ * @return the display mode: FULL, NET_ONLY, or UNAVAILABLE
+ */
+static inline tx_display_mode_t decide_tx_display_mode(bool fee_trustworthy,
+ bool commits_all_outputs) {
+ if (!commits_all_outputs) {
+ return TX_DISPLAY_UNAVAILABLE;
+ }
+ return fee_trustworthy ? TX_DISPLAY_FULL : TX_DISPLAY_NET_ONLY;
+}
diff --git a/src/handler/get_wallet_address.c b/src/handler/get_wallet_address.c
index 0816ca4..5ad6047 100644
--- a/src/handler/get_wallet_address.c
+++ b/src/handler/get_wallet_address.c
@@ -124,7 +124,7 @@ void handler_get_wallet_address(dispatcher_context_t *dc, uint8_t protocol_versi
if (is_array_all_zeros(wallet_hmac, sizeof(wallet_hmac))) {
// No hmac, verify that the policy is indeed a default one
- if (!is_wallet_policy_standard(dc, &wallet_header, &wallet_policy_map.parsed)) {
+ if (!is_wallet_policy_standard(dc, &wallet_header, &wallet_policy_map.parsed, NULL, NULL)) {
SEND_SW(dc, SW_INCORRECT_DATA);
return;
}
diff --git a/src/handler/lib/policy.c b/src/handler/lib/policy.c
index 205f870..86788eb 100644
--- a/src/handler/lib/policy.c
+++ b/src/handler/lib/policy.c
@@ -1437,7 +1437,9 @@ static int get_bip44_purpose(const policy_node_t *descriptor_template) {
bool is_wallet_policy_standard(dispatcher_context_t *dispatcher_context,
const policy_map_wallet_header_t *wallet_policy_header,
- const policy_node_t *descriptor_template) {
+ const policy_node_t *descriptor_template,
+ int *out_bip44_purpose,
+ uint32_t *out_bip44_account) {
// Based on the address type, we set the expected bip44 purpose
int bip44_purpose = get_bip44_purpose(descriptor_template);
if (bip44_purpose < 0) {
@@ -1508,6 +1510,13 @@ bool is_wallet_policy_standard(dispatcher_context_t *dispatcher_context,
return false;
}
+ if (out_bip44_purpose != NULL) {
+ *out_bip44_purpose = bip44_purpose;
+ }
+ if (out_bip44_account != NULL) {
+ *out_bip44_account = key_info.master_key_derivation[2] - H;
+ }
+
return true;
}
diff --git a/src/handler/lib/policy.h b/src/handler/lib/policy.h
index 2bbcf71..0e34291 100644
--- a/src/handler/lib/policy.h
+++ b/src/handler/lib/policy.h
@@ -165,13 +165,19 @@ int get_policy_address_type(const policy_node_t *policy);
* Pointer the wallet policy header
* @param[in] descriptor_template
* Pointer to the root node of the policy
+ * @param[out] out_bip44_purpose
+ * If not NULL and the policy is standard, receives the BIP-44 purpose (44/49/84/86).
+ * @param[out] out_bip44_account
+ * If not NULL and the policy is standard, receives the (unhardened) account index.
*
* @return true if the descriptor_template is not standard; false if not, or in case of error.
*/
__attribute__((warn_unused_result)) bool is_wallet_policy_standard(
dispatcher_context_t *dispatcher_context,
const policy_map_wallet_header_t *wallet_policy_header,
- const policy_node_t *descriptor_template);
+ const policy_node_t *descriptor_template,
+ int *out_bip44_purpose,
+ uint32_t *out_bip44_account);
/**
* Computes and returns the wallet_hmac, using the symmetric key derived
diff --git a/src/handler/sign_psbt.h b/src/handler/sign_psbt.h
index 6e149aa..f9a0947 100644
--- a/src/handler/sign_psbt.h
+++ b/src/handler/sign_psbt.h
@@ -134,6 +134,10 @@ typedef struct {
// true iff the wallet policy is a default (BIP-44/49/84/86) policy used without HMAC.
bool is_default;
+ // BIP-44 purpose/account from m/purpose'/coin'/account'; only when is_default.
+ int bip44_purpose;
+ uint32_t bip44_account;
+
__attribute__((aligned(4))) uint8_t policy_map_bytes[MAX_WALLET_POLICY_BYTES];
policy_node_t *policy_map;
@@ -155,9 +159,22 @@ typedef struct {
uint64_t inputs_total_amount;
+ // Sum of the internal (signed) inputs only; used for total_spent.
+ uint64_t internal_inputs_total_amount;
+
unsigned int n_external_inputs;
unsigned int n_external_outputs;
+ // Set if any signed input opens the inputs (ANYONECANPAY) / outputs (NONE/SINGLE).
+ bool sighash_inputs_open;
+ bool sighash_outputs_open;
+
+ // Common sighash seen across the inputs we sign (DEFAULT canonicalized to ALL); valid if
+ // !sighash_mixed.
+ uint32_t seen_sighash;
+ bool seen_sighash_set;
+ bool sighash_mixed; // seen inputs disagree on sighash -> can't display coherently
+
// set to true if at least a PSBT_IN_MUSIG2_PUB_NONCE field is present in the PSBT
bool has_musig2_pub_nonces;
diff --git a/src/handler/sign_psbt/init_global_state.c b/src/handler/sign_psbt/init_global_state.c
index 8d07ab2..716a192 100644
--- a/src/handler/sign_psbt/init_global_state.c
+++ b/src/handler/sign_psbt/init_global_state.c
@@ -225,8 +225,12 @@ static bool __attribute__((noinline)) load_wallet_account(dispatcher_context_t *
st->account.policy_map = (policy_node_t *) st->account.policy_map_bytes;
if (st->account.is_default) {
- // No hmac, verify that the policy is indeed a default one
- if (!is_wallet_policy_standard(dc, &st->account.wallet_header, st->account.policy_map)) {
+ // No hmac: verify it's a default policy and capture its purpose/account for the label.
+ if (!is_wallet_policy_standard(dc,
+ &st->account.wallet_header,
+ st->account.policy_map,
+ &st->account.bip44_purpose,
+ &st->account.bip44_account)) {
PRINTF("Non-standard policy, and no hmac provided\n");
SEND_SW_EC(dc, SW_INCORRECT_DATA, EC_SIGN_PSBT_MISSING_HMAC_FOR_NONDEFAULT_POLICY);
return false;
diff --git a/src/handler/sign_psbt/preprocess_inputs.c b/src/handler/sign_psbt/preprocess_inputs.c
index 2a5ff03..235aea9 100644
--- a/src/handler/sign_psbt/preprocess_inputs.c
+++ b/src/handler/sign_psbt/preprocess_inputs.c
@@ -105,6 +105,18 @@ void input_keys_callback(dispatcher_context_t *dc,
}
}
+// Track the sighash seen across the inputs we sign (DEFAULT canonicalized to ALL);
+// disagreement -> sighash_mixed.
+static void track_seen_sighash(sign_psbt_state_t *st, uint32_t sighash_type) {
+ uint32_t canon = (sighash_type == SIGHASH_DEFAULT) ? SIGHASH_ALL : sighash_type;
+ if (!st->seen_sighash_set) {
+ st->seen_sighash = canon;
+ st->seen_sighash_set = true;
+ } else if (st->seen_sighash != canon) {
+ st->sighash_mixed = true;
+ }
+}
+
bool __attribute__((noinline)) preprocess_inputs(
dispatcher_context_t *dc,
sign_psbt_state_t *st,
@@ -242,6 +254,7 @@ bool __attribute__((noinline)) preprocess_inputs(
}
bitvector_set(internal_inputs, cur_input_index, 1);
+ st->internal_inputs_total_amount += input.prevout_amount;
int segwit_version = get_policy_segwit_version(st->account.policy_map);
@@ -277,6 +290,7 @@ bool __attribute__((noinline)) preprocess_inputs(
// SIGHASH_ALL, we show a warning
if (!input.has_sighash_type) {
+ track_seen_sighash(st, SIGHASH_ALL); // no explicit sighash => commits all
continue;
}
@@ -317,6 +331,16 @@ bool __attribute__((noinline)) preprocess_inputs(
return false;
}
+ // track whether any signed input leaves the inputs or outputs open
+ if (!sighash_input_set_closed(input.sighash_type)) {
+ st->sighash_inputs_open = true;
+ }
+ if (!sighash_output_set_closed(input.sighash_type)) {
+ st->sighash_outputs_open = true;
+ }
+
+ track_seen_sighash(st, input.sighash_type);
+
if (((input.sighash_type & SIGHASH_SINGLE) == SIGHASH_SINGLE) &&
(cur_input_index >= st->n_outputs)) {
PRINTF("SIGHASH_SINGLE with input idx >= n_output is not allowed \n");
diff --git a/src/handler/sign_psbt/preprocess_outputs.c b/src/handler/sign_psbt/preprocess_outputs.c
index ee9c71b..ae16ed6 100644
--- a/src/handler/sign_psbt/preprocess_outputs.c
+++ b/src/handler/sign_psbt/preprocess_outputs.c
@@ -205,9 +205,11 @@ bool __attribute__((noinline)) preprocess_outputs(
st->n_external_outputs = external_outputs_count;
- if (st->inputs_total_amount < st->outputs.total_amount) {
+ // Reject inputs < outputs only when the whole tx is committed; an open sighash
+ // (ANYONECANPAY/NONE/SINGLE) may still add inputs later.
+ if (!st->sighash_inputs_open && !st->sighash_outputs_open &&
+ st->inputs_total_amount < st->outputs.total_amount) {
PRINTF("Negative fee is invalid\n");
- // negative fee transaction is invalid
SEND_SW(dc, SW_INCORRECT_DATA);
return false;
}
diff --git a/src/handler/sign_psbt/transaction_display.c b/src/handler/sign_psbt/transaction_display.c
index eb85362..c31c201 100644
--- a/src/handler/sign_psbt/transaction_display.c
+++ b/src/handler/sign_psbt/transaction_display.c
@@ -33,6 +33,7 @@
#include "menu.h"
#include "psbt.h"
#include "script.h"
+#include "sighash.h"
#include "sw.h"
static bool __attribute__((noinline)) display_output(
@@ -204,13 +205,89 @@ static bool __attribute__((noinline)) display_warnings(dispatcher_context_t *dc,
return true;
}
+// Human-readable label for a default (unregistered) account, e.g. "Native SegWit #2"
+static bool format_default_account_label(int bip44_purpose,
+ uint32_t account,
+ char *out,
+ size_t out_len) {
+ const char *type;
+ switch (bip44_purpose) {
+ case 44:
+ type = "Legacy";
+ break;
+ case 49:
+ type = "Nested SegWit";
+ break;
+ case 84:
+ type = "Native SegWit";
+ break;
+ case 86:
+ type = "Taproot";
+ break;
+ default:
+ return false;
+ }
+ return snprintf(out, out_len, "%s #%u", type, (unsigned int) account) > 0;
+}
+
+// Account label for the review, or NULL for no row. Registered: its name (always);
+// default: a derived label, but only in the trust-reduced modes (FULL stays lean).
+static const char *account_review_label(const sign_psbt_state_t *st,
+ tx_display_mode_t mode,
+ char *buf,
+ size_t buf_len) {
+ if (!st->account.is_default) {
+ return st->account.wallet_header.name;
+ }
+ if (mode != TX_DISPLAY_FULL && format_default_account_label(st->account.bip44_purpose,
+ st->account.bip44_account,
+ buf,
+ buf_len)) {
+ return buf;
+ }
+ return NULL;
+}
+
bool __attribute__((noinline)) display_transaction(
dispatcher_context_t *dc,
sign_psbt_state_t *st,
const uint8_t internal_outputs[static BITVECTOR_REAL_SIZE(MAX_N_OUTPUTS_CAN_SIGN)]) {
LOG_PROCESSOR(__FILE__, __LINE__, __func__);
+ char account_label_buf[MAX_WALLET_NAME_LENGTH + 1];
+
+ // only shown for a default sighash (FULL); may underflow otherwise, but then unused
uint64_t fee = st->inputs_total_amount - st->outputs.total_amount;
+ int64_t total_spent =
+ (int64_t) st->internal_inputs_total_amount - (int64_t) st->outputs.change_total_amount;
+
+ // Default sighash => FULL. Non-default => show only what the signed inputs commit to.
+ tx_display_mode_t mode = TX_DISPLAY_FULL;
+ if (st->warnings.non_default_sighash) {
+ if (st->sighash_mixed) {
+ mode = TX_DISPLAY_UNAVAILABLE; // signed inputs disagree: nothing coherent
+ } else {
+ // uniform non-default sighash never fixes the whole set => fee never trustworthy
+ bool fee_trustworthy = false;
+ bool outputs_committed =
+ sighash_commits_provided_outputs(st->seen_sighash, st->n_outputs);
+ mode = decide_tx_display_mode(fee_trustworthy, outputs_committed);
+ }
+ }
+
+ tx_summary_t summary = {.mode = mode,
+ .fee = fee,
+ .total_spent = total_spent,
+ .seen_sighash = st->seen_sighash,
+ .sighash_mixed = st->sighash_mixed};
+
+ // From when net-spending, To when net-receiving, unknown direction for UNAVAILABLE.
+ account_role_t account_role = ACCOUNT_ROLE_FROM;
+ if (mode == TX_DISPLAY_UNAVAILABLE) {
+ account_role = ACCOUNT_ROLE_UNKNOWN;
+ } else if (mode == TX_DISPLAY_NET_ONLY && total_spent < 0) {
+ account_role = ACCOUNT_ROLE_TO;
+ }
/** INPUT VERIFICATION ALERTS
*
@@ -220,8 +297,9 @@ bool __attribute__((noinline)) display_transaction(
* - non-default sighash types
*/
- // if the value of fees is 10% or more of the amount, and it's more than 100000
- st->warnings.high_fee = 10 * fee >= st->inputs_total_amount && st->inputs_total_amount > 100000;
+ // high-fee warning only applies when we trust the fee
+ st->warnings.high_fee = (mode == TX_DISPLAY_FULL) && (10 * fee >= st->inputs_total_amount &&
+ st->inputs_total_amount > 100000);
// Display warnings/risks information before the transaction title
// for the both classical and streaming cases.
@@ -229,6 +307,23 @@ bool __attribute__((noinline)) display_transaction(
return false;
}
+ if (mode == TX_DISPLAY_UNAVAILABLE) {
+ // Nothing reliable to show: no outputs/amounts, only a notice to confirm.
+ ui_transaction_simplified_init(
+ account_review_label(st, mode, account_label_buf, sizeof(account_label_buf)),
+ 0,
+ st->warnings,
+ account_role,
+ st->account.is_default,
+ &summary);
+ ui_set_processing_screen_text(GA_SIGNING_TRANSACTION);
+ if (!ui_transaction_simplified_show(dc)) {
+ SEND_SW(dc, SW_DENY);
+ return false;
+ }
+ return true;
+ }
+
if (st->n_external_outputs <= MAX_EXT_OUTPUT_SIMPLIFIED_NUMBER) {
// A simplified flow for most transactions: show it using the classical review if there is
// exactly 0 (self-transfer) or <= MAX_EXT_OUTPUT_SIMPLIFIED_NUMBER external outputs to show
@@ -236,12 +331,25 @@ bool __attribute__((noinline)) display_transaction(
bool is_self_transfer = st->n_external_outputs == 0;
+ // With DISPLAY_FULL and no external outputs, we add a line to make it clear that
+ // the amount sent is 0 (except the fee that is shown separately).
+ bool show_self_transfer_row = is_self_transfer && mode == TX_DISPLAY_FULL;
+
+ // Number of amount rows shown: external outputs, or one "self-transfer" row (if any).
+ unsigned int n_output_rows = st->n_external_outputs;
+ if (is_self_transfer) {
+ n_output_rows = show_self_transfer_row ? 1 : 0;
+ }
+
/** TRANSACTION CONFIRMATION */
/* Init*/
ui_transaction_simplified_init(
- st->account.is_default ? NULL : st->account.wallet_header.name,
- is_self_transfer ? 1 : st->n_external_outputs,
- st->warnings);
+ account_review_label(st, mode, account_label_buf, sizeof(account_label_buf)),
+ n_output_rows,
+ st->warnings,
+ account_role,
+ st->account.is_default,
+ &summary);
/* Adding outputs */
if (!is_self_transfer) {
@@ -258,27 +366,29 @@ bool __attribute__((noinline)) display_transaction(
return false;
}
- ui_transaction_simplified_add(is_self_transfer ? 0 : st->outputs.output_amounts[i],
- is_self_transfer ? NULL : output_description);
+ ui_transaction_simplified_add(st->outputs.output_amounts[i], output_description);
}
- } else {
+ } else if (show_self_transfer_row) {
ui_transaction_simplified_add(0, NULL);
}
/* Start the review */
ui_set_processing_screen_text(GA_SIGNING_TRANSACTION);
- if (!ui_transaction_simplified_show(dc, fee)) {
+ if (!ui_transaction_simplified_show(dc)) {
SEND_SW(dc, SW_DENY);
return false;
}
} else {
- // Transactions with more than one external output; show one output per page,
- // using the streaming NBGL API.
+ // Transactions with more than MAX_EXT_OUTPUT_SIMPLIFIED_NUMBER external outputs;
+ // show one output per page, using the streaming NBGL API.
// If it's not a default wallet policy, let's save this info to ask the user for
// confirmation
- ui_prepare_authorize_wallet_spend(!st->account.is_default ? st->account.wallet_header.name
- : NULL);
+ ui_prepare_authorize_wallet_spend(
+ account_review_label(st, mode, account_label_buf, sizeof(account_label_buf)),
+ account_role,
+ st->account.is_default,
+ &summary);
// "Review transaction to send Bitcoin"
if (!ui_transaction_streaming_prompt(dc)) {
@@ -298,7 +408,7 @@ bool __attribute__((noinline)) display_transaction(
*/
// Show final user validation UI
ui_set_processing_screen_text(GA_SIGNING_TRANSACTION);
- if (!ui_transaction_streaming_validate(dc, fee, st->warnings, false)) {
+ if (!ui_transaction_streaming_validate(dc, st->warnings, false)) {
SEND_SW(dc, SW_DENY);
return false;
}
diff --git a/src/ui/display.c b/src/ui/display.c
index 7918277..38e41f8 100644
--- a/src/ui/display.c
+++ b/src/ui/display.c
@@ -200,14 +200,22 @@ bool ui_display_wallet_address(dispatcher_context_t *context,
return io_ui_process(context);
}
-void ui_prepare_authorize_wallet_spend(const char *wallet_name) {
+static void prepare_tx_summary(ui_validate_transaction_state_t *state, const tx_summary_t *summary);
+
+void ui_prepare_authorize_wallet_spend(const char *wallet_name,
+ account_role_t account_role,
+ bool account_is_default,
+ const tx_summary_t *summary) {
ui_validate_transaction_state_t *state = (ui_validate_transaction_state_t *) &g_ui_state;
+ state->account_role = account_role;
+ state->account_is_default = account_is_default;
if (wallet_name == NULL) {
state->has_wallet_policy = false;
} else {
strncpy(state->wallet_policy_name, wallet_name, sizeof(state->wallet_policy_name));
state->has_wallet_policy = true;
}
+ prepare_tx_summary(state, summary);
}
bool ui_warn_external_inputs(dispatcher_context_t *context) {
@@ -279,8 +287,26 @@ bool ui_transaction_streaming_validate_output(dispatcher_context_t *context,
return io_ui_process(context);
}
+// Format the money value for the review: the network fee (FULL) or the net amount (NET_ONLY).
+// Shared by both flows.
+static void prepare_tx_summary(ui_validate_transaction_state_t *state,
+ const tx_summary_t *summary) {
+ state->display_mode = summary->mode;
+ state->spent_is_receive = false;
+ state->seen_sighash = summary->seen_sighash;
+ state->sighash_mixed = summary->sighash_mixed;
+ if (summary->mode == TX_DISPLAY_FULL) {
+ format_sats_amount(COIN_COINID_SHORT, summary->fee, state->fee);
+ } else if (summary->mode == TX_DISPLAY_NET_ONLY) {
+ state->spent_is_receive = summary->total_spent < 0;
+ uint64_t magnitude = summary->total_spent < 0 ? (uint64_t) -summary->total_spent
+ : (uint64_t) summary->total_spent;
+ format_sats_amount(COIN_COINID_SHORT, magnitude, state->net_amount);
+ }
+ // TX_DISPLAY_UNAVAILABLE: nothing to format
+}
+
bool ui_transaction_streaming_validate(dispatcher_context_t *context,
- uint64_t fee,
tx_ux_warning_t warnings,
bool is_self_transfer) {
#ifdef HAVE_AUTOAPPROVE_FOR_PERF_TESTS
@@ -289,7 +315,7 @@ bool ui_transaction_streaming_validate(dispatcher_context_t *context,
ui_validate_transaction_state_t *state = (ui_validate_transaction_state_t *) &g_ui_state;
- format_sats_amount(COIN_COINID_SHORT, fee, state->fee);
+ // the summary is applied earlier, in ui_prepare_authorize_wallet_spend
state->warnings = warnings;
ui_display_transaction_streaming_flow(is_self_transfer);
@@ -299,7 +325,10 @@ bool ui_transaction_streaming_validate(dispatcher_context_t *context,
void ui_transaction_simplified_init(const char *wallet_policy_name,
unsigned int outputs_num,
- tx_ux_warning_t warnings) {
+ tx_ux_warning_t warnings,
+ account_role_t account_role,
+ bool account_is_default,
+ const tx_summary_t *summary) {
ui_validate_transaction_state_t *state = (ui_validate_transaction_state_t *) &g_ui_state;
memset(state, 0, sizeof(ui_validate_transaction_state_t));
@@ -312,6 +341,11 @@ void ui_transaction_simplified_init(const char *wallet_policy_name,
}
state->n_outputs = outputs_num;
state->warnings = warnings;
+ state->account_role = account_role;
+ state->account_is_default = account_is_default;
+
+ // Apply the summary up front so the context page and page breaks see the mode/sighash.
+ prepare_tx_summary(state, summary);
ui_display_transaction_simplified_flow_init();
}
@@ -335,14 +369,11 @@ void ui_transaction_simplified_add(uint64_t amount, const char *address_or_descr
state->output_index++;
}
-bool ui_transaction_simplified_show(dispatcher_context_t *context, uint64_t fee) {
+bool ui_transaction_simplified_show(dispatcher_context_t *context) {
#ifdef HAVE_AUTOAPPROVE_FOR_PERF_TESTS
return true;
#endif
- ui_validate_transaction_state_t *state = (ui_validate_transaction_state_t *) &g_ui_state;
-
- format_sats_amount(COIN_COINID_SHORT, fee, state->fee);
-
+ // the summary is applied earlier, in ui_transaction_simplified_init
ui_display_transaction_simplified_flow_show();
return io_ui_process(context);
diff --git a/src/ui/display.h b/src/ui/display.h
index 35facad..ffa2632 100644
--- a/src/ui/display.h
+++ b/src/ui/display.h
@@ -1,6 +1,7 @@
#pragma once
#include <stdbool.h>
+#include <stdint.h>
/* SDK headers */
#include "bip32.h"
@@ -8,6 +9,7 @@
#include "format.h"
/* Local headers */
+#include "sighash.h" // tx_display_mode_t
#include "constants.h"
#include "dispatcher.h"
#include "display.h"
@@ -111,6 +113,22 @@ typedef struct {
char signer_index[sizeof("Key @999 <theirs>")];
} ui_cosigner_pubkey_and_index_state_t;
+// Which side of the transaction the wallet account is on, for the account review row.
+typedef enum {
+ ACCOUNT_ROLE_FROM, // net spend (and the default/FULL case)
+ ACCOUNT_ROLE_TO, // net receive
+ ACCOUNT_ROLE_UNKNOWN, // direction not knowable (UNAVAILABLE)
+} account_role_t;
+
+// Amount summary for the transaction review (mode from decide_tx_display_mode).
+typedef struct {
+ tx_display_mode_t mode;
+ uint64_t fee; // for TX_DISPLAY_FULL
+ int64_t total_spent; // for TX_DISPLAY_NET_ONLY (negative = net receive)
+ uint32_t seen_sighash; // effective sighash, for the "Signing rule" row
+ bool sighash_mixed; // signed inputs disagree -> shown as "Mixed"
+} tx_summary_t;
+
typedef struct {
tx_ux_warning_t warnings;
bool has_wallet_policy;
@@ -122,7 +140,16 @@ typedef struct {
char address_or_description[MAX_EXT_OUTPUT_SIMPLIFIED_NUMBER]
[MAX(MAX_ADDRESS_LENGTH_STR + 1, MAX_OPRETURN_OUTPUT_DESC_SIZE)];
char amount[MAX_EXT_OUTPUT_SIMPLIFIED_NUMBER][MAX_AMOUNT_LENGTH + 1];
- char fee[MAX_AMOUNT_LENGTH + 1];
+ char fee[MAX_AMOUNT_LENGTH + 1]; // formatted network fee (FULL only)
+ char net_amount[MAX_AMOUNT_LENGTH + 1]; // formatted |total_spent|, the "You spend/receive"
+ // value (NET_ONLY only)
+
+ tx_display_mode_t display_mode;
+ bool spent_is_receive; // for TX_DISPLAY_NET_ONLY: total_spent < 0
+ account_role_t account_role; // From / To / unknown for the account row
+ bool account_is_default; // default derivation vs registered policy, for the row label
+ uint32_t seen_sighash; // for the "Signing rule" row
+ bool sighash_mixed;
} ui_validate_transaction_state_t;
/**
@@ -176,7 +203,10 @@ bool ui_display_wallet_address(dispatcher_context_t *context,
bool ui_display_unusual_path(dispatcher_context_t *context, const char *bip32_path_str);
-void ui_prepare_authorize_wallet_spend(const char *wallet_name);
+void ui_prepare_authorize_wallet_spend(const char *wallet_name,
+ account_role_t account_role,
+ bool account_is_default,
+ const tx_summary_t *summary);
bool ui_warn_external_inputs(dispatcher_context_t *context);
@@ -190,16 +220,20 @@ void ui_warn_nondefault_sighash_disabled(dispatcher_context_t *context);
bool ui_warn_high_fee(dispatcher_context_t *context);
/* These 3 functions have to be called in following order:
- * 1. init - to initialize the transaction signature flow with basic parameters.
+ * 1. init - initialize the flow; the summary is applied here so the account row, the
+ * "Signing rule" line and the per-output page breaks are all available up front.
* 2. add - to add information for an output.
* 3. show - to actually start showing the transaction screens.
* These functions call respectively init, add and show functions from display_nbgl module.
*/
void ui_transaction_simplified_init(const char *wallet_policy_name,
unsigned int outputs_num,
- tx_ux_warning_t warnings);
+ tx_ux_warning_t warnings,
+ account_role_t account_role,
+ bool account_is_default,
+ const tx_summary_t *summary);
void ui_transaction_simplified_add(uint64_t amount, const char *address_or_description);
-bool ui_transaction_simplified_show(dispatcher_context_t *context, uint64_t fee);
+bool ui_transaction_simplified_show(dispatcher_context_t *context);
bool ui_transaction_streaming_prompt(dispatcher_context_t *context);
bool ui_transaction_streaming_validate_output(dispatcher_context_t *context,
@@ -208,7 +242,6 @@ bool ui_transaction_streaming_validate_output(dispatcher_context_t *context,
const char *address_or_description,
uint64_t amount);
bool ui_transaction_streaming_validate(dispatcher_context_t *context,
- uint64_t fee,
tx_ux_warning_t warnings,
bool is_self_transfer);
diff --git a/src/ui/display_nbgl.c b/src/ui/display_nbgl.c
index ef2cce0..fb70b29 100644
--- a/src/ui/display_nbgl.c
+++ b/src/ui/display_nbgl.c
@@ -54,12 +54,105 @@ const char GA_LOADING_TRANSACTION[] = "Loading transaction";
const char GA_SIGNING_TRANSACTION[] = "Signing transaction";
const char GA_LOADING_MESSAGE[] = "Loading message";
-#define N_UX_PAIRS 51
+// Non-default-sighash transaction summary labels (trustworthy-or-bust display)
+const char GA_FEE_NOT_AVAILABLE[] = "Not available";
+const char GA_YOU_SPEND[] = "You spend";
+const char GA_YOU_RECEIVE[] = "You receive";
+const char GA_AMOUNTS_UNAVAILABLE_TITLE[] = "Amounts & fees";
+#ifdef SCREEN_SIZE_WALLET
+const char GA_AMOUNTS_UNAVAILABLE[] =
+ "Cannot be verified for these signing rules. Only sign if you expected it and fully trust the "
+ "software wallet.";
+#else
+const char GA_AMOUNTS_UNAVAILABLE[] = "Cannot be verified\nReject if not sure";
+#endif
+const char GA_SIGNING_RULE_TITLE[] = "Signing rule";
+
+// Size of the tag/value pool for a transaction review; see the breakdown in the
+// static assert below (account row, per-output rows, fees, high-fee, "Signing rule").
+#define N_UX_PAIRS 52
static nbgl_layoutTagValue_t pairs[N_UX_PAIRS];
static unsigned int n_pairs;
static nbgl_layoutTagValueList_t pairList;
+// Account row label: direction (From/To/unknown) + type. "default" = a standard derivation,
+// "registered" = a registered policy. On Nano there's no room for the type, so we keep only
+// the direction.
+static const char *account_role_label(account_role_t role, bool is_default) {
+#ifdef SCREEN_SIZE_WALLET
+ switch (role) {
+ case ACCOUNT_ROLE_TO:
+ return is_default ? "To default account" : "To registered account";
+ case ACCOUNT_ROLE_UNKNOWN:
+ return is_default ? "Default account" : "Registered account";
+ default:
+ return is_default ? "From default account" : "From registered account";
+ }
+#else
+ (void) is_default;
+ switch (role) {
+ case ACCOUNT_ROLE_TO:
+ return "To account";
+ case ACCOUNT_ROLE_UNKNOWN:
+ return "Account";
+ default:
+ return "From account";
+ }
+#endif
+}
+
+// Name of the (uniform) sighash flag; NULL for default, "Mixed" if signed inputs disagree.
+static const char *signing_rule_str(uint32_t sighash, bool mixed) {
+ if (mixed) {
+ return "Mixed";
+ }
+ switch (sighash) {
+ case SIGHASH_NONE:
+ return "NONE";
+ case SIGHASH_SINGLE:
+ return "SINGLE";
+ case SIGHASH_ANYONECANPAY | SIGHASH_ALL:
+ return "ACP | ALL";
+ case SIGHASH_ANYONECANPAY | SIGHASH_NONE:
+ return "ACP | NONE";
+ case SIGHASH_ANYONECANPAY | SIGHASH_SINGLE:
+ return "ACP | SINGLE";
+ default:
+ return NULL;
+ }
+}
+
+// Appends the context pairs (account row + non-default "Signing rule") at idx, returns the
+// new count. Shared by the simplified and streaming flows.
+static unsigned int append_context_pairs(const ui_validate_transaction_state_t *state,
+ unsigned int idx) {
+ if (state->has_wallet_policy) {
+ pairs[idx++] = (nbgl_layoutTagValue_t) {
+ .item = account_role_label(state->account_role, state->account_is_default),
+ .value = state->wallet_policy_name,
+ };
+ }
+ const char *signing_rule = signing_rule_str(state->seen_sighash, state->sighash_mixed);
+ if (signing_rule != NULL) {
+ pairs[idx++] =
+ (nbgl_layoutTagValue_t) {.item = GA_SIGNING_RULE_TITLE, .value = signing_rule};
+ }
+ return idx;
+}
+
+// Appends the NET_ONLY money rows ("You spend/receive" + "Fees: Not available") at idx.
+static unsigned int append_net_only_pairs(const ui_validate_transaction_state_t *state,
+ unsigned int idx,
+ bool force_page) {
+ pairs[idx++] =
+ (nbgl_layoutTagValue_t) {.item = state->spent_is_receive ? GA_YOU_RECEIVE : GA_YOU_SPEND,
+ .value = state->net_amount,
+ .forcePageStart = force_page};
+ pairs[idx++] = (nbgl_layoutTagValue_t) {.item = "Fees", .value = GA_FEE_NOT_AVAILABLE};
+ return idx;
+}
+
extern bool G_was_processing_screen_shown;
static void finish_transaction_flow(bool choice);
@@ -157,19 +250,15 @@ static void start_transaction_callback(bool confirm) {
#define SELF_TRANSFER_DESCRIPTION COMBINE("0 ", COMBINE(COIN_COINID_SHORT, " (self-transfer)"))
void ui_display_transaction_simplified_flow_init(void) {
- /* 1 From + MAX_EXT_OUTPUT_SIMPLIFIED_NUMBER*3 + 1 Fees + 1 High fees */
- _Static_assert(N_UX_PAIRS >= (1 + MAX_EXT_OUTPUT_SIMPLIFIED_NUMBER * 3 + 1 + 1),
+ /* 1 From/To + MAX_EXT_OUTPUT_SIMPLIFIED_NUMBER*3 + 1 Fees + 1 High fees + 1 Signing rule */
+ _Static_assert(N_UX_PAIRS >= (1 + MAX_EXT_OUTPUT_SIMPLIFIED_NUMBER * 3 + 1 + 1 + 1),
"Insufficient pairs for this flow");
n_pairs = 0;
ui_validate_transaction_state_t *state = (ui_validate_transaction_state_t *) &g_ui_state;
- if (state->has_wallet_policy) {
- pairs[n_pairs++] = (nbgl_layoutTagValue_t) {
- .item = "From",
- .value = state->wallet_policy_name,
- };
- }
+ // Context page: account row + "Signing rule", so the caveat precedes any amount.
+ n_pairs = append_context_pairs(state, n_pairs);
}
void ui_display_transaction_simplified_flow_add(void) {
@@ -183,9 +272,12 @@ void ui_display_transaction_simplified_flow_add(void) {
.value = state->output_index_str[output_index],
.forcePageStart = true};
}
+ // Keep the single output off the context page (multi-output already breaks above).
+ bool force_output_page =
+ state->display_mode != TX_DISPLAY_FULL && state->n_outputs == 1 && output_index == 0;
pairs[n_pairs++] = (nbgl_layoutTagValue_t) {.item = "Amount",
.value = state->amount[output_index],
- .forcePageStart = false};
+ .forcePageStart = force_output_page};
pairs[n_pairs++] = (nbgl_layoutTagValue_t) {
.item = "To",
@@ -200,17 +292,27 @@ void ui_display_transaction_simplified_flow_add(void) {
void ui_display_transaction_simplified_flow_show(void) {
ui_validate_transaction_state_t *state = (ui_validate_transaction_state_t *) &g_ui_state;
- if (state->warnings.high_fee) {
- pairs[n_pairs++] = (nbgl_contentTagValue_t) {.item = GA_WARN_HIGH_FEES_TITLE,
- .value = GA_WARN_HIGH_FEES,
+ if (state->display_mode == TX_DISPLAY_UNAVAILABLE) {
+ // Only the notice (the "Signing rule" is on the context page); it must stay the last pair.
+ pairs[n_pairs++] = (nbgl_contentTagValue_t) {.item = GA_AMOUNTS_UNAVAILABLE_TITLE,
+ .value = GA_AMOUNTS_UNAVAILABLE,
.centeredInfo = true,
- .valueIcon = &ICON_APP_IMPORTANT};
+ .valueIcon = &ICON_APP_WARNING};
+ } else if (state->display_mode == TX_DISPLAY_NET_ONLY) {
+ // Money-summary page: the net "You spend/receive" + untrusted fee on their own page.
+ n_pairs = append_net_only_pairs(state, n_pairs, /* force_page */ true);
+ } else { // TX_DISPLAY_FULL
+ if (state->warnings.high_fee) {
+ pairs[n_pairs++] = (nbgl_contentTagValue_t) {.item = GA_WARN_HIGH_FEES_TITLE,
+ .value = GA_WARN_HIGH_FEES,
+ .centeredInfo = true,
+ .valueIcon = &ICON_APP_IMPORTANT};
+ }
+ pairs[n_pairs++] = (nbgl_layoutTagValue_t) {.item = "Fees",
+ .value = state->fee,
+ .forcePageStart = state->n_outputs > 1 ? 1 : 0};
}
- pairs[n_pairs++] = (nbgl_layoutTagValue_t) {.item = "Fees",
- .value = state->fee,
- .forcePageStart = state->n_outputs > 1 ? 1 : 0};
-
nbgl_useCaseReview(TYPE_TRANSACTION,
make_pair_list(n_pairs, false),
&ICON_APP_ACTION,
@@ -228,13 +330,11 @@ void ui_display_transaction_streaming_prompt(void) {
start_transaction_callback);
ui_validate_transaction_state_t *state = (ui_validate_transaction_state_t *) &g_ui_state;
- if (state->has_wallet_policy) {
- pairs[0] = (nbgl_layoutTagValue_t) {
- .item = "From",
- .value = state->wallet_policy_name,
- };
-
- nbgl_useCaseReviewStreamingContinue(make_pair_list(1, false), start_transaction_callback);
+ // Context page: account row + (non-default) "Signing rule", same as the simplified flow.
+ unsigned int l_n_pairs = append_context_pairs(state, 0);
+ if (l_n_pairs > 0) {
+ nbgl_useCaseReviewStreamingContinue(make_pair_list(l_n_pairs, false),
+ start_transaction_callback);
}
}
@@ -257,20 +357,23 @@ void ui_display_transaction_streaming_flow(bool is_self_transfer) {
unsigned int l_n_pairs = 0;
ui_validate_transaction_state_t *state = (ui_validate_transaction_state_t *) &g_ui_state;
- if (state->warnings.high_fee) {
+ // high-fee warning only applies when we actually have a fee
+ if (state->display_mode == TX_DISPLAY_FULL && state->warnings.high_fee) {
pairs[l_n_pairs++] = (nbgl_contentTagValue_t) {.item = GA_WARN_HIGH_FEES_TITLE,
.value = GA_WARN_HIGH_FEES,
.centeredInfo = true,
.valueIcon = &ICON_APP_IMPORTANT};
}
- if (!is_self_transfer) {
- pairs[l_n_pairs].item = "Fees";
- pairs[l_n_pairs++].value = state->fee;
- } else {
+ if (is_self_transfer) {
pairs[l_n_pairs].item = "Amount";
pairs[l_n_pairs++].value = "Self-transfer";
+ }
+ if (state->display_mode == TX_DISPLAY_NET_ONLY) {
+ // net "You spend/receive" + untrusted fee (the "Signing rule" is on the context page)
+ l_n_pairs = append_net_only_pairs(state, l_n_pairs, /* force_page */ false);
+ } else { // TX_DISPLAY_FULL
pairs[l_n_pairs].item = "Fees";
pairs[l_n_pairs++].value = state->fee;
}
Why this scored 59/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.