size an unresolved silent payment output as the p2tr output it becomes when deriving the fee
What changed, and why it matters
This commit fixes how Sparrow Wallet calculates transaction size and fees for silent-payment transactions before they are fully signed. Previously, the wallet treated an unresolved silent-payment output as if it took up no space, which made the displayed transaction size, fee rate, and effective fee rate look smaller than they would be once broadcast. The fix sizes these outputs as the Taproot (P2TR) outputs they become after signing, so fee calculations and warnings match reality.
Treat as a low-severity correctness fix. Users relying on pre-sign fee-rate estimates for silent-payment sends should upgrade so displayed and warned fee rates match the final broadcast transaction. No immediate incident response is indicated from the diff alone.
Security signals we found
Silent payment output script not yet resolved at fee-estimation time
Fee-rate display and high-fee warning used under-sized transaction
Potential for user to broadcast a transaction with a higher effective fee rate than shown
Fix adds SilentPayment.OUTPUT_SCRIPT_LENGTH to virtual size for unresolved outputs
Evidence from the diff
Silent Payments (BIP 352) require the output script to be derived from the inputs at signing time. Before signing, the transaction’s output script bytes are empty, so Transaction.getVirtualSize() under-reports the final vByte size. This patch introduces a corrected virtual-size calculation that adds SilentPayment.OUTPUT_SCRIPT_LENGTH for any PSBT output that still has a silent-payment address and an empty script. It applies this corrected size to fee-rate displays, the high-fee-rate broadcast warning, and effective fee-rate calculations. The drongo submodule bump likely contains the underlying WalletTransaction.getVirtualSize() implementation.
Changed components
TransactionDiagram.java tooltip size/fee-rate displayHeadersController.java size/fee-rate display and broadcast warningSendController.java effective fee-rate calculationdrongo submodule (WalletTransaction.getVirtualSize implementation)Inspect captured patch +27 / −7
### drongo
@@ -1 +1 @@
-Subproject commit 1f3e2a952eb241d9aee4ad4a23bca258a2580922
+Subproject commit ae2fca2e6f8adb7d1e0f793b0e0fc304f7cc8dee
### src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java
@@ -945,8 +945,8 @@ private Pane getTransactionPane() {
Label txLabel = new Label(txDesc);
boolean isFinalized = walletTx.getTransaction().hasScriptSigs() || walletTx.getTransaction().hasWitnesses();
Tooltip tooltip = new Tooltip(walletTx.getTransaction().getLength() + " bytes\n"
- + String.format("%.2f", walletTx.getTransaction().getVirtualSize()) + " vBytes"
- + (walletTx.getFee() < 0 ? "" : "\n" + String.format("%.2f", walletTx.getFee() / walletTx.getTransaction().getVirtualSize()) + " sats/vB" + (isFinalized ? "" : " (non-final)")));
+ + String.format("%.2f", walletTx.getVirtualSize()) + " vBytes"
+ + (walletTx.getFee() < 0 ? "" : "\n" + String.format("%.2f", walletTx.getFee() / walletTx.getVirtualSize()) + " sats/vB" + (isFinalized ? "" : " (non-final)")));
tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
tooltip.setShowDuration(Duration.INDEFINITE);
tooltip.getStyleClass().add("transaction-tooltip");
### src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
@@ -620,7 +620,27 @@ private void updateType() {
private void updateSize() {
size.setText(headersForm.getTransaction().getSize() + " B");
- virtualSize.setText(String.format("%.2f", headersForm.getTransaction().getVirtualSize()) + " vB");
+ virtualSize.setText(String.format("%.2f", getVirtualSize()) + " vB");
+ }
+
+ /**
+ * Returns the virtual size the transaction will have once broadcast. Until a silent payments transaction is signed its output scripts have not
+ * been computed, so the transaction is short of the P2TR outputs they become, and the size the send tab derived the fee from is the larger one.
+ */
+ private double getVirtualSize() {
+ double virtualSize = headersForm.getTransaction().getVirtualSize();
+ if(headersForm.getPsbt() != null) {
+ //Signing computes the output scripts on the PSBT outputs alone, so whether one is still to be added is asked of the transaction being sized
+ List<TransactionOutput> txOutputs = headersForm.getTransaction().getOutputs();
+ List<PSBTOutput> psbtOutputs = headersForm.getPsbt().getPsbtOutputs();
+ for(int i = 0; i < txOutputs.size(); i++) {
+ if(psbtOutputs.get(i).getSilentPaymentAddress() != null && txOutputs.get(i).getScriptBytes().length == 0) {
+ virtualSize += SilentPayment.OUTPUT_SCRIPT_LENGTH;
+ }
+ }
+ }
+
+ return virtualSize;
}
private Long calculateFee(Map<Sha256Hash, BlockTransaction> inputTransactions) {
@@ -657,7 +677,7 @@ private Long calculateFee(Map<Sha256Hash, BlockTransaction> inputTransactions) {
private void updateFee(Long feeAmt) {
fee.setValue(feeAmt);
- double feeRateAmt = feeAmt.doubleValue() / headersForm.getTransaction().getVirtualSize();
+ double feeRateAmt = feeAmt.doubleValue() / getVirtualSize();
feeRate.setText(String.format("%.2f", feeRateAmt) + " sats/vB" + (headersForm.isTransactionFinalized() ? "" : " (non-final)"));
}
@@ -1380,7 +1400,7 @@ public void broadcastTransaction(ActionEvent event) {
}
if(fee.getValue() > 0) {
- double feeRateAmt = fee.getValue() / headersForm.getTransaction().getVirtualSize();
+ double feeRateAmt = fee.getValue() / getVirtualSize();
if(feeRateAmt > AppServices.getLongFeeRatesRange().getLast() || (AppServices.getTargetBlockFeeRates() != null && feeRateAmt > AppServices.getDefaultFeeRate() * FEE_MULTIPLE_LIMIT)) {
Optional<ButtonType> optType = AppServices.showWarningDialog("Very high fee rate!",
"This transaction pays a very high fee rate of " + String.format("%.0f", feeRateAmt) + " sats/vB.\n\nBroadcast this transaction?", ButtonType.YES, ButtonType.NO);
### src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java
@@ -909,7 +909,7 @@ private void setEffectiveFeeRate(WalletTransaction walletTransaction) {
long utxoTxFee = unconfirmedUtxoTxs.stream().mapToLong(BlockTransaction::getFee).sum();
double utxoTxSize = unconfirmedUtxoTxs.stream().mapToDouble(blkTx -> blkTx.getTransaction().getVirtualSize()).sum();
long thisFee = walletTransaction.getFee();
- double thisSize = walletTransaction.getTransaction().getVirtualSize();
+ double thisSize = walletTransaction.getVirtualSize();
double thisRate = thisFee / thisSize;
double effectiveRate = (utxoTxFee + thisFee) / (utxoTxSize + thisSize);
if(thisRate > effectiveRate) {Why this scored 42/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.