Display total amount of external inputs when reliable
What changed, and why it matters
This commit changes what information the Ledger Bitcoin app shows on its screen when signing a transaction that includes 'external inputs'—coins the user did not provide and whose amounts the device cannot independently verify. Previously the app only warned that such inputs exist. After this change, when the signature commits to the full set of inputs (i.e., no ANYONECANPAY sighash flag), the app also displays the total value of those external inputs and the net amount the user's wallet is spending or receiving. This is a user-interface improvement intended to reduce confusion, not a fix for a code-execution or theft vulnerability. The underlying security model—external inputs are still 'unverified'—is unchanged.
Treat as a routine UX improvement rather than a security patch. Reviewers may verify that the new rows are only rendered when show_external_inputs_amount is true (closed input set) and that the buffer-size static assertions cover the maximum number of displayed rows. No urgent user action is required.
Security signals we found
UI/UX hardening: additional contextual data shown for transactions with unverified external inputs
No change to cryptographic checks, sighash validation, or input verification
External inputs warning is preserved; new data is displayed only when the input set is closed (no ANYONECANPAY)
Static assertion for UI pair buffer size increased to accommodate new rows
No memory allocations or buffer writes beyond newly sized fields
Evidence from the diff
The patch extends the transaction summary UI in src/handler/sign_psbt/transaction_display.c and the NBGL/Bagl display code to compute and render two new values when external inputs are present and the input set is closed (sighash_inputs_open == false): external_inputs_amount (total of unverified inputs) and the signed net total_spent. It adds fields to tx_summary_t and ui_validate_transaction_state_t, formats the amounts, and appends rows such as ‘External inputs amount’ and ‘You spend/receive’ in both FULL and NET_ONLY display modes. The change is purely presentational: no signature validation, PSBT parsing, or key-handling logic is modified. The warning for external inputs remains, and the amount is only shown when it is cryptographically committed to by the signature (no ANYONECANPAY).
Changed components
Ledger Bitcoin app transaction signing UIsrc/handler/sign_psbt/transaction_display.csrc/ui/display.csrc/ui/display.hsrc/ui/display_nbgl.cInspect captured patch +126 / −17
diff --git a/src/handler/sign_psbt/transaction_display.c b/src/handler/sign_psbt/transaction_display.c
index c31c201..5175d5a 100644
--- a/src/handler/sign_psbt/transaction_display.c
+++ b/src/handler/sign_psbt/transaction_display.c
@@ -261,6 +261,19 @@ bool __attribute__((noinline)) display_transaction(
int64_t total_spent =
(int64_t) st->internal_inputs_total_amount - (int64_t) st->outputs.change_total_amount;
+ // Total of the external (unverified) inputs. When present, the outputs and fee alone don't tell
+ // the user how much really leaves (or enters) their wallet, so we additionally show this amount
+ // and the net `total_spent`. (inputs_total_amount always includes
+ // internal_inputs_total_amount.)
+ bool has_external_inputs = st->warnings.external_inputs;
+ uint64_t external_inputs_amount = st->inputs_total_amount - st->internal_inputs_total_amount;
+
+ // The external inputs total is only meaningful once their set is fixed: if any signed input is
+ // ANYONECANPAY the set is open and more inputs could be appended after signing. When closed,
+ // the amount is trustworthy regardless of the output/fee display mode, so we show it in
+ // NET_ONLY too.
+ bool show_external_inputs_amount = has_external_inputs && !st->sighash_inputs_open;
+
// 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) {
@@ -279,13 +292,19 @@ bool __attribute__((noinline)) display_transaction(
.fee = fee,
.total_spent = total_spent,
.seen_sighash = st->seen_sighash,
- .sighash_mixed = st->sighash_mixed};
+ .sighash_mixed = st->sighash_mixed,
+ .has_external_inputs = has_external_inputs,
+ .show_external_inputs_amount = show_external_inputs_amount,
+ .external_inputs_amount = external_inputs_amount};
// From when net-spending, To when net-receiving, unknown direction for UNAVAILABLE.
+ // The net amount is shown both in NET_ONLY and in FULL mode with external inputs.
+ bool net_amount_shown =
+ (mode == TX_DISPLAY_NET_ONLY) || (mode == TX_DISPLAY_FULL && has_external_inputs);
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) {
+ } else if (net_amount_shown && total_spent < 0) {
account_role = ACCOUNT_ROLE_TO;
}
@@ -331,9 +350,11 @@ 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;
+ // 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). Omit it when there are external
+ // inputs, since "You spend/receive" already states the net amount (and could be a receive).
+ bool show_self_transfer_row =
+ is_self_transfer && mode == TX_DISPLAY_FULL && !has_external_inputs;
// Number of amount rows shown: external outputs, or one "self-transfer" row (if any).
unsigned int n_output_rows = st->n_external_outputs;
diff --git a/src/ui/display.c b/src/ui/display.c
index 38e41f8..3b31103 100644
--- a/src/ui/display.c
+++ b/src/ui/display.c
@@ -295,8 +295,28 @@ static void prepare_tx_summary(ui_validate_transaction_state_t *state,
state->spent_is_receive = false;
state->seen_sighash = summary->seen_sighash;
state->sighash_mixed = summary->sighash_mixed;
+ state->has_external_inputs = summary->has_external_inputs;
+ state->show_external_inputs_amount = summary->show_external_inputs_amount;
+
+ // The "External inputs amount" row is shown in both FULL and NET_ONLY when the input set
+ // is closed, so format it up front (independently of the mode).
+ if (summary->show_external_inputs_amount) {
+ format_sats_amount(COIN_COINID_SHORT,
+ summary->external_inputs_amount,
+ state->unverified_inputs);
+ }
+
if (summary->mode == TX_DISPLAY_FULL) {
format_sats_amount(COIN_COINID_SHORT, summary->fee, state->fee);
+
+ if (summary->has_external_inputs) {
+ // Also show the net amount actually spent/received, so the outputs and fee alone don't
+ // mislead the user.
+ 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);
+ }
} 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
diff --git a/src/ui/display.h b/src/ui/display.h
index ffa2632..d53e41c 100644
--- a/src/ui/display.h
+++ b/src/ui/display.h
@@ -124,9 +124,21 @@ typedef enum {
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)
+ int64_t total_spent; // net amount leaving the account (negative = net receive); used for
+ // TX_DISPLAY_NET_ONLY and, in FULL mode, when there are external inputs
uint32_t seen_sighash; // effective sighash, for the "Signing rule" row
bool sighash_mixed; // signed inputs disagree -> shown as "Mixed"
+
+ // With external (unverified) inputs, the outputs and fee alone don't tell the user how much
+ // really leaves (or enters) their wallet, so we additionally show the net amount actually
+ // spent/received (has_external_inputs, FULL mode) and the total amount of the external inputs.
+ bool has_external_inputs;
+ // Whether to show the "External inputs amount" row: only when there are external inputs
+ // *and* the input set is closed (no ANYONECANPAY), so their total is fixed after signing.
+ // This is trustworthy independently of the output/fee display mode, hence shown in both FULL
+ // and NET_ONLY.
+ bool show_external_inputs_amount;
+ uint64_t external_inputs_amount; // total amount of the external inputs
} tx_summary_t;
typedef struct {
@@ -142,10 +154,14 @@ typedef struct {
char amount[MAX_EXT_OUTPUT_SIMPLIFIED_NUMBER][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)
+ // value (NET_ONLY, or FULL with external inputs)
+
+ char unverified_inputs[MAX_AMOUNT_LENGTH + 1]; // formatted external_inputs_amount
+ bool has_external_inputs; // net "You spend/receive" row in FULL mode
+ bool show_external_inputs_amount; // "External inputs amount" row (FULL and NET_ONLY)
tx_display_mode_t display_mode;
- bool spent_is_receive; // for TX_DISPLAY_NET_ONLY: total_spent < 0
+ bool spent_is_receive; // for NET_ONLY, or FULL with external inputs: 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
diff --git a/src/ui/display_nbgl.c b/src/ui/display_nbgl.c
index 52e802e..18a5775 100644
--- a/src/ui/display_nbgl.c
+++ b/src/ui/display_nbgl.c
@@ -68,9 +68,17 @@ 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
+// Shown in FULL mode, and in NET_ONLY with a closed input set, when the transaction has external
+// (unverified) inputs.
+#ifdef SCREEN_SIZE_WALLET
+const char GA_UNVERIFIED_INPUTS_TITLE[] = "External inputs amount";
+#else
+const char GA_UNVERIFIED_INPUTS_TITLE[] = "External amounts";
+#endif
+
+// Size of the tag/value pool for a transaction review; see the breakdown in the static assert
+// below (account row, per-output rows, external-inputs rows, fees, high-fee, "Signing rule").
+#define N_UX_PAIRS 54
static nbgl_layoutTagValue_t pairs[N_UX_PAIRS];
static unsigned int n_pairs;
@@ -153,6 +161,28 @@ static unsigned int append_net_only_pairs(const ui_validate_transaction_state_t
return idx;
}
+// Appends the "External inputs amount" row (external inputs total, closed input set).
+static unsigned int append_external_amounts_row(const ui_validate_transaction_state_t *state,
+ unsigned int idx,
+ bool force_page) {
+ pairs[idx++] = (nbgl_layoutTagValue_t) {.item = GA_UNVERIFIED_INPUTS_TITLE,
+ .value = state->unverified_inputs,
+ .forcePageStart = force_page};
+ return idx;
+}
+
+// Appends, for a FULL-mode transaction with external inputs, the "External inputs amount" row
+// and the net "You spend/receive" row. The trustworthy "Fees" row is still added by the caller.
+static unsigned int append_external_inputs_pairs(const ui_validate_transaction_state_t *state,
+ unsigned int idx,
+ bool force_page) {
+ idx = append_external_amounts_row(state, idx, force_page);
+ pairs[idx++] =
+ (nbgl_layoutTagValue_t) {.item = state->spent_is_receive ? GA_YOU_RECEIVE : GA_YOU_SPEND,
+ .value = state->net_amount};
+ return idx;
+}
+
extern bool G_was_processing_screen_shown;
static void finish_transaction_flow(bool choice);
@@ -250,8 +280,10 @@ 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/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),
+ /* 1 From/To + MAX_EXT_OUTPUT_SIMPLIFIED_NUMBER*3 + 2 external-inputs rows + 1 Fees + 1 High
+ * fees
+ * + 1 Signing rule */
+ _Static_assert(N_UX_PAIRS >= (1 + MAX_EXT_OUTPUT_SIMPLIFIED_NUMBER * 3 + 2 + 1 + 1 + 1),
"Insufficient pairs for this flow");
n_pairs = 0;
@@ -299,8 +331,14 @@ void ui_display_transaction_simplified_flow_show(void) {
.centeredInfo = true,
.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);
+ // Money-summary page: with external inputs (closed set) the "External inputs amount"
+ // starts the page, followed by the net "You spend/receive" + untrusted fee.
+ if (state->show_external_inputs_amount) {
+ n_pairs = append_external_amounts_row(state, n_pairs, /* force_page */ true);
+ n_pairs = append_net_only_pairs(state, n_pairs, /* force_page */ false);
+ } else {
+ 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,
@@ -308,9 +346,15 @@ void ui_display_transaction_simplified_flow_show(void) {
.centeredInfo = true,
.valueIcon = &ICON_APP_IMPORTANT};
}
+ bool force_summary_page = state->n_outputs > 1;
+ if (state->has_external_inputs) {
+ // "External inputs amount" + net "You spend/receive", then the fee flows after.
+ n_pairs = append_external_inputs_pairs(state, n_pairs, force_summary_page);
+ force_summary_page = false;
+ }
pairs[n_pairs++] = (nbgl_layoutTagValue_t) {.item = "Fees",
.value = state->fee,
- .forcePageStart = state->n_outputs > 1 ? 1 : 0};
+ .forcePageStart = force_summary_page};
}
nbgl_useCaseReview(TYPE_TRANSACTION,
@@ -371,9 +415,17 @@ void ui_display_transaction_streaming_flow(bool is_self_transfer) {
}
if (state->display_mode == TX_DISPLAY_NET_ONLY) {
- // net "You spend/receive" + untrusted fee (the "Signing rule" is on the context page)
+ // "External inputs amount" (closed set) + net "You spend/receive" + untrusted fee
+ // (the "Signing rule" is on the context page)
+ if (state->show_external_inputs_amount) {
+ l_n_pairs = append_external_amounts_row(state, l_n_pairs, /* force_page */ false);
+ }
l_n_pairs = append_net_only_pairs(state, l_n_pairs, /* force_page */ false);
} else { // TX_DISPLAY_FULL
+ if (state->has_external_inputs) {
+ // "External inputs amount" + net "You spend/receive", then the trustworthy fee.
+ l_n_pairs = append_external_inputs_pairs(state, l_n_pairs, /* force_page */ false);
+ }
pairs[l_n_pairs].item = "Fees";
pairs[l_n_pairs++].value = state->fee;
}
Why this scored 21/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.