sign_psbt: add support for additional_info/swap signing
What changed, and why it matters
This commit adds Liquid swap signing support for PSBTs in the Blockstream Jade hardware wallet firmware. It also restructures cleanup paths so memory is freed correctly when validation fails. The change is a feature addition with defensive cleanup improvements, not a clear-cut security fix, but it touches code that protects user funds during transaction signing.
Review the new params_additional_info parsing and validate_elements_outputs() integration for correctness, especially around partial transactions and fee-skipping logic. Ensure that passing NULL process/params from QR/USB modes cannot be bypassed by a malicious caller. Verify that the cleanup_tx path correctly frees all newly allocated structures under every error condition.
Security signals we found
Feature expansion of transaction signing path handling swap/partial Liquid transactions
Refactored error handling and cleanup labels to prevent memory leaks on validation failure
QR/USB PSBT entry points explicitly pass NULL for additional_info, limiting swap signing to wired/JSON-RPC-style callers
Adds validation of asset summaries and commitments before user confirmation and signing
Prior code returned an error for swap PSBT signing; this commit removes that hard block
Evidence from the diff
The commit extends sign_psbt() to accept the jade_process_t and CBOR params pointers so it can parse optional ‘additional_info’ for Liquid transactions, mirroring the existing sign_tx path. It enables TXTYPE_SWAP and partial transaction flows for PSBTs, validates input/output asset summaries, and shows the swap confirmation UI. The prior code hard-disabled swap PSBT signing with a #if 0 block returning CBOR_RPC_BAD_PARAMETERS. Error handling was also refactored: a new cleanup_tx label ensures the extracted wally_tx is freed even when early validation fails, and the renamed psbt_update_outputs no longer implies full validation. QR and USB PSBT modes pass NULL process/params because they cannot carry additional_info, explicitly disabling swap signing over those channels.
Changed components
main/process/sign_psbt.cmain/process/sign_tx.cmain/qrmode.cmain/usbhmsc/usbmode.cInspect captured patch +74 / −40
diff --git a/main/process/sign_psbt.c b/main/process/sign_psbt.c
index 88d1b22..3fb6d94 100644
--- a/main/process/sign_psbt.c
+++ b/main/process/sign_psbt.c
@@ -448,7 +448,7 @@ static bool get_suitable_descriptor_record(const key_iter* iter, const uint32_t*
}
// Examine outputs for change we can automatically validate
-static bool validate_outputs(const network_t network_id, struct wally_psbt* psbt, const uint8_t signing_flags,
+static bool psbt_update_outputs(const network_t network_id, struct wally_psbt* psbt, const uint8_t signing_flags,
const char* wallet_name, const multisig_data_t* multisig_data, const descriptor_data_t* descriptor,
output_info_t* output_info, const char** errmsg)
{
@@ -653,7 +653,8 @@ static bool validate_outputs(const network_t network_id, struct wally_psbt* psbt
// Sign a psbt/pset - the passed wally psbt struct is updated with any signatures.
// Returns 0 if no errors occurred - does not necessarily indicate that signatures were added.
// Returns an rpc/message error code on error, and the error string should be populated.
-int sign_psbt(const network_t network_id, struct wally_psbt* psbt, const char** errmsg)
+int sign_psbt(jade_process_t* process, CborValue* params, const network_t network_id, struct wally_psbt* psbt,
+ const char** errmsg)
{
JADE_ASSERT(psbt);
JADE_INIT_OUT_PPTR(errmsg);
@@ -666,9 +667,8 @@ int sign_psbt(const network_t network_id, struct wally_psbt* psbt, const char**
*errmsg = "Network/psbt type mismatch";
return CBOR_RPC_BAD_PARAMETERS;
}
+ const bool for_liquid = is_elements;
- const TxType_t txtype = TXTYPE_SEND_PAYMENT; // FIXME: Liquid: assumed for now
- const bool is_partial = false; // FIXME: Liquid: assumed for now
uint64_t explicit_fee = 0; // Liquid: Value of the explicit fee output
struct wally_tx* tx = NULL; // Holds the extracted tx
@@ -683,7 +683,20 @@ int sign_psbt(const network_t network_id, struct wally_psbt* psbt, const char**
if (!params_txn_validate(network_id, for_liquid, tx, &explicit_fee, errmsg)) {
retval = CBOR_RPC_BAD_PARAMETERS;
- goto cleanup;
+ goto cleanup_tx;
+ }
+
+ asset_summary_t *in_sums = NULL, *out_sums = NULL;
+ size_t num_in_sums = 0, num_out_sums = 0;
+ bool is_partial = false;
+ TxType_t txtype = TXTYPE_SEND_PAYMENT;
+ // Liquid: Get any data from the optional 'additional_info' section
+ if (for_liquid && process && params
+ && !params_additional_info(
+ process, params, tx, &txtype, &is_partial, &in_sums, &num_in_sums, &out_sums, &num_out_sums)) {
+ // Note in_sums/out_sums are cleared automatically at proces exit
+ retval = CBOR_RPC_BAD_PARAMETERS;
+ goto cleanup_tx;
}
key_iter iter; // Holds any public/private key in use
@@ -716,7 +729,7 @@ int sign_psbt(const network_t network_id, struct wally_psbt* psbt, const char**
retval = CBOR_RPC_BAD_PARAMETERS;
goto cleanup;
}
- if (!is_elements) {
+ if (!for_liquid) {
// Bitcoin: Collect input total for fee calculation
input_amount += utxo->satoshi;
}
@@ -742,7 +755,7 @@ int sign_psbt(const network_t network_id, struct wally_psbt* psbt, const char**
}
// Check sighash, but only if one was provided (the default is always valid)
- if (input->sighash && !sighash_is_supported(txtype, sig_type, input->sighash, is_elements, is_partial)) {
+ if (input->sighash && !sighash_is_supported(txtype, sig_type, input->sighash, for_liquid, is_partial)) {
JADE_LOGW("Unsupported sighash for signing input %u", index);
*errmsg = "Unsupported sighash value";
retval = CBOR_RPC_BAD_PARAMETERS;
@@ -816,7 +829,7 @@ int sign_psbt(const network_t network_id, struct wally_psbt* psbt, const char**
}
// NOTE: descriptors not supported for elements atm
- if (!multisig_data && !is_elements) {
+ if (!multisig_data && !for_liquid) {
descriptor = JADE_MALLOC(sizeof(descriptor_data_t));
if (get_suitable_descriptor_record(&iter, &path[path_tail_start], path_tail_len, utxo->script,
utxo->script_len, network_id, wallet_name, sizeof(wallet_name), descriptor)) {
@@ -839,7 +852,7 @@ int sign_psbt(const network_t network_id, struct wally_psbt* psbt, const char**
} // iterate keys
// Examine outputs for liquid unblinded info and fees, and for change we can automatically validate
- if (!validate_outputs(
+ if (!psbt_update_outputs(
network_id, psbt, signing_flags, wallet_name, multisig_data, descriptor, output_info, errmsg)) {
// errmsg will be populated
retval = CBOR_RPC_BAD_PARAMETERS;
@@ -847,27 +860,38 @@ int sign_psbt(const network_t network_id, struct wally_psbt* psbt, const char**
}
// Explicit fee is only valid for Liquid
- JADE_ASSERT(!explicit_fee || is_elements);
+ JADE_ASSERT(!explicit_fee || for_liquid);
+
+ if (for_liquid) {
+ // Liquid: Validate commitments, outputs and additional_info
+ if (!validate_elements_outputs(
+ network_id, tx, txtype, output_info, in_sums, num_in_sums, out_sums, num_out_sums, errmsg)) {
+ retval = CBOR_RPC_BAD_PARAMETERS;
+ goto cleanup;
+ }
+
+ // Liquid: Check the summary information for each asset as previously confirmed
+ // by the user is consistent with the verified input and outputs.
+ if (!asset_summary_validate(in_sums, num_in_sums) || !asset_summary_validate(out_sums, num_out_sums)) {
+ JADE_LOGW("Failed to fully validate input and output summary information");
+ *errmsg = "Failed to validate input/output summary information";
+ retval = CBOR_RPC_BAD_PARAMETERS;
+ goto cleanup;
+ } else if (in_sums || out_sums) {
+ JADE_LOGI("Input and output summary information validated");
+ }
- if (is_elements) {
const asset_info_t* assets = NULL;
const size_t num_assets = 0;
if (txtype == TXTYPE_SWAP) {
-#if 0
- // FIXME: Support swaps/partial txs
- // Confirm wallet-summary info (ie. net inputs and outputs)
- if (!show_elements_swap_activity(network_id, is_partial, in_sums, num_in_sums,
- out_sums, num_out_sums, assets, num_assets)) {
- *errmsg = "User declined to sign psbt";
+ // Liquid: Confirm wallet-summary info (ie. net inputs and outputs)
+ if (!show_elements_swap_activity(
+ network_id, is_partial, in_sums, num_in_sums, out_sums, num_out_sums, assets, num_assets)) {
+ *errmsg = "User declined to sign swap transaction";
retval = CBOR_RPC_USER_CANCELLED;
goto cleanup;
}
-#else
- *errmsg = "Swap psbt signing is not yet supported";
- retval = CBOR_RPC_BAD_PARAMETERS;
- goto cleanup;
-#endif
} else {
// Confirm all non-change outputs
if (!show_elements_transaction_outputs_activity(network_id, tx, output_info, assets, num_assets)) {
@@ -878,21 +902,26 @@ int sign_psbt(const network_t network_id, struct wally_psbt* psbt, const char**
}
JADE_LOGD("User accepted outputs");
- // User to agree fee amount
- // Check to see whether user accepted or declined
- if (!show_elements_fee_confirmation_activity(
- network_id, tx, output_info, aggregate_inputs_scripts_flavour, explicit_fee, txtype, is_partial)) {
- *errmsg = "User declined to sign psbt";
- retval = CBOR_RPC_USER_CANCELLED;
- goto cleanup;
+ if (is_partial && !explicit_fee) {
+ // Partial tx without fees - can skip the fee screen
+ JADE_LOGI("No fees for partial tx, so skipping fee confirmation screen");
+ } else {
+ // User to agree fee amount
+ // Check to see whether user accepted or declined
+ if (!show_elements_fee_confirmation_activity(
+ network_id, tx, output_info, aggregate_inputs_scripts_flavour, explicit_fee, txtype, is_partial)) {
+ *errmsg = "User declined to sign psbt";
+ retval = CBOR_RPC_USER_CANCELLED;
+ goto cleanup;
+ }
+ JADE_LOGD("User accepted fee");
}
- JADE_LOGD("User accepted fee");
} else {
// Bitcoin: Sanity check amounts
uint64_t output_amount;
JADE_WALLY_VERIFY(wally_tx_get_total_output_satoshi(tx, &output_amount));
if (output_amount > input_amount) {
- *errmsg = "Invalid input/output amounts";
+ *errmsg = "Total input amounts less than total output amounts";
retval = CBOR_RPC_BAD_PARAMETERS;
goto cleanup;
}
@@ -977,13 +1006,14 @@ int sign_psbt(const network_t network_id, struct wally_psbt* psbt, const char**
cleanup:
SENSITIVE_POP(&iter);
- if (tx && psbt->version != WALLY_PSBT_VERSION_0) {
- JADE_WALLY_VERIFY(wally_tx_free(tx));
- }
free(descriptor);
free(multisig_data);
free(sig_types);
free(output_info);
+cleanup_tx:
+ if (tx && psbt->version != WALLY_PSBT_VERSION_0) {
+ JADE_WALLY_VERIFY(wally_tx_free(tx));
+ }
return retval;
}
@@ -1094,7 +1124,7 @@ void sign_psbt_process(void* process_ptr)
// Sign the psbt - parameter updated with any signatures
const char* errmsg = NULL;
- const int errcode = sign_psbt(network_id, psbt, &errmsg);
+ const int errcode = sign_psbt(process, ¶ms, network_id, psbt, &errmsg);
if (errcode) {
jade_process_reject_message(process, errcode, errmsg);
goto cleanup;
diff --git a/main/process/sign_tx.c b/main/process/sign_tx.c
index 88f290d..487b9a5 100644
--- a/main/process/sign_tx.c
+++ b/main/process/sign_tx.c
@@ -861,7 +861,7 @@ static void sign_tx_impl(jade_process_t* process, const bool for_liquid)
JADE_LOGI("Input and output summary information validated");
}
if (is_partial && !explicit_fee) {
- // Partial tx without fees - can skip the fee screen ?
+ // Partial tx without fees - can skip the fee screen
JADE_LOGI("No fees for partial tx, so skipping fee confirmation screen");
} else {
// User to agree fee amount
diff --git a/main/qrmode.c b/main/qrmode.c
index 917d97c..200654f 100644
--- a/main/qrmode.c
+++ b/main/qrmode.c
@@ -89,7 +89,8 @@ bool select_registered_wallet(const char multisig_names[][NVS_KEY_NAME_MAX_SIZE]
// PSBT struct and functions
struct wally_psbt;
-int sign_psbt(const network_t network_id, struct wally_psbt* psbt, const char** errmsg);
+int sign_psbt(
+ jade_process_t* process, CborValue* params, network_t network_id, struct wally_psbt* psbt, const char** errmsg);
int wally_psbt_free(struct wally_psbt* psbt);
#define EXPORT_XPUB_PATH_LEN 4
@@ -1138,7 +1139,8 @@ static bool parse_sign_display_bcur_psbt_qr(const uint8_t* cbor, const size_t cb
} else {
network_id = NETWORK_BITCOIN;
}
- const int errcode = sign_psbt(network_id, psbt, &errmsg);
+ // Note we pass NULL process/params as we don't have any additional info
+ const int errcode = sign_psbt(NULL, NULL, network_id, psbt, &errmsg);
if (errcode) {
if (errcode != CBOR_RPC_USER_CANCELLED) {
const char* message[] = { errmsg };
diff --git a/main/usbhmsc/usbmode.c b/main/usbhmsc/usbmode.c
index a044950..6d1a9f9 100644
--- a/main/usbhmsc/usbmode.c
+++ b/main/usbhmsc/usbmode.c
@@ -29,7 +29,8 @@ void await_qr_help_activity(const char* url);
// PSBT serialisation functions
bool deserialise_psbt(const uint8_t* bytes, size_t bytes_len, struct wally_psbt** psbt_out);
bool serialise_psbt(const struct wally_psbt* psbt, uint8_t** output, size_t* output_len);
-int sign_psbt(const network_t network_id, struct wally_psbt* psbt, const char** errmsg);
+int sign_psbt(
+ jade_process_t* process, CborValue* params, network_t network_id, struct wally_psbt* psbt, const char** errmsg);
#define MAX_FILENAME_SIZE 256
#define MAX_FILE_ENTRIES 64
@@ -833,7 +834,8 @@ static bool sign_usb_psbt(const usbstorage_action_context_t* ctx)
} else {
network_id = NETWORK_BITCOIN;
}
- const int errcode = sign_psbt(network_id, psbt, &errmsg);
+ // Note we pass NULL process/params as we don't have any additional info
+ const int errcode = sign_psbt(NULL, NULL, network_id, psbt, &errmsg);
if (errcode) {
if (errcode != CBOR_RPC_USER_CANCELLED) {
const char* message[] = { errmsg };
Why this scored 35/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.