psbt: return an error if fetching output_amount when not present
What changed, and why it matters
This commit changes a library function so that it returns an error when asked for an output amount that is not actually present, instead of silently returning zero. This prevents callers from accidentally treating a missing amount as a real zero-value output, which could lead to incorrect transaction handling in Bitcoin/Elements PSBT workflows.
Treat this as a hardening/bug-fix change. Downstream projects using wally_psbt_get_output_amount should update call sites to handle WALLY_EINVAL when the amount may be absent (e.g., blinded PSET outputs), and should not assume zero is a valid amount. Review any code that previously relied on the zero-default behavior.
Security signals we found
API now distinguishes 'missing amount' from 'amount is zero' via explicit error code
Presence check (has_amount) is enforced before returning sensitive numeric value
Test updated to assert exception when fetching amount on blinded output
ABI break explicitly noted by commit author
Evidence from the diff
The patch replaces the auto-generated PSBT_GET_I macro implementation of wally_psbt_get_output_amount with an explicit function that validates the PSBT version, output index, and presence flag (p->has_amount) before returning the value. If the amount is absent, it now returns WALLY_EINVAL rather than 0. The Python tests are updated to expect an exception in the blinded-output case where no explicit amount exists, and a new hasfn parameter is threaded through test helpers to verify presence/absence semantics.
Changed components
src/psbt.csrc/swig_python/contrib/psbt.pywally_psbt_get_output_amountpsbt_get_output_amount Python bindingInspect captured patch +32 / −16
### src/psbt.c
@@ -5994,7 +5994,17 @@ PSBT_FIELD(output, redeem_script, PSBT_0)
PSBT_FIELD(output, witness_script, PSBT_0)
PSBT_GET_M(output, keypath)
PSBT_GET_M(output, unknown)
-PSBT_GET_I(output, amount, uint64_t, PSBT_2)
+int wally_psbt_get_output_amount(const struct wally_psbt *psbt, size_t index,
+ uint64_t *written)
+{
+ struct wally_psbt_output *p = psbt_get_output(psbt, index);
+ if (written) *written = 0;
+ if (!p || !written || psbt->version != PSBT_2 || !p->has_amount)
+ return WALLY_EINVAL;
+ *written = p->amount;
+ return WALLY_OK;
+}
+
int wally_psbt_has_output_amount(const struct wally_psbt *psbt, size_t index, size_t *written) {
struct wally_psbt_output *p = psbt_get_output(psbt, index);
if (written) *written = 0;
### src/swig_python/contrib/psbt.py
@@ -53,16 +53,22 @@ def _try_set(self, func, psbt, valid_value, null_value=None, mandatory=False, al
self._throws(func, psbt, 0, null_value)
self._try_invalid(func, psbt, valid_value)
- def _try_get_set_i(self, setfn, clearfn, getfn, psbt, valid_value, invalid_value=None, mandatory=False):
+ def _try_get_set_i(self, setfn, clearfn, getfn, hasfn, psbt,
+ valid_value, invalid_value=None, mandatory=False):
self._try_invalid(setfn, psbt, valid_value)
setfn(psbt, 0, valid_value) # Set
+ if hasfn:
+ self.assertTrue(hasfn(psbt, 0))
self._round_trip(psbt)
self._try_invalid(getfn, psbt)
ret = getfn(psbt, 0) # Get
self.assertEqual(valid_value, ret)
if clearfn:
self._try_invalid(clearfn, psbt)
clearfn(psbt, 0)
+ if hasfn:
+ self.assertFalse(hasfn(psbt, 0))
+ self._throws(getfn, psbt, 0)
if mandatory:
setfn(psbt, 0, valid_value) # Set Again
else:
@@ -195,7 +201,7 @@ def test_add_remove_tx_items(self):
# txout has blinded value/asset, expect no values
# and the commitments set in the PSET
self.assertEqual(psbt_has_output_amount(pset2, 0), 0)
- self.assertEqual(psbt_get_output_amount(pset2, 0), 0)
+ self._throws(psbt_get_output_amount, pset2, 0)
self.assertEqual(psbt_get_output_value_commitment_len(pset2, 0), len(blinded_value))
self.assertEqual(psbt_get_output_value_commitment(pset2, 0), blinded_value)
self.assertEqual(psbt_get_output_script(pset2, 0), script)
@@ -504,7 +510,7 @@ def test_psbt(self):
p, dummy_unknowns, dummy_unknown_key)
psbt_set_input_signatures(p, 0, empty_signatures)
self._try_get_set_i(psbt_set_input_sighash, None,
- psbt_get_input_sighash, p, 0xff) # FIXME 0x100 as invalid_value should fail
+ psbt_get_input_sighash, None, p, 0xff) # FIXME 0x100 as invalid_value should fail
for sig_type in [dummy_sig_tap_default, dummy_sig_tap_all, dummy_sig_tap_single]:
psbt_set_input_taproot_signature(p, 0, sig_type)
self.assertEqual(psbt_get_input_taproot_signature(p, 0), sig_type)
@@ -542,19 +548,19 @@ def test_psbt(self):
# V2: Output Index
self._throws(psbt_set_input_output_index, psbt, 0, 1234) # Non v2 PSBT
- self._try_get_set_i(psbt_set_input_output_index,
- None,
- psbt_get_input_output_index, psbt2, 1234)
+ self._try_get_set_i(psbt_set_input_output_index, None,
+ psbt_get_input_output_index, None,
+ psbt2, 1234)
# For v0 PSBTs, fetching returns the value from the global tx
out_idx = tx_get_input_index(global_tx, 0)
self.assertEqual(psbt_get_input_output_index(psbt, 0), out_idx)
# V2: Sequence
self._throws(psbt_set_input_sequence, psbt, 0, 1234) # Non v2 PSBT
self._throws(psbt_clear_input_sequence, psbt, 0) # Non v2 PSBT
- self._try_get_set_i(psbt_set_input_sequence,
- psbt_clear_input_sequence,
- psbt_get_input_sequence, psbt2, 1234)
+ self._try_get_set_i(psbt_set_input_sequence, psbt_clear_input_sequence,
+ psbt_get_input_sequence, None,
+ psbt2, 1234)
# If no sequence is present, it defaults to final (0xffffffff)
psbt_clear_input_sequence(psbt2, 0)
self.assertEqual(psbt_get_input_sequence(psbt2, 0), 0xffffffff)
@@ -573,7 +579,7 @@ def test_psbt(self):
self._throws(g_fn, psbt, 0) # Non v2 PSBT
self._throws(h_fn, psbt, 0) # Non v2 PSBT
self._throws(c_fn, psbt, 0) # Non v2 PSBT
- self._try_get_set_i(s_fn, c_fn, g_fn, psbt2, v)
+ self._try_get_set_i(s_fn, c_fn, g_fn, h_fn, psbt2, v)
#
# Inputs: PSET
@@ -587,7 +593,7 @@ def test_psbt(self):
(psbt_set_input_pegin_amount, psbt_get_input_pegin_amount)]:
self._throws(setfn, psbt, 0, 1234) # Non v2 PSBT
self._throws(getfn, psbt, 0) # Non v2 PSBT
- self._try_get_set_i(setfn, None, getfn, pset2, 1234)
+ self._try_get_set_i(setfn, None, getfn, None, pset2, 1234)
# Explicit amount
self._throws(psbt_clear_input_amount, psbt, 0) # Non v2 PSBT
@@ -687,9 +693,9 @@ def test_psbt(self):
self._throws(psbt_has_output_amount, psbt2, 1) # Invalid Index
self.assertEqual(psbt_has_output_amount(psbt2, 0), 1) # Non v2 PSBT
self._throws(psbt_clear_output_amount, psbt, 0) # Non v2 PSBT
- self._try_get_set_i(psbt_set_output_amount,
- psbt_clear_output_amount,
- psbt_get_output_amount, psbt2, 1234, mandatory=True)
+ self._try_get_set_i(psbt_set_output_amount, psbt_clear_output_amount,
+ psbt_get_output_amount, psbt_has_output_amount,
+ psbt2, 1234, mandatory=True)
# V2: Script
self._throws(psbt_set_output_script, psbt, 0, dummy_bytes) # Non v2 PSBT
@@ -711,7 +717,7 @@ def test_psbt(self):
(psbt_set_output_blinder_index, psbt_get_output_blinder_index)]:
self._throws(setfn, psbt, 0, 1234) # Non v2 PSBT
self._throws(getfn, psbt, 0) # Non v2 PSBT
- self._try_get_set_i(setfn, None, getfn, pset2, 1234)
+ self._try_get_set_i(setfn, None, getfn, None, pset2, 1234)
cases = [
('value_commitment', dummy_blind_value, dummy_blind_asset),Why this scored 34/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.