derive the fee of a wallet transaction from the transactions funding its inputs rather than the value its history entry reports
What changed, and why it matters
This commit changes how Sparrow Wallet calculates transaction fees. Previously, the wallet trusted the fee value reported by the Electrum server for each transaction. Now, when the wallet already knows the transactions that fund a given transaction's inputs, it calculates the fee directly from those input amounts minus the outputs. The server's reported fee is only used as a fallback when the wallet cannot derive the fee itself. This reduces the risk that a malicious or faulty server could trick the wallet into using an incorrect fee—particularly when the user later tries to bump a transaction's fee using RBF or CPFP.
Treat this as a security-hardening fix. Review the drongo submodule diff (commit 22546c2166adb652e2e8e95c68a8826b9e5347ff) to confirm getFee(Function<Sha256Hash, Transaction>) handles all input types, edge cases, and null returns safely. Ensure the fallback ordering (derived → server-reported → cached) is consistently applied across all code paths that display or use fees, especially RBF/CPFP dialogs. Consider whether any UI still displays a server-reported mempool fee before local derivation completes, which could briefly mislead users.
Security signals we found
Server-reported fee no longer trusted blindly when wallet can derive fee from input transactions
Fee derivation added in transaction fetch path before wallet stores BlockTransaction
Nullable fee type introduced to distinguish unknown fees from zero fees
New unit tests model inflated/malicious server fee and assert local derivation wins
RBF/CPFP fee-bumping context explicitly mentioned in commit message and tests as motivation
Evidence from the diff
The patch modifies ElectrumServer.java and ScriptHashTx.java, plus a drongo submodule bump. ScriptHashTx.fee changes from primitive long to nullable Long, allowing unknown/missing fees instead of defaulting to 0. In ElectrumServer.getTransactions(), a new inputTransactions function resolves prior transactions from the current fetch pass or the wallet’s stored transactions. transaction.getFee(inputTransactions) is called to compute the fee from input values; if derivation fails, the code falls back to reference.getFee() and then to any cached fee. Tests (TransactionFeeTest) explicitly cover: derived fee when funding tx is known, derivation across the same fetch pass, fallback to reported fee when funding tx unknown, null fee when neither source provides one, carrying over stored fees, and preferring derived fee over stored/server-reported fee. The change is defensive: it does not remove server fee reporting but makes locally derivable values authoritative.
Changed components
ElectrumServer transaction retrieval and history calculationScriptHashTx fee field serializationWallet transaction fee storage (BlockTransaction / BlockTransactionHashIndex)drongo submodule (transaction fee derivation logic likely lives there)Inspect captured patch +176 / −13
### drongo
@@ -1 +1 @@
-Subproject commit 22d2c90ef3f7741a604ffd15c91f81cfe611bdb1
+Subproject commit 22546c2166adb652e2e8e95c68a8826b9e5347ff
### src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
@@ -46,6 +46,7 @@
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -374,7 +375,7 @@ private static List<ScriptHashTx> getScriptHashes(String scriptHash, WalletNode
return 0;
});
- return txos.stream().map(txo -> new ScriptHashTx(txo.getHeight(), txo.getHashAsString(), txo.getFee() == null ? 0 : txo.getFee())).toList();
+ return txos.stream().map(txo -> new ScriptHashTx(txo.getHeight(), txo.getHashAsString(), txo.getFee())).toList();
}
static String getScriptHashStatus(List<ScriptHashTx> scriptHashTxes) {
@@ -709,7 +710,7 @@ public void getReferences(Wallet wallet, Collection<WalletNode> nodes, Map<Walle
blkTx.getTransaction().getInputs().stream().map(txInput -> getPrevOutput(wallet, txInput))
.filter(Objects::nonNull).map(ElectrumServer::getScriptHash).anyMatch(scriptHash::equals)) {
List<ScriptHashTx> scriptHashTxes = new ArrayList<>(getScriptHashes(scriptHash, node));
- scriptHashTxes.add(new ScriptHashTx(candidateHeights.get(txid), txid.toString(), blkTx.getFee() == null ? 0 : blkTx.getFee()));
+ scriptHashTxes.add(new ScriptHashTx(candidateHeights.get(txid), txid.toString(), blkTx.getFee()));
String status = getScriptHashStatus(scriptHashTxes);
if(Objects.equals(status, subscribedStatus)) {
@@ -727,7 +728,7 @@ public void getReferences(Wallet wallet, Collection<WalletNode> nodes, Map<Walle
for(ScriptHashTx scriptHashTx : scriptHashTxes) {
if(scriptHashTx.height <= 0) {
scriptHashTx.height = AppServices.getCurrentBlockHeight();
- scriptHashTx.fee = 0;
+ scriptHashTx.fee = null;
}
}
@@ -1870,6 +1871,25 @@ public Map<Sha256Hash, BlockTransaction> getTransactions(Wallet wallet, Map<Bloc
}
}
+ //A fee is the one thing a transaction does not carry and the txid re-hash above cannot test, so where every transaction funding the inputs
+ //is known it is worked out from them, and the fee the server reported is used only where it cannot be. A transaction of this pass reaches
+ //the wallet only once this method returns, so the references are keyed by hash to be asked alongside it
+ Map<Sha256Hash, Transaction> referencedTransactions = new HashMap<>(references.size());
+ for(Map.Entry<BlockTransactionHash, Transaction> entry : references.entrySet()) {
+ if(entry.getValue() != null) {
+ referencedTransactions.put(entry.getKey().getHash(), entry.getValue());
+ }
+ }
+ Function<Sha256Hash, Transaction> inputTransactions = txid -> {
+ Transaction referenced = referencedTransactions.get(txid);
+ if(referenced != null) {
+ return referenced;
+ }
+
+ BlockTransaction walletTransaction = wallet == null ? null : wallet.getWalletTransaction(txid);
+ return walletTransaction == null ? null : walletTransaction.getTransaction();
+ };
+
for(BlockTransactionHash reference : references.keySet()) {
Transaction transaction = references.get(reference);
if(transaction == null) {
@@ -1890,7 +1910,10 @@ public Map<Sha256Hash, BlockTransaction> getTransactions(Wallet wallet, Map<Bloc
}
BlockTransaction cached = wallet == null ? null : wallet.getWalletTransaction(reference.getHash());
- Long fee = reference.getFee();
+ Long fee = transaction.getFee(inputTransactions);
+ if(fee == null) {
+ fee = reference.getFee();
+ }
if(fee == null && cached != null && cached.getFee() != null) {
fee = cached.getFee();
}
@@ -1953,7 +1976,7 @@ public void calculateNodeHistory(Wallet wallet, Map<WalletNode, Set<BlockTransac
for(int outputIndex = 0; outputIndex < transaction.getOutputs().size(); outputIndex++) {
TransactionOutput output = transaction.getOutputs().get(outputIndex);
if (output.getScript().equals(nodeScript)) {
- BlockTransactionHashIndex receivingTXO = new BlockTransactionHashIndex(reference.getHash(), reference.getHeight(), blockTransaction.getDate(), reference.getFee(), output.getIndex(), output.getValue());
+ BlockTransactionHashIndex receivingTXO = new BlockTransactionHashIndex(reference.getHash(), reference.getHeight(), blockTransaction.getDate(), blockTransaction.getFee(), output.getIndex(), output.getValue());
transactionOutputs.add(receivingTXO);
}
}
@@ -1989,8 +2012,8 @@ public void calculateNodeHistory(Wallet wallet, Map<WalletNode, Set<BlockTransac
TransactionOutput spentOutput = previousTransaction.getTransaction().getOutputs().get((int)input.getOutpoint().getIndex());
if(spentOutput.getScript().equals(nodeScript)) {
- BlockTransactionHashIndex spendingTXI = new BlockTransactionHashIndex(reference.getHash(), reference.getHeight(), blockTransaction.getDate(), reference.getFee(), inputIndex, spentOutput.getValue());
- BlockTransactionHashIndex spentTXO = new BlockTransactionHashIndex(spentTxHash.getHash(), spentTxHash.getHeight(), previousTransaction.getDate(), spentTxHash.getFee(), spentOutput.getIndex(), spentOutput.getValue(), spendingTXI);
+ BlockTransactionHashIndex spendingTXI = new BlockTransactionHashIndex(reference.getHash(), reference.getHeight(), blockTransaction.getDate(), blockTransaction.getFee(), inputIndex, spentOutput.getValue());
+ BlockTransactionHashIndex spentTXO = new BlockTransactionHashIndex(spentTxHash.getHash(), spentTxHash.getHeight(), previousTransaction.getDate(), previousTransaction.getFee(), spentOutput.getIndex(), spentOutput.getValue(), spendingTXI);
Optional<BlockTransactionHashIndex> optionalReference = transactionOutputs.stream().filter(receivedTXO -> receivedTXO.getHash().equals(spentTXO.getHash()) && receivedTXO.getIndex() == spentTXO.getIndex()).findFirst();
if(optionalReference.isEmpty()) {
### src/main/java/com/sparrowwallet/sparrow/net/ScriptHashTx.java
@@ -14,11 +14,11 @@ public BlockTransactionHash getBlockchainTransactionHash() {
public int height;
public String tx_hash;
- public long fee;
+ public Long fee;
public ScriptHashTx() {}
- public ScriptHashTx(int height, String tx_hash, long fee) {
+ public ScriptHashTx(int height, String tx_hash, Long fee) {
this.height = height;
this.tx_hash = tx_hash;
this.fee = fee;
### src/test/java/com/sparrowwallet/sparrow/net/TransactionFeeTest.java
@@ -0,0 +1,140 @@
+package com.sparrowwallet.sparrow.net;
+
+import com.sparrowwallet.drongo.Utils;
+import com.sparrowwallet.drongo.protocol.Script;
+import com.sparrowwallet.drongo.protocol.Sha256Hash;
+import com.sparrowwallet.drongo.protocol.Transaction;
+import com.sparrowwallet.drongo.wallet.BlockTransaction;
+import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
+import com.sparrowwallet.drongo.wallet.Wallet;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.TreeMap;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * The fee a wallet transaction is stored with. A server reports one alongside every mempool entry in a history, and unlike the transaction itself and
+ * the height it is held at, nothing later can test it - the txid re-hash proves the body and a merkle proof the height, but a fee is not committed to
+ * anywhere. It is worked out here from the transactions funding the inputs wherever the wallet holds them all, since the number reaches the user as
+ * the fee an RBF or CPFP is built from.
+ */
+public class TransactionFeeTest {
+ private static final String P2PKH_SCRIPT = "76a914000000000000000000000000000000000000000088ac";
+
+ private static final String EXTERNAL_TXID = "aa00000000000000000000000000000000000000000000000000000000000011";
+
+ private static final long INFLATED_FEE = 500000L;
+
+ @Test
+ public void derivesTheFeeWhereTheWalletHoldsTheFundingTransaction() throws Exception {
+ Transaction funding = transaction(EXTERNAL_TXID, 100000L);
+ Transaction spending = spendingTransaction(funding, 99000L);
+
+ Wallet wallet = new Wallet("test");
+ wallet.updateTransactions(Map.of(funding.getTxId(), new BlockTransaction(funding.getTxId(), 1, null, null, funding)));
+
+ Map<Sha256Hash, BlockTransaction> transactions = getTransactions(wallet, references(spending, INFLATED_FEE));
+
+ assertEquals(1000L, transactions.get(spending.getTxId()).getFee());
+ }
+
+ @Test
+ public void derivesTheFeeFromAFundingTransactionInTheSamePass() throws Exception {
+ Transaction funding = transaction(EXTERNAL_TXID, 100000L);
+ Transaction spending = spendingTransaction(funding, 99000L);
+
+ //A wallet restored from scratch learns both transactions in the one history, and the fee must not depend on which was seen first
+ Map<BlockTransactionHash, Transaction> references = references(spending, INFLATED_FEE);
+ references.putAll(references(funding, INFLATED_FEE));
+
+ Map<Sha256Hash, BlockTransaction> transactions = getTransactions(new Wallet("test"), references);
+
+ assertEquals(1000L, transactions.get(spending.getTxId()).getFee());
+ }
+
+ @Test
+ public void keepsTheReportedFeeWhereAFundingTransactionIsUnknown() throws Exception {
+ Transaction funding = transaction(EXTERNAL_TXID, 100000L);
+ Transaction spending = spendingTransaction(funding, 99000L);
+
+ //An incoming payment is funded by transactions that paid nothing to this wallet, so its fee is the sender's word until the inputs are fetched
+ Map<Sha256Hash, BlockTransaction> transactions = getTransactions(new Wallet("test"), references(spending, 1000L));
+
+ assertEquals(1000L, transactions.get(spending.getTxId()).getFee());
+ }
+
+ @Test
+ public void reportsNoFeeWhereTheServerReportsNoneAndTheWalletCannotDeriveOne() throws Exception {
+ Transaction funding = transaction(EXTERNAL_TXID, 100000L);
+ Transaction spending = spendingTransaction(funding, 99000L);
+
+ Map<Sha256Hash, BlockTransaction> transactions = getTransactions(new Wallet("test"), references(spending, null));
+
+ assertNull(transactions.get(spending.getTxId()).getFee());
+ }
+
+ @Test
+ public void carriesTheStoredFeeOverWhereTheServerReportsNone() throws Exception {
+ Transaction funding = transaction(EXTERNAL_TXID, 100000L);
+ Transaction spending = spendingTransaction(funding, 99000L);
+
+ //A history entry carries a fee only while the transaction is unconfirmed, so what was learned of an incoming payment is kept
+ Wallet wallet = new Wallet("test");
+ wallet.updateTransactions(Map.of(spending.getTxId(), new BlockTransaction(spending.getTxId(), 0, null, 1000L, spending)));
+
+ Map<Sha256Hash, BlockTransaction> transactions = getTransactions(wallet, references(spending, null));
+
+ assertEquals(1000L, transactions.get(spending.getTxId()).getFee());
+ }
+
+ @Test
+ public void prefersTheDerivedFeeToTheStoredOne() throws Exception {
+ Transaction funding = transaction(EXTERNAL_TXID, 100000L);
+ Transaction spending = spendingTransaction(funding, 99000L);
+
+ //A fee stored from an earlier pass may be one the server asserted, so a derivation that is now possible replaces it
+ Wallet wallet = new Wallet("test");
+ wallet.updateTransactions(Map.of(funding.getTxId(), new BlockTransaction(funding.getTxId(), 1, null, null, funding),
+ spending.getTxId(), new BlockTransaction(spending.getTxId(), 0, null, INFLATED_FEE, spending)));
+
+ Map<Sha256Hash, BlockTransaction> transactions = getTransactions(wallet, references(spending, INFLATED_FEE));
+
+ assertEquals(1000L, transactions.get(spending.getTxId()).getFee());
+ }
+
+ /**
+ * Builds the given references with their transactions already in hand, which is the state the fetch loop leaves them in and lets the fee be
+ * exercised without a server.
+ */
+ private Map<Sha256Hash, BlockTransaction> getTransactions(Wallet wallet, Map<BlockTransactionHash, Transaction> references) throws ServerException {
+ return new ElectrumServer().getTransactions(wallet, references, Collections.emptyMap());
+ }
+
+ private Map<BlockTransactionHash, Transaction> references(Transaction transaction, Long fee) {
+ Map<BlockTransactionHash, Transaction> references = new TreeMap<>();
+ references.put(new BlockTransaction(transaction.getTxId(), 0, null, fee, null), transaction);
+ return references;
+ }
+
+ private Transaction transaction(String fundedBy, long... values) {
+ Transaction transaction = new Transaction();
+ transaction.addInput(Sha256Hash.wrap(fundedBy), 0, new Script(new byte[0]));
+ for(long value : values) {
+ transaction.addOutput(value, new Script(Utils.hexToBytes(P2PKH_SCRIPT)));
+ }
+
+ return transaction;
+ }
+
+ private Transaction spendingTransaction(Transaction funding, long value) {
+ Transaction spending = new Transaction();
+ spending.addInput(funding.getTxId(), 0, new Script(new byte[0]));
+ spending.addOutput(value, new Script(Utils.hexToBytes(P2PKH_SCRIPT)));
+
+ return spending;
+ }
+}
### src/test/java/com/sparrowwallet/sparrow/net/TransactionProofTest.java
@@ -969,8 +969,8 @@ public void doesNotReadAStoredDemotionAsTheWholeHistoryChanging() throws Excepti
//The server has not changed its mind, and still will not prove it
String scriptHash = ElectrumServer.getScriptHash(node);
- server.serveHistory(node.getDerivationPath(), new ScriptHashTx(PROVEN_HEIGHT, transaction.getTxId().toString(), 0));
- server.serveScriptHashStatus(scriptHash, ElectrumServer.getScriptHashStatus(List.of(new ScriptHashTx(PROVEN_HEIGHT, transaction.getTxId().toString(), 0))));
+ server.serveHistory(node.getDerivationPath(), new ScriptHashTx(PROVEN_HEIGHT, transaction.getTxId().toString(), null));
+ server.serveScriptHashStatus(scriptHash, ElectrumServer.getScriptHashStatus(List.of(new ScriptHashTx(PROVEN_HEIGHT, transaction.getTxId().toString(), null))));
ElectrumServer electrumServer = new ElectrumServer();
assertTrue(electrumServer.fetchAndCalculateHistory(wallet, null, null));
@@ -1034,7 +1034,7 @@ private Transaction confirmedPayment(Wallet wallet, WalletNode node) throws Exce
chain.get(PROVEN_HEIGHT - 1).getHash())));
node.getTransactionOutputs().add(new BlockTransactionHashIndex(transaction.getTxId(), PROVEN_HEIGHT, null, 0L, 0, 10000));
- ScriptHashTx confirmed = new ScriptHashTx(PROVEN_HEIGHT, transaction.getTxId().toString(), 0);
+ ScriptHashTx confirmed = new ScriptHashTx(PROVEN_HEIGHT, transaction.getTxId().toString(), null);
server.serveHistory(node.getDerivationPath(), confirmed);
server.serveScriptHashStatus(ElectrumServer.getScriptHash(node), ElectrumServer.getScriptHashStatus(List.of(confirmed)));
Why this scored 63/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.