Compare commits

..

37 Commits
sp ... master

Author SHA1 Message Date
Craig Raw
3dc50682a3 upgrade lanterna to v3.1.5 2026-06-23 09:32:44 +02:00
Craig Raw
d5b119e338 bump to v2.5.3 2026-05-31 14:24:55 +02:00
Craig Raw
b457caa5d2 revise wording for non-default sighash warnings 2026-05-31 12:27:16 +02:00
Craig Raw
61ed816c87 improve verification of psbt sighash types 2026-05-31 11:55:38 +02:00
Craig Raw
9a603d7547 make date axis formatter tests locale-stable 2026-05-30 17:57:05 +02:00
PeterXMR
ac666545be
show full year on balance chart x-axis 2026-05-30 16:54:27 +02:00
Craig Raw
c4c77d84e0 use configured unit format in send to many dialog instead of jvm default 2026-05-30 14:04:09 +02:00
Craig Raw
8d126869a6 fix potential off by 1 sat rounding error on imported send to many dialog amounts 2026-05-30 12:32:05 +02:00
Craig Raw
464fade68f improve url validation for auth47 and lnurl-auth 2026-05-30 11:34:53 +02:00
Craig Raw
79517da131 fix potential npes resulting from get transactions 2026-05-28 14:51:24 +02:00
Craig Raw
086297436e followup for precomputed sp outputs 2026-05-26 15:40:31 +02:00
Craig Raw
d69e274b18 warn on loading transactions with non-zero outputs of unknown script type 2026-05-26 12:37:27 +02:00
Craig Raw
d1e67ad4a0 update lark for hid4java 2026-05-24 19:01:46 +02:00
Craig Raw
37ca98c2b0 implement dust detection for sp wallets on received utxos at a higher default limit 2026-05-24 11:23:33 +02:00
Craig Raw
287c943b44 add custom context menu to signature text area in message sign dialog 2026-05-23 15:17:08 +02:00
Craig Raw
cb92f76546 improve loaded psbt verification 2026-05-23 14:52:35 +02:00
Craig Raw
24e9c39cb8 bump to v2.5.2 2026-05-22 18:35:16 +02:00
Craig Raw
754ebf7bbf fix incorrect script type selected in settings on p2tr wallet load 2026-05-22 17:32:41 +02:00
Craig Raw
bc7a0be87e update bip322 implementation to match completed spec 2026-05-22 15:00:53 +02:00
Craig Raw
87af1ed9f5 fix potential npe on transaction entry tooltip 2026-05-22 10:28:17 +02:00
Craig Raw
da476c9d77 bump to v2.5.1 2026-05-21 13:38:15 +02:00
Craig Raw
d8e4582b3c minor ui tweaks followup 2026-05-21 11:45:57 +02:00
Craig Raw
ad5c695946 minor ui tweaks 2026-05-21 11:20:11 +02:00
Craig Raw
3150a96aab bump to v2.5.0 2026-05-21 09:27:26 +02:00
Craig Raw
28521cbc1a update wallet settings help labels 2026-05-20 19:49:49 +02:00
Craig Raw
79bbe7df9f finalize external inputs in cross-wallet psbts to avoid empty witnesses 2026-05-19 18:16:08 +02:00
Craig Raw
e339c9f51a extend post-broadcast mempool poll timeout to support bitcoin core privatebroadcast 2026-05-19 12:27:41 +02:00
Craig Raw
6dd1bda0cb update jzbar to v0.4.0 2026-05-19 11:28:00 +02:00
Craig Raw
9fe0d17aac add frigate.2140.dev public electrum server and auto-select based on requirements for open wallets 2026-05-19 11:04:04 +02:00
Craig Raw
a2eb937fce add bip322 message signing for silent payments wallets 2026-05-18 14:56:50 +02:00
Craig Raw
e985a03a58 persist silent payment address mappings for safe rbf of sp-sending transactions 2026-05-18 12:41:56 +02:00
Craig Raw
273d2aacf3 release electrum transport read lock during socket reads to avoid client request starvation 2026-05-15 11:00:06 +02:00
Craig Raw
97383a4e35 restrict to required sighash types when sending sp outputs 2026-05-15 09:35:29 +02:00
Craig Raw
712750a25c hide receive actions for address entry cells in sp wallets 2026-05-15 08:15:44 +02:00
Craig Raw
1be9ac1072 hide wallet rescan hyperlink when nothing further can be scanned 2026-05-14 12:40:02 +02:00
Craig Raw
a035767e38 default sp wallet birthdate to creation time to avoid full rescans 2026-05-14 11:23:12 +02:00
Craig Raw
1ad237c623 switch electrum server notification detection to streaming json token parse 2026-05-14 10:10:41 +02:00
42 changed files with 983 additions and 175 deletions

View File

@ -20,7 +20,7 @@ if(System.getProperty("os.arch") == "aarch64") {
def headless = "true".equals(System.getProperty("java.awt.headless"))
group = 'com.sparrowwallet'
version = '2.4.3'
version = '2.5.3'
repositories {
mavenCentral()
@ -104,12 +104,12 @@ dependencies {
implementation('org.apache.commons:commons-lang3:3.20.0')
implementation('org.apache.commons:commons-compress:1.28.0')
implementation('com.github.librepdf:openpdf:1.3.43')
implementation('com.googlecode.lanterna:lanterna:3.1.3')
implementation('com.googlecode.lanterna:lanterna:3.1.5')
implementation('net.coobird:thumbnailator:0.4.21')
implementation('com.github.hervegirod:fxsvgimage:1.1')
implementation('com.sparrowwallet:toucan:0.9.0')
implementation('com.jcraft:jzlib:1.1.3')
implementation('io.github.doblon8:jzbar:0.3.1')
implementation('io.github.doblon8:jzbar:0.4.0')
testImplementation('org.junit.jupiter:junit-jupiter-api:5.14.1')
testRuntimeOnly('org.junit.jupiter:junit-jupiter-engine:5.14.1')
testRuntimeOnly('org.junit.platform:junit-platform-launcher')

View File

@ -56,7 +56,7 @@ sudo apt install -y rpm fakeroot binutils
First, assign a temporary variable in your shell for the specific release you want to build. For the current one specify:
```shell
GIT_TAG="2.4.2"
GIT_TAG="2.5.2"
```
The project can then be initially cloned as follows:

2
drongo

@ -1 +1 @@
Subproject commit 29cd4b7909a2d891107af55b1d4d6c2690cfa2ab
Subproject commit 077d2142cc3aad84f6f58868cf8f17fc61027fdc

2
lark

@ -1 +1 @@
Subproject commit 45e7a2f97eaf15fc31ef151194703f0f368e264c
Subproject commit e9c6f35fe66aee105ef3c532fcefeb7130dab169

View File

@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.4.3</string>
<string>2.5.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->

View File

@ -1328,13 +1328,16 @@ public class AppController implements Initializable {
return;
}
WalletNameDialog nameDlg = new WalletNameDialog(wallet.getName(), true, wallet.getBirthDate());
WalletNameDialog nameDlg = new WalletNameDialog(wallet.getName(), true, wallet.getPolicyType(), wallet.getBirthDate(), false);
nameDlg.initOwner(rootStack.getScene().getWindow());
Optional<WalletNameDialog.NameAndBirthDate> optNameAndBirthDate = nameDlg.showAndWait();
if(optNameAndBirthDate.isPresent()) {
WalletNameDialog.NameAndBirthDate nameAndBirthDate = optNameAndBirthDate.get();
wallet.setName(nameAndBirthDate.getName());
wallet.setBirthDate(nameAndBirthDate.getBirthDate());
if(wallet.getPolicyType() == PolicyType.SINGLE_SP && wallet.getBirthDate() == null) {
wallet.setBirthDate(new Date());
}
} else {
return;
}
@ -1473,7 +1476,7 @@ public class AppController implements Initializable {
WalletForm selectedWalletForm = getSelectedWalletForm();
if(selectedWalletForm != null) {
Wallet wallet = selectedWalletForm.getWallet();
if(wallet.getPolicyType() == PolicyType.SINGLE_HD) {
if(wallet.getPolicyType() == PolicyType.SINGLE_HD || wallet.getPolicyType() == PolicyType.SINGLE_SP) {
//Can sign and verify
messageSignDialog = new MessageSignDialog(wallet);
}
@ -1508,7 +1511,7 @@ public class AppController implements Initializable {
bitcoinUnit = wallet.getAutoUnit();
}
sendToManyDialog = new SendToManyDialog(bitcoinUnit, initialPayments);
sendToManyDialog = new SendToManyDialog(bitcoinUnit, Config.get().getUnitFormat(), initialPayments);
sendToManyDialog.initModality(Modality.NONE);
Optional<List<Payment>> optPayments = sendToManyDialog.showAndWait();
sendToManyDialog = null;
@ -2055,6 +2058,34 @@ public class AppController implements Initializable {
AppServices.showErrorDialog("Invalid PSBT", e.getMessage());
return;
}
try {
psbt.verifySigHashes();
} catch(PSBTSignatureException e) {
Optional<ButtonType> result = AppServices.showWarningDialog("Non-Default Sighash",
e.getMessage() + "\n\nReview this PSBT carefully before signing.\n\nOpen the transaction?", ButtonType.YES, ButtonType.NO);
if(result.isEmpty() || result.get() != ButtonType.YES) {
return;
}
}
}
//Skip the warning for already-confirmed transactions loaded for inspection
if(blockTransaction == null) {
List<TransactionOutput> unknownScriptOutputs = new ArrayList<>();
for(int i = 0; i < transaction.getOutputs().size(); i++) {
TransactionOutput txOutput = transaction.getOutputs().get(i);
if(txOutput.getValue() > 0 && txOutput.getScript().getToAddress() == null) {
//Silent payment outputs have an empty script and non-zero value until the recipient script is computed
if(psbt != null && i < psbt.getPsbtOutputs().size() && psbt.getPsbtOutputs().get(i).getSilentPaymentAddress() != null) {
continue;
}
unknownScriptOutputs.add(txOutput);
}
}
if(!unknownScriptOutputs.isEmpty() && !confirmUnknownScriptOutputs(unknownScriptOutputs)) {
return;
}
}
try {
@ -2161,6 +2192,23 @@ public class AppController implements Initializable {
return result.isPresent() && result.get() == ButtonType.YES;
}
private boolean confirmUnknownScriptOutputs(List<TransactionOutput> unknownScriptOutputs) {
long totalAmount = unknownScriptOutputs.stream().mapToLong(TransactionOutput::getValue).sum();
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
BitcoinUnit unit = Config.get().getBitcoinUnit();
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
unit = totalAmount >= BitcoinUnit.getAutoThreshold() ? BitcoinUnit.BTC : BitcoinUnit.SATOSHIS;
}
String amount = unit.equals(BitcoinUnit.BTC) ? format.formatBtcValue(totalAmount) + " BTC" : format.formatSatsValue(totalAmount) + " sats";
String outputDesc = unknownScriptOutputs.size() == 1 ? "an output" : unknownScriptOutputs.size() + " outputs";
Optional<ButtonType> result = AppServices.showWarningDialog("Unknown Script Type",
"This transaction contains " + outputDesc + " of a non-standard or unrecognised script type, totalling " + amount + ".\n\n" +
"Sparrow cannot resolve these outputs to addresses, so they will not appear in the transaction diagram. " +
"Review the individual output(s) in the transaction tree carefully before signing or broadcasting.\n\n" +
"Open the transaction?", ButtonType.YES, ButtonType.NO);
return result.isPresent() && result.get() == ButtonType.YES;
}
private String getTabName(Tab tab) {
return ((Label)tab.getGraphic()).getText();
}

View File

@ -69,9 +69,11 @@ import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.AppController.CONNECTION_FAILED_PREFIX;
import static com.sparrowwallet.sparrow.control.DownloadVerifierDialog.*;
public class AppServices {
@ -367,15 +369,18 @@ public class AppServices {
onlineProperty.setValue(false);
onlineProperty.addListener(onlineServicesListener);
log.debug("Connection failed", failEvent.getSource().getException());
if(Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER) {
Config.get().changePublicServer();
connectionService.setPeriod(Duration.seconds(PUBLIC_SERVER_RETRY_PERIOD_SECS));
boolean changed = changePublicServer();
connectionService.setPeriod(changed ? Duration.seconds(PUBLIC_SERVER_RETRY_PERIOD_SECS) : Duration.seconds(PRIVATE_SERVER_RETRY_PERIOD_SECS));
EventManager.get().post(new ConnectionFailedEvent(failEvent.getSource().getException()));
if(!changed) {
Platform.runLater(() -> EventManager.get().post(new StatusEvent(CONNECTION_FAILED_PREFIX + "No public servers available that can serve the open wallets, retrying later...")));
}
} else {
connectionService.setPeriod(Duration.seconds(PRIVATE_SERVER_RETRY_PERIOD_SECS));
EventManager.get().post(new ConnectionFailedEvent(failEvent.getSource().getException()));
}
log.debug("Connection failed", failEvent.getSource().getException());
EventManager.get().post(new ConnectionFailedEvent(failEvent.getSource().getException()));
});
return connectionService;
@ -866,6 +871,22 @@ public class AppServices {
return Storage.isWalletFile(file);
}
public boolean changePublicServer() {
List<PolicyType> policyTypes = getOpenWallets().keySet().stream().map(Wallet::getPolicyType).filter(Objects::nonNull).collect(Collectors.toList());
return changePublicServer(policyTypes.isEmpty() ? List.of(PolicyType.SINGLE_HD) : policyTypes);
}
private boolean changePublicServer(List<PolicyType> policyTypes) {
Config config = Config.get();
List<Server> otherServers = PublicElectrumServer.getServers().stream().filter(pes -> pes.supportsAllPolicyTypes(policyTypes))
.map(PublicElectrumServer::getServer).filter(server -> !server.equals(config.getPublicElectrumServer())).collect(Collectors.toList());
if(!otherServers.isEmpty()) {
config.setPublicElectrumServer(otherServers.get(ThreadLocalRandom.current().nextInt(otherServers.size())));
return true;
}
return false;
}
public static Optional<ButtonType> showWarningDialog(String title, String content, ButtonType... buttons) {
return showAlertDialog(title, content, Alert.AlertType.WARNING, buttons);
}
@ -1463,9 +1484,16 @@ public class AppServices {
@Subscribe
public void walletHistoryFailed(WalletHistoryFailedEvent event) {
if(Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER && isConnected()) {
String currentName = Config.get().getServerDisplayName();
onlineProperty.set(false);
log.warn("Failed to fetch wallet history from " + Config.get().getServerDisplayName() + ", reconnecting to another server...");
Config.get().changePublicServer();
boolean changed = changePublicServer();
if(changed) {
log.warn("Failed to fetch wallet history from " + currentName + ", reconnecting to another server...");
} else {
log.warn("Failed to fetch wallet history from " + currentName + ", retrying later");
connectionService.setDelay(Duration.seconds(PRIVATE_SERVER_RETRY_PERIOD_SECS));
EventManager.get().post(new StatusEvent("Wallet load failed: No other public servers available that can serve the open wallets, retrying later..."));
}
onlineProperty.set(true);
}
}

View File

@ -18,7 +18,7 @@ import java.util.*;
public class SparrowWallet {
public static final String APP_ID = "sparrow";
public static final String APP_NAME = "Sparrow";
public static final String APP_VERSION = "2.4.3";
public static final String APP_VERSION = "2.5.3";
public static final String APP_VERSION_SUFFIX = "";
public static final String APP_HOME_PROPERTY = "sparrow.home";
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";

View File

@ -16,7 +16,10 @@ import com.sparrowwallet.sparrow.event.WalletDataChangedEvent;
import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.ServerType;
import com.sparrowwallet.sparrow.net.cormorant.Cormorant;
import com.sparrowwallet.sparrow.net.cormorant.bitcoind.BitcoindClient;
import com.sparrowwallet.sparrow.wallet.Entry;
import io.reactivex.Observable;
import io.reactivex.subjects.PublishSubject;
@ -107,7 +110,7 @@ public class CoinTreeTable extends TreeTableView<Entry> {
setPlaceholder(new Label("Error loading transactions: " + event.getErrorMessage()));
} else if(event.isLoading()) {
if(event.getStatusMessage() != null) {
setPlaceholder(new Label(event.getStatusMessage() + "..."));
setPlaceholder(new Label(event.getStatusMessage() + (event.getStatusMessage().contains("...") ? "" : "...")));
} else {
setPlaceholder(new Label("Loading transactions..."));
}
@ -123,7 +126,7 @@ public class CoinTreeTable extends TreeTableView<Entry> {
StackPane stackPane = new StackPane();
stackPane.getChildren().add(AppServices.isConnecting() ? new Label("Loading transactions...") : new Label("No transactions"));
if((Config.get().getServerType() == ServerType.BITCOIN_CORE || wallet.getPolicyType() == PolicyType.SINGLE_SP) && !AppServices.isConnecting()) {
if((Config.get().getServerType() == ServerType.BITCOIN_CORE || wallet.getPolicyType() == PolicyType.SINGLE_SP) && !AppServices.isConnecting() && !isFullyScanned(wallet)) {
Hyperlink hyperlink = new Hyperlink();
hyperlink.setTranslateY(30);
hyperlink.setOnAction(event -> {
@ -150,12 +153,47 @@ public class CoinTreeTable extends TreeTableView<Entry> {
}
stackPane.getChildren().add(hyperlink);
} else if(!AppServices.isConnecting() && Config.get().getServerType() == ServerType.BITCOIN_CORE && isFullyScanned(wallet)) {
Date prunedDate = getPrunedDate();
if(prunedDate != null) {
DateFormat dateFormat = new SimpleDateFormat(DateStringConverter.FORMAT_PATTERN);
Label prunedLabel = new Label("Scanned to pruned start date of " + dateFormat.format(prunedDate));
prunedLabel.setTranslateY(30);
stackPane.getChildren().add(prunedLabel);
}
}
stackPane.setAlignment(Pos.CENTER);
return stackPane;
}
private boolean isFullyScanned(Wallet wallet) {
if(wallet.getPolicyType() == PolicyType.SINGLE_SP) {
return wallet.isValid() && ElectrumServer.isSilentPaymentsFullyCovered(wallet.getSilentPaymentScanAddress());
}
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
Date prunedDate = getPrunedDate();
return prunedDate != null && wallet.getBirthDate() != null && !wallet.getBirthDate().after(prunedDate);
}
return false;
}
private static Date getPrunedDate() {
Cormorant cormorant = ElectrumServer.getCormorant();
if(cormorant == null) {
return null;
}
BitcoindClient bitcoindClient = cormorant.getBitcoindClient();
if(bitcoindClient == null || !bitcoindClient.isPruned()) {
return null;
}
return bitcoindClient.getCachedPrunedDate();
}
protected void setupColumnSort(int defaultColumnIndex, TreeTableColumn.SortType defaultSortType) {
WalletTable.Sort columnSort = getSavedColumnSort();
if(columnSort == null) {

View File

@ -11,7 +11,7 @@ import java.util.Date;
public class DateAxisFormatter extends StringConverter<Number> {
private static final DateFormat HOUR_FORMAT = new SimpleDateFormat("HH:mm");
private static final DateFormat DAY_FORMAT = new SimpleDateFormat("d MMM");
private static final DateFormat MONTH_FORMAT = new SimpleDateFormat("MMM yy");
private static final DateFormat MONTH_FORMAT = new SimpleDateFormat("MMM yyyy");
private final DateFormat dateFormat;
private int oddCounter;

View File

@ -136,7 +136,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
HBox actionBox = new HBox();
actionBox.getStyleClass().add("cell-actions");
if(!nodeEntry.getNode().getWallet().isBip47()) {
if(!nodeEntry.getNode().getWallet().isBip47() && nodeEntry.getNode().getWallet().getPolicyType() != PolicyType.SINGLE_SP) {
Button receiveButton = new Button("");
receiveButton.setGraphic(getReceiveGlyph());
receiveButton.setOnAction(event -> {
@ -414,7 +414,8 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
private static boolean canSignMessage(WalletNode walletNode) {
Wallet wallet = walletNode.getWallet();
return wallet.getPolicyType() == PolicyType.SINGLE_HD && (!wallet.isBip47() || walletNode.getKeyPurpose() == KeyPurpose.RECEIVE);
PolicyType policyType = wallet.getPolicyType();
return (policyType == PolicyType.SINGLE_HD || policyType == PolicyType.SINGLE_SP) && (!wallet.isBip47() || walletNode.getKeyPurpose() == KeyPurpose.RECEIVE);
}
private static boolean containsWalletOutputs(TransactionEntry transactionEntry) {
@ -669,7 +670,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
public static class AddressContextMenu extends ContextMenu {
public AddressContextMenu(Address address, String outputDescriptor, NodeEntry nodeEntry, boolean addUtxoItems, TreeTableView<Entry> treetable) {
if(nodeEntry == null || !nodeEntry.getWallet().isBip47()) {
if(nodeEntry == null || (!nodeEntry.getWallet().isBip47() && nodeEntry.getWallet().getPolicyType() != PolicyType.SINGLE_SP)) {
MenuItem receiveToAddress = new MenuItem("Receive To");
receiveToAddress.setGraphic(getReceiveGlyph());
receiveToAddress.setOnAction(event -> {

View File

@ -160,6 +160,17 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
signature.setStyle("-fx-pref-height: 80px");
signature.setWrapText(true);
signature.setOnMouseClicked(event -> signature.selectAll());
ContextMenu signatureMenu = new ContextMenu();
MenuItem copyItem = new MenuItem("Copy");
copyItem.setOnAction(e -> signature.copy());
MenuItem pasteItem = new MenuItem("Paste");
pasteItem.setOnAction(e -> signature.paste());
MenuItem clearItem = new MenuItem("Clear");
clearItem.setOnAction(e -> signature.clear());
signatureMenu.getItems().addAll(copyItem, pasteItem, clearItem);
signature.setContextMenu(signatureMenu);
signatureField.getInputs().add(signature);
Field formatField = new Field();
@ -295,8 +306,8 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
}
private void checkWalletSigning(Wallet wallet) {
if(wallet.getKeystores().size() != 1 || wallet.getPolicyType() != PolicyType.SINGLE_HD) {
throw new IllegalArgumentException("Cannot sign messages using a non-HD wallet or a wallet with multiple keystores");
if(wallet.getKeystores().size() != 1 || (wallet.getPolicyType() != PolicyType.SINGLE_HD && wallet.getPolicyType() != PolicyType.SINGLE_SP)) {
throw new IllegalArgumentException("Cannot sign messages using this wallet type");
}
}
@ -377,18 +388,24 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
private void signUnencryptedKeystore(Wallet decryptedWallet) {
try {
Keystore keystore = decryptedWallet.getKeystores().getFirst();
ECKey privKey = keystore.getKey(walletNode);
String signatureText;
if(isBip322()) {
ScriptType scriptType = decryptedWallet.getScriptType();
signatureText = Bip322.signMessageBip322(scriptType, message.getText().trim(), privKey);
if(decryptedWallet.getPolicyType() == PolicyType.SINGLE_SP) {
ECKey spendPrivKey = keystore.getSpendPrivateKey(Collections.emptyMap());
signatureText = Bip322.signMessageBip322Sp(walletNode.getAddress(), message.getText().trim(), spendPrivKey, walletNode.getSilentPaymentTweak());
spendPrivKey.clear();
} else {
ScriptType scriptType = isElectrumSignatureFormat() ? ScriptType.P2PKH : decryptedWallet.getScriptType();
signatureText = privKey.signMessage(message.getText().trim(), scriptType);
ECKey privKey = keystore.getKey(walletNode);
if(isBip322()) {
ScriptType scriptType = decryptedWallet.getScriptType();
signatureText = Bip322.signMessageBip322(scriptType, message.getText().trim(), privKey);
} else {
ScriptType scriptType = isElectrumSignatureFormat() ? ScriptType.P2PKH : decryptedWallet.getScriptType();
signatureText = privKey.signMessage(message.getText().trim(), scriptType);
}
privKey.clear();
}
signature.clear();
signature.appendText(signatureText);
privKey.clear();
} catch(Exception e) {
log.error("Could not sign message", e);
AppServices.showErrorDialog("Could not sign message", e.getMessage());
@ -498,10 +515,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
private void showBip322Qr() {
Wallet signingWallet = walletNode.getWallet();
ScriptType scriptType = signingWallet.getScriptType();
PSBT psbt = Bip322.getBip322Psbt(scriptType, walletNode.getAddress(), message.getText().trim());
addBip322DerivationInfo(psbt, signingWallet);
PSBT psbt = buildBip322Psbt(signingWallet);
byte[] psbtBytes = psbt.getForExport().serialize();
CryptoPSBT cryptoPSBT = new CryptoPSBT(psbtBytes);
@ -514,6 +528,40 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
}
}
private PSBT buildBip322Psbt(Wallet signingWallet) {
if(signingWallet.getPolicyType() == PolicyType.SINGLE_SP) {
Keystore keystore = signingWallet.getKeystores().getFirst();
ECKey spendPubKey = keystore.getSilentPaymentScanAddress().getSpendKey();
KeyDerivation spendDerivation = new KeyDerivation(keystore.getKeyDerivation().getMasterFingerprint(), KeyDerivation.writePath(KeyDerivation.getBip352SpendDerivation(keystore.getKeyDerivation().getDerivation())));
return Bip322.getBip322PsbtSp(walletNode.getAddress(), message.getText().trim(), walletNode.getSilentPaymentTweak(), Map.of(spendPubKey, spendDerivation));
}
PSBT psbt = Bip322.getBip322Psbt(signingWallet.getScriptType(), walletNode.getAddress(), message.getText().trim());
addBip322DerivationInfo(psbt, signingWallet);
return psbt;
}
private String extractBip322Signature(PSBT signedPsbt) {
String psbtMessage = signedPsbt.getGenericSignedMessage();
if(psbtMessage != null && !psbtMessage.equals(message.getText().trim())) {
Optional<ButtonType> response = AppServices.showWarningDialog("Message mismatch",
"The message in the signed PSBT does not match the message in this dialog.\n\nPSBT message: " + psbtMessage +
"\n\nContinue extracting the signature?", ButtonType.NO, ButtonType.YES);
if(response.isEmpty() || response.get() != ButtonType.YES) {
return null;
}
}
Wallet signingWallet = walletNode.getWallet();
if(signingWallet.getPolicyType() == PolicyType.SINGLE_SP) {
return Bip322.getBip322SignatureFromPsbtSp(signedPsbt);
}
ECKey pubKey = signingWallet.getKeystores().getFirst().getPubKey(walletNode);
return Bip322.getBip322SignatureFromPsbt(signingWallet.getScriptType(), signedPsbt, pubKey);
}
private void addBip322DerivationInfo(PSBT psbt, Wallet signingWallet) {
ScriptType scriptType = signingWallet.getScriptType();
PSBTInput psbtInput = psbt.getPsbtInputs().get(0);
@ -537,11 +585,11 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
QRScanDialog.Result result = optionalResult.get();
if(result.psbt != null) {
try {
Wallet signingWallet = walletNode.getWallet();
ECKey pubKey = signingWallet.getKeystores().get(0).getPubKey(walletNode);
String sig = Bip322.getBip322SignatureFromPsbt(signingWallet.getScriptType(), result.psbt, pubKey);
signature.clear();
signature.appendText(sig);
String sig = extractBip322Signature(result.psbt);
if(sig != null) {
signature.clear();
signature.appendText(sig);
}
} catch(Exception e) {
log.error("Error extracting BIP-322 signature from PSBT", e);
AppServices.showErrorDialog("Error extracting signature", e.getMessage());
@ -599,9 +647,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
private void exportBip322File() {
Wallet signingWallet = walletNode.getWallet();
ScriptType scriptType = signingWallet.getScriptType();
PSBT psbt = Bip322.getBip322Psbt(scriptType, walletNode.getAddress(), message.getText().trim());
addBip322DerivationInfo(psbt, signingWallet);
PSBT psbt = buildBip322Psbt(signingWallet);
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
@ -642,10 +688,11 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
try {
byte[] psbtBytes = Files.readAllBytes(file.toPath());
PSBT signedPsbt = new PSBT(psbtBytes, false);
ECKey pubKey = walletNode.getWallet().getKeystores().get(0).getPubKey(walletNode);
String sig = Bip322.getBip322SignatureFromPsbt(walletNode.getWallet().getScriptType(), signedPsbt, pubKey);
signature.clear();
signature.appendText(sig);
String sig = extractBip322Signature(signedPsbt);
if(sig != null) {
signature.clear();
signature.appendText(sig);
}
return;
} catch(Exception e) {
if(file.getName().toLowerCase(Locale.ROOT).endsWith(".psbt")) {

View File

@ -9,13 +9,13 @@ import com.sparrowwallet.drongo.dns.DnsPayment;
import com.sparrowwallet.drongo.dns.DnsPaymentCache;
import com.sparrowwallet.drongo.dns.DnsPaymentResolver;
import com.sparrowwallet.drongo.dns.DnsPaymentValidationException;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.silentpayments.SilentPayment;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.drongo.uri.BitcoinURIParseException;
import com.sparrowwallet.drongo.wallet.Payment;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.event.RequestConnectEvent;
import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
import com.sparrowwallet.sparrow.io.Config;
@ -28,6 +28,8 @@ import javafx.event.ActionEvent;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.util.StringConverter;
@ -35,20 +37,26 @@ import org.controlsfx.control.spreadsheet.*;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class SendToManyDialog extends Dialog<List<Payment>> {
private final BitcoinUnit bitcoinUnit;
private final UnitFormat unitFormat;
private final UnitFormatDoubleCellType amountCellType;
private final SpreadsheetView spreadsheetView;
public static final SendToAddressCellType SEND_TO_ADDRESS = new SendToAddressCellType();
public SendToManyDialog(BitcoinUnit bitcoinUnit, List<Payment> payments) {
public SendToManyDialog(BitcoinUnit bitcoinUnit, UnitFormat unitFormat, List<Payment> payments) {
this.bitcoinUnit = bitcoinUnit;
this.unitFormat = unitFormat == null ? UnitFormat.DOT : unitFormat;
this.amountCellType = new UnitFormatDoubleCellType(this.unitFormat);
final DialogPane dialogPane = new SendToManyDialogPane();
setDialogPane(dialogPane);
@ -119,11 +127,9 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
addressCell.getStyleClass().add("fixed-width");
list.add(addressCell);
double amount = (double)sendToPayment.payment().getAmount();
if(bitcoinUnit == BitcoinUnit.BTC) {
amount = amount / Transaction.SATOSHIS_PER_BITCOIN;
}
SpreadsheetCell amountCell = SpreadsheetCellType.DOUBLE.createCell(row, 1, 1, 1, amount < 0 ? null : amount);
long rawAmount = sendToPayment.payment().getAmount();
Double amount = rawAmount < 0 ? null : bitcoinUnit.getValue(rawAmount);
SpreadsheetCell amountCell = amountCellType.createCell(row, 1, 1, 1, amount);
amountCell.setFormat(bitcoinUnit == BitcoinUnit.BTC ? "0.00000000" : "###,###");
amountCell.getStyleClass().add("number-value");
if(OsType.getCurrent() == OsType.MACOS) {
@ -177,7 +183,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
for(int row = 0; row < spreadsheetView.getGrid().getRowCount(); row++) {
ObservableList<SpreadsheetCell> rowCells = spreadsheetView.getItems().get(row);
SendToAddress sendToAddress = (SendToAddress)rowCells.getFirst().getItem();
if(sendToAddress.hrn != null && DnsPaymentCache.getDnsPayment(sendToAddress.hrn) == null) {
if(sendToAddress != null && sendToAddress.hrn != null && DnsPaymentCache.getDnsPayment(sendToAddress.hrn) == null) {
return true;
}
}
@ -216,12 +222,15 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
try {
String rawAmount = csvReader.get(1).trim();
String groupingStripped = rawAmount.replaceAll(Pattern.quote(unitFormat.getGroupingSeparator()), "");
long amount;
if(bitcoinUnit == BitcoinUnit.BTC) {
double doubleAmount = Double.parseDouble(csvReader.get(1).replace(",", ""));
amount = (long)(doubleAmount * Transaction.SATOSHIS_PER_BITCOIN);
String normalised = groupingStripped.replaceAll(Pattern.quote(unitFormat.getDecimalSeparator()), ".");
double doubleAmount = Double.parseDouble(normalised);
amount = bitcoinUnit.getSatsValue(doubleAmount);
} else {
amount = Long.parseLong(csvReader.get(1).replace(",", ""));
amount = Long.parseLong(groupingStripped);
}
String label = csvReader.get(2);
Optional<String> optDnsPaymentHrn = DnsPayment.getHrn(csvReader.get(0));
@ -359,6 +368,160 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
};
private static class UnitFormatDoubleCellType extends SpreadsheetCellType<Double> {
private final UnitFormat unitFormat;
UnitFormatDoubleCellType(UnitFormat unitFormat) {
super(new UnitFormatDoubleConverter(unitFormat));
this.unitFormat = unitFormat;
}
@Override
public String toString() {
return "double";
}
public SpreadsheetCell createCell(int row, int column, int rowSpan, int columnSpan, Double value) {
SpreadsheetCell cell = new SpreadsheetCellBase(row, column, rowSpan, columnSpan, this);
cell.setItem(value);
return cell;
}
@Override
public SpreadsheetCellEditor createEditor(SpreadsheetView view) {
return new UnitFormatDoubleEditor(view, unitFormat);
}
@Override
public boolean match(Object value, Object... options) {
if(value == null || value instanceof Number) {
return true;
}
try {
String s = value.toString();
return s == null || s.isEmpty() || converter.fromString(s) != null;
} catch(Exception e) {
return false;
}
}
@Override
public Double convertValue(Object value) {
if(value instanceof Double d) {
return d;
}
if(value instanceof Number n) {
return n.doubleValue();
}
return converter.fromString(value == null ? null : value.toString());
}
@Override
public String toString(Double item) {
return converter.toString(item);
}
@Override
public String toString(Double item, String format) {
return ((StringConverterWithFormat<Double>)converter).toStringFormat(item, format);
}
}
private static class UnitFormatDoubleConverter extends StringConverterWithFormat<Double> {
private final UnitFormat unitFormat;
UnitFormatDoubleConverter(UnitFormat unitFormat) {
this.unitFormat = unitFormat;
}
@Override
public Double fromString(String str) {
if(str == null || str.isEmpty()) {
return null;
}
String normalised = str.trim()
.replaceAll(Pattern.quote(unitFormat.getGroupingSeparator()), "")
.replaceAll(Pattern.quote(unitFormat.getDecimalSeparator()), ".");
try {
return Double.valueOf(normalised);
} catch(NumberFormatException e) {
return null;
}
}
@Override
public String toString(Double item) {
return toStringFormat(item, "");
}
@Override
public String toStringFormat(Double item, String format) {
if(item == null || item.isNaN()) {
return "";
}
if(format == null || format.isEmpty()) {
return Double.toString(item);
}
return new DecimalFormat(format, unitFormat.getDecimalFormatSymbols()).format(item);
}
}
private static class UnitFormatDoubleEditor extends SpreadsheetCellEditor {
private final UnitFormat unitFormat;
private final TextField textField;
UnitFormatDoubleEditor(SpreadsheetView view, UnitFormat unitFormat) {
super(view);
this.unitFormat = unitFormat;
this.textField = new TextField();
this.textField.setTextFormatter(new CoinTextFormatter(unitFormat));
}
@Override
public void startEdit(Object item, String format, Object... options) {
if(item instanceof Double d && !d.isNaN()) {
String text = (format == null || format.isEmpty())
? Double.toString(d)
: new DecimalFormat(format, unitFormat.getDecimalFormatSymbols()).format(d);
textField.setText(text);
} else {
textField.setText("");
}
textField.getStyleClass().removeAll("error");
textField.setOnKeyPressed(this::onKeyPressed);
textField.requestFocus();
textField.selectAll();
}
@Override
public void end() {
textField.setOnKeyPressed(null);
textField.setOnKeyReleased(null);
textField.getStyleClass().removeAll("error");
}
@Override
public TextField getEditor() {
return textField;
}
@Override
public String getControlValue() {
String raw = textField.getText();
return raw == null ? "" : raw.trim();
}
private void onKeyPressed(KeyEvent event) {
if(event.getCode() == KeyCode.ENTER) {
endEdit(true);
event.consume();
} else if(event.getCode() == KeyCode.ESCAPE) {
endEdit(false);
event.consume();
}
}
}
public static class SendToAddress {
private final String hrn;
private final Address address;
@ -487,11 +650,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
if(sendToAddress != null && value != null) {
if(bitcoinUnit == BitcoinUnit.BTC) {
value = value * Transaction.SATOSHIS_PER_BITCOIN;
}
payments.add(sendToAddress.toPayment(label, value.longValue(), false));
payments.add(sendToAddress.toPayment(label, bitcoinUnit.getSatsValue(value), false));
}
}

View File

@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
@ -48,9 +49,13 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
}
public WalletNameDialog(String initialName, boolean hasExistingTransactions, Date startDate, boolean rename) {
this(initialName, hasExistingTransactions, null, startDate, rename);
}
public WalletNameDialog(String initialName, boolean hasExistingTransactions, PolicyType walletPolicyType, Date startDate, boolean rename) {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
boolean requestBirthDate = !rename && (Config.get().getServerType() == null || Config.get().getServerType() == ServerType.BITCOIN_CORE);
boolean requestBirthDate = !rename && (walletPolicyType == PolicyType.SINGLE_SP || Config.get().getServerType() == null || Config.get().getServerType() == ServerType.BITCOIN_CORE);
setTitle("Wallet Name");
dialogPane.setHeaderText("Enter a name for this wallet:");

View File

@ -0,0 +1,9 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public class WalletSilentPaymentAddressesChangedEvent extends WalletChangedEvent {
public WalletSilentPaymentAddressesChangedEvent(Wallet wallet) {
super(wallet.resolveMasterWallet());
}
}

View File

@ -18,12 +18,12 @@ import org.slf4j.LoggerFactory;
import java.io.*;
import java.lang.reflect.Type;
import java.util.*;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.AppServices.ENUMERATE_HW_PERIOD_SECS;
import static com.sparrowwallet.sparrow.net.PagedBatchRequestBuilder.DEFAULT_PAGE_SIZE;
import static com.sparrowwallet.sparrow.net.TcpTransport.DEFAULT_MAX_TIMEOUT;
import static com.sparrowwallet.sparrow.wallet.WalletUtxosEntry.DUST_ATTACK_THRESHOLD_SATS;
import static com.sparrowwallet.sparrow.wallet.WalletUtxosEntry.DUST_ATTACK_THRESHOLD_SP_SATS;
public class Config {
private static final Logger log = LoggerFactory.getLogger(Config.class);
@ -64,6 +64,7 @@ public class Config {
private List<File> recentWalletFiles;
private Integer keyDerivationPeriod;
private long dustAttackThreshold = DUST_ATTACK_THRESHOLD_SATS;
private long dustAttackThresholdSp = DUST_ATTACK_THRESHOLD_SP_SATS;
private int enumerateHwPeriod = ENUMERATE_HW_PERIOD_SECS;
private QRDensity qrDensity;
private QREncoding qrEncoding;
@ -448,6 +449,10 @@ public class Config {
return dustAttackThreshold;
}
public long getDustAttackThresholdSp() {
return dustAttackThresholdSp;
}
public int getEnumerateHwPeriod() {
return enumerateHwPeriod;
}
@ -556,13 +561,6 @@ public class Config {
flush();
}
public void changePublicServer() {
List<Server> otherServers = PublicElectrumServer.getServers().stream().map(PublicElectrumServer::getServer).filter(server -> !server.equals(getPublicElectrumServer())).collect(Collectors.toList());
if(!otherServers.isEmpty()) {
setPublicElectrumServer(otherServers.get(new Random().nextInt(otherServers.size())));
}
}
public Server getCoreServer() {
return coreServer;
}

View File

@ -337,6 +337,11 @@ public class DbPersistence implements Persistence {
walletConfigDao.addOrUpdate(wallet, wallet.getWalletConfig());
}
if(dirtyPersistables.silentPaymentAddresses) {
SilentPaymentAddressDao silentPaymentAddressDao = handle.attach(SilentPaymentAddressDao.class);
silentPaymentAddressDao.clearAndAddAll(wallet);
}
if(dirtyPersistables.walletTable != null) {
WalletTableDao walletTableDao = handle.attach(WalletTableDao.class);
walletTableDao.addOrUpdate(wallet, dirtyPersistables.walletTable.getTableType(), dirtyPersistables.walletTable);
@ -888,6 +893,13 @@ public class DbPersistence implements Persistence {
}
}
@Subscribe
public void walletSilentPaymentAddressesChanged(WalletSilentPaymentAddressesChangedEvent event) {
if(persistsFor(event.getWallet())) {
updateExecutor.execute(() -> dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).silentPaymentAddresses = true);
}
}
private static class DirtyPersistables {
public boolean deleteAccount;
public boolean clearHistory;
@ -906,6 +918,7 @@ public class DbPersistence implements Persistence {
public final List<Keystore> labelKeystores = new ArrayList<>();
public final List<Keystore> encryptionKeystores = new ArrayList<>();
public final List<Keystore> registrationKeystores = new ArrayList<>();
public boolean silentPaymentAddresses;
public String toString() {
return "Dirty Persistables" +
@ -927,7 +940,8 @@ public class DbPersistence implements Persistence {
"\nUTXO mixes removed:" + removedUtxoMixes +
"\nKeystore labels:" + labelKeystores.stream().map(Keystore::getLabel).collect(Collectors.toList()) +
"\nKeystore encryptions:" + encryptionKeystores.stream().map(Keystore::getLabel).collect(Collectors.toList()) +
"\nKeystore registrations:" + registrationKeystores.stream().map(Keystore::getDeviceRegistration).collect(Collectors.toList());
"\nKeystore registrations:" + registrationKeystores.stream().map(Keystore::getDeviceRegistration).collect(Collectors.toList()) +
"\nSilent payment addresses:" + silentPaymentAddresses;
}
}
}

View File

@ -0,0 +1,40 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.drongo.wallet.Wallet;
import org.jdbi.v3.sqlobject.config.RegisterRowMapper;
import org.jdbi.v3.sqlobject.statement.SqlBatch;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public interface SilentPaymentAddressDao {
@SqlQuery("select address, silentPaymentAddress from silentPaymentAddress")
@RegisterRowMapper(SilentPaymentAddressMapper.class)
Map<Address, SilentPaymentAddress> getAll();
@SqlBatch("insert into silentPaymentAddress (address, silentPaymentAddress) values (?, ?)")
void insertSilentPaymentAddresses(List<byte[]> addresses, List<byte[]> silentPaymentAddresses);
@SqlUpdate("delete from silentPaymentAddress")
void clear();
default void clearAndAddAll(Wallet wallet) {
clear();
List<byte[]> addresses = new ArrayList<>();
List<byte[]> silentPaymentAddresses = new ArrayList<>();
for(Map.Entry<Address, SilentPaymentAddress> entry : wallet.getSilentPaymentAddresses().entrySet()) {
addresses.add(entry.getKey().getData());
silentPaymentAddresses.add(entry.getValue().serialize());
}
if(!addresses.isEmpty()) {
insertSilentPaymentAddresses(addresses, silentPaymentAddresses);
}
}
}

View File

@ -0,0 +1,36 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.P2TRAddress;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import org.jdbi.v3.core.mapper.RowMapper;
import org.jdbi.v3.core.statement.StatementContext;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Map;
public class SilentPaymentAddressMapper implements RowMapper<Map.Entry<Address, SilentPaymentAddress>> {
@Override
public Map.Entry<Address, SilentPaymentAddress> map(ResultSet rs, StatementContext ctx) throws SQLException {
Address address = new P2TRAddress(rs.getBytes("address"));
SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.fromBytes(rs.getBytes("silentPaymentAddress"));
return new Map.Entry<>() {
@Override
public Address getKey() {
return address;
}
@Override
public SilentPaymentAddress getValue() {
return silentPaymentAddress;
}
@Override
public SilentPaymentAddress setValue(SilentPaymentAddress value) {
return null;
}
};
}
}

View File

@ -30,6 +30,9 @@ public interface WalletDao {
@CreateSqlObject
DetachedLabelDao createDetachedLabelDao();
@CreateSqlObject
SilentPaymentAddressDao createSilentPaymentAddressDao();
@CreateSqlObject
WalletConfigDao createWalletConfigDao();
@ -121,6 +124,8 @@ public interface WalletDao {
Map<String, String> detachedLabels = createDetachedLabelDao().getAll();
wallet.getDetachedLabels().putAll(detachedLabels);
wallet.getSilentPaymentAddresses().putAll(createSilentPaymentAddressDao().getAll());
wallet.setWalletConfig(createWalletConfigDao().getForWalletId(wallet.getId()));
Map<TableType, WalletTable> walletTables = createWalletTableDao().getForWalletId(wallet.getId());
@ -144,6 +149,7 @@ public interface WalletDao {
createWalletNodeDao().addWalletNodes(wallet);
createBlockTransactionDao().addBlockTransactions(wallet);
createDetachedLabelDao().clearAndAddAll(wallet);
createSilentPaymentAddressDao().clearAndAddAll(wallet);
createWalletConfigDao().addWalletConfig(wallet);
createWalletTableDao().addWalletTables(wallet);
createMixConfigDao().addMixConfig(wallet);

View File

@ -4,6 +4,7 @@ import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.ScriptType;
@ -69,19 +70,17 @@ public class Auth47 {
this.srbnName = srbnUrl.getUserInfo();
this.callback = new URI(HTTPS_PROTOCOL + srbnUrl.getHost()).toURL();
} else {
this.callback = new URI(strCallback).toURL();
URI callbackUri = new URI(strCallback);
if(!Utils.isSecureUrl(callbackUri)) {
throw new IllegalArgumentException("Invalid callback parameter (not https, http .onion or srbn): " + strCallback);
}
this.callback = callbackUri.toURL();
}
this.expiry = parameterMap.get("e");
this.resource = parameterMap.get("r");
if(resource == null) {
if(srbn) {
this.resource = "srbn";
} else if(strCallback.startsWith("http")) {
this.resource = strCallback;
} else {
throw new IllegalArgumentException("Invalid callback parameter (not http/s or srbn): " + strCallback);
}
this.resource = srbn ? "srbn" : strCallback;
}
}

View File

@ -89,6 +89,8 @@ public class ElectrumServer {
private static final Map<String, SilentPaymentsScanCache> spScanCaches = new ConcurrentHashMap<>();
private static final int TAPROOT_ACTIVATION_HEIGHT = 709632;
private final static Map<String, Integer> subscribedRecent = new ConcurrentHashMap<>();
private final static Map<String, String> broadcastRecent = new ConcurrentHashMap<>();
@ -875,6 +877,11 @@ public class ElectrumServer {
for(BlockTransactionHash reference : references.keySet()) {
Transaction transaction = references.get(reference);
if(transaction == null) {
transactionMap.put(reference.getHash(), UNFETCHABLE_BLOCK_TRANSACTION);
checkReferences.removeIf(ref -> ref.getHash().equals(reference.getHash()));
continue;
}
Date blockDate = null;
if(reference.getHeight() > 0) {
@ -888,7 +895,7 @@ public class ElectrumServer {
}
Long fee = reference.getFee();
if(fee == null) {
if(fee == null && wallet != null) {
BlockTransaction cached = wallet.getWalletTransaction(reference.getHash());
if(cached != null && cached.getFee() != null) {
fee = cached.getFee();
@ -1377,6 +1384,33 @@ public class ElectrumServer {
return spScanCaches.containsKey(scanAddress.getAddress());
}
public static boolean isSilentPaymentsFullyCovered(SilentPaymentScanAddress scanAddress) {
if(scanAddress == null) {
return false;
}
SilentPaymentsScanCache cache = spScanCaches.get(scanAddress.getAddress());
if(cache == null) {
return false;
}
cache.lock();
try {
Integer serverStart = cache.getServerStart();
if(!cache.isCompleted() || serverStart == null) {
return false;
}
int earliestPossibleStart = Network.get() == Network.MAINNET ? TAPROOT_ACTIVATION_HEIGHT : 0;
return serverStart <= earliestPossibleStart;
} finally {
cache.unlock();
}
}
public static Cormorant getCormorant() {
return cormorant;
}
private static void cancelSilentPaymentScans() {
for(SilentPaymentsScanCache cache : spScanCaches.values()) {
cache.lock();
@ -1500,7 +1534,7 @@ public class ElectrumServer {
}
}
if(cache.isCancelled()) {
throw new ServerException("Silent payments scan was cancelled for " + spAddress);
throw new ServerException("Silent payments scan was cancelled for " + spAddress.substring(0, 10) + "...");
}
return cache.snapshotEntries();
} finally {

View File

@ -41,9 +41,17 @@ public class LnurlAuth {
public LnurlAuth(URI uri) throws MalformedURLException, URISyntaxException {
String lnurl = uri.getSchemeSpecificPart();
Bech32.Bech32Data bech32 = Bech32.decode(lnurl, 2000);
if(!"lnurl".equals(bech32.hrp)) {
throw new IllegalArgumentException("LNURL-auth bech32 prefix must be lnurl");
}
byte[] urlBytes = Bech32.convertBits(bech32.data, 0, bech32.data.length, 5, 8, false);
String strUrl = new String(urlBytes, StandardCharsets.UTF_8);
this.url = new URI(strUrl).toURL();
URI decodedUri = new URI(strUrl);
if(!Utils.isSecureUrl(decodedUri)) {
throw new IllegalArgumentException("LNURL-auth URL must be https or http .onion");
}
this.url = decodedUri.toURL();
Map<String, String> parameterMap = new LinkedHashMap<>();
String query = url.getQuery();

View File

@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.net;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.sparrow.io.Server;
import java.util.Arrays;
@ -20,17 +21,24 @@ public enum PublicElectrumServer {
TESTNET_QTORNADO_COM("testnet.qtornado.com", "ssl://testnet.qtornado.com:51002", Network.TESTNET),
SIGNET_MEMPOOL_SPACE("mempool.space", "ssl://mempool.space:60602", Network.SIGNET),
TESTNET4_MEMPOOL_SPACE("mempool.space", "ssl://mempool.space:40002", Network.TESTNET4),
TESTNET4_C3_SOFT("blackie.c3-soft.com", "ssl://blackie.c3-soft.com:57010", Network.TESTNET4);
TESTNET4_C3_SOFT("blackie.c3-soft.com", "ssl://blackie.c3-soft.com:57010", Network.TESTNET4),
FRIGATE_2140_DEV("frigate.2140.dev", "ssl://frigate.2140.dev:50002", Network.MAINNET, List.of(PolicyType.SINGLE_HD, PolicyType.MULTI_HD, PolicyType.SINGLE_SP));
PublicElectrumServer(String name, String url, Network network) {
this(name, url, network, List.of(PolicyType.SINGLE_HD, PolicyType.MULTI_HD));
}
PublicElectrumServer(String name, String url, Network network, List<PolicyType> supportedPolicyTypes) {
this.server = new Server(url, name);
this.network = network;
this.supportedPolicyTypes = supportedPolicyTypes;
}
public static final List<Network> SUPPORTED_NETWORKS = List.of(Network.MAINNET, Network.TESTNET, Network.SIGNET, Network.TESTNET4);
private final Server server;
private final Network network;
private final List<PolicyType> supportedPolicyTypes;
public Server getServer() {
return server;
@ -44,6 +52,14 @@ public enum PublicElectrumServer {
return network;
}
public boolean isSupportedPolicyType(PolicyType policyType) {
return supportedPolicyTypes.contains(policyType);
}
public boolean supportsAllPolicyTypes(List<PolicyType> policyTypes) {
return policyTypes.stream().allMatch(this::isSupportedPolicyType);
}
public static List<PublicElectrumServer> getServers() {
return Arrays.stream(values()).filter(server -> server.network == Network.get()).collect(Collectors.toList());
}

View File

@ -43,6 +43,10 @@ class SilentPaymentsScanCache {
return state == State.CANCELLED;
}
boolean isCompleted() {
return state == State.COMPLETED;
}
void cancel() {
assert lock.isHeldByCurrentThread();
if(state == State.SCANNING) {

View File

@ -1,5 +1,8 @@
package com.sparrowwallet.sparrow.net;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.github.arteam.simplejsonrpc.server.JsonRpcServer;
import com.google.common.base.Splitter;
import com.google.common.net.HostAndPort;
@ -37,6 +40,7 @@ public class TcpTransport implements CloseableTransport, TimeoutCounter {
public static final long PER_REQUEST_READ_TIMEOUT_MILLIS = 50;
public static final int SOCKET_READ_TIMEOUT_MILLIS = 5000;
private static final Pattern ID_PATTERN = Pattern.compile("\"id\"\\s*:\\s*(\\d+)");
private static final JsonFactory JSON_FACTORY = new JsonFactory();
protected final HostAndPort server;
protected final SocketFactory socketFactory;
@ -52,9 +56,9 @@ public class TcpTransport implements CloseableTransport, TimeoutCounter {
private final Condition readingCondition = readLock.newCondition();
private final ReentrantLock clientRequestLock = new ReentrantLock();
private boolean running = false;
private volatile boolean running = false;
private volatile boolean reading = true;
private boolean closed = false;
private volatile boolean closed = false;
private boolean firstRead = true;
private int readTimeoutIndex;
private int requestIdCount = 1;
@ -160,7 +164,7 @@ public class TcpTransport implements CloseableTransport, TimeoutCounter {
firstRead = false;
}
while(reading) {
while(reading && running) {
try {
readingCondition.await();
} catch(InterruptedException e) {
@ -174,6 +178,10 @@ public class TcpTransport implements CloseableTransport, TimeoutCounter {
throw new IOException("Error reading response: " + lastException.getMessage(), lastException);
}
if(!running) {
throw new IOException("Transport closed");
}
reading = true;
readingCondition.signal();
@ -184,58 +192,80 @@ public class TcpTransport implements CloseableTransport, TimeoutCounter {
}
public void readInputLoop() throws ServerException {
readLock.lock();
readReadySignal.countDown();
BufferedReader in;
try {
try {
//Don't start reading until first RPC request is sent
readingCondition.await();
} catch(InterruptedException e) {
Thread.currentThread().interrupt();
}
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
while(running) {
try {
String received = readInputStream(in);
wireLog.info("< " + received);
if(received.contains("method") && !received.contains("error")) {
//Handle subscription notification
jsonRpcServer.handle(received, subscriptionService);
} else {
//Handle client's response
response = received;
reading = false;
readingCondition.signal();
readingCondition.await();
}
} catch(InterruptedException e) {
//Restore interrupt status and continue
Thread.currentThread().interrupt();
} catch(Exception e) {
log.trace("Connection error while reading", e);
if(running) {
lastException = e;
reading = false;
readingCondition.signal();
//Allow this thread to terminate as we will need to reconnect with a new transport anyway
running = false;
}
}
}
in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
} catch(IOException e) {
if(!closed) {
log.error("Error opening socket inputstream", e);
}
if(running) {
lastException = e;
reading = false;
readingCondition.signal();
//Allow this thread to terminate as we will need to reconnect with a new transport anyway
signalException(e);
running = false;
}
return;
}
//Wait for first RPC request before starting to read. The lock must be acquired before
//signaling readiness so readResponse() blocks until we reach the atomic await/unlock.
readLock.lock();
try {
readReadySignal.countDown();
if(running) {
readingCondition.await();
}
} catch(InterruptedException e) {
Thread.currentThread().interrupt();
return;
} finally {
readLock.unlock();
}
while(running) {
try {
String received = readInputStream(in);
wireLog.info("< " + received);
if(isNotification(received)) {
jsonRpcServer.handle(received, subscriptionService);
} else {
deliverResponse(received);
}
} catch(InterruptedException e) {
//Restore interrupt status and continue
Thread.currentThread().interrupt();
} catch(Exception e) {
if(!closed) {
log.trace("Connection error while reading", e);
}
if(running) {
signalException(e);
//Allow this thread to terminate as we will need to reconnect with a new transport anyway
running = false;
}
}
}
}
private void deliverResponse(String received) throws InterruptedException {
readLock.lock();
try {
response = received;
reading = false;
readingCondition.signal();
while(!reading && running) {
readingCondition.await();
}
} finally {
readLock.unlock();
}
}
private void signalException(Exception e) {
readLock.lock();
try {
lastException = e;
reading = false;
readingCondition.signal();
} finally {
readLock.unlock();
}
@ -301,10 +331,19 @@ public class TcpTransport implements CloseableTransport, TimeoutCounter {
@Override
public void close() throws IOException {
running = false;
closed = true;
readLock.lock();
try {
readingCondition.signalAll();
} finally {
readLock.unlock();
}
if(socket != null) {
socket.close();
}
closed = true;
}
@Override
@ -312,6 +351,26 @@ public class TcpTransport implements CloseableTransport, TimeoutCounter {
return readTimeoutIndex;
}
private static boolean isNotification(String json) {
try(JsonParser parser = JSON_FACTORY.createParser(json)) {
if(parser.nextToken() != JsonToken.START_OBJECT) {
return false;
}
while(parser.nextToken() == JsonToken.FIELD_NAME) {
String field = parser.currentName();
JsonToken value = parser.nextToken();
if("method".equals(field)) {
return value == JsonToken.VALUE_STRING;
}
parser.skipChildren();
}
return false;
} catch(Exception e) {
log.warn("Could not parse JSON-RPC message from server: " + e.getMessage());
return false;
}
}
private static Set<String> extractIdSet(String json) {
if(json == null || json.isEmpty()) {
return Collections.emptySet();

View File

@ -115,4 +115,8 @@ public class Cormorant {
public static EventBus getEventBus() {
return EVENT_BUS;
}
public BitcoindClient getBitcoindClient() {
return bitcoindClient;
}
}

View File

@ -77,6 +77,7 @@ public class BitcoindClient {
private final boolean useWallets;
private boolean pruned;
private Integer pruneHeight;
private volatile Date cachedPrunedDate;
private boolean legacyWalletExists;
private final Lock syncingLock = new ReentrantLock();
@ -285,12 +286,18 @@ public class BitcoindClient {
if(blockchainInfo.pruned()) {
String pruneBlockHash = getBitcoindService().getBlockHash(blockchainInfo.pruneheight());
VerboseBlockHeader pruneBlockHeader = getBitcoindService().getBlockHeader(pruneBlockHash);
return Optional.of(new Date(pruneBlockHeader.time() * 1000));
Date prunedDate = new Date(pruneBlockHeader.time() * 1000);
cachedPrunedDate = prunedDate;
return Optional.of(prunedDate);
}
return Optional.empty();
}
public Date getCachedPrunedDate() {
return cachedPrunedDate;
}
private ScanDate getScanDate(String normalizedDescriptor, Wallet wallet, KeyPurpose keyPurpose, Date earliestBirthDate) {
Integer range = (keyPurpose == null ? null : Math.max(getWalletRange(normalizedDescriptor, wallet, keyPurpose), getDefaultRange(wallet, keyPurpose)));

View File

@ -6,6 +6,8 @@ import com.google.common.eventbus.Subscribe;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.Mode;
@ -47,10 +49,9 @@ import java.io.FileInputStream;
import java.security.cert.CertificateFactory;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Random;
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
public class ServerSettingsController extends SettingsDetailController {
private static final Logger log = LoggerFactory.getLogger(ServerSettingsController.class);
@ -215,6 +216,9 @@ public class ServerSettingsController extends SettingsDetailController {
}
serverTypeToggleGroup.selectToggle(serverTypeToggleGroup.getToggles().stream().filter(toggle -> toggle.getUserData() == serverType).findFirst().orElse(null));
List<PolicyType> policyTypes = AppServices.get().getOpenWallets().keySet().stream().map(Wallet::getPolicyType).filter(Objects::nonNull).collect(Collectors.toList());
publicElectrumServer.setButtonCell(new PublicElectrumServerButtonCell());
publicElectrumServer.setCellFactory(_ -> new PublicElectrumServerListCell(policyTypes));
publicElectrumServer.setItems(FXCollections.observableList(PublicElectrumServer.getServers()));
publicElectrumServer.getSelectionModel().selectedItemProperty().addListener(getPublicElectrumServerListener(config));
@ -442,7 +446,7 @@ public class ServerSettingsController extends SettingsDetailController {
if(configPublicElectrumServer == null && PublicElectrumServer.supportedNetwork()) {
List<PublicElectrumServer> servers = PublicElectrumServer.getServers();
if(!servers.isEmpty()) {
publicElectrumServer.setValue(servers.get(new Random().nextInt(servers.size())));
publicElectrumServer.setValue(servers.get(ThreadLocalRandom.current().nextInt(servers.size())));
}
} else {
publicElectrumServer.setValue(configPublicElectrumServer);
@ -1038,4 +1042,38 @@ public class ServerSettingsController extends SettingsDetailController {
}
}
}
private static class PublicElectrumServerButtonCell extends ListCell<PublicElectrumServer> {
@Override
protected void updateItem(PublicElectrumServer server, boolean empty) {
super.updateItem(server, empty);
if(server == null || empty) {
setText(null);
setGraphic(null);
} else {
setText(server.toString());
setGraphic(null);
}
}
}
private static class PublicElectrumServerListCell extends ListCell<PublicElectrumServer> {
private final List<PolicyType> openPolicyTypes;
public PublicElectrumServerListCell(List<PolicyType> openPolicyTypes) {
this.openPolicyTypes = openPolicyTypes;
}
@Override
protected void updateItem(PublicElectrumServer server, boolean empty) {
super.updateItem(server, empty);
if(server == null || empty) {
setText(null);
setGraphic(null);
} else {
setText(server + (openPolicyTypes.contains(PolicyType.SINGLE_SP) && server.isSupportedPolicyType(PolicyType.SINGLE_SP) ? " (supports Silent Payments)" : ""));
setGraphic(null);
}
}
}
}

View File

@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.terminal.settings;
import com.googlecode.lanterna.TerminalSize;
import com.googlecode.lanterna.gui2.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.PublicElectrumServer;
@ -32,7 +33,7 @@ public class PublicElectrumDialog extends ServerProxyDialog {
url.addItem(server);
}
if(Config.get().getPublicElectrumServer() == null) {
Config.get().changePublicServer();
AppServices.get().changePublicServer();
}
url.setSelectedItem(PublicElectrumServer.fromServer(Config.get().getPublicElectrumServer()));
url.addListener((selectedIndex, previousSelection, changedByUserInteraction) -> {

View File

@ -20,6 +20,7 @@ import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
public class Bip39Dialog extends NewWalletDialog {
@ -160,6 +161,9 @@ public class Bip39Dialog extends NewWalletDialog {
Keystore keystore = importer.getKeystore(type.policyType(), wallet.getScriptType().getDefaultDerivation(), getWords(), passphrase.getText());
wallet.getKeystores().add(keystore);
wallet.setDefaultPolicy(Policy.getPolicy(type.policyType(), wallet.getScriptType(), wallet.getKeystores(), 1));
if(type.policyType() == PolicyType.SINGLE_SP) {
wallet.setBirthDate(new Date());
}
return List.of(wallet);
}

View File

@ -532,8 +532,10 @@ public class HeadersController extends TransactionFormController implements Init
noWalletsWarningLink.visibleProperty().bind(noWalletsWarning.visibleProperty());
boolean taprootInput = psbt.getPsbtInputs().stream().anyMatch(PSBTInput::isTaproot);
SigHash psbtSigHash = psbt.getPsbtInputs().stream().map(PSBTInput::getSigHash).filter(Objects::nonNull).findFirst().orElse(taprootInput ? SigHash.DEFAULT : SigHash.ALL);
sigHash.setItems(FXCollections.observableList(taprootInput ? SigHash.TAPROOT_SIGNING_TYPES : SigHash.LEGACY_SIGNING_TYPES));
boolean silentPaymentOutput = psbt.getPsbtOutputs().stream().anyMatch(o -> o.getSilentPaymentAddress() != null);
SigHash requiredSigHash = taprootInput ? SigHash.DEFAULT : SigHash.ALL;
SigHash psbtSigHash = silentPaymentOutput ? requiredSigHash : psbt.getPsbtInputs().stream().map(PSBTInput::getSigHash).filter(Objects::nonNull).findFirst().orElse(requiredSigHash);
sigHash.setItems(FXCollections.observableList(silentPaymentOutput ? List.of(requiredSigHash) : (taprootInput ? SigHash.TAPROOT_SIGNING_TYPES : SigHash.LEGACY_SIGNING_TYPES)));
sigHash.setValue(psbtSigHash);
sigHash.setConverter(new StringConverter<>() {
@Override
@ -542,7 +544,8 @@ public class HeadersController extends TransactionFormController implements Init
return "";
}
return sigHash.getName() + ((taprootInput && sigHash == SigHash.DEFAULT) || (!taprootInput && sigHash == SigHash.ALL) ? " (Recommended)" : "");
boolean recommended = (taprootInput && sigHash == SigHash.DEFAULT) || (!taprootInput && sigHash == SigHash.ALL);
return sigHash.getName() + (recommended ? (silentPaymentOutput ? " (Required)" : " (Recommended)") : "");
}
@Override
@ -575,6 +578,8 @@ public class HeadersController extends TransactionFormController implements Init
int threshold = signingWallet.getDefaultPolicy().getNumSignaturesRequired();
signaturesProgressBar.initialize(headersForm.getSignatureKeystoreMap(), threshold);
learnSilentPaymentAddresses(signingWallet, headersForm.getPsbt());
});
blockchainForm.setDynamicUpdate(this);
@ -1153,6 +1158,11 @@ public class HeadersController extends TransactionFormController implements Init
Map<PSBTInput, WalletNode> signingNodes = unencryptedWallet.getSigningNodes(headersForm.getPsbt());
List<SilentPayment> silentPayments = unencryptedWallet.computeSilentPaymentOutputs(headersForm.getPsbt(), signingNodes);
if(!silentPayments.isEmpty()) {
Wallet signingWallet = headersForm.getSigningWallet();
for(SilentPayment silentPayment : silentPayments) {
signingWallet.addSilentPaymentAddress(silentPayment.getAddress(), silentPayment.getSilentPaymentAddress());
}
EventManager.get().post(new WalletSilentPaymentAddressesChangedEvent(signingWallet));
EventManager.get().post(new TransactionOutputsChangedEvent(headersForm.getTransaction()));
}
unencryptedWallet.sign(signingNodes);
@ -1283,7 +1293,7 @@ public class HeadersController extends TransactionFormController implements Init
Platform.runLater(() -> EventManager.get().post(new WalletNodeHistoryChangedEvent(scriptHashes.iterator().next())));
}
if(transactionMempoolService.getIterationCount() > 3 && !transactionMempoolService.isCancelled()) {
if(transactionMempoolService.getIterationCount() > 12 && !transactionMempoolService.isCancelled()) {
transactionMempoolService.cancel();
broadcastProgressBar.setProgress(0);
log.error("Timeout searching for broadcasted transaction");
@ -1440,6 +1450,26 @@ public class HeadersController extends TransactionFormController implements Init
requestPayjoinPSBTService.start();
}
private void learnSilentPaymentAddresses(Wallet wallet, PSBT psbt) {
if(wallet == null || psbt == null) {
return;
}
Map<Address, SilentPaymentAddress> verified = wallet.verifySilentPaymentOutputs(psbt);
boolean changed = false;
for(Map.Entry<Address, SilentPaymentAddress> entry : verified.entrySet()) {
if(!entry.getValue().equals(wallet.getSilentPaymentAddress(entry.getKey()))) {
wallet.addSilentPaymentAddress(entry.getKey(), entry.getValue());
changed = true;
}
}
if(changed) {
EventManager.get().post(new WalletSilentPaymentAddressesChangedEvent(wallet));
}
}
@Override
public void update() {
BlockTransaction blockTransaction = headersForm.getBlockTransaction();
@ -1661,6 +1691,7 @@ public class HeadersController extends TransactionFormController implements Init
@Subscribe
public void psbtCombined(PSBTCombinedEvent event) {
if(event.getPsbt().equals(headersForm.getPsbt())) {
learnSilentPaymentAddresses(headersForm.getSigningWallet(), headersForm.getPsbt());
if(headersForm.getSigningWallet() != null) {
updateSignedKeystores(headersForm.getSigningWallet());
} else if(headersForm.getPsbt().isSigned()) {
@ -1675,6 +1706,7 @@ public class HeadersController extends TransactionFormController implements Init
@Subscribe
public void psbtFinalized(PSBTFinalizedEvent event) {
if(event.getPsbt().equals(headersForm.getPsbt())) {
learnSilentPaymentAddresses(headersForm.getSigningWallet(), headersForm.getPsbt());
if(headersForm.getSigningWallet() != null) {
updateSignedKeystores(headersForm.getSigningWallet());
}

View File

@ -1559,6 +1559,11 @@ public class SendController extends WalletFormController implements Initializabl
@Subscribe
public void excludeUtxo(ExcludeUtxoEvent event) {
if(event.getWalletTransaction() == walletTransactionProperty.get()) {
BlockTransaction replacedTransaction = replacedTransactionProperty.get();
if(replacedTransaction != null && !getWalletForm().getWallet().isSafeToAddInputsOrOutputs(replacedTransaction)) {
AppServices.showErrorDialog("Cannot Exclude Input", "Removing an input from this replacement transaction could break silent payment outputs as the original output script depends on the input set.");
return;
}
UtxoSelector utxoSelector = utxoSelectorProperty.get();
if(utxoSelector instanceof MaxUtxoSelector) {
Collection<BlockTransactionHashIndex> utxos = event.getWalletTransaction().getSelectedUtxos().keySet();

View File

@ -125,9 +125,8 @@ public class SettingsController extends WalletFormController implements Initiali
walletForm.getWallet().setPolicyType(policyType);
scriptType.setItems(FXCollections.observableArrayList(ScriptType.getAddressableScriptTypes(policyType)));
scriptType.getSelectionModel().select(policyType.getDefaultScriptType());
if(!initialising) {
scriptType.getSelectionModel().select(policyType.getDefaultScriptType());
clearKeystoreTabs();
}
initialising = false;
@ -259,6 +258,9 @@ public class SettingsController extends WalletFormController implements Initiali
revert.setDisable(true);
apply.setDisable(true);
boolean addressChange = ((SettingsWalletForm)walletForm).isAddressChange();
if(walletForm.getWallet().getPolicyType() == PolicyType.SINGLE_SP && walletForm.getWallet().getBirthDate() == null && walletForm.getStorage().getEncryptionPubKey() == null) {
walletForm.getWallet().setBirthDate(new Date());
}
saveWallet(false, false);
Wallet wallet = walletForm.getWallet();

View File

@ -245,8 +245,11 @@ public class TransactionEntry extends Entry implements Comparable<TransactionEnt
public Long getVSizeFromTip() {
if(!AppServices.getMempoolHistogram().isEmpty()) {
Double feeRate = blockTransaction.getFeeRate();
if(feeRate == null) {
return null;
}
Set<MempoolRateSize> rateSizes = AppServices.getMempoolHistogram().get(AppServices.getMempoolHistogram().lastKey());
double feeRate = blockTransaction.getFeeRate();
return rateSizes.stream().filter(rateSize -> rateSize.getFee() > feeRate).mapToLong(MempoolRateSize::getVSize).sum();
}

View File

@ -228,7 +228,7 @@ public class WalletForm {
boolean shouldHold = !spSubscriptionHeld;
ElectrumServer.SilentPaymentScanService scanService = new ElectrumServer.SilentPaymentScanService(wallet, shouldHold, computeNeededStart(wallet));
ElectrumServer.SilentPaymentScanService scanService = new ElectrumServer.SilentPaymentScanService(wallet, shouldHold, wallet.getNeededScanStart());
scanService.setOnSucceeded(workerStateEvent -> {
spScanInProgress = false;
spSubscriptionHeld = true;
@ -296,20 +296,6 @@ public class WalletForm {
}
}
private static int computeNeededStart(Wallet wallet) {
Integer stored = wallet.getStoredBlockHeight();
if(stored != null && stored > 0) {
return Math.max(0, stored - BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM);
}
if(wallet.getBirthHeight() != null) {
return Math.max(0, wallet.getBirthHeight() - BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM);
}
if(wallet.getBirthDate() != null) {
return (int)(wallet.getBirthDate().getTime() / 1000L);
}
return 0;
}
private void updateWallets(Integer blockHeight, Wallet previousWallet) {
List<WalletNode> nestedHistoryChangedNodes = new ArrayList<>();
for(Wallet childWallet : new ArrayList<>(wallet.getChildWallets())) {
@ -763,6 +749,13 @@ public class WalletForm {
}
}
@Subscribe
public void walletSilentPaymentAddressesChanged(WalletSilentPaymentAddressesChangedEvent event) {
if(event.getWallet() == wallet) {
Platform.runLater(() -> EventManager.get().post(new WalletDataChangedEvent(wallet)));
}
}
@Subscribe
public void walletTabsClosed(WalletTabsClosedEvent event) {
for(WalletTabData tabData : event.getClosedWalletTabData()) {

View File

@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.wallet;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.io.Config;
@ -9,6 +10,7 @@ import java.util.stream.Collectors;
public class WalletUtxosEntry extends Entry {
public static final int DUST_ATTACK_THRESHOLD_SATS = 1000;
public static final int DUST_ATTACK_THRESHOLD_SP_SATS = 5000;
public WalletUtxosEntry(Wallet wallet) {
super(wallet, wallet.getName(), wallet.getWalletUtxos().entrySet().stream().map(entry -> new UtxoEntry(entry.getValue().getWallet(), entry.getKey(), HashIndexEntry.Type.OUTPUT, entry.getValue())).collect(Collectors.toList()));
@ -50,14 +52,22 @@ public class WalletUtxosEntry extends Entry {
}
protected void calculateDust() {
long dustAttackThreshold = Config.get().getDustAttackThreshold();
Set<WalletNode> duplicateNodes = getWallet().getWalletTxos().values().stream()
.collect(Collectors.groupingBy(e -> e, Collectors.counting()))
.entrySet().stream().filter(e -> e.getValue() > 1).map(Map.Entry::getKey).collect(Collectors.toSet());
if(getWallet().getPolicyType() == PolicyType.SINGLE_SP) {
long dustAttackThreshold = Config.get().getDustAttackThresholdSp();
for(Entry entry : getChildren()) {
UtxoEntry utxoEntry = (UtxoEntry) entry;
utxoEntry.setDustAttack(utxoEntry.getValue() <= dustAttackThreshold && !utxoEntry.getWallet().allInputsFromWallet(utxoEntry.getHashIndex().getHash()));
}
} else {
long dustAttackThreshold = Config.get().getDustAttackThreshold();
Set<WalletNode> duplicateNodes = getWallet().getWalletTxos().values().stream()
.collect(Collectors.groupingBy(e -> e, Collectors.counting()))
.entrySet().stream().filter(e -> e.getValue() > 1).map(Map.Entry::getKey).collect(Collectors.toSet());
for(Entry entry : getChildren()) {
UtxoEntry utxoEntry = (UtxoEntry) entry;
utxoEntry.setDustAttack(utxoEntry.getValue() <= dustAttackThreshold && duplicateNodes.contains(utxoEntry.getNode()) && !utxoEntry.getWallet().allInputsFromWallet(utxoEntry.getHashIndex().getHash()));
for(Entry entry : getChildren()) {
UtxoEntry utxoEntry = (UtxoEntry) entry;
utxoEntry.setDustAttack(utxoEntry.getValue() <= dustAttackThreshold && duplicateNodes.contains(utxoEntry.getNode()) && !utxoEntry.getWallet().allInputsFromWallet(utxoEntry.getHashIndex().getHash()));
}
}
}

View File

@ -1,3 +1,4 @@
alter table wallet add column birthHeight integer after birthDate;
alter table walletNode add column silentPaymentTweak varbinary(32) after addressData;
alter table keystore add column silentPaymentScanAddress varbinary(65) after externalPaymentCode;
alter table keystore add column silentPaymentScanAddress varbinary(65) after externalPaymentCode;
create table silentPaymentAddress (address varbinary(32) primary key not null, silentPaymentAddress varbinary(67) not null);

View File

@ -32,7 +32,7 @@
<Form GridPane.columnIndex="0" GridPane.rowIndex="0">
<Fieldset inputGrow="SOMETIMES" text="Settings" styleClass="header">
<Field text="Policy Type:">
<ComboBox fx:id="policyType" prefWidth="160">
<ComboBox fx:id="policyType" prefWidth="180">
<items>
<FXCollections fx:factory="observableArrayList">
<PolicyType fx:constant="SINGLE_HD" />
@ -41,7 +41,7 @@
</FXCollections>
</items>
</ComboBox>
<HelpLabel helpText="Single signature wallets use a single keystore (hardware wallet or seed).\nMultisignature wallets require multiple keystores (N), of which a certain number (M) need to sign." />
<HelpLabel helpText="Single signature wallets use a single keystore (hardware wallet or seed).\nMultisignature wallets require multiple keystores, of which a certain number need to sign.\nSilent Payments wallets also use a single keystore and can receive privately to a reusable address." />
</Field>
<Field text="Script Type:">
<ComboBox fx:id="scriptType">
@ -57,7 +57,7 @@
</FXCollections>
</items>
</ComboBox>
<HelpLabel helpText="Native Segwit types are the default and are usually the best choice.\nNested Segwit types are a good choice for the widest compatibility with older wallets.\nLegacy types should be avoided, unless configuring an old wallet." />
<HelpLabel helpText="Native Segwit the type chosen for most new wallets.\nTaproot is a newer type useful for specific needs.\nNested Segwit and Legacy are useful for recovering older wallets.\nFor existing wallets, be sure to choose the type that matches the wallet you are restoring." />
</Field>
</Fieldset>
</Form>

View File

@ -0,0 +1,64 @@
package com.sparrowwallet.sparrow.control;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.TimeZone;
public class DateAxisFormatterTest {
private static final long HOUR = 60 * 60 * 1000L;
private static final long DAY = 24 * HOUR;
private static final long YEAR = 365 * DAY;
private static String thirdLabel(DateAxisFormatter formatter, long timestamp) {
formatter.toString(timestamp);
formatter.toString(timestamp);
return formatter.toString(timestamp);
}
private static long timestamp(int year, int month, int day) {
Calendar cal = new GregorianCalendar(TimeZone.getTimeZone("UTC"));
cal.clear();
cal.set(year, month, day);
return cal.getTimeInMillis();
}
@Test
public void multiYearDurationIncludesFourDigitYear() {
DateAxisFormatter formatter = new DateAxisFormatter(2 * YEAR);
long ts = timestamp(2024, Calendar.AUGUST, 15);
String actual = thirdLabel(formatter, ts);
String fourDigitYear = new SimpleDateFormat("yyyy").format(new Date(ts));
Assertions.assertTrue(actual.contains(fourDigitYear),
"Expected 4-digit year " + fourDigitYear + " in label, got: " + actual);
}
@Test
public void subYearDurationUsesDayMonth() {
DateAxisFormatter formatter = new DateAxisFormatter(30 * DAY);
long ts = timestamp(2024, Calendar.AUGUST, 15);
Assertions.assertEquals(new SimpleDateFormat("d MMM").format(new Date(ts)),
thirdLabel(formatter, ts));
}
@Test
public void subDayDurationUsesHourMinute() {
DateAxisFormatter formatter = new DateAxisFormatter(2 * HOUR);
long ts = timestamp(2024, Calendar.AUGUST, 15);
Assertions.assertEquals(new SimpleDateFormat("HH:mm").format(new Date(ts)),
thirdLabel(formatter, ts));
}
@Test
public void everyThirdLabelIsRendered() {
DateAxisFormatter formatter = new DateAxisFormatter(2 * YEAR);
long ts = timestamp(2024, Calendar.AUGUST, 15);
Assertions.assertEquals("", formatter.toString(ts));
Assertions.assertEquals("", formatter.toString(ts));
Assertions.assertNotEquals("", formatter.toString(ts));
}
}

View File

@ -0,0 +1,38 @@
package com.sparrowwallet.sparrow.net;
import org.junit.jupiter.api.Test;
import java.net.URI;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
public class Auth47Test {
@Test
public void acceptsHttpsCallbacksWithResource() throws Exception {
Auth47 auth47 = new Auth47(new URI("auth47://nonce?c=https://example.com/auth&r=example"));
assertEquals("https", auth47.getCallback().getProtocol());
assertEquals("example.com", auth47.getCallback().getHost());
}
@Test
public void acceptsSrbnCallbacks() throws Exception {
Auth47 auth47 = new Auth47(new URI("auth47://nonce?c=srbn://alice@relay.example.com"));
assertEquals("https", auth47.getCallback().getProtocol());
assertEquals("relay.example.com", auth47.getCallback().getHost());
}
@Test
public void rejectsHttpClearnetCallbacks() {
assertThrows(IllegalArgumentException.class, () ->
new Auth47(new URI("auth47://nonce?c=http://example.com/auth&r=example")));
}
@Test
public void rejectsNonHttpCallbacksWithResource() {
assertThrows(IllegalArgumentException.class, () ->
new Auth47(new URI("auth47://nonce?c=file:///tmp/auth47&r=example")));
}
}

View File

@ -0,0 +1,57 @@
package com.sparrowwallet.sparrow.net;
import com.sparrowwallet.drongo.protocol.Bech32;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
public class LnurlAuthTest {
private static final String K1 = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
@Test
public void acceptsHttpsCallbacks() throws Exception {
LnurlAuth lnurlAuth = new LnurlAuth(lightningUri("https://example.com/lnurl-auth?tag=login&k1=" + K1));
assertEquals("example.com", lnurlAuth.getDomain());
assertEquals("login to example.com", lnurlAuth.getLoginMessage());
}
@Test
public void acceptsHttpOnionCallbacks() throws Exception {
LnurlAuth lnurlAuth = new LnurlAuth(lightningUri("http://abcdefghijklmnopqrstuvwxyzabcdefghijklmnop.onion/lnurl-auth?tag=login&k1=" + K1));
assertEquals("abcdefghijklmnopqrstuvwxyzabcdefghijklmnop.onion", lnurlAuth.getDomain());
}
@Test
public void rejectsHttpClearnetCallbacks() {
assertThrows(IllegalArgumentException.class, () ->
new LnurlAuth(lightningUri("http://example.com/lnurl-auth?tag=login&k1=" + K1)));
}
@Test
public void rejectsNonHttpCallbacks() {
assertThrows(IllegalArgumentException.class, () ->
new LnurlAuth(lightningUri("ftp://example.com/lnurl-auth?tag=login&k1=" + K1)));
}
@Test
public void rejectsNonLnurlBech32Prefix() {
assertThrows(IllegalArgumentException.class, () ->
new LnurlAuth(lightningUri("lnurlx", "https://example.com/lnurl-auth?tag=login&k1=" + K1)));
}
private static URI lightningUri(String url) throws Exception {
return lightningUri("lnurl", url);
}
private static URI lightningUri(String prefix, String url) throws Exception {
byte[] urlBytes = url.getBytes(StandardCharsets.UTF_8);
byte[] lnurlData = Bech32.convertBits(urlBytes, 0, urlBytes.length, 8, 5, true);
return new URI("lightning:" + Bech32.encode(prefix, Bech32.Encoding.BECH32, lnurlData));
}
}