sign_tx: split out signing tx validation, add more checks
What changed, and why it matters
This commit reorganizes how Blockstream Jade validates transaction data before signing. It moves several Liquid-network checks earlier in the process and adds new rules: a transaction output with no recipient script (typically a network fee) must now be unblinded, non-zero, use the network's official policy asset, and there can only be one such fee output. The change also prevents a transaction from being silently treated as a different network type (Bitcoin vs. Liquid). These are defensive hardening measures rather than a fix for a confirmed active attack.
Review the new `params_txn_validate()` logic for completeness and ensure it is invoked consistently for all signing paths, including any future multi-signature or PSBT flows. Consider whether additional tests cover the new fee-output rejection cases. No urgent patch is indicated by the commit alone.
Security signals we found
Hardening: earlier and stricter validation of transaction structure before signing
New check: fee outputs must be explicit/unblinded on Liquid
New check: fee outputs must use the network policy asset
New check: fee outputs must be non-zero
New check: only one fee output allowed
Refactor: fee extraction moved from output validation to initial transaction parsing
No CVE, advisory, or researcher attribution present in commit or supplied references
Evidence from the diff
The patch splits transaction validation into a new params_txn_validate() helper in sign_utils.c and calls it immediately after parsing the transaction in params_txn(). For Liquid transactions it now: (1) verifies the parsed tx’s elements flag matches the requested network; (2) checks each output’s asset and value explicit-prefix consistency; (3) enforces that any output without a script is an explicit (unblinded) fee output with non-zero value and the correct policy asset; and (4) rejects multiple fee outputs. The previous fee-collection logic is removed from validate_elements_outputs(), which no longer takes a fees pointer. The explicit fee is now computed during initial validation and passed back to the caller.
Changed components
main/process/sign_tx.cmain/process/sign_utils.cmain/process/sign_utils.hInspect captured patch +81 / −49
diff --git a/main/process/sign_tx.c b/main/process/sign_tx.c
index 26a51fd..2cb87eb 100644
--- a/main/process/sign_tx.c
+++ b/main/process/sign_tx.c
@@ -23,8 +23,8 @@
#include "sign_utils.h"
-static struct wally_tx* params_txn(
- jade_process_t* process, const CborValue* params, const network_t network_id, const bool for_liquid)
+static struct wally_tx* params_txn(jade_process_t* process, const CborValue* params, const network_t network_id,
+ const bool for_liquid, uint64_t* explicit_fee)
{
struct wally_tx* tx = NULL;
const char* errmsg = NULL;
@@ -72,22 +72,7 @@ static struct wally_tx* params_txn(
errmsg = "Unexpected number of inputs for transaction";
goto fail;
}
-
- if (for_liquid) {
- for (size_t i = 0; i < tx->num_outputs; ++i) {
- bool exp_asset = tx->outputs[i].asset[0] == WALLY_TX_ASSET_CT_EXPLICIT_PREFIX;
- bool exp_value = tx->outputs[i].value[0] == WALLY_TX_ASSET_CT_EXPLICIT_PREFIX;
- if (exp_asset != exp_value) {
- errmsg = "Output asset and value blinding inconsistent";
- goto fail;
- }
- }
- }
-
- size_t is_elements = 0;
- JADE_WALLY_VERIFY(wally_tx_is_elements(tx, &is_elements));
- if (for_liquid != is_elements) {
- errmsg = "Transaction is the wrong type for the current network";
+ if (!params_txn_validate(network_id, for_liquid, tx, explicit_fee, &errmsg)) {
goto fail;
}
return tx;
@@ -458,7 +443,8 @@ static void sign_tx_impl(jade_process_t* process, const bool for_liquid)
CHECK_NETWORK_CONSISTENT(process);
const jade_msg_source_t source = process->ctx.source;
- struct wally_tx* tx = params_txn(process, ¶ms, network_id, for_liquid);
+ uint64_t explicit_fee = 0;
+ struct wally_tx* tx = params_txn(process, ¶ms, network_id, for_liquid, &explicit_fee);
if (!tx) {
goto cleanup;
}
@@ -497,7 +483,6 @@ static void sign_tx_impl(jade_process_t* process, const bool for_liquid)
asset_summary_t *in_sums = NULL, *out_sums = NULL;
size_t num_in_sums = 0, num_out_sums = 0;
bool is_partial = false;
- uint64_t explicit_fee = 0;
TxType_t txtype = TXTYPE_SEND_PAYMENT;
// Liquid: Get any data from the optional 'additional_info' section
if (for_liquid
@@ -508,8 +493,8 @@ static void sign_tx_impl(jade_process_t* process, const bool for_liquid)
// Liquid: Validate commitment, outputs and additional_info
if (for_liquid
- && !validate_elements_outputs(process, network_id, tx, txtype, commitments, output_info, in_sums, num_in_sums,
- out_sums, num_out_sums, &explicit_fee)) {
+ && !validate_elements_outputs(
+ process, network_id, tx, txtype, commitments, output_info, in_sums, num_in_sums, out_sums, num_out_sums)) {
goto cleanup;
}
diff --git a/main/process/sign_utils.c b/main/process/sign_utils.c
index 763548a..1485a74 100644
--- a/main/process/sign_utils.c
+++ b/main/process/sign_utils.c
@@ -16,6 +16,75 @@
static const char TX_TYPE_STR_SWAP[] = "swap";
static const char TX_TYPE_STR_SEND_PAYMENT[] = "send_payment";
+bool params_txn_validate(const network_t network_id, const bool for_liquid, const struct wally_tx* const tx,
+ uint64_t* explicit_fee, const char** errmsg)
+{
+ JADE_ASSERT(tx);
+ JADE_ASSERT(explicit_fee);
+ JADE_INIT_OUT_PPTR(errmsg);
+
+ size_t is_elements = 0;
+ JADE_WALLY_VERIFY(wally_tx_is_elements(tx, &is_elements));
+ if (for_liquid != is_elements) {
+ *errmsg = "Transaction is the wrong type for the current network";
+ return false;
+ }
+
+ if (!for_liquid) {
+ return true; // Bitcoin: No further checks needed
+ }
+
+ // Liquid checks
+ uint8_t policy_asset[ASSET_TAG_LEN];
+ network_to_policy_asset(network_id, policy_asset, sizeof(policy_asset));
+ reverse_in_place(policy_asset, sizeof(policy_asset));
+
+ for (size_t i = 0; i < tx->num_outputs; ++i) {
+ const struct wally_tx_output* const txout = tx->outputs + i;
+ JADE_ASSERT(txout->asset && txout->asset_len == WALLY_TX_ASSET_CT_LEN);
+ JADE_ASSERT(txout->value && txout->value_len);
+ const uint8_t explicit_prefix = WALLY_TX_ASSET_CT_EXPLICIT_PREFIX;
+ const bool is_explicit_asset = txout->asset[0] == explicit_prefix;
+ const bool is_explicit_value = txout->value[0] == explicit_prefix;
+
+ if (is_explicit_asset != is_explicit_value) {
+ *errmsg = "Output asset and value blinding inconsistent";
+ return false;
+ }
+ if (is_explicit_value) {
+ JADE_ASSERT(txout->value_len == WALLY_TX_ASSET_CT_VALUE_UNBLIND_LEN);
+ } else {
+ JADE_ASSERT(txout->value_len == WALLY_TX_ASSET_CT_VALUE_LEN);
+ }
+
+ if (tx->outputs[i].script) {
+ continue; // Not a fee output, no further checks needed
+ }
+
+ // Fee output
+ if (!is_explicit_asset || !is_explicit_value) {
+ *errmsg = "Fee output (without script) cannot be blinded";
+ return false;
+ }
+ if (*explicit_fee) {
+ *errmsg = "Unexpected multiple fee outputs (without script)";
+ return false;
+ }
+ JADE_WALLY_VERIFY(wally_tx_confidential_value_to_satoshi(txout->value, txout->value_len, explicit_fee));
+ if (!*explicit_fee) {
+ *errmsg = "Fee output (without script) cannot be 0";
+ return false;
+ }
+ // Note we compare the asset id ignoring the initial explicit byte
+ if (memcmp(txout->asset + 1, policy_asset, sizeof(policy_asset))) {
+ *errmsg = "Unexpected fee output (without script) asset-id";
+ return false;
+ }
+ }
+
+ return true;
+}
+
// Map a txtype string to an enum value
#define TX_TYPE_STR_MATCH(typestr) ((len == sizeof(typestr) - 1 && !strncmp(type, typestr, sizeof(typestr) - 1)))
static bool rpc_get_txtype(jade_process_t* process, CborValue* value, TxType_t* txtype)
@@ -549,15 +618,13 @@ static bool add_output_info(
bool validate_elements_outputs(jade_process_t* process, const network_t network_id, const struct wally_tx* tx,
const TxType_t txtype, commitment_t* commitments, output_info_t* output_info, asset_summary_t* in_sums,
- const size_t num_in_sums, asset_summary_t* out_sums, const size_t num_out_sums, uint64_t* fees)
+ const size_t num_in_sums, asset_summary_t* out_sums, const size_t num_out_sums)
{
JADE_ASSERT(tx);
JADE_ASSERT(commitments);
JADE_ASSERT(output_info);
- JADE_ASSERT(fees);
const char* errmsg = NULL;
- *fees = 0;
uint8_t policy_asset[ASSET_TAG_LEN];
network_to_policy_asset(network_id, policy_asset, sizeof(policy_asset));
@@ -570,7 +637,6 @@ bool validate_elements_outputs(jade_process_t* process, const network_t network_
// By default/in the basic 'send payment' case all outputs must have unconfidential/unblinded.
const bool allow_blind_outputs = txtype == TXTYPE_SWAP; // swaps allow 'other wallets' blind outputs
- // Save fees for the final confirmation screen
for (size_t i = 0; i < tx->num_outputs; ++i) {
// Gather the (unblinded) output info for user confirmation
output_info_t* outinfo = output_info + i;
@@ -586,28 +652,6 @@ bool validate_elements_outputs(jade_process_t* process, const network_t network_
}
}
- // Collect fees (ie. outputs with no script)
- // NOTE: fees must be unconfidential, and must be denominated in the policy asset
- if (!tx->outputs[i].script) {
- if (outinfo->flags & OUTPUT_FLAG_CONFIDENTIAL) {
- errmsg = "Fee output (without script) cannot be blinded";
- goto done;
- }
- if (memcmp(outinfo->asset_id, policy_asset, sizeof(policy_asset))) {
- errmsg = "Unexpected fee output (without script) asset-id";
- goto done;
- }
- if (!outinfo->value) {
- errmsg = "Fee output (without script) cannot be 0";
- goto done;
- }
- if (*fees) {
- errmsg = "Unexpected multiple fee outputs (without script)";
- goto done;
- }
- *fees += outinfo->value;
- }
-
// If the output has been verified as belonging to this wallet, we can
// use it to validate some part of any passed input- or output- summary.
if (outinfo->flags & OUTPUT_FLAG_VALIDATED) {
diff --git a/main/process/sign_utils.h b/main/process/sign_utils.h
index d3162f5..48177ee 100644
--- a/main/process/sign_utils.h
+++ b/main/process/sign_utils.h
@@ -19,6 +19,9 @@ typedef struct _asset_summary {
uint64_t validated_value;
} asset_summary_t;
+bool params_txn_validate(network_t network_id, bool for_liquid, const struct wally_tx* const tx, uint64_t* explicit_fee,
+ const char** errmsg);
+
bool params_trusted_commitments(
jade_process_t* process, const CborValue* params, const struct wally_tx* tx, commitment_t** data);
@@ -35,7 +38,7 @@ bool asset_summary_validate(asset_summary_t* sums, size_t num_sums);
bool validate_elements_outputs(jade_process_t* process, network_t network_id, const struct wally_tx* tx,
TxType_t txtype, commitment_t* commitments, output_info_t* output_info, asset_summary_t* in_sums,
- size_t num_in_sums, asset_summary_t* out_sums, size_t num_out_sums, uint64_t* fees);
+ size_t num_in_sums, asset_summary_t* out_sums, size_t num_out_sums);
bool show_btc_fee_confirmation_activity(network_t network_id, const struct wally_tx* tx, const output_info_t* outinfo,
script_flavour_t aggregate_inputs_scripts_flavour, uint64_t input_amount, uint64_t output_amount);
Why this scored 57/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.