fix psbtv2 and dst related transaction editor issues around tx version and locktime
What changed, and why it matters
This commit fixes UI bugs in Sparrow Wallet's transaction editor when working with PSBT v2 (a modern format for partially-signed Bitcoin transactions) and 'DST' (likely descriptor/transaction templates). Previously, when a user changed transaction version, locktime, or input sequence numbers in the editor, the underlying PSBT object was not updated to match. This could cause the displayed/edited transaction to disagree with the actual PSBT being signed or exported, potentially leading to unexpected transaction behavior or failed signing. The patch makes the editor keep the PSBT in sync and also disables locktime controls when the form is read-only.
Treat as a bug-fix commit with possible security side effects. Review the corresponding drongo submodule update for any related PSBT parsing/serialization changes. Users relying on PSBT v2, locktime, or RBF/sequence editing should upgrade. No immediate exploit code is evident, but verify whether inconsistent PSBT state could be induced by editing and then signing/exporting.
Security signals we found
PSBT field desynchronization between UI model and serialized PSBT
Incorrect locktime timestamp conversion using current offset instead of zone-aware conversion
Locktime controls enabled on read-only transaction forms
Missing propagation of transaction version to PSBTv2 txVersion field
Missing propagation of input sequence number to PSBTv2 input sequence field
Evidence from the diff
The diff adds helper methods that propagate changes from the in-memory Transaction/TransactionInput objects into the associated PSBT (PSBTv2) fields: setTransactionLocktime() updates tx.locktime and psbt.fallbackLocktime; tx version changes now call psbt.setTxVersion(); and setInputSequenceNumber() updates both txInput.sequence and psbtInput.sequence. It also fixes a locktime epoch-second calculation (using atZone instead of toEpochSecond with a fixed offset) and disables locktime UI controls when headersForm.isEditable() is false. The ‘drongo’ submodule reference changed, suggesting a related library update. No explicit security claim is made by the vendor.
Changed components
Sparrow Wallet transaction editor UIHeadersController.javaInputController.javadrongo submodule/libraryInspect captured patch +31 / −14
diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
index 87a1de4..400d07e 100644
--- a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
@@ -268,6 +268,13 @@ public class HeadersController extends TransactionFormController implements Init
return headersForm;
}
+ private void setTransactionLocktime(Transaction tx, long locktime) {
+ tx.setLocktime(locktime);
+ if(headersForm.getPsbt() != null) {
+ headersForm.getPsbt().setFallbackLocktime(locktime);
+ }
+ }
+
private void initializeView() {
Transaction tx = headersForm.getTransaction();
@@ -280,6 +287,9 @@ public class HeadersController extends TransactionFormController implements Init
}
tx.setVersion(newValue);
+ if(headersForm.getPsbt() != null) {
+ headersForm.getPsbt().setTxVersion((long)newValue);
+ }
if(oldValue != null) {
EventManager.get().post(new TransactionChangedEvent(tx));
}
@@ -296,7 +306,7 @@ public class HeadersController extends TransactionFormController implements Init
locktimeFieldset.getChildren().remove(locktimeBlockField);
locktimeFieldset.getChildren().remove(locktimeNoneField);
locktimeFieldset.getChildren().add(locktimeNoneField);
- tx.setLocktime(0);
+ setTransactionLocktime(tx, 0);
if(old_toggle != null) {
EventManager.get().post(new TransactionChangedEvent(tx));
}
@@ -309,7 +319,7 @@ public class HeadersController extends TransactionFormController implements Init
if(block != null) {
locktimeCurrentHeight.setVisible(headersForm.isEditable() && AppServices.getCurrentBlockHeight() != null && block < AppServices.getCurrentBlockHeight());
futureBlockWarning.setVisible(AppServices.getCurrentBlockHeight() != null && block > AppServices.getCurrentBlockHeight());
- tx.setLocktime(block);
+ setTransactionLocktime(tx, block);
if(old_toggle != null) {
EventManager.get().post(new TransactionChangedEvent(tx));
}
@@ -323,7 +333,7 @@ public class HeadersController extends TransactionFormController implements Init
if(date != null) {
locktimeDate.setDateTimeValue(date);
futureDateWarning.setVisible(date.isAfter(LocalDateTime.now()));
- tx.setLocktime(date.toEpochSecond(OffsetDateTime.now(ZoneId.systemDefault()).getOffset()));
+ setTransactionLocktime(tx, date.atZone(ZoneId.systemDefault()).toEpochSecond());
if(old_toggle != null) {
EventManager.get().post(new TransactionChangedEvent(tx));
}
@@ -361,7 +371,7 @@ public class HeadersController extends TransactionFormController implements Init
return;
}
- tx.setLocktime(newValue);
+ setTransactionLocktime(tx, newValue);
locktimeCurrentHeight.setVisible(headersForm.isEditable() && AppServices.getCurrentBlockHeight() != null && newValue < AppServices.getCurrentBlockHeight());
futureBlockWarning.setVisible(AppServices.getCurrentBlockHeight() != null && newValue > AppServices.getCurrentBlockHeight());
if(oldValue != null) {
@@ -386,7 +396,7 @@ public class HeadersController extends TransactionFormController implements Init
int caret = locktimeDate.getEditor().getCaretPosition();
locktimeDate.getEditor().setText(newValue.format(DateTimeFormatter.ofPattern(locktimeDate.getFormat())));
locktimeDate.getEditor().positionCaret(caret);
- tx.setLocktime(newValue.toEpochSecond(OffsetDateTime.now(ZoneId.systemDefault()).getOffset()));
+ setTransactionLocktime(tx, newValue.atZone(ZoneId.systemDefault()).toEpochSecond());
futureDateWarning.setVisible(newValue.isAfter(LocalDateTime.now()));
if(oldValue != null) {
EventManager.get().post(new TransactionChangedEvent(tx));
@@ -1441,7 +1451,7 @@ public class HeadersController extends TransactionFormController implements Init
public void transactionChanged(TransactionChangedEvent event) {
if(headersForm.getTransaction().equals(event.getTransaction())) {
updateTxId();
- boolean locktimeEnabled = headersForm.getTransaction().isLocktimeSequenceEnabled();
+ boolean locktimeEnabled = headersForm.isEditable() && headersForm.getTransaction().isLocktimeSequenceEnabled();
locktimeNoneType.setDisable(!locktimeEnabled);
locktimeBlockType.setDisable(!locktimeEnabled);
locktimeBlock.setDisable(!locktimeEnabled);
diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/InputController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/InputController.java
index a38c498..956986a 100644
--- a/src/main/java/com/sparrowwallet/sparrow/transaction/InputController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/transaction/InputController.java
@@ -330,14 +330,14 @@ public class InputController extends TransactionFormController implements Initia
if(txInput.isAbsoluteTimeLockDisabled()) {
locktimeToggleGroup.selectToggle(locktimeAbsoluteType);
} else if(txInput.isAbsoluteTimeLocked()) {
- txInput.setSequenceNumber(TransactionInput.SEQUENCE_RBF_ENABLED);
+ setInputSequenceNumber(txInput, TransactionInput.SEQUENCE_RBF_ENABLED);
if(oldValue != null) {
EventManager.get().post(new TransactionChangedEvent(transaction));
}
}
} else {
if(txInput.isAbsoluteTimeLocked()) {
- txInput.setSequenceNumber(TransactionInput.SEQUENCE_RBF_DISABLED);
+ setInputSequenceNumber(txInput, TransactionInput.SEQUENCE_RBF_DISABLED);
if(oldValue != null) {
EventManager.get().post(new TransactionChangedEvent(transaction));
}
@@ -366,6 +366,13 @@ public class InputController extends TransactionFormController implements Initia
}
}
+ private void setInputSequenceNumber(TransactionInput txInput, long sequence) {
+ txInput.setSequenceNumber(sequence);
+ if(inputForm.getPsbtInput() != null) {
+ inputForm.getPsbtInput().setSequence(sequence);
+ }
+ }
+
private void initializeLocktimeFields(TransactionInput txInput) {
Transaction transaction = inputForm.getTransaction();
locktimeToggleGroup.selectedToggleProperty().addListener((ov, old_toggle, new_toggle) -> {
@@ -376,7 +383,7 @@ public class InputController extends TransactionFormController implements Initia
locktimeFieldset.getChildren().add(locktimeAbsoluteField);
updateAbsoluteLocktimeField(transaction);
locktimeAbsoluteField.setDisable(true);
- txInput.setSequenceNumber(TransactionInput.SEQUENCE_LOCKTIME_DISABLED);
+ setInputSequenceNumber(txInput, TransactionInput.SEQUENCE_LOCKTIME_DISABLED);
rbf.setSelected(false);
if(old_toggle != null) {
EventManager.get().post(new TransactionChangedEvent(transaction));
@@ -387,9 +394,9 @@ public class InputController extends TransactionFormController implements Initia
updateAbsoluteLocktimeField(transaction);
locktimeAbsoluteField.setDisable(false);
if(rbf.selectedProperty().getValue()) {
- txInput.setSequenceNumber(TransactionInput.SEQUENCE_RBF_ENABLED);
+ setInputSequenceNumber(txInput, TransactionInput.SEQUENCE_RBF_ENABLED);
} else {
- txInput.setSequenceNumber(TransactionInput.SEQUENCE_RBF_DISABLED);
+ setInputSequenceNumber(txInput, TransactionInput.SEQUENCE_RBF_DISABLED);
}
if(old_toggle != null) {
EventManager.get().post(new TransactionChangedEvent(transaction));
@@ -468,10 +475,10 @@ public class InputController extends TransactionFormController implements Initia
String relativeSelection = locktimeRelativeCombo.getValue();
if(relativeSelection.equals("blocks")) {
Integer value = locktimeRelativeBlocks.getValue();
- txInput.setSequenceNumber(value & TransactionInput.RELATIVE_TIMELOCK_VALUE_MASK);
+ setInputSequenceNumber(txInput, value & TransactionInput.RELATIVE_TIMELOCK_VALUE_MASK);
} else {
long value = locktimeRelativeSeconds.getValue().toSeconds() / TransactionInput.RELATIVE_TIMELOCK_SECONDS_INCREMENT;
- txInput.setSequenceNumber((value & TransactionInput.RELATIVE_TIMELOCK_VALUE_MASK) | TransactionInput.RELATIVE_TIMELOCK_TYPE_FLAG);
+ setInputSequenceNumber(txInput, (value & TransactionInput.RELATIVE_TIMELOCK_VALUE_MASK) | TransactionInput.RELATIVE_TIMELOCK_TYPE_FLAG);
}
if(changed) {
EventManager.get().post(new TransactionChangedEvent(transaction));
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.