What changed, and why it matters
This commit adds user-interface support for receiving Bitcoin 'silent payments' in Sparrow Wallet. It changes how receive addresses are displayed and copied when the wallet uses the new SINGLE_SP policy type. There is no security vulnerability visible in the diff; it is a feature implementation.
No security action required. Review as normal feature code; ensure silent-payment address derivation in the drongo library is correct separately.
Security signals we found
No input from untrusted sources is parsed or executed
No cryptographic operations are introduced
No privilege changes or network calls are added
Clipboard usage is write-only and user-triggered
QR generation uses the same existing library path with fixed parameters
Evidence from the diff
The patch implements UI handling for silent payments (BIP-352 style) receive flows. In both the desktop (JavaFX) and terminal (Lanterna) UIs, it detects PolicyType.SINGLE_SP and shows a single static silent-payment scan address instead of a derived address with derivation path/last-used metadata. It adds copy-to-clipboard, QR generation, and display toggles for the silent-payment address. The QRCodeDialog title is truncated to 70 characters to avoid overly long window titles for silent-payment addresses.
Changed components
Sparrow Wallet desktop receive view (ReceiveController.java, receive.fxml, receive.css)Sparrow Wallet terminal receive dialog (ReceiveDialog.java, QRCodeDialog.java)Inspect captured patch +193 / −18
diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/QRCodeDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/QRCodeDialog.java
index e644ded..ab18cfb 100644
--- a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/QRCodeDialog.java
+++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/QRCodeDialog.java
@@ -14,7 +14,7 @@ import java.util.Map;
public class QRCodeDialog extends DialogWindow {
public QRCodeDialog(String data) throws WriterException {
- super(data);
+ super(data.length() > 70 ? data.substring(0, 70) + "..." : data);
setHints(List.of(Hint.CENTERED));
diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/ReceiveDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/ReceiveDialog.java
index acbe974..07c1ad1 100644
--- a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/ReceiveDialog.java
+++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/ReceiveDialog.java
@@ -4,6 +4,7 @@ import com.google.common.eventbus.Subscribe;
import com.googlecode.lanterna.gui2.*;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.KeyPurpose;
+import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.WalletNode;
@@ -38,19 +39,28 @@ public class ReceiveDialog extends WalletDialog {
Panel mainPanel = new Panel(new GridLayout(2).setHorizontalSpacing(2).setVerticalSpacing(1).setTopMarginSize(1));
+ boolean isSilentPayments = walletForm.getWallet().getPolicyType() == PolicyType.SINGLE_SP;
+
mainPanel.addComponent(new Label("Address"));
address = new Label("").addTo(mainPanel);
- mainPanel.addComponent(new Label("Derivation"));
- derivation = new Label("").addTo(mainPanel);
+ if(!isSilentPayments) {
+ mainPanel.addComponent(new Label("Derivation"));
+ derivation = new Label("").addTo(mainPanel);
- mainPanel.addComponent(new Label("Last Used"));
- lastUsed = new Label("").addTo(mainPanel);
+ mainPanel.addComponent(new Label("Last Used"));
+ lastUsed = new Label("").addTo(mainPanel);
+ } else {
+ derivation = new Label("");
+ lastUsed = new Label("");
+ }
Panel buttonPanel = new Panel();
buttonPanel.setLayoutManager(new GridLayout(2).setHorizontalSpacing(1));
buttonPanel.addComponent(new Button("Back", () -> onBack(Function.RECEIVE)));
- buttonPanel.addComponent(new Button("Get Fresh Address", this::refreshAddress).setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.CENTER, GridLayout.Alignment.CENTER, true, false)));
+ if(!isSilentPayments) {
+ buttonPanel.addComponent(new Button("Get Fresh Address", this::refreshAddress).setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.CENTER, GridLayout.Alignment.CENTER, true, false)));
+ }
mainPanel.addComponent(new Button("Show QR", this::showQR));
@@ -61,12 +71,17 @@ public class ReceiveDialog extends WalletDialog {
}
public void showQR() {
- if(currentEntry == null) {
- return;
- }
-
try {
- QRCodeDialog qrCodeDialog = new QRCodeDialog(currentEntry.getAddress().toString());
+ String qrAddress;
+ if(getWalletForm().getWallet().getPolicyType() == PolicyType.SINGLE_SP) {
+ qrAddress = getWalletForm().getWallet().getKeystores().getFirst().getSilentPaymentScanAddress().getAddress();
+ } else if(currentEntry != null) {
+ qrAddress = currentEntry.getAddress().toString();
+ } else {
+ return;
+ }
+
+ QRCodeDialog qrCodeDialog = new QRCodeDialog(qrAddress);
qrCodeDialog.showDialog(SparrowTerminal.get().getGui());
} catch(Exception e) {
log.error("Error creating QR", e);
@@ -76,6 +91,12 @@ public class ReceiveDialog extends WalletDialog {
public void refreshAddress() {
SparrowTerminal.get().getGuiThread().invokeLater(() -> {
+ if(getWalletForm().getWallet().getPolicyType() == PolicyType.SINGLE_SP) {
+ String silentPaymentAddress = getWalletForm().getWallet().getKeystores().getFirst().getSilentPaymentScanAddress().getAddress();
+ address.setText(silentPaymentAddress);
+ return;
+ }
+
NodeEntry freshEntry = getWalletForm().getFreshNodeEntry(KeyPurpose.RECEIVE, currentEntry);
setNodeEntry(freshEntry);
});
@@ -109,6 +130,10 @@ public class ReceiveDialog extends WalletDialog {
}
private void updateLastUsed() {
+ if(currentEntry == null) {
+ return;
+ }
+
SparrowTerminal.get().getGuiThread().invokeLater(() -> {
Set<BlockTransactionHashIndex> currentOutputs = currentEntry.getNode().getTransactionOutputs();
if(AppServices.onlineProperty().get() && currentOutputs.isEmpty()) {
diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/ReceiveController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/ReceiveController.java
index 0e901a8..5f10f51 100644
--- a/src/main/java/com/sparrowwallet/sparrow/wallet/ReceiveController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/wallet/ReceiveController.java
@@ -9,6 +9,7 @@ import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OutputDescriptor;
+import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
@@ -20,6 +21,8 @@ import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Device;
import com.sparrowwallet.sparrow.io.Hwi;
+import javafx.animation.KeyFrame;
+import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
@@ -27,9 +30,14 @@ import javafx.fxml.Initializable;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
+import javafx.scene.input.Clipboard;
+import javafx.scene.input.ClipboardContent;
+import javafx.scene.layout.AnchorPane;
+import javafx.util.Duration;
import org.controlsfx.glyphfont.Glyph;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import tornadofx.control.Form;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
@@ -44,6 +52,9 @@ public class ReceiveController extends WalletFormController implements Initializ
private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm");
+ @FXML
+ private Form receiveForm;
+
@FXML
private CopyableTextField address;
@@ -56,18 +67,45 @@ public class ReceiveController extends WalletFormController implements Initializ
@FXML
private Label lastUsed;
+ @FXML
+ private AnchorPane qrCodePane;
+
@FXML
private ImageView qrCode;
+ @FXML
+ private Form spReceiveForm;
+
+ @FXML
+ private AnchorPane spCopyPane;
+
+ @FXML
+ private TextArea spAddress;
+
+ @FXML
+ private Form scriptPubKeyForm;
+
@FXML
private ScriptArea scriptPubKeyArea;
+ @FXML
+ private Form outputDescriptorForm;
+
@FXML
private SelectableCodeArea outputDescriptor;
+ @FXML
+ private Form spQrForm;
+
+ @FXML
+ private ImageView spQrCode;
+
@FXML
private Button displayAddress;
+ @FXML
+ private Button nextAddress;
+
private NodeEntry currentEntry;
private QRDisplayDialog addressQrDialog;
@@ -80,12 +118,39 @@ public class ReceiveController extends WalletFormController implements Initializ
@Override
public void initializeView() {
address.setSkin(new AddressTextFieldSkin(address));
+ receiveForm.managedProperty().bind(receiveForm.visibleProperty());
+ qrCodePane.managedProperty().bind(qrCodePane.visibleProperty());
+ spReceiveForm.managedProperty().bind(spReceiveForm.visibleProperty());
+ spCopyPane.managedProperty().bind(spCopyPane.visibleProperty());
+ scriptPubKeyForm.managedProperty().bind(scriptPubKeyForm.visibleProperty());
+ outputDescriptorForm.managedProperty().bind(outputDescriptorForm.visibleProperty());
+ spQrForm.managedProperty().bind(spQrForm.visibleProperty());
+
+ qrCodePane.visibleProperty().bind(receiveForm.visibleProperty());
+ spReceiveForm.visibleProperty().bind(receiveForm.visibleProperty().not());
+ spCopyPane.visibleProperty().bind(receiveForm.visibleProperty().not());
+ scriptPubKeyForm.visibleProperty().bind(receiveForm.visibleProperty());
+ outputDescriptorForm.visibleProperty().bind(receiveForm.visibleProperty());
+ spQrForm.visibleProperty().bind(receiveForm.visibleProperty().not());
+
+ updateFromWalletPolicy();
+
initializeScriptField(scriptPubKeyArea);
displayAddress.managedProperty().bind(displayAddress.visibleProperty());
displayAddress.setVisible(false);
- qrCode.setOnMouseClicked(event -> {
+ nextAddress.managedProperty().bind(nextAddress.visibleProperty());
+
+ spAddress.setOnMouseClicked(event -> {
+ copySilentPaymentsAddress(null);
+ Tooltip tooltip = new Tooltip("Copied!");
+ tooltip.show(spAddress, event.getScreenX(), event.getScreenY());
+ Timeline timeline = new Timeline(new KeyFrame(Duration.seconds(1), e -> tooltip.hide()));
+ timeline.play();
+ });
+
+ qrCode.setOnMouseClicked(_ -> {
if(currentEntry != null && addressQrDialog == null) {
addressQrDialog = new QRDisplayDialog(currentEntry.getAddress().toString());
addressQrDialog.initOwner(address.getScene().getWindow());
@@ -94,9 +159,16 @@ public class ReceiveController extends WalletFormController implements Initializ
}
});
+ spQrCode.setOnMouseClicked(_ -> copySilentPaymentsAddress(null));
+
refreshAddress();
}
+ public void updateFromWalletPolicy() {
+ receiveForm.setVisible(walletForm.getWallet().getPolicyType() != PolicyType.SINGLE_SP);
+ nextAddress.setVisible(walletForm.getWallet().getPolicyType() != PolicyType.SINGLE_SP);
+ }
+
public void setNodeEntry(NodeEntry nodeEntry) {
if(currentEntry != null) {
label.textProperty().unbindBidirectional(currentEntry.labelProperty());
@@ -128,6 +200,10 @@ public class ReceiveController extends WalletFormController implements Initializ
}
private void updateLastUsed() {
+ if(currentEntry == null) {
+ return;
+ }
+
Set<BlockTransactionHashIndex> currentOutputs = currentEntry.getNode().getTransactionOutputs();
if(AppServices.isConnected() && currentOutputs.isEmpty()) {
lastUsed.setText("Never");
@@ -192,6 +268,31 @@ public class ReceiveController extends WalletFormController implements Initializ
return null;
}
+ public void copySilentPaymentsAddress(ActionEvent actionEvent) {
+ if(walletForm.getWallet().getPolicyType() == PolicyType.SINGLE_SP) {
+ ClipboardContent content = new ClipboardContent();
+ content.putString(walletForm.getWallet().getKeystores().getFirst().getSilentPaymentScanAddress().getAddress());
+ Clipboard.getSystemClipboard().setContent(content);
+ }
+ }
+
+ private Image getSilentPaymentsQrCode(String address) {
+ try {
+ QRCodeWriter qrCodeWriter = new QRCodeWriter();
+ BitMatrix qrMatrix = qrCodeWriter.encode(address, BarcodeFormat.QR_CODE, 400, 400, Map.of(EncodeHintType.MARGIN, 2));
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ MatrixToImageWriter.writeToStream(qrMatrix, "PNG", baos, new MatrixToImageConfig());
+
+ ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
+ return new Image(bais);
+ } catch(Exception e) {
+ log.error("Error generating QR", e);
+ }
+
+ return null;
+ }
+
public void getNewAddress(ActionEvent event) {
refreshAddress();
if(currentEntry != null) {
@@ -200,6 +301,16 @@ public class ReceiveController extends WalletFormController implements Initializ
}
public void refreshAddress() {
+ if(walletForm.getWallet().getPolicyType() == PolicyType.SINGLE_SP) {
+ String silentPaymentAddress = walletForm.getWallet().getKeystores().getFirst().getSilentPaymentScanAddress().getAddress();
+ spAddress.setText(silentPaymentAddress);
+ Image qrImage = getSilentPaymentsQrCode(silentPaymentAddress);
+ if(qrImage != null) {
+ spQrCode.setImage(qrImage);
+ }
+ return;
+ }
+
NodeEntry freshEntry = getWalletForm().getFreshNodeEntry(KeyPurpose.RECEIVE, currentEntry);
while(freshEntry.getLabel() != null && !freshEntry.getLabel().isEmpty()) {
freshEntry = getWalletForm().getFreshNodeEntry(KeyPurpose.RECEIVE, freshEntry);
@@ -315,6 +426,7 @@ public class ReceiveController extends WalletFormController implements Initializ
@Subscribe
public void walletAddressesChanged(WalletAddressesChangedEvent event) {
+ updateFromWalletPolicy();
displayAddress.setUserData(null);
}
diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/receive.css b/src/main/resources/com/sparrowwallet/sparrow/wallet/receive.css
index a9edfa9..b9efb46 100644
--- a/src/main/resources/com/sparrowwallet/sparrow/wallet/receive.css
+++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/receive.css
@@ -16,6 +16,14 @@
-fx-effect: dropshadow(three-pass-box, rgba(0,0,0,0.5), 10, 0, 0, 0);
}
+#spQrCode {
+ -fx-cursor: default;
+}
+
+#spQrCode:hover {
+ -fx-effect: dropshadow(three-pass-box, rgba(0,0,0,0.8), 10, 0, 0, 0);
+}
+
#lastUsedField .input-container, #derivationField .input-container {
-fx-alignment: center-left;
}
\ No newline at end of file
diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/receive.fxml b/src/main/resources/com/sparrowwallet/sparrow/wallet/receive.fxml
index d311c0d..c37b3aa 100644
--- a/src/main/resources/com/sparrowwallet/sparrow/wallet/receive.fxml
+++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/receive.fxml
@@ -32,7 +32,7 @@
<rowConstraints>
<RowConstraints />
</rowConstraints>
- <Form GridPane.columnIndex="0" GridPane.rowIndex="0">
+ <Form fx:id="receiveForm" GridPane.columnIndex="0" GridPane.rowIndex="0">
<Fieldset inputGrow="ALWAYS" text="Receive" styleClass="header">
<Field text="Address:">
<CopyableTextField fx:id="address" styleClass="address-text-field" editable="false"/>
@@ -40,22 +40,44 @@
<Field text="Label:">
<TextField fx:id="label" />
</Field>
- <Field fx:id="derivationField" text="Derivation:">
+ <Field text="Derivation:">
<CopyableLabel fx:id="derivationPath" />
</Field>
- <Field fx:id="lastUsedField" text="Last Used:">
+ <Field text="Last Used:">
<Label fx:id="lastUsed" />
</Field>
</Fieldset>
</Form>
- <AnchorPane GridPane.columnIndex="1" GridPane.rowIndex="0">
+ <AnchorPane fx:id="qrCodePane" GridPane.columnIndex="1" GridPane.rowIndex="0">
<ImageView fx:id="qrCode" styleClass="qr-code" AnchorPane.rightAnchor="5"/>
</AnchorPane>
+ <Form fx:id="spReceiveForm" GridPane.columnIndex="0" GridPane.rowIndex="0">
+ <padding>
+ <Insets bottom="10" />
+ </padding>
+ <Fieldset inputGrow="ALWAYS" text="Receive" styleClass="header">
+ <Field text="Address:">
+ <TextArea fx:id="spAddress" styleClass="address-text-field" wrapText="true" prefRowCount="2" maxHeight="52" editable="false" />
+ </Field>
+ </Fieldset>
+ </Form>
+
+ <AnchorPane fx:id="spCopyPane" GridPane.columnIndex="1" GridPane.rowIndex="0">
+ <padding>
+ <Insets bottom="10" />
+ </padding>
+ <Button text="Copy" graphicTextGap="10" AnchorPane.rightAnchor="5" AnchorPane.bottomAnchor="18" prefHeight="52" prefWidth="100" onAction="#copySilentPaymentsAddress">
+ <graphic>
+ <Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="COPY" />
+ </graphic>
+ </Button>
+ </AnchorPane>
+
<Separator styleClass="form-separator" GridPane.columnIndex="0" GridPane.columnSpan="2" GridPane.rowIndex="1" />
- <Form GridPane.columnIndex="0" GridPane.columnSpan="2" GridPane.rowIndex="2">
+ <Form fx:id="scriptPubKeyForm" GridPane.columnIndex="0" GridPane.columnSpan="2" GridPane.rowIndex="2">
<Fieldset inputGrow="SOMETIMES" text="Required ScriptPubKey">
<Field text="Script:">
<VirtualizedScrollPane>
@@ -67,7 +89,7 @@
</Fieldset>
</Form>
- <Form GridPane.columnIndex="0" GridPane.columnSpan="2" GridPane.rowIndex="3">
+ <Form fx:id="outputDescriptorForm" GridPane.columnIndex="0" GridPane.columnSpan="2" GridPane.rowIndex="3">
<Fieldset inputGrow="SOMETIMES" text="Output Descriptor">
<Field text="Descriptor:">
<VirtualizedScrollPane>
@@ -79,6 +101,14 @@
</Fieldset>
</Form>
+ <Form fx:id="spQrForm" GridPane.columnIndex="0" GridPane.rowIndex="2" alignment="CENTER">
+ <Fieldset inputGrow="SOMETIMES" text="">
+ <Field text="">
+ <ImageView fx:id="spQrCode" styleClass="qr-code" />
+ </Field>
+ </Fieldset>
+ </Form>
+
</GridPane>
</center>
<bottom>
Why this scored 15/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.