show a height the transaction tab takes from the server as unverified until it is proven
What changed, and why it matters
This commit changes Sparrow Wallet so that when you open a transaction in the transaction tab, the block height claimed by the server is not automatically trusted. Instead, the wallet now asks the server for a cryptographic proof (a Merkle proof) that the transaction really is in that block. Until the proof arrives and checks out, the height is shown as 'Unverified'. Previously, the transaction tab would display the server's claimed height and block hash as if they were confirmed facts, even though they came only from the server. This change closes a trust gap: a malicious or mistaken server could no longer make an unconfirmed or differently-located transaction appear confirmed in the UI simply by reporting a fake height or block hash.
This is a defensive hardening patch and should be applied. Users and downstream packagers should upgrade to a release containing this commit. No immediate incident response is required, but the change is security-relevant because it removes a UI trust assumption that a server-reported block height/block hash was proven. Reviewers should verify that the new TransactionVerificationService correctly handles connection loss, reorgs, and concurrent tab redraws, and that the proof cache keying by txid:height cannot be confused across servers or chains.
Security signals we found
UI now distinguishes server-reported heights from cryptographically proven heights
Server-supplied block hashes are no longer treated as proof of inclusion
Merkle proofs are verified against locally verified block headers before a height is trusted
Proof cache is cleared on reorg to prevent stale trust after chain reorganizations
Cross-reorg proof races are handled by checking reorgCount under headerSyncLock
Servers that do not support blockchain.transaction.get_merkle disable verification rather than marking everything unverified
New unit tests specifically exercise malicious/failing server behaviors
Evidence from the diff
The patch adds server-side transaction-height verification for the transaction tab. It introduces getProvenHeader() in ElectrumServer, which fetches a blockchain.transaction.get_merkle proof for a txid+height, verifies it against a locally verified block header, and caches the proven header. VerboseTransaction no longer passes the server’s blockhash through as evidence; it drops non-zero block hashes and only preserves Sha256Hash.ZERO_HASH as an ‘incomplete response’ marker. HeadersController now labels confirmed heights as ‘(Unverified)’ and shows a tooltip when the block hash is absent despite verification being enabled, and it starts a TransactionVerificationService to prove the height in the background. Reorgs clear the proof cache and re-trigger verification. Tests cover proof acceptance, refusal, tampered branches, reorg races, caching behavior, and servers lacking the proof RPC.
Changed components
ElectrumServer.javaVerboseTransaction.javaHeadersController.javaTransactionController.javaTransactionProofTest.javaVerboseTransactionTest.javaInspect captured patch +427 / −10
### src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
@@ -100,6 +100,13 @@ public class ElectrumServer {
//Session cache of headers below the last pin, verified by hash linkage to it and deliberately never persisted
static final Map<Integer, BlockHeader> verifiedHistoricalHeaders = new ConcurrentHashMap<>();
+ //Session cache of the transactions proven at a height, keyed by the pair proven. Kept across servers, since a proof reconstructs a header the
+ //compiled in checkpoints anchor rather than anything the server that supplied it vouches for, and dropped only where a reorg replaces the block
+ static final Map<String, BlockHeader> provenTransactionHeaders = new ConcurrentHashMap<>();
+
+ //Counts the rewinds of the header store, so that a proof can tell whether one happened while it was being obtained. Written under headerSyncLock
+ static volatile int reorgCount;
+
//The deepest fork point the store has been rewound to this session, at or above which a stored height may have been proven against an orphaned
//header. Written only under headerSyncLock, which is what makes the min in reconcile atomic; volatile is for the readers that do not take it
static volatile int lastReorgForkHeight = Integer.MAX_VALUE;
@@ -1176,6 +1183,91 @@ private Map<BlockTransactionHash, BlockHeader> verifyMerkleProofs(Wallet wallet,
return proven;
}
+ /**
+ * The header the given transaction is proven to be included in at the given height, or null where the connected server did not substantiate it,
+ * whether by declining the proof, answering for another block, or supplying a branch that does not reconstruct. For a transaction reached outside
+ * a wallet, where there is no history to demote and nothing to report per wallet: the caller has only the header to show, or its absence.
+ * <p>
+ * A server that does not implement the call at all disables verification for the session here as it does on the wallet paths, since a server
+ * lacking it substantiates nothing and marking every transaction against it unverified says something about the server that is not true.
+ */
+ public BlockHeader getProvenHeader(Sha256Hash txid, int height) throws ServerException {
+ String pair = txid + ":" + height;
+ BlockHeader provenHeader = provenTransactionHeaders.get(pair);
+ if(provenHeader != null) {
+ return provenHeader; //a transaction reopened, or open in a second tab, and the answer cannot have changed while the block stands
+ }
+
+ BlockTransactionHash reference = new BlockTransaction(txid, height, null, null, null);
+ int reorgsBefore = reorgCount;
+ try {
+ Map<String, TransactionMerkleProof> proofs = electrumServerRpc.getTransactionMerkleProofs(getTransport(), null, List.of(reference));
+ TransactionMerkleProof proof = proofs.get(pair);
+ if(proof == null || proof == TransactionMerkleProof.ERROR_PROOF || proof.block_height != height) {
+ return null;
+ }
+
+ BlockHeader header = getVerifiedHeader(height);
+ if(header == null || !verifyProof(txid, proof, header)) {
+ return null; //only what was proven is cached: a server that will not prove it now is not the server that will be asked next
+ }
+
+ //Remembered only where no reorg intervened, under the lock reconcile holds while it clears: a proof resolving across one would otherwise
+ //be written behind that clear, leaving an entry proven against a header the chain no longer holds. The proof itself still stands or falls
+ //on the header it reconstructed, so it is returned either way
+ synchronized(headerSyncLock) {
+ if(reorgCount == reorgsBefore) {
+ provenTransactionHeaders.put(pair, header);
+ }
+ }
+
+ return header;
+ } catch(UnsupportedMethodException e) {
+ //Before the catch below, as elsewhere: a property of the server rather than of this transaction, so it is settled for the session on the
+ //same terms the wallet paths settle it, and raised rather than recorded where verification is mandatory
+ disableVerification(e);
+ return null;
+ } catch(ElectrumServerRpcException e) {
+ throw new ServerException(e.getMessage(), e.getCause()); //the server said nothing about this transaction, so it is a failed call
+ }
+ }
+
+ /**
+ * Whether a transaction carries the block it was proven to be in. The zero hash is not one: it marks a response that could not carry a height at
+ * all, and says nothing about a block.
+ */
+ public static boolean isProven(BlockTransaction blockTransaction) {
+ Sha256Hash blockHash = blockTransaction.getBlockHash();
+ return blockHash != null && !Sha256Hash.ZERO_HASH.equals(blockHash);
+ }
+
+ /**
+ * The given transaction carrying the block it was proven to be in, where this session has proved its height, and the transaction itself where it
+ * has not. The proof outlives the objects a wallet or a fetch hands out, none of which carry what was established after they were built, so what
+ * has been proven is asked here rather than read from whichever of them a form was last handed.
+ */
+ public static BlockTransaction getProvenTransaction(BlockTransaction blockTransaction) {
+ if(blockTransaction.getHeight() <= 0 || isProven(blockTransaction)) {
+ return blockTransaction;
+ }
+
+ BlockHeader provenHeader = provenTransactionHeaders.get(blockTransaction.getHashAsString() + ":" + blockTransaction.getHeight());
+ if(provenHeader == null) {
+ return blockTransaction;
+ }
+
+ return getProvenTransaction(blockTransaction, provenHeader);
+ }
+
+ /**
+ * The given transaction carrying the given block, for a caller holding the header a proof was verified against. What was proven is shown from the
+ * proof itself rather than from the cache above, which is an optimisation and does not remember a proof obtained across a reorg.
+ */
+ public static BlockTransaction getProvenTransaction(BlockTransaction blockTransaction, BlockHeader provenHeader) {
+ return new BlockTransaction(blockTransaction.getHash(), blockTransaction.getHeight(), provenHeader.getTimeAsDate(),
+ blockTransaction.getFee(), blockTransaction.getTransaction(), provenHeader.getHash());
+ }
+
/**
* Whether the branch reconstructs the merkle root of the given verified header from the given transaction. Any malformed proof is a proof that
* does not verify rather than a broken session.
@@ -1534,6 +1626,9 @@ private void reconcile(HeaderStore store, int tipHeight) throws ServerException,
//one - only the current tip is replaced by the next announcement. Dropped with the headers they came from
int reorganisedFrom = forkHeight;
retrievedBlockHeaders.keySet().removeIf(height -> height > reorganisedFrom);
+ //Cleared whole rather than above the fork, the height being half of the key: a reorg is rare, and what it costs is one proof per transaction reopened
+ provenTransactionHeaders.clear();
+ reorgCount++;
try {
store.append(segment);
} finally {
@@ -1689,7 +1784,7 @@ static List<BlockHeader> getLinkedHeaders(BlockHeaders chunk, int count, Sha256H
* against headers it also supplied establishes nothing it has not already been trusted for. Cormorant declares as much in its capability, but
* bwt takes over where cormorant cannot start, and the same node should not verify or not according to which one did.
*/
- static boolean isVerifyingTransactions() {
+ public static boolean isVerifyingTransactions() {
if(!Config.get().isVerifyTransactions() || Config.get().getServerType() == ServerType.BITCOIN_CORE
|| serverCapability == null || !serverCapability.supportsMerkleProofs()) {
return false;
@@ -3681,6 +3776,38 @@ protected Map<String, TransactionMerkleProof> call() {
}
}
+ /**
+ * Proves a transaction the tab is displaying, off the thread that fetched it: the proof is a round trip of its own, and the header it is checked
+ * against can cost a range fetch below the last pin, neither of which the transaction should wait behind to be shown.
+ * <p>
+ * Succeeding with no header is the server answering that it will not substantiate the height, which is a finding. Failing is not reaching the
+ * server at all, which is nothing, and the two are kept apart here so that the caller can tell what it has been told.
+ */
+ public static class TransactionVerificationService extends Service<BlockHeader> {
+ private final Sha256Hash txid;
+ private final int height;
+
+ public TransactionVerificationService(Sha256Hash txid, int height) {
+ this.txid = txid;
+ this.height = height;
+ }
+
+ @Override
+ protected Task<BlockHeader> createTask() {
+ return new Task<>() {
+ @Override
+ protected BlockHeader call() throws ServerException {
+ //getTransport() opens one where there is none, so a task running after the connection closed must not reach it
+ if(!isConnected()) {
+ throw new ServerException("Not connected");
+ }
+
+ return new ElectrumServer().getProvenHeader(txid, height);
+ }
+ };
+ }
+ }
+
public static class BroadcastTransactionService extends Service<Sha256Hash> {
private final Transaction transaction;
private final Long fee;
### src/main/java/com/sparrowwallet/sparrow/net/VerboseTransaction.java
@@ -50,6 +50,9 @@ public BlockTransaction getBlockTransaction() {
throw new IllegalStateException("Server returned transaction " + transaction.getTxId() + " for declared txid " + declaredTxid);
}
- return new BlockTransaction(declaredTxid, getHeight(), getDate(), 0L, transaction, blockhash == null ? null : Sha256Hash.wrap(blockhash));
+ //A block hash records the block a transaction was proven to be in, and nothing here is proven: the server's own is dropped, and only the marker
+ //for a response that could not carry one is passed on, that being a statement about the response rather than about the block
+ boolean incomplete = Sha256Hash.ZERO_HASH.toString().equals(blockhash);
+ return new BlockTransaction(declaredTxid, getHeight(), getDate(), 0L, transaction, incomplete ? Sha256Hash.ZERO_HASH : null);
}
}
### src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
@@ -90,6 +90,11 @@ public class HeadersController extends TransactionFormController implements Init
private HeadersForm headersForm;
+ //The txid:height last asked about, so that a redraw does not ask again, and the request that asked it
+ private String verificationRequestedPair;
+
+ private ElectrumServer.TransactionVerificationService verificationService;
+
@FXML
private IdLabel id;
@@ -827,24 +832,35 @@ private void updateEditable(boolean editable) {
locktimeCurrentHeight.setDisable(!locktimeEnabled);
}
- private void updateBlockchainForm(BlockTransaction blockTransaction, Integer currentHeight) {
+ private void updateBlockchainForm(BlockTransaction reportedTransaction, Integer currentHeight) {
signaturesForm.setVisible(false);
blockchainForm.setVisible(true);
updateEditable(false);
+ //Carrying the block it was proven to be in where this session has proved it, so that the form reads the same whichever redraw arrives last:
+ //the wallet's own transaction and the one an input fetch reports are both handed over without what a proof has since established
+ BlockTransaction blockTransaction = ElectrumServer.getProvenTransaction(reportedTransaction);
+
+ //A block hash is recorded only where the transaction was proven to be in that block, so a confirmed height without one is the server's word
+ //alone. Asked of what is shown rather than of the wallets: a height a wallet refused and demoted is fetched from the server again, and the
+ //server's answer is what reaches this form
+ boolean unverified = blockTransaction.getHeight() > 0 && !ElectrumServer.isProven(blockTransaction) && ElectrumServer.isVerifyingTransactions();
+ String unverifiedSuffix = unverified ? " (Unverified)" : "";
+ blockStatus.setTooltip(unverified ? new Tooltip("The server reported this height but has not proven the transaction was included in that block") : null);
+
if(Sha256Hash.ZERO_HASH.equals(blockTransaction.getBlockHash()) && blockTransaction.getHeight() == 0 && headersForm.getPsbt() == null) {
//A zero block hash indicates that this blocktransaction is incomplete and the height is likely incorrect if we are not sending a tx
blockStatus.setText("Unknown");
} else if(currentHeight == null) {
- blockStatus.setText(blockTransaction.getHeight() > 0 ? "Confirmed" : "Unconfirmed");
+ blockStatus.setText(blockTransaction.getHeight() > 0 ? "Confirmed" + unverifiedSuffix : "Unconfirmed");
} else {
int confirmations = blockTransaction.getHeight() > 0 ? currentHeight - blockTransaction.getHeight() + 1 : 0;
if(confirmations == 0) {
blockStatus.setText("Unconfirmed");
} else if(confirmations == 1) {
- blockStatus.setText(confirmations + " Confirmation");
+ blockStatus.setText(confirmations + " Confirmation" + unverifiedSuffix);
} else {
- blockStatus.setText(confirmations + " Confirmations");
+ blockStatus.setText(confirmations + " Confirmations" + unverifiedSuffix);
}
if(confirmations <= BlockTransactionHash.BLOCKS_TO_CONFIRM) {
@@ -894,6 +910,58 @@ private void updateBlockchainForm(BlockTransaction blockTransaction, Integer cur
} else {
signedByField.setVisible(false);
}
+
+ if(unverified) {
+ verifyBlockTransaction(blockTransaction);
+ }
+ }
+
+ /**
+ * Asks the server to prove the height it reported for a transaction that did not arrive proven, whether or not a wallet holds it: what a wallet
+ * proved is carried on the transaction it holds, and a height fetched from the server again is the server's however familiar the txid. Started
+ * from the form rather than from the fetch so that the transaction is shown while this runs, and shown unverified until it returns a header: a
+ * server that declines the proof leaves the claim standing as its own.
+ * <p>
+ * The pair records what this server has answered, so only an answer records it. Asked while offline, or cut off partway, nothing has been
+ * answered and the next redraw asks again - the capability that decides whether to ask at all is settled at connect and outlives the connection,
+ * so without this an offline redraw would leave the tab reading unverified on a question no server was ever put.
+ */
+ private void verifyBlockTransaction(BlockTransaction blockTransaction) {
+ String pair = blockTransaction.getHashAsString() + ":" + blockTransaction.getHeight();
+ if(pair.equals(verificationRequestedPair) || !AppServices.isConnected()) {
+ return;
+ }
+
+ verificationRequestedPair = pair;
+ ElectrumServer.TransactionVerificationService transactionVerificationService =
+ new ElectrumServer.TransactionVerificationService(blockTransaction.getHash(), blockTransaction.getHeight());
+ verificationService = transactionVerificationService;
+ transactionVerificationService.setOnSucceeded(workerStateEvent -> {
+ BlockHeader provenHeader = transactionVerificationService.getValue();
+ //Asked of the chain as it was: a reorg since drops the request, the proof having reconstructed a header that is no longer at that height
+ if(verificationService == transactionVerificationService && headersForm.getTransaction().getTxId().equals(blockTransaction.getHash())) {
+ //Written only where there is something to write: the transaction captured when this was asked is older than whatever the form has
+ //learned since, and a wallet proving what this request was refused is exactly what it would overwrite. Built from the header the proof
+ //was verified against rather than looked up, so what is shown does not rest on the proof having been remembered
+ if(provenHeader != null) {
+ headersForm.setBlockTransaction(ElectrumServer.getProvenTransaction(blockTransaction, provenHeader));
+ }
+
+ //Redrawn whatever the answer, since a server turning out not to implement the call turns verification off, and the form would else be
+ //left qualifying a height on grounds that no longer hold
+ BlockTransaction shown = headersForm.getBlockTransaction() == null ? blockTransaction : headersForm.getBlockTransaction();
+ updateBlockchainForm(shown, AppServices.getCurrentBlockHeight());
+ }
+ });
+ transactionVerificationService.setOnFailed(workerStateEvent -> {
+ log.debug("Could not reach the server to verify transaction " + blockTransaction.getHashAsString(), workerStateEvent.getSource().getException());
+ //Only the request still outstanding releases the pair: one a reconnect has already replaced is asking about the same pair, so the pair
+ //cannot tell them apart, and letting the older failure release it would put a third request behind the one in flight
+ if(verificationService == transactionVerificationService) {
+ verificationRequestedPair = null;
+ }
+ });
+ transactionVerificationService.start();
}
private void initializeSignButton(Wallet signingWallet) {
@@ -1499,6 +1567,9 @@ public void close() {
if(transactionMempoolService != null) {
transactionMempoolService.cancel();
}
+ if(verificationService != null) {
+ verificationService.cancel();
+ }
}
@Subscribe
@@ -1515,7 +1586,8 @@ public void blockTransactionFetched(BlockTransactionFetchedEvent event) {
if(event.getTxId().equals(headersForm.getTransaction().getTxId())) {
if(event.getBlockTransaction() != null && (!Sha256Hash.ZERO_HASH.equals(event.getBlockTransaction().getBlockHash()) || headersForm.getBlockTransaction() == null)) {
updateBlockchainForm(event.getBlockTransaction(), AppServices.getCurrentBlockHeight());
- } else if(headersForm.getPsbt() == null && headersForm.getBlockTransaction() == null) {
+ } else if(headersForm.getPsbt() == null && headersForm.getBlockTransaction() == null && event.getPageStart() == 0) {
+ //Only the first page asks about the transaction itself, so only its silence says the transaction is not on chain
updateSignedTransactionForm();
}
@@ -1860,13 +1932,37 @@ public void psbtReordered(PSBTReorderedEvent event) {
}
}
+ /**
+ * A block above the fork is no longer the block a transaction at that height was proven to be in, so what was proven is dropped and asked again.
+ * Posted on the syncing thread while it holds the sync lock, so nothing is done here beyond hopping to the application thread.
+ */
+ @Subscribe
+ public void chainReorg(ChainReorgEvent event) {
+ Platform.runLater(() -> {
+ BlockTransaction blockTransaction = headersForm.getBlockTransaction();
+ if(blockTransaction != null && blockTransaction.getHeight() > event.getForkHeight()) {
+ BlockTransaction reorganised = new BlockTransaction(blockTransaction.getHash(), blockTransaction.getHeight(), null,
+ blockTransaction.getFee(), blockTransaction.getTransaction(), null);
+ headersForm.setBlockTransaction(reorganised);
+ //Both dropped, so that the proof is asked again and an answer already on its way is not applied to a chain it was not asked of
+ verificationRequestedPair = null;
+ verificationService = null;
+ updateBlockchainForm(reorganised, AppServices.getCurrentBlockHeight());
+ }
+ });
+ }
+
@Subscribe
public void connection(ConnectionEvent event) {
broadcastProgressBar.setDisable(false);
+ if(headersForm.getBlockTransaction() != null) {
+ updateBlockchainForm(headersForm.getBlockTransaction(), event.getBlockHeight());
+ }
}
@Subscribe
public void disconnection(DisconnectionEvent event) {
+ verificationRequestedPair = null;
broadcastProgressBar.setDisable(true);
if(broadcastProgressBar.getProgress() < 0) {
broadcastProgressBar.setProgress(0);
### src/main/java/com/sparrowwallet/sparrow/transaction/TransactionController.java
@@ -398,7 +398,9 @@ private void fetchThisAndInputBlockTransactions(int indexStart, int indexEnd) {
Platform.runLater(() -> EventManager.get().post(new BlockTransactionFetchedEvent(getTransaction(), walletBlockTx, Collections.emptyMap(), 0, getTransaction().getInputs().size())));
} else if(AppServices.isConnected() && indexStart < getTransaction().getInputs().size()) {
Set<Sha256Hash> references = new HashSet<>();
- if(getPSBT() == null) {
+ //Asked for with the first page alone, since a later page cannot learn anything about it that the first did not, and would replace what has
+ //been proven since with the server's unproven word. A confirmed wallet transaction is not asked for at all, being proven at its height already
+ if(getPSBT() == null && indexStart == 0 && (walletBlockTx == null || walletBlockTx.getHeight() <= 0)) {
references.add(getTransaction().getTxId());
}
@@ -411,14 +413,24 @@ private void fetchThisAndInputBlockTransactions(int indexStart, int indexEnd) {
}
if(references.isEmpty()) {
+ //A coinbase transaction the wallet already holds proven: there is no input to fetch and no reason to ask about the transaction itself,
+ //but the form is still waiting to be told what the wallet has
+ transactionsFetched = true;
+ Platform.runLater(() -> EventManager.get().post(new BlockTransactionFetchedEvent(getTransaction(), walletBlockTx, Collections.emptyMap(), indexStart, maxIndex)));
return;
}
ElectrumServer.TransactionReferenceService transactionReferenceService = new ElectrumServer.TransactionReferenceService(references);
transactionReferenceService.setOnSucceeded(successEvent -> {
- transactionsFetched = true;
+ //The first page is the one that asks about the transaction itself, so only it settles whether a reconnect need fetch again: a later
+ //page succeeding says nothing about a first page that failed, and would otherwise stand in for it
+ if(indexStart == 0) {
+ transactionsFetched = true;
+ }
Map<Sha256Hash, BlockTransaction> transactionMap = transactionReferenceService.getValue();
- BlockTransaction thisBlockTx = null;
+ //Only the page that asks about the transaction reports it, since a page that did not ask has nothing of its own to say and would
+ //otherwise answer with what the wallet holds over what the first page was told
+ BlockTransaction thisBlockTx = indexStart == 0 ? walletBlockTx : null;
Map<Sha256Hash, BlockTransaction> retrievedInputTransactions = new HashMap<>();
for(Sha256Hash txid : transactionMap.keySet()) {
BlockTransaction retrievedBlockTx = transactionMap.get(txid);
### src/test/java/com/sparrowwallet/sparrow/net/TransactionProofTest.java
@@ -44,6 +44,7 @@
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
+import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
@@ -60,6 +61,7 @@
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -123,6 +125,7 @@ public void setUp() throws Exception {
ElectrumServer.headerStore = null;
ElectrumServer.lastReorgForkHeight = Integer.MAX_VALUE;
ElectrumServer.verifiedHistoricalHeaders.clear();
+ ElectrumServer.provenTransactionHeaders.clear();
ElectrumServer.clearPreviousServerState();
ElectrumServer.getSubscribedScriptHashes().clear();
previousElectrumServerRpc = ElectrumServer.electrumServerRpc;
@@ -155,6 +158,7 @@ public void tearDown() {
ElectrumServer.headerStore = null;
ElectrumServer.lastReorgForkHeight = Integer.MAX_VALUE;
ElectrumServer.verifiedHistoricalHeaders.clear();
+ ElectrumServer.provenTransactionHeaders.clear();
//Every wallet here derives from the same key, so one test's cached script hash state is the next one's, and the caches are keyed by script hash
ElectrumServer.clearPreviousServerState();
ElectrumServer.getSubscribedScriptHashes().clear();
@@ -187,6 +191,137 @@ public void provesAConfirmedTransactionAndRecordsItsBlock() throws Exception {
assertTrue(listener.isEmpty());
}
+ /**
+ * The same proof asked for a transaction that reached the transaction tab rather than a wallet, where there is no history to demote: the header it
+ * was proven against is returned for the tab to show the block by, and nothing is reported per wallet.
+ */
+ @Test
+ public void provesATransactionOutsideAWallet() throws Exception {
+ Transaction transaction = blockTransactions.getFirst();
+ server.serveProof(transaction, PROVEN_HEIGHT, 0);
+
+ BlockHeader provenHeader = new ElectrumServer().getProvenHeader(transaction.getTxId(), PROVEN_HEIGHT);
+
+ assertNotNull(provenHeader);
+ assertEquals(chain.get(PROVEN_HEIGHT - 1).getHash(), provenHeader.getHash());
+ assertTrue(listener.isEmpty());
+ }
+
+ /**
+ * The three ways the answer is no, each of which leaves the tab showing the height as the server's word alone: a proof declined, one answered for
+ * a different block than the one asked about, and one whose branch does not reconstruct.
+ */
+ @Test
+ public void doesNotProveATransactionTheServerWillNotSubstantiate() throws Exception {
+ Transaction transaction = blockTransactions.getFirst();
+ ElectrumServer electrumServer = new ElectrumServer();
+
+ //Declined: nothing served for this pair at all
+ assertNull(electrumServer.getProvenHeader(transaction.getTxId(), PROVEN_HEIGHT));
+
+ //Answered for another block, which substantiates nothing about the height asked for
+ TransactionMerkleProof otherBlock = server.serveProof(transaction, PROVEN_HEIGHT, 0);
+ otherBlock.block_height = PROVEN_HEIGHT + 1;
+ assertNull(electrumServer.getProvenHeader(transaction.getTxId(), PROVEN_HEIGHT));
+
+ //Tampered: the branch does not reconstruct the merkle root of the header at that height
+ TransactionMerkleProof tampered = server.serveProof(transaction, PROVEN_HEIGHT, 0);
+ tampered.merkle.set(0, Sha256Hash.ZERO_HASH.toString());
+ assertNull(electrumServer.getProvenHeader(transaction.getTxId(), PROVEN_HEIGHT));
+
+ assertTrue(listener.isEmpty());
+ }
+
+ /**
+ * A proof answered once is answered for the session. A transaction reopened, or open in a second tab, does not put the same question again, which
+ * is what keeps history predating the proofs from costing a round trip every time one of it is looked at.
+ */
+ @Test
+ public void provesATransactionOutsideAWalletOnce() throws Exception {
+ Transaction transaction = blockTransactions.getFirst();
+ server.serveProof(transaction, PROVEN_HEIGHT, 0);
+ ElectrumServer electrumServer = new ElectrumServer();
+
+ assertNotNull(electrumServer.getProvenHeader(transaction.getTxId(), PROVEN_HEIGHT));
+ assertNotNull(electrumServer.getProvenHeader(transaction.getTxId(), PROVEN_HEIGHT));
+
+ assertEquals(1, server.getProofRequests());
+ }
+
+ /**
+ * A proof obtained across a reorg is returned but not remembered: the clear a reorg performs holds the sync lock, and the proof would otherwise be
+ * written behind it and left proven against a header the chain no longer holds. A later proof, with no reorg under it, is remembered as usual.
+ */
+ @Test
+ public void doesNotRememberAProofObtainedAcrossAReorg() throws Exception {
+ Transaction transaction = blockTransactions.getFirst();
+ server.serveProof(transaction, PROVEN_HEIGHT, 0);
+ server.reorgWhileProving();
+ ElectrumServer electrumServer = new ElectrumServer();
+
+ assertNotNull(electrumServer.getProvenHeader(transaction.getTxId(), PROVEN_HEIGHT));
+ assertNotNull(electrumServer.getProvenHeader(transaction.getTxId(), PROVEN_HEIGHT));
+ assertEquals(2, server.getProofRequests());
+
+ //Nothing rewound under this one, so the session remembers it and the third request is not made
+ assertNotNull(electrumServer.getProvenHeader(transaction.getTxId(), PROVEN_HEIGHT));
+ assertEquals(2, server.getProofRequests());
+ }
+
+ /**
+ * What a proof established is carried onto a transaction built without it, which is how a form reads the same whichever of the objects handed
+ * around for one transaction it is redrawn from. A transaction not proven this session is returned as it is.
+ */
+ @Test
+ public void carriesAProofOntoATransactionBuiltWithoutIt() throws Exception {
+ Transaction transaction = blockTransactions.getFirst();
+ server.serveProof(transaction, PROVEN_HEIGHT, 0);
+ BlockTransaction reported = new BlockTransaction(transaction.getTxId(), PROVEN_HEIGHT, new Date(0), 0L, transaction);
+
+ assertSame(reported, ElectrumServer.getProvenTransaction(reported));
+
+ assertNotNull(new ElectrumServer().getProvenHeader(transaction.getTxId(), PROVEN_HEIGHT));
+
+ BlockTransaction proven = ElectrumServer.getProvenTransaction(reported);
+ assertEquals(chain.get(PROVEN_HEIGHT - 1).getHash(), proven.getBlockHash());
+ assertEquals(chain.get(PROVEN_HEIGHT - 1).getTimeAsDate(), proven.getDate());
+ assertEquals(PROVEN_HEIGHT, proven.getHeight());
+ }
+
+ /**
+ * Only what was proven is remembered. A server that would not prove it is not necessarily the server that will be asked next, and caching the
+ * refusal would outlast the connection that earned it.
+ */
+ @Test
+ public void doesNotRememberARefusal() throws Exception {
+ Transaction transaction = blockTransactions.getFirst();
+ server.serveProof(transaction, PROVEN_HEIGHT, 0);
+ server.refuseFirstAttempts(transaction, PROVEN_HEIGHT, 1);
+ ElectrumServer electrumServer = new ElectrumServer();
+
+ assertNull(electrumServer.getProvenHeader(transaction.getTxId(), PROVEN_HEIGHT));
+ assertNotNull(electrumServer.getProvenHeader(transaction.getTxId(), PROVEN_HEIGHT));
+
+ assertEquals(2, server.getProofRequests());
+ }
+
+ /**
+ * A server that does not implement the call is settled for the session rather than answered per transaction. Nothing else would settle it where
+ * the tab is the only caller, and every transaction would then be shown unverified against a server that had not refused anything.
+ */
+ @Test
+ public void disablesVerificationWhereTheServerLacksTheProofCall() throws Exception {
+ Transaction transaction = blockTransactions.getFirst();
+ server.serveProof(transaction, PROVEN_HEIGHT, 0);
+ server.setProofFailure(new UnsupportedMethodException("blockchain.transaction.get_merkle", null));
+
+ assertNull(new ElectrumServer().getProvenHeader(transaction.getTxId(), PROVEN_HEIGHT));
+
+ assertFalse(ElectrumServer.serverCapability.supportsMerkleProofs());
+ assertFalse(ElectrumServer.isVerifyingTransactions());
+ assertTrue(listener.isEmpty());
+ }
+
/**
* A branch that does not reconstruct the root of a verified header is the server proven wrong, which is the one outcome the dishonesty wording is
* reserved for. The transaction stays in the wallet as unconfirmed rather than being removed.
@@ -1093,6 +1228,7 @@ private class FakeProofServer extends SimpleElectrumServerRpc {
private volatile int proofFailureAfter;
private volatile int proofFailureUntil = Integer.MAX_VALUE;
private volatile RuntimeException headersFailure;
+ private volatile boolean reorgWhileProving;
public FakeProofServer(List<BlockHeader> chain) {
this.chain = List.copyOf(chain);
@@ -1102,6 +1238,10 @@ public FakeProofServer(List<BlockHeader> chain) {
@Override
public Map<String, TransactionMerkleProof> getTransactionMerkleProofs(Transport transport, Wallet wallet, Collection<BlockTransactionHash> references) {
int request = proofRequests.incrementAndGet();
+ if(reorgWhileProving) {
+ reorgWhileProving = false;
+ ElectrumServer.reorgCount++;
+ }
proofRequestKeys.add(references.stream().map(FakeProofServer::key).collect(Collectors.toCollection(LinkedHashSet::new)));
if(proofFailure != null && request > proofFailureAfter && request <= proofFailureUntil) {
throw proofFailure;
@@ -1212,6 +1352,13 @@ public TransactionMerkleProof serveProof(Transaction transaction, int height, in
return proof;
}
+ /**
+ * Rewinds the header store while the next proof is in flight, which is a reorg landing between a proof being verified and being remembered.
+ */
+ public void reorgWhileProving() {
+ this.reorgWhileProving = true;
+ }
+
public void serveProof(Sha256Hash txid, int height, TransactionMerkleProof proof) {
proofs.put(txid + ":" + height, proof);
}
### src/test/java/com/sparrowwallet/sparrow/net/VerboseTransactionTest.java
@@ -1,10 +1,12 @@
package com.sparrowwallet.sparrow.net;
+import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
public class VerboseTransactionTest {
@@ -36,6 +38,36 @@ public void acceptsSegwitTransactionDeclaredByTxid() {
assertNotEquals(blockTransaction.getTransaction().getTxId(), blockTransaction.getTransaction().getWTxId());
}
+ /**
+ * The server's own block hash is not evidence that the transaction is in that block, and everywhere a block hash is read it is read as the block
+ * the transaction was proven to be in. Dropped here, so the transaction tab can tell a proven height from a reported one.
+ */
+ @Test
+ public void dropsTheServersUnprovenBlockHash() {
+ VerboseTransaction verboseTransaction = verboseTransaction(LEGACY_TXID, LEGACY_HEX);
+ verboseTransaction.blockhash = "00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72728a054";
+ verboseTransaction.confirmations = 6;
+
+ BlockTransaction blockTransaction = verboseTransaction.getBlockTransaction();
+
+ assertNull(blockTransaction.getBlockHash());
+ }
+
+ /**
+ * The one block hash that survives says nothing about a block: it marks a response that could not carry a height at all, which the transaction tab
+ * shows as an unknown status rather than as a confirmation.
+ */
+ @Test
+ public void keepsTheMarkerForAnIncompleteResponse() {
+ VerboseTransaction verboseTransaction = verboseTransaction(LEGACY_TXID, LEGACY_HEX);
+ verboseTransaction.blockhash = Sha256Hash.ZERO_HASH.toString();
+
+ BlockTransaction blockTransaction = verboseTransaction.getBlockTransaction();
+
+ assertEquals(Sha256Hash.ZERO_HASH, blockTransaction.getBlockHash());
+ assertEquals(0, blockTransaction.getHeight());
+ }
+
@Test
public void rejectsHexNotMatchingDeclaredTxid() {
VerboseTransaction verboseTransaction = verboseTransaction(LEGACY_TXID, SEGWIT_HEX);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.