skip addresses already given out under a label and widen the gap limit on an explicit advance in the terminal receive dialog
What changed, and why it matters
This commit fixes two related Bitcoin wallet behaviors in Sparrow. First, when you ask for a new receive address, the wallet now skips any address that already has a label, because a label means that address was already given to someone. Previously, the desktop app skipped labeled addresses but the terminal app did not, so the terminal could accidentally hand the same address to two different payers. Second, when you explicitly advance past the normal gap limit, the wallet now widens the gap limit so that a later wallet recovery with the saved gap limit will still find that address. This prevents funds sent to a far-ahead address from being missed during recovery scans.
No immediate user action required; this is a defensive correctness fix. Users relying on labeled empty addresses as 'reserved' should verify behavior matches their workflow. Wallet developers should ensure gap-limit changes are persisted and tested against recovery scenarios.
Security signals we found
Address reuse prevention: labeled-but-empty addresses are now skipped consistently across desktop and terminal receive flows
Gap-limit widening on explicit advance reduces risk of missing funds during wallet recovery/rescan
Logic centralized in WalletForm to reduce UI-specific divergence
Unit tests added for both new behaviors
Evidence from the diff
The patch refactors address issuance logic in WalletForm so that getFreshNodeEntry skips NodeEntry objects whose label is non-empty, treating a label as evidence the address has already been given out. It moves the gap-limit widening helper from ReceiveController into WalletForm.ensureSufficientGapLimit(NodeEntry) and calls it from both the desktop ReceiveController.getNewAddress and the terminal ReceiveDialog.getNewAddress. A new private getUnusedNodeEntry helper separates ‘next unused derivation index’ from ‘next address not already allocated by label’. Tests verify labeled addresses are skipped and that gap limit is widened only when the issued index exceeds highestUsedIndex + existingGapLimit.
Changed components
src/main/java/com/sparrowwallet/sparrow/terminal/wallet/ReceiveDialog.javasrc/main/java/com/sparrowwallet/sparrow/wallet/ReceiveController.javasrc/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.javasrc/test/java/com/sparrowwallet/sparrow/wallet/WalletFormTest.javaInspect captured patch +85 / −16
### src/main/java/com/sparrowwallet/sparrow/terminal/wallet/ReceiveDialog.java
@@ -59,7 +59,7 @@ public ReceiveDialog(WalletForm walletForm) {
buttonPanel.setLayoutManager(new GridLayout(2).setHorizontalSpacing(1));
buttonPanel.addComponent(new Button("Back", () -> onBack(Function.RECEIVE)));
if(!isSilentPayments) {
- buttonPanel.addComponent(new Button("Get Fresh Address", this::refreshAddress).setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.CENTER, GridLayout.Alignment.CENTER, true, false)));
+ buttonPanel.addComponent(new Button("Get Fresh Address", this::getNewAddress).setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.CENTER, GridLayout.Alignment.CENTER, true, false)));
}
mainPanel.addComponent(new Button("Show QR", this::showQR));
@@ -89,6 +89,15 @@ public void showQR() {
}
}
+ public void getNewAddress() {
+ refreshAddress();
+ SparrowTerminal.get().getGuiThread().invokeLater(() -> {
+ if(currentEntry != null) {
+ getWalletForm().ensureSufficientGapLimit(currentEntry);
+ }
+ });
+ }
+
public void refreshAddress() {
SparrowTerminal.get().getGuiThread().invokeLater(() -> {
if(getWalletForm().getWallet().getPolicyType() == PolicyType.SINGLE_SP) {
### src/main/java/com/sparrowwallet/sparrow/wallet/ReceiveController.java
@@ -296,7 +296,7 @@ private Image getSilentPaymentsQrCode(String address) {
public void getNewAddress(ActionEvent event) {
refreshAddress();
if(currentEntry != null) {
- ensureSufficientGapLimit(currentEntry.getNode().getIndex());
+ walletForm.ensureSufficientGapLimit(currentEntry);
}
}
@@ -312,26 +312,12 @@ public void refreshAddress() {
}
NodeEntry freshEntry = getWalletForm().getFreshNodeEntry(KeyPurpose.RECEIVE, currentEntry);
- while(freshEntry.getLabel() != null && !freshEntry.getLabel().isEmpty()) {
- freshEntry = getWalletForm().getFreshNodeEntry(KeyPurpose.RECEIVE, freshEntry);
- }
setNodeEntry(freshEntry);
if(addressQrDialog != null) {
addressQrDialog.close();
}
}
- private void ensureSufficientGapLimit(int index) {
- Wallet wallet = getWalletForm().getWallet();
- Integer highestIndex = wallet.getNode(KeyPurpose.RECEIVE).getHighestUsedIndex();
- int highestUsedIndex = highestIndex == null ? -1 : highestIndex;
- int existingGapLimit = wallet.getGapLimit();
- if(index > highestUsedIndex + existingGapLimit) {
- wallet.setGapLimit(Math.max(wallet.getGapLimit(), index - highestUsedIndex));
- EventManager.get().post(new WalletGapLimitChangedEvent(getWalletForm().getWalletId(), wallet, existingGapLimit));
- }
- }
-
@SuppressWarnings("unchecked")
public void displayAddress(ActionEvent event) {
Wallet wallet = getWalletForm().getWallet();
### src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java
@@ -462,6 +462,16 @@ public NodeEntry getNodeEntry(KeyPurpose keyPurpose) {
}
public NodeEntry getFreshNodeEntry(KeyPurpose keyPurpose, NodeEntry currentEntry) {
+ NodeEntry freshEntry = getUnusedNodeEntry(keyPurpose, currentEntry);
+ //A label marks an address already given out to a payer, even though nothing has been received to it yet
+ while(freshEntry.getLabel() != null && !freshEntry.getLabel().isEmpty()) {
+ freshEntry = getUnusedNodeEntry(keyPurpose, freshEntry);
+ }
+
+ return freshEntry;
+ }
+
+ private NodeEntry getUnusedNodeEntry(KeyPurpose keyPurpose, NodeEntry currentEntry) {
NodeEntry rootEntry = getNodeEntry(keyPurpose);
WalletNode freshNode = getWallet().getFreshNode(keyPurpose, currentEntry == null ? null : currentEntry.getNode());
@@ -477,6 +487,17 @@ public NodeEntry getFreshNodeEntry(KeyPurpose keyPurpose, NodeEntry currentEntry
return freshEntry;
}
+ public void ensureSufficientGapLimit(NodeEntry nodeEntry) {
+ WalletNode node = nodeEntry.getNode();
+ Integer highestIndex = wallet.getNode(node.getKeyPurpose()).getHighestUsedIndex();
+ int highestUsedIndex = highestIndex == null ? -1 : highestIndex;
+ int existingGapLimit = wallet.getGapLimit();
+ if(node.getIndex() > highestUsedIndex + existingGapLimit) {
+ wallet.setGapLimit(Math.max(wallet.getGapLimit(), node.getIndex() - highestUsedIndex));
+ EventManager.get().post(new WalletGapLimitChangedEvent(getWalletId(), wallet, existingGapLimit));
+ }
+ }
+
public WalletTransactionsEntry getWalletTransactionsEntry() {
if(walletTransactionsEntry == null) {
walletTransactionsEntry = new WalletTransactionsEntry(wallet);
### src/test/java/com/sparrowwallet/sparrow/wallet/WalletFormTest.java
@@ -164,6 +164,59 @@ public void doesNotReportANodeTwice() {
assertEquals(List.of(node), changedNodes);
}
+ /**
+ * A labelled address with no history has already been given out, so the desktop and terminal receive views both pass over it rather than handing it to
+ * a second payer.
+ */
+ @Test
+ public void freshEntrySkipsLabelledAddresses() {
+ Wallet wallet = testWallet();
+ receiveNode(wallet, 0).setLabel("Invoice 1");
+ receiveNode(wallet, 1).setLabel("Invoice 2");
+ WalletForm walletForm = new WalletForm(null, wallet);
+
+ NodeEntry freshEntry = walletForm.getFreshNodeEntry(KeyPurpose.RECEIVE, null);
+ assertEquals(2, freshEntry.getNode().getIndex());
+ assertEquals(3, walletForm.getFreshNodeEntry(KeyPurpose.RECEIVE, freshEntry).getNode().getIndex());
+
+ //An empty label does not allocate
+ receiveNode(wallet, 3).setLabel("");
+ assertEquals(3, walletForm.getFreshNodeEntry(KeyPurpose.RECEIVE, freshEntry).getNode().getIndex());
+ }
+
+ /**
+ * Stepping past the gap limit widens it, so that a recovery with the persisted limit still reaches the address that was issued. An address within the
+ * limit leaves it unchanged.
+ */
+ @Test
+ public void issuingAnAddressPastTheGapLimitWidensIt() {
+ Wallet wallet = testWallet();
+ WalletForm walletForm = new WalletForm(null, wallet) {
+ @Override
+ public String getWalletId() {
+ return "test";
+ }
+ };
+
+ int gapLimit = wallet.getGapLimit();
+ walletForm.ensureSufficientGapLimit(walletForm.getFreshNodeEntry(KeyPurpose.RECEIVE, entryAt(walletForm, gapLimit - 2)));
+ assertEquals(gapLimit, wallet.getGapLimit());
+
+ walletForm.ensureSufficientGapLimit(walletForm.getFreshNodeEntry(KeyPurpose.RECEIVE, entryAt(walletForm, gapLimit + 4)));
+ assertEquals(gapLimit + 6, wallet.getGapLimit());
+
+ //Measured from the highest used address rather than from the first: index 29 is past the limit from nothing used, and within it from index 10
+ receiveNode(wallet, 10).getTransactionOutputs().add(new BlockTransactionHashIndex(TXID, HEIGHT, new Date(1600000000000L), 0L, 0, 10000));
+ walletForm.ensureSufficientGapLimit(walletForm.getFreshNodeEntry(KeyPurpose.RECEIVE, entryAt(walletForm, 28)));
+ assertEquals(gapLimit + 6, wallet.getGapLimit());
+ }
+
+ private static NodeEntry entryAt(WalletForm walletForm, int index) {
+ Wallet wallet = walletForm.getWallet();
+ wallet.getNode(KeyPurpose.RECEIVE).fillToIndex(wallet, index);
+ return new NodeEntry(wallet, receiveNode(wallet, index));
+ }
+
private static BlockTransaction blockTransaction(Date date, Sha256Hash blockHash) {
return new BlockTransaction(TXID, HEIGHT, date, 0L, null, blockHash);
}Why this scored 37/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.