diff --git a/build.gradle b/build.gradle index 932fa067..f4fc9714 100644 --- a/build.gradle +++ b/build.gradle @@ -20,7 +20,7 @@ if(System.getProperty("os.arch") == "aarch64") { def headless = "true".equals(System.getProperty("java.awt.headless")) group 'com.sparrowwallet' -version '2.2.2' +version '2.2.4' repositories { mavenCentral() @@ -78,7 +78,7 @@ dependencies { implementation('com.fasterxml.jackson.core:jackson-databind:2.17.2') implementation('com.sparrowwallet:hummingbird:1.7.4') implementation('co.nstant.in:cbor:0.9') - implementation('org.openpnp:openpnp-capture-java:0.0.28-5') + implementation('org.openpnp:openpnp-capture-java:0.0.28-6') implementation("io.matthewnelson.kmp-tor:runtime:2.2.1") implementation("io.matthewnelson.kmp-tor:resource-exec-tor-gpl:408.16.3") implementation('org.jetbrains.kotlinx:kotlinx-coroutines-javafx:1.10.1') { diff --git a/docs/reproducible.md b/docs/reproducible.md index 5df236eb..e6e97ea0 100644 --- a/docs/reproducible.md +++ b/docs/reproducible.md @@ -83,7 +83,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.2.1" +GIT_TAG="2.2.3" ``` The project can then be initially cloned as follows: diff --git a/drongo b/drongo index abb598d3..13e1fafb 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit abb598d3b041a9d0b3d0ba41b5fb9785e2100193 +Subproject commit 13e1fafbe8892d7005a043ae561e09ed66f7cea6 diff --git a/src/main/deploy/package/osx/Info.plist b/src/main/deploy/package/osx/Info.plist index dfb179de..54cb165d 100644 --- a/src/main/deploy/package/osx/Info.plist +++ b/src/main/deploy/package/osx/Info.plist @@ -21,7 +21,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2.2.2 + 2.2.4 CFBundleSignature ???? diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index df2286ab..38b18758 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -1462,6 +1462,10 @@ public class AppController implements Initializable { } public void sendToMany(ActionEvent event) { + sendToMany(Collections.emptyList()); + } + + private void sendToMany(List initialPayments) { if(sendToManyDialog != null) { Stage stage = (Stage)sendToManyDialog.getDialogPane().getScene().getWindow(); stage.setAlwaysOnTop(true); @@ -1477,7 +1481,7 @@ public class AppController implements Initializable { bitcoinUnit = wallet.getAutoUnit(); } - sendToManyDialog = new SendToManyDialog(bitcoinUnit); + sendToManyDialog = new SendToManyDialog(bitcoinUnit, initialPayments); sendToManyDialog.initModality(Modality.NONE); Optional> optPayments = sendToManyDialog.showAndWait(); sendToManyDialog = null; @@ -3147,6 +3151,11 @@ public class AppController implements Initializable { } } + @Subscribe + public void requestSendToMany(RequestSendToManyEvent event) { + sendToMany(event.getPayments()); + } + @Subscribe public void functionAction(FunctionActionEvent event) { selectTab(event.getWallet()); diff --git a/src/main/java/com/sparrowwallet/sparrow/AppServices.java b/src/main/java/com/sparrowwallet/sparrow/AppServices.java index e5adc238..53df02ac 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppServices.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppServices.java @@ -136,6 +136,8 @@ public class AppServices { private static Map targetBlockFeeRates; + private static Double nextBlockMedianFeeRate; + private static final TreeMap> mempoolHistogram = new TreeMap<>(); private static Double minimumRelayFeeRate; @@ -748,6 +750,10 @@ public class AppServices { return Math.max(minRate, Transaction.DUST_RELAY_TX_FEE); } + public static Double getNextBlockMedianFeeRate() { + return nextBlockMedianFeeRate == null ? getDefaultFeeRate() : nextBlockMedianFeeRate; + } + public static double getFallbackFeeRate() { return Network.get() == Network.MAINNET ? FALLBACK_FEE_RATE : TESTNET_FALLBACK_FEE_RATE; } @@ -1249,11 +1255,13 @@ public class AppServices { if(AppServices.currentBlockHeight != null) { blockSummaries.keySet().removeIf(height -> AppServices.currentBlockHeight - height > 5); } + nextBlockMedianFeeRate = event.getNextBlockMedianFeeRate(); } @Subscribe public void feesUpdated(FeeRatesUpdatedEvent event) { targetBlockFeeRates = event.getTargetBlockFeeRates(); + nextBlockMedianFeeRate = event.getNextBlockMedianFeeRate(); } @Subscribe diff --git a/src/main/java/com/sparrowwallet/sparrow/SparrowWallet.java b/src/main/java/com/sparrowwallet/sparrow/SparrowWallet.java index 776ddc53..946906f8 100644 --- a/src/main/java/com/sparrowwallet/sparrow/SparrowWallet.java +++ b/src/main/java/com/sparrowwallet/sparrow/SparrowWallet.java @@ -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.2.2"; + public static final String APP_VERSION = "2.2.4"; 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"; diff --git a/src/main/java/com/sparrowwallet/sparrow/control/BlockCube.java b/src/main/java/com/sparrowwallet/sparrow/control/BlockCube.java index d02e449c..b8722270 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/BlockCube.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/BlockCube.java @@ -52,7 +52,10 @@ public class BlockCube extends Group { public BlockCube(Integer weight, Double medianFee, Integer height, Integer txCount, Long timestamp, boolean confirmed) { getStyleClass().addAll("block-" + Network.getCanonical().getName(), "block-cube"); this.confirmedProperty.set(confirmed); - this.feeRatesSource.set(Config.get().getFeeRatesSource()); + + FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource(); + feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource); + this.feeRatesSource.set(feeRatesSource); this.weightProperty.addListener((_, _, _) -> { if(front != null) { @@ -198,6 +201,8 @@ public class BlockCube extends Group { } else { feeRateIcon.getChildren().clear(); } + } else { + feeRateIcon.getChildren().clear(); } } } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/ConfirmationAlert.java b/src/main/java/com/sparrowwallet/sparrow/control/ConfirmationAlert.java new file mode 100644 index 00000000..3fa613b0 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/ConfirmationAlert.java @@ -0,0 +1,39 @@ +package com.sparrowwallet.sparrow.control; + +import com.sparrowwallet.sparrow.AppServices; +import javafx.geometry.Insets; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; +import javafx.scene.control.CheckBox; +import javafx.scene.control.Label; +import javafx.scene.layout.VBox; + +import static com.sparrowwallet.sparrow.AppServices.getActiveWindow; +import static com.sparrowwallet.sparrow.AppServices.setStageIcon; + +public class ConfirmationAlert extends Alert { + private final CheckBox dontAskAgain; + + public ConfirmationAlert(String title, String contentText, ButtonType... buttons) { + super(AlertType.CONFIRMATION, contentText, buttons); + + initOwner(getActiveWindow()); + setStageIcon(getDialogPane().getScene().getWindow()); + getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm()); + setTitle(title); + setHeaderText(title); + + VBox contentBox = new VBox(20); + contentBox.setPadding(new Insets(10, 20, 10, 20)); + Label contentLabel = new Label(contentText); + contentLabel.setWrapText(true); + dontAskAgain = new CheckBox("Don't ask again"); + contentBox.getChildren().addAll(contentLabel, dontAskAgain); + + getDialogPane().setContent(contentBox); + } + + public boolean isDontAskAgain() { + return dontAskAgain.isSelected(); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/control/MessageSignDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/MessageSignDialog.java index 867e702d..fac7e84a 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/MessageSignDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/MessageSignDialog.java @@ -240,6 +240,9 @@ public class MessageSignDialog extends Dialog { setFormatFromScriptType(address.getScriptType()); if(wallet != null) { setWalletNodeFromAddress(wallet, address); + if(walletNode != null) { + setFormatFromScriptType(getSigningScriptType(walletNode)); + } } } catch(InvalidAddressException e) { //can't happen @@ -273,7 +276,7 @@ public class MessageSignDialog extends Dialog { } if(wallet != null && walletNode != null) { - setFormatFromScriptType(wallet.getScriptType()); + setFormatFromScriptType(getSigningScriptType(walletNode)); } else { formatGroup.selectToggle(formatElectrum); } @@ -287,9 +290,13 @@ public class MessageSignDialog extends Dialog { } private boolean canSign(Wallet wallet) { - return wallet.getKeystores().get(0).hasPrivateKey() - || wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB - || wallet.getKeystores().get(0).getWalletModel().isCard(); + return wallet.getKeystores().getFirst().hasPrivateKey() + || wallet.getKeystores().getFirst().getSource() == KeystoreSource.HW_USB + || wallet.getKeystores().getFirst().getWalletModel().isCard(); + } + + private boolean canSignBip322(Wallet wallet) { + return wallet.getKeystores().getFirst().hasPrivateKey(); } private Address getAddress()throws InvalidAddressException { @@ -313,6 +320,11 @@ public class MessageSignDialog extends Dialog { walletNode = wallet.getWalletAddresses().get(address); } + private ScriptType getSigningScriptType(WalletNode walletNode) { + ScriptType scriptType = walletNode.getWallet().getScriptType(); + return canSign(walletNode.getWallet()) && !canSignBip322(walletNode.getWallet()) ? ScriptType.P2PKH : scriptType; + } + private void setFormatFromScriptType(ScriptType scriptType) { formatElectrum.setDisable(scriptType == ScriptType.P2TR); formatTrezor.setDisable(scriptType == ScriptType.P2TR || scriptType == ScriptType.P2PKH); @@ -345,7 +357,7 @@ public class MessageSignDialog extends Dialog { //Note we can expect a single keystore due to the check in the constructor Wallet signingWallet = walletNode.getWallet(); - if(signingWallet.getKeystores().get(0).hasPrivateKey()) { + if(signingWallet.getKeystores().getFirst().hasPrivateKey()) { if(signingWallet.isEncrypted()) { EventManager.get().post(new RequestOpenWalletsEvent()); } else { @@ -358,7 +370,7 @@ public class MessageSignDialog extends Dialog { private void signUnencryptedKeystore(Wallet decryptedWallet) { try { - Keystore keystore = decryptedWallet.getKeystores().get(0); + Keystore keystore = decryptedWallet.getKeystores().getFirst(); ECKey privKey = keystore.getKey(walletNode); String signatureText; if(isBip322()) { @@ -378,8 +390,8 @@ public class MessageSignDialog extends Dialog { } private void signDeviceKeystore(Wallet deviceWallet) { - List fingerprints = List.of(deviceWallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint()); - KeyDerivation fullDerivation = deviceWallet.getKeystores().get(0).getKeyDerivation().extend(walletNode.getDerivation()); + List fingerprints = List.of(deviceWallet.getKeystores().getFirst().getKeyDerivation().getMasterFingerprint()); + KeyDerivation fullDerivation = deviceWallet.getKeystores().getFirst().getKeyDerivation().extend(walletNode.getDerivation()); DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(fingerprints, deviceWallet, message.getText().trim(), fullDerivation); deviceSignMessageDialog.initOwner(getDialogPane().getScene().getWindow()); Optional optSignature = deviceSignMessageDialog.showAndWait(); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java index d6c39809..608468c7 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java @@ -122,19 +122,21 @@ public class QRScanDialog extends Dialog { if(percentComplete.get() <= 0.0) { Platform.runLater(() -> percentComplete.set(opening ? 0.0 : -1.0)); } + }); - if(opening) { + webcamService.openedProperty().addListener((_, _, opened) -> { + if(opened) { Platform.runLater(() -> { try { postOpenUpdate = true; - List newDevices = new ArrayList<>(webcamService.getDevices()); + List newDevices = new ArrayList<>(webcamService.getAvailableDevices()); newDevices.removeAll(foundDevices); foundDevices.addAll(newDevices); foundDevices.removeIf(device -> !webcamService.getDevices().contains(device)); - if(Config.get().getWebcamDevice() != null && webcamDeviceProperty.get() == null) { + if(webcamService.getDevice() != null) { for(CaptureDevice device : foundDevices) { - if(device.getName().equals(Config.get().getWebcamDevice())) { + if(device.equals(webcamService.getDevice())) { webcamDeviceProperty.set(device); } } @@ -146,10 +148,7 @@ public class QRScanDialog extends Dialog { postOpenUpdate = false; } }); - } - }); - webcamService.closedProperty().addListener((_, _, closed) -> { - if(closed && webcamResolutionProperty.get() != null) { + } else if(webcamResolutionProperty.get() != null) { webcamService.setResolution(webcamResolutionProperty.get()); webcamService.setDevice(webcamDeviceProperty.get()); Platform.runLater(() -> { diff --git a/src/main/java/com/sparrowwallet/sparrow/control/RecentBlocksView.java b/src/main/java/com/sparrowwallet/sparrow/control/RecentBlocksView.java index db47bd84..53f76084 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/RecentBlocksView.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/RecentBlocksView.java @@ -50,7 +50,9 @@ public class RecentBlocksView extends Pane { } })); - updateFeeRatesSource(Config.get().getFeeRatesSource()); + FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource(); + feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource); + updateFeeRatesSource(feeRatesSource); Tooltip.install(this, tooltip); } @@ -106,7 +108,7 @@ public class RecentBlocksView extends Pane { } } - public void addNewBlock(List latestBlocks, Double currentFeeRate) { + private void addNewBlock(List latestBlocks, Double currentFeeRate) { if(getCubes().isEmpty()) { return; } @@ -140,8 +142,10 @@ public class RecentBlocksView extends Pane { public void updateFeeRate(Map targetBlockFeeRates) { int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1); - Double defaultRate = targetBlockFeeRates.get(defaultTarget); - updateFeeRate(defaultRate); + if(targetBlockFeeRates.get(defaultTarget) != null) { + Double defaultRate = targetBlockFeeRates.get(defaultTarget); + updateFeeRate(defaultRate); + } } public void updateFeeRate(Double currentFeeRate) { diff --git a/src/main/java/com/sparrowwallet/sparrow/control/SendToManyDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/SendToManyDialog.java index 0899baf8..bad10169 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/SendToManyDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/SendToManyDialog.java @@ -13,8 +13,6 @@ import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.scene.Node; import javafx.scene.control.*; -import javafx.scene.image.Image; -import javafx.scene.image.ImageView; import javafx.scene.input.Clipboard; import javafx.scene.layout.StackPane; import javafx.stage.FileChooser; @@ -34,7 +32,7 @@ public class SendToManyDialog extends Dialog> { private final SpreadsheetView spreadsheetView; public static final AddressCellType ADDRESS = new AddressCellType(); - public SendToManyDialog(BitcoinUnit bitcoinUnit) { + public SendToManyDialog(BitcoinUnit bitcoinUnit, List payments) { this.bitcoinUnit = bitcoinUnit; final DialogPane dialogPane = new SendToManyDialogPane(); @@ -44,7 +42,8 @@ public class SendToManyDialog extends Dialog> { dialogPane.setHeaderText("Send to many recipients by specifying addresses and amounts.\nOnly the first row's label is necessary."); dialogPane.setGraphic(new DialogImage(DialogImage.Type.SPARROW)); - List initialPayments = IntStream.range(0, 100).mapToObj(i -> new Payment(null, null, -1, false)).collect(Collectors.toList()); + List initialPayments = IntStream.range(0, 100) + .mapToObj(i -> i < payments.size() ? payments.get(i) : new Payment(null, null, -1, false)).collect(Collectors.toList()); Grid grid = getGrid(initialPayments); spreadsheetView = new SpreadsheetView(grid) { diff --git a/src/main/java/com/sparrowwallet/sparrow/control/WebcamPixelFormat.java b/src/main/java/com/sparrowwallet/sparrow/control/WebcamPixelFormat.java index fe72cf65..9845fe79 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/WebcamPixelFormat.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/WebcamPixelFormat.java @@ -6,8 +6,8 @@ public enum WebcamPixelFormat { //Only V4L2 formats defined in linux/videodev2.h are required here, declared in order of priority for supported formats PIX_FMT_RGB24("RGB3", true), PIX_FMT_YUYV("YUYV", true), - PIX_FMT_MJPG("MJPG", true), - PIX_FMT_NV12("NV12", false); + PIX_FMT_NV12("NV12", true), + PIX_FMT_MJPG("MJPG", true); private final String name; private final boolean supported; @@ -25,6 +25,14 @@ public enum WebcamPixelFormat { return supported; } + public int getFourCC() { + char a = name.charAt(0); + char b = name.charAt(1); + char c = name.charAt(2); + char d = name.charAt(3); + return ((int) a) | ((int) b << 8) | ((int) c << 16) | ((int) d << 24); + } + public String toString() { return name; } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/WebcamService.java b/src/main/java/com/sparrowwallet/sparrow/control/WebcamService.java index e7f97973..ab3f4b68 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/WebcamService.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/WebcamService.java @@ -27,6 +27,9 @@ import java.awt.image.BufferedImage; import java.awt.image.WritableRaster; import java.util.*; import java.util.List; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -34,13 +37,18 @@ import java.util.stream.Stream; public class WebcamService extends ScheduledService { private static final Logger log = LoggerFactory.getLogger(WebcamService.class); + private final Semaphore taskSemaphore = new Semaphore(1); + private final AtomicBoolean cancelRequested = new AtomicBoolean(false); + private final AtomicBoolean captureClosed = new AtomicBoolean(false); + private List devices; + private List availableDevices; private Set resolutions; private WebcamResolution resolution; private CaptureDevice device; private final BooleanProperty opening = new SimpleBooleanProperty(false); - private final BooleanProperty closed = new SimpleBooleanProperty(false); + private final BooleanProperty opened = new SimpleBooleanProperty(false); private final ObjectProperty resultProperty = new SimpleObjectProperty<>(null); @@ -105,24 +113,36 @@ public class WebcamService extends ScheduledService { return new Task<>() { @Override protected Image call() throws Exception { + if(cancelRequested.get() || isCancelled() || captureClosed.get()) { + return null; + } + + if(!taskSemaphore.tryAcquire()) { + log.warn("Skipped execution of webcam capture task, another task is running"); + return null; + } + try { - if(stream == null) { + if(devices == null) { devices = capture.getDevices(); + availableDevices = new ArrayList<>(devices); if(devices.isEmpty()) { throw new UnsupportedOperationException("No cameras available"); } + } - CaptureDevice selectedDevice = devices.stream().filter(d -> !d.getFormats().isEmpty()).findFirst().orElse(devices.getFirst()); + while(stream == null && !availableDevices.isEmpty()) { + CaptureDevice selectedDevice = availableDevices.stream().filter(d -> !d.getFormats().isEmpty()).findFirst().orElse(availableDevices.getFirst()); if(device != null) { - for(CaptureDevice webcam : devices) { + for(CaptureDevice webcam : availableDevices) { if(webcam.getName().equals(device.getName())) { selectedDevice = webcam; } } } else if(Config.get().getWebcamDevice() != null) { - for(CaptureDevice webcam : devices) { + for(CaptureDevice webcam : availableDevices) { if(webcam.getName().equals(Config.get().getWebcamDevice())) { selectedDevice = webcam; } @@ -163,22 +183,35 @@ public class WebcamService extends ScheduledService { } } + //On Linux, formats not defined in WebcamPixelFormat are unsupported + if(OsType.getCurrent() == OsType.UNIX && WebcamPixelFormat.fromFourCC(format.getFormatInfo().fourcc) == null) { + log.warn("Unsupported camera pixel format " + WebcamPixelFormat.fourCCToString(format.getFormatInfo().fourcc)); + } + if(log.isDebugEnabled()) { - log.debug("Opening capture stream on " + device + " with format " + format.getFormatInfo().width + "x" + format.getFormatInfo().height + " (" + WebcamPixelFormat.fourCCToString(format.getFormatInfo().fourcc) + ")"); + log.debug("Opening capture stream on " + device + " with format " + format.getFormatInfo().width + "x" + format.getFormatInfo().height + " (" + WebcamPixelFormat.fourCCToString(format.getFormatInfo().fourcc) + ")"); } opening.set(true); stream = device.openStream(format); opening.set(false); - closed.set(false); try { zoomLimits = stream.getPropertyLimits(CaptureProperty.Zoom); } catch(Throwable e) { log.debug("Error getting zoom limits on " + device + ", assuming no zoom function"); } + + if(stream == null) { + availableDevices.remove(device); + } } + if(stream == null) { + throw new UnsupportedOperationException("No usable cameras available, tried " + devices); + } + + opened.set(true); BufferedImage originalImage = stream.capture(); CroppedDimension cropped = getCroppedDimension(originalImage); BufferedImage croppedImage = originalImage.getSubimage(cropped.x, cropped.y, cropped.length, cropped.length); @@ -195,6 +228,7 @@ public class WebcamService extends ScheduledService { return image; } finally { opening.set(false); + taskSemaphore.release(); } } }; @@ -204,21 +238,38 @@ public class WebcamService extends ScheduledService { public void reset() { stream = null; zoomLimits = null; + cancelRequested.set(false); super.reset(); } @Override public boolean cancel() { - if(stream != null) { - stream.close(); - closed.set(true); + cancelRequested.set(true); + boolean cancelled = super.cancel(); + + try { + if(taskSemaphore.tryAcquire(1, TimeUnit.SECONDS)) { + taskSemaphore.release(); + } else { + log.error("Timed out waiting for task semaphore to be available to cancel, cancelling anyway"); + } + } catch(InterruptedException e) { + log.error("Interrupted while waiting for task semaphore to be available to cancel, cancelling anyway"); } - return super.cancel(); + if(stream != null) { + stream.close(); + opened.set(false); + } + + return cancelled; } - public void close() { - capture.close(); + public synchronized void close() { + if(!captureClosed.get()) { + captureClosed.set(true); + capture.close(); + } } public PropertyLimits getZoomLimits() { @@ -336,6 +387,10 @@ public class WebcamService extends ScheduledService { return devices; } + public List getAvailableDevices() { + return availableDevices; + } + public Set getResolutions() { return resolutions; } @@ -376,8 +431,12 @@ public class WebcamService extends ScheduledService { return opening; } - public BooleanProperty closedProperty() { - return closed; + public BooleanProperty openedProperty() { + return opened; + } + + public boolean getCancelRequested() { + return cancelRequested.get(); } public static > T getNearestEnum(T target) { diff --git a/src/main/java/com/sparrowwallet/sparrow/control/WebcamView.java b/src/main/java/com/sparrowwallet/sparrow/control/WebcamView.java index a34f50fb..5e9b87f5 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/WebcamView.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/WebcamView.java @@ -61,7 +61,7 @@ public class WebcamView { }); service.valueProperty().addListener((observable, oldValue, newValue) -> { - if(newValue != null) { + if(newValue != null && !service.getCancelRequested()) { imageProperty.set(newValue); } }); diff --git a/src/main/java/com/sparrowwallet/sparrow/event/BlockSummaryEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/BlockSummaryEvent.java index 4cd74e14..973560b1 100644 --- a/src/main/java/com/sparrowwallet/sparrow/event/BlockSummaryEvent.java +++ b/src/main/java/com/sparrowwallet/sparrow/event/BlockSummaryEvent.java @@ -6,12 +6,18 @@ import java.util.Map; public class BlockSummaryEvent { private final Map blockSummaryMap; + private final Double nextBlockMedianFeeRate; - public BlockSummaryEvent(Map blockSummaryMap) { + public BlockSummaryEvent(Map blockSummaryMap, Double nextBlockMedianFeeRate) { this.blockSummaryMap = blockSummaryMap; + this.nextBlockMedianFeeRate = nextBlockMedianFeeRate; } public Map getBlockSummaryMap() { return blockSummaryMap; } + + public Double getNextBlockMedianFeeRate() { + return nextBlockMedianFeeRate; + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/event/FeeRatesUpdatedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/FeeRatesUpdatedEvent.java index 82a99958..660d882c 100644 --- a/src/main/java/com/sparrowwallet/sparrow/event/FeeRatesUpdatedEvent.java +++ b/src/main/java/com/sparrowwallet/sparrow/event/FeeRatesUpdatedEvent.java @@ -7,13 +7,23 @@ import java.util.Set; public class FeeRatesUpdatedEvent extends MempoolRateSizesUpdatedEvent { private final Map targetBlockFeeRates; + private final Double nextBlockMedianFeeRate; public FeeRatesUpdatedEvent(Map targetBlockFeeRates, Set mempoolRateSizes) { + this(targetBlockFeeRates, mempoolRateSizes, null); + } + + public FeeRatesUpdatedEvent(Map targetBlockFeeRates, Set mempoolRateSizes, Double nextBlockMedianFeeRate) { super(mempoolRateSizes); this.targetBlockFeeRates = targetBlockFeeRates; + this.nextBlockMedianFeeRate = nextBlockMedianFeeRate; } public Map getTargetBlockFeeRates() { return targetBlockFeeRates; } + + public Double getNextBlockMedianFeeRate() { + return nextBlockMedianFeeRate; + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/event/RequestSendToManyEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/RequestSendToManyEvent.java new file mode 100644 index 00000000..2e88d634 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/RequestSendToManyEvent.java @@ -0,0 +1,17 @@ +package com.sparrowwallet.sparrow.event; + +import com.sparrowwallet.drongo.wallet.Payment; + +import java.util.List; + +public class RequestSendToManyEvent { + private final List payments; + + public RequestSendToManyEvent(List payments) { + this.payments = payments; + } + + public List getPayments() { + return payments; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/event/WalletNodeHistoryChangedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/WalletNodeHistoryChangedEvent.java index 483a9d14..5697c57f 100644 --- a/src/main/java/com/sparrowwallet/sparrow/event/WalletNodeHistoryChangedEvent.java +++ b/src/main/java/com/sparrowwallet/sparrow/event/WalletNodeHistoryChangedEvent.java @@ -14,9 +14,16 @@ import java.util.List; */ public class WalletNodeHistoryChangedEvent { private final String scriptHash; + private final String status; public WalletNodeHistoryChangedEvent(String scriptHash) { this.scriptHash = scriptHash; + this.status = null; + } + + public WalletNodeHistoryChangedEvent(String scriptHash, String status) { + this.scriptHash = scriptHash; + this.status = status; } public WalletNode getWalletNode(Wallet wallet) { @@ -70,4 +77,8 @@ public class WalletNodeHistoryChangedEvent { public String getScriptHash() { return scriptHash; } + + public String getStatus() { + return status; + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Config.java b/src/main/java/com/sparrowwallet/sparrow/io/Config.java index 5c226f6c..4def51a2 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Config.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Config.java @@ -52,6 +52,8 @@ public class Config { private boolean showDeprecatedImportExport = false; private boolean signBsmsExports = false; private boolean preventSleep = false; + private Boolean connectToBroadcast; + private Boolean suggestSendToMany; private List recentWalletFiles; private Integer keyDerivationPeriod; private long dustAttackThreshold = DUST_ATTACK_THRESHOLD_SATS; @@ -69,6 +71,7 @@ public class Config { private File coreDataDir; private String coreAuth; private boolean useLegacyCoreWallet; + private boolean legacyServer; private Server electrumServer; private List recentElectrumServers; private File electrumServerCert; @@ -355,6 +358,25 @@ public class Config { public void setPreventSleep(boolean preventSleep) { this.preventSleep = preventSleep; + flush(); + } + + public Boolean getConnectToBroadcast() { + return connectToBroadcast; + } + + public void setConnectToBroadcast(Boolean connectToBroadcast) { + this.connectToBroadcast = connectToBroadcast; + flush(); + } + + public Boolean getSuggestSendToMany() { + return suggestSendToMany; + } + + public void setSuggestSendToMany(Boolean suggestSendToMany) { + this.suggestSendToMany = suggestSendToMany; + flush(); } public List getRecentWalletFiles() { @@ -557,6 +579,15 @@ public class Config { flush(); } + public boolean isLegacyServer() { + return legacyServer; + } + + public void setLegacyServer(boolean legacyServer) { + this.legacyServer = legacyServer; + flush(); + } + public Server getElectrumServer() { return electrumServer; } diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Descriptor.java b/src/main/java/com/sparrowwallet/sparrow/io/Descriptor.java index e1cfde7b..e836ab22 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Descriptor.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Descriptor.java @@ -126,7 +126,7 @@ public class Descriptor implements WalletImport, WalletExport { } else if(line.startsWith("#")) { continue; } else { - paragraph.append(line); + paragraph.append(line.replaceFirst("^.+:", "").trim()); } } diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ElectrumPersonalServer.java b/src/main/java/com/sparrowwallet/sparrow/io/ElectrumPersonalServer.java index 6f1e4d42..ddcb4cf2 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/ElectrumPersonalServer.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/ElectrumPersonalServer.java @@ -37,7 +37,8 @@ public class ElectrumPersonalServer implements WalletExport { try { BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)); writer.write("# Electrum Personal Server configuration file fragments\n"); - writer.write("# Copy the lines below into the relevant sections in your EPS config.ini file\n\n"); + writer.write("# First close Sparrow and edit your config file in Sparrow home to set \"legacyServer\": true\n"); + writer.write("# Then copy the lines below into the relevant sections in your EPS config.ini file\n\n"); writer.write("# Copy into [master-public-keys] section\n"); Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet(); writeWalletXpub(masterWallet, writer); diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java index 83441f97..c262e3ba 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java @@ -76,6 +76,10 @@ public class ElectrumServer { private static final Set sameHeightTxioScriptHashes = ConcurrentHashMap.newKeySet(); + private final static Map subscribedRecent = new ConcurrentHashMap<>(); + + private final static Map broadcastRecent = new ConcurrentHashMap<>(); + private static ElectrumServerRpc electrumServerRpc = new SimpleElectrumServerRpc(); private static Cormorant cormorant; @@ -936,6 +940,20 @@ public class ElectrumServer { return targetBlocksFeeRatesSats; } + public Double getNextBlockMedianFeeRate() { + FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource(); + feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource); + if(feeRatesSource.supportsNetwork(Network.get())) { + try { + return feeRatesSource.getNextBlockMedianFeeRate(); + } catch(Exception e) { + return null; + } + } + + return null; + } + public Map getDefaultFeeEstimates(List targetBlocks) throws ServerException { try { Map targetBlocksFeeRatesBtcKb = electrumServerRpc.getFeeEstimates(getTransport(), targetBlocks); @@ -1048,6 +1066,11 @@ public class ElectrumServer { List recentTransactions = feeRatesSource.getRecentMempoolTransactions(); Map setReferences = new HashMap<>(); setReferences.put(recentTransactions.getFirst(), null); + if(recentTransactions.size() > 1) { + Random random = new Random(); + int halfSize = recentTransactions.size() / 2; + setReferences.put(recentTransactions.get(halfSize == 1 ? 1 : random.nextInt(halfSize) + 1), null); + } Map transactions = getTransactions(null, setReferences, Collections.emptyMap()); return transactions.values().stream().filter(blxTx -> blxTx.getTransaction() != null).toList(); } catch(Exception e) { @@ -1232,11 +1255,11 @@ public class ElectrumServer { if(!serverVersion.isEmpty()) { String server = serverVersion.getFirst().toLowerCase(Locale.ROOT); if(server.contains("electrumx")) { - return new ServerCapability(true); + return new ServerCapability(true, true); } if(server.startsWith("cormorant")) { - return new ServerCapability(true, false, true); + return new ServerCapability(true, false, true, false); } if(server.startsWith("electrs/")) { @@ -1248,7 +1271,7 @@ public class ElectrumServer { try { Version version = new Version(electrsVersion); if(version.compareTo(ELECTRS_MIN_BATCHING_VERSION) >= 0) { - return new ServerCapability(true); + return new ServerCapability(true, true); } } catch(Exception e) { //ignore @@ -1264,7 +1287,7 @@ public class ElectrumServer { try { Version version = new Version(fulcrumVersion); if(version.compareTo(FULCRUM_MIN_BATCHING_VERSION) >= 0) { - return new ServerCapability(true); + return new ServerCapability(true, true); } } catch(Exception e) { //ignore @@ -1283,15 +1306,19 @@ public class ElectrumServer { Version version = new Version(mempoolElectrsVersion); if(version.compareTo(MEMPOOL_ELECTRS_MIN_BATCHING_VERSION) > 0 || (version.compareTo(MEMPOOL_ELECTRS_MIN_BATCHING_VERSION) == 0 && (!mempoolElectrsSuffix.contains("dev") || mempoolElectrsSuffix.contains("dev-249848d")))) { - return new ServerCapability(true, 25); + return new ServerCapability(true, 25, false); } } catch(Exception e) { //ignore } } + + if(server.startsWith("electrumpersonalserver")) { + return new ServerCapability(false, false); + } } - return new ServerCapability(false); + return new ServerCapability(false, true); } public static class ServerVersionService extends Service> { @@ -1456,8 +1483,9 @@ public class ElectrumServer { if(elapsed > FEE_RATES_PERIOD) { Map blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE, false); Set mempoolRateSizes = electrumServer.getMempoolRateSizes(); + Double nextBlockMedianFeeRate = electrumServer.getNextBlockMedianFeeRate(); feeRatesRetrievedAt = System.currentTimeMillis(); - return new FeeRatesUpdatedEvent(blockTargetFeeRates, mempoolRateSizes); + return new FeeRatesUpdatedEvent(blockTargetFeeRates, mempoolRateSizes, nextBlockMedianFeeRate); } } else { closeConnection(); @@ -1583,6 +1611,31 @@ public class ElectrumServer { Set mempoolRateSizes = electrumServer.getMempoolRateSizes(); EventManager.get().post(new MempoolRateSizesUpdatedEvent(mempoolRateSizes)); } + + @Subscribe + public void walletNodeHistoryChanged(WalletNodeHistoryChangedEvent event) { + String status = broadcastRecent.remove(event.getScriptHash()); + if(status != null && status.equals(event.getStatus())) { + Map subscribeScriptHashes = new HashMap<>(); + Random random = new Random(); + int subscriptions = random.nextInt(2) + 1; + for(int i = 0; i < subscriptions; i++) { + byte[] randomScriptHashBytes = new byte[32]; + random.nextBytes(randomScriptHashBytes); + String randomScriptHash = Utils.bytesToHex(randomScriptHashBytes); + if(!subscribedScriptHashes.containsKey(randomScriptHash)) { + subscribeScriptHashes.put("m/" + subscribeScriptHashes.size(), randomScriptHash); + } + } + + try { + electrumServerRpc.subscribeScriptHashes(transport, null, subscribeScriptHashes); + subscribeScriptHashes.values().forEach(scriptHash -> subscribedRecent.put(scriptHash, AppServices.getCurrentBlockHeight())); + } catch(ElectrumServerRpcException e) { + log.debug("Error subscribing to recent mempool transaction outputs", e); + } + } + } } public static class ReadRunnable implements Runnable { @@ -1935,7 +1988,8 @@ public class ElectrumServer { protected FeeRatesUpdatedEvent call() throws ServerException { ElectrumServer electrumServer = new ElectrumServer(); Map blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE, false); - return new FeeRatesUpdatedEvent(blockTargetFeeRates, null); + Double nextBlockMedianFeeRate = electrumServer.getNextBlockMedianFeeRate(); + return new FeeRatesUpdatedEvent(blockTargetFeeRates, null, nextBlockMedianFeeRate); } }; } @@ -1982,10 +2036,14 @@ public class ElectrumServer { Config config = Config.get(); if(!isBlockstorm(totalBlocks) && !AppServices.isUsingProxy() && config.getServer().getProtocol().equals(Protocol.SSL) && (config.getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER || config.getServerType() == ServerType.ELECTRUM_SERVER)) { - subscribeRecent(electrumServer); + subscribeRecent(electrumServer, AppServices.getCurrentBlockHeight() == null ? endHeight : AppServices.getCurrentBlockHeight()); } - return new BlockSummaryEvent(blockSummaryMap); + Double nextBlockMedianFeeRate = null; + if(!isBlockstorm(totalBlocks)) { + nextBlockMedianFeeRate = electrumServer.getNextBlockMedianFeeRate(); + } + return new BlockSummaryEvent(blockSummaryMap, nextBlockMedianFeeRate); } }; } @@ -1994,43 +2052,72 @@ public class ElectrumServer { return Network.get() != Network.MAINNET && totalBlocks > 2; } - private final static Set subscribedRecent = Collections.newSetFromMap(new ConcurrentHashMap<>()); - - private void subscribeRecent(ElectrumServer electrumServer) { - Set unsubscribeScriptHashes = new HashSet<>(subscribedRecent); + private void subscribeRecent(ElectrumServer electrumServer, int currentHeight) { + Set unsubscribeScriptHashes = subscribedRecent.entrySet().stream().filter(entry -> entry.getValue() == null || entry.getValue() <= currentHeight - 3) + .map(Map.Entry::getKey).collect(Collectors.toSet()); unsubscribeScriptHashes.removeIf(subscribedScriptHashes::containsKey); - electrumServerRpc.unsubscribeScriptHashes(transport, unsubscribeScriptHashes); - subscribedRecent.removeAll(unsubscribeScriptHashes); + if(!unsubscribeScriptHashes.isEmpty() && serverCapability.supportsUnsubscribe()) { + electrumServerRpc.unsubscribeScriptHashes(transport, unsubscribeScriptHashes); + } + subscribedRecent.keySet().removeAll(unsubscribeScriptHashes); + broadcastRecent.keySet().removeAll(unsubscribeScriptHashes); Map subscribeScriptHashes = new HashMap<>(); List recentTransactions = electrumServer.getRecentMempoolTransactions(); for(BlockTransaction blkTx : recentTransactions) { - for(int i = 0; i < blkTx.getTransaction().getOutputs().size() && subscribeScriptHashes.size() < 10; i++) { + for(int i = 0; i < blkTx.getTransaction().getOutputs().size(); i++) { TransactionOutput txOutput = blkTx.getTransaction().getOutputs().get(i); String scriptHash = getScriptHash(txOutput); if(!subscribedScriptHashes.containsKey(scriptHash)) { - subscribeScriptHashes.put("m/" + i, getScriptHash(txOutput)); + subscribeScriptHashes.put("m/" + subscribeScriptHashes.size(), scriptHash); + } + if(Math.random() < 0.1d) { + break; } } } if(!subscribeScriptHashes.isEmpty()) { + Random random = new Random(); + int additionalRandomScriptHashes = random.nextInt(8); + for(int i = 0; i < additionalRandomScriptHashes; i++) { + byte[] randomScriptHashBytes = new byte[32]; + random.nextBytes(randomScriptHashBytes); + String randomScriptHash = Utils.bytesToHex(randomScriptHashBytes); + if(!subscribedScriptHashes.containsKey(randomScriptHash)) { + subscribeScriptHashes.put("m/" + subscribeScriptHashes.size(), randomScriptHash); + } + } + try { electrumServerRpc.subscribeScriptHashes(transport, null, subscribeScriptHashes); - subscribedRecent.addAll(subscribeScriptHashes.values()); + subscribeScriptHashes.values().forEach(scriptHash -> subscribedRecent.put(scriptHash, currentHeight)); } catch(ElectrumServerRpcException e) { log.debug("Error subscribing to recent mempool transactions", e); } } + if(!recentTransactions.isEmpty()) { + broadcastRecent(electrumServer, recentTransactions); + } + } + + private void broadcastRecent(ElectrumServer electrumServer, List recentTransactions) { ScheduledService broadcastService = new ScheduledService<>() { @Override protected Task createTask() { return new Task<>() { @Override protected Void call() throws Exception { - for(BlockTransaction blkTx : recentTransactions) { - electrumServer.broadcastTransaction(blkTx.getTransaction()); + if(!recentTransactions.isEmpty()) { + Random random = new Random(); + if(random.nextBoolean()) { + BlockTransaction blkTx = recentTransactions.get(random.nextInt(recentTransactions.size())); + String scriptHash = getScriptHash(blkTx.getTransaction().getOutputs().getFirst()); + String status = getScriptHashStatus(List.of(new ScriptHashTx(0, blkTx.getHashAsString(), blkTx.getFee()))); + broadcastRecent.put(scriptHash, status); + electrumServer.broadcastTransaction(blkTx.getTransaction()); + } } return null; } diff --git a/src/main/java/com/sparrowwallet/sparrow/net/FeeRatesSource.java b/src/main/java/com/sparrowwallet/sparrow/net/FeeRatesSource.java index 5b99f782..4eebee6e 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/FeeRatesSource.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/FeeRatesSource.java @@ -34,6 +34,12 @@ public enum FeeRatesSource { return getThreeTierFeeRates(this, defaultblockTargetFeeRates, url); } + @Override + public Double getNextBlockMedianFeeRate() throws Exception { + String url = getApiUrl() + "v1/fees/mempool-blocks"; + return requestNextBlockMedianFeeRate(this, url); + } + @Override public BlockSummary getBlockSummary(Sha256Hash blockId) throws Exception { String url = getApiUrl() + "v1/block/" + Utils.bytesToHex(blockId.getReversedBytes()); @@ -130,6 +136,10 @@ public enum FeeRatesSource { public abstract Map getBlockTargetFeeRates(Map defaultblockTargetFeeRates); + public Double getNextBlockMedianFeeRate() throws Exception { + throw new UnsupportedOperationException(name + " does not support retrieving the next block median fee rate"); + } + public BlockSummary getBlockSummary(Sha256Hash blockId) throws Exception { throw new UnsupportedOperationException(name + " does not support block summaries"); } @@ -199,6 +209,30 @@ public enum FeeRatesSource { return httpClientService.requestJson(url, ThreeTierRates.class, null); } + protected static Double requestNextBlockMedianFeeRate(FeeRatesSource feeRatesSource, String url) throws Exception { + if(log.isInfoEnabled()) { + log.info("Requesting next block median fee rate from " + url); + } + + HttpClientService httpClientService = AppServices.getHttpClientService(); + try { + MempoolBlock[] mempoolBlocks = feeRatesSource.requestMempoolBlocks(url, httpClientService); + return mempoolBlocks.length > 0 ? mempoolBlocks[0].medianFee : null; + } catch (Exception e) { + if(log.isDebugEnabled()) { + log.warn("Error retrieving next block median fee rate from " + url, e); + } else { + log.warn("Error retrieving next block median fee rate from " + url + " (" + e.getMessage() + ")"); + } + + throw e; + } + } + + protected MempoolBlock[] requestMempoolBlocks(String url, HttpClientService httpClientService) throws Exception { + return httpClientService.requestJson(url, MempoolBlock[].class, null); + } + protected static BlockSummary requestBlockSummary(FeeRatesSource feeRatesSource, String url) throws Exception { if(log.isInfoEnabled()) { log.info("Requesting block summary from " + url); @@ -309,6 +343,8 @@ public enum FeeRatesSource { } } + protected record MempoolBlock(Integer nTx, Double medianFee) {} + protected record MempoolBlockSummary(String id, Integer height, Long timestamp, Integer tx_count, Integer weight, MempoolBlockSummaryExtras extras) { public Double getMedianFee() { return extras == null ? null : extras.medianFee(); diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ServerCapability.java b/src/main/java/com/sparrowwallet/sparrow/net/ServerCapability.java index 98c7099b..fb15e240 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/ServerCapability.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/ServerCapability.java @@ -7,27 +7,30 @@ public class ServerCapability { private final int maxTargetBlocks; private final boolean supportsRecentMempool; private final boolean supportsBlockStats; + private final boolean supportsUnsubscribe; - public ServerCapability(boolean supportsBatching) { - this(supportsBatching, AppServices.TARGET_BLOCKS_RANGE.getLast()); + public ServerCapability(boolean supportsBatching, boolean supportsUnsubscribe) { + this(supportsBatching, AppServices.TARGET_BLOCKS_RANGE.getLast(), supportsUnsubscribe); } - public ServerCapability(boolean supportsBatching, int maxTargetBlocks) { + public ServerCapability(boolean supportsBatching, int maxTargetBlocks, boolean supportsUnsubscribe) { this.supportsBatching = supportsBatching; this.maxTargetBlocks = maxTargetBlocks; this.supportsRecentMempool = false; this.supportsBlockStats = false; + this.supportsUnsubscribe = supportsUnsubscribe; } - public ServerCapability(boolean supportsBatching, boolean supportsRecentMempool, boolean supportsBlockStats) { - this(supportsBatching, AppServices.TARGET_BLOCKS_RANGE.getLast(), supportsRecentMempool, supportsBlockStats); + public ServerCapability(boolean supportsBatching, boolean supportsRecentMempool, boolean supportsBlockStats, boolean supportsUnsubscribe) { + this(supportsBatching, AppServices.TARGET_BLOCKS_RANGE.getLast(), supportsRecentMempool, supportsBlockStats, supportsUnsubscribe); } - public ServerCapability(boolean supportsBatching, int maxTargetBlocks, boolean supportsRecentMempool, boolean supportsBlockStats) { + public ServerCapability(boolean supportsBatching, int maxTargetBlocks, boolean supportsRecentMempool, boolean supportsBlockStats, boolean supportsUnsubscribe) { this.supportsBatching = supportsBatching; this.maxTargetBlocks = maxTargetBlocks; this.supportsRecentMempool = supportsRecentMempool; this.supportsBlockStats = supportsBlockStats; + this.supportsUnsubscribe = supportsUnsubscribe; } public boolean supportsBatching() { @@ -45,4 +48,8 @@ public class ServerCapability { public boolean supportsBlockStats() { return supportsBlockStats; } + + public boolean supportsUnsubscribe() { + return supportsUnsubscribe; + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/net/SimpleElectrumServerRpc.java b/src/main/java/com/sparrowwallet/sparrow/net/SimpleElectrumServerRpc.java index 7a16e03b..91179334 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/SimpleElectrumServerRpc.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/SimpleElectrumServerRpc.java @@ -10,6 +10,7 @@ import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent; +import com.sparrowwallet.sparrow.io.Config; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,16 +39,32 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc { @Override public List getServerVersion(Transport transport, String clientName, String[] supportedVersions) { + if(Config.get().getServerType() == ServerType.ELECTRUM_SERVER && Config.get().isLegacyServer()) { + return getLegacyServerVersion(transport, clientName); + } + try { JsonRpcClient client = new JsonRpcClient(transport); - //Using 1.4 as the version number as EPS tries to parse this number to a float :( return new RetryLogic>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() -> - client.createRequest().returnAsList(String.class).method("server.version").id(idCounter.incrementAndGet()).params(clientName, "1.4").execute()); + client.createRequest().returnAsList(String.class).method("server.version").id(idCounter.incrementAndGet()).params(clientName, supportedVersions).execute()); + } catch(JsonRpcException e) { + return getLegacyServerVersion(transport, clientName); } catch(Exception e) { throw new ElectrumServerRpcException("Error getting server version", e); } } + private List getLegacyServerVersion(Transport transport, String clientName) { + try { + //Fallback to using 1.4 as the version number as EPS tries to parse this number to a float :( + JsonRpcClient client = new JsonRpcClient(transport); + return new RetryLogic>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() -> + client.createRequest().returnAsList(String.class).method("server.version").id(idCounter.incrementAndGet()).params(clientName, "1.4").execute()); + } catch(Exception ex) { + throw new ElectrumServerRpcException("Error getting legacy server version", ex); + } + } + @Override public String getServerBanner(Transport transport) { try { diff --git a/src/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.java b/src/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.java index 1410a0a0..b7b16a33 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.java @@ -38,6 +38,6 @@ public class SubscriptionService { existingStatuses.add(status); } - Platform.runLater(() -> EventManager.get().post(new WalletNodeHistoryChangedEvent(scriptHash))); + Platform.runLater(() -> EventManager.get().post(new WalletNodeHistoryChangedEvent(scriptHash, status))); } } diff --git a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/ElectrumServerService.java b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/ElectrumServerService.java index 9a3e71cf..65e3529d 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/ElectrumServerService.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/ElectrumServerService.java @@ -35,10 +35,11 @@ public class ElectrumServerService { } @JsonRpcMethod("server.version") - public List getServerVersion(@JsonRpcParam("client_name") String clientName, @JsonRpcParam("protocol_version") String protocolVersion) throws UnsupportedVersionException { - Version clientVersion = new Version(protocolVersion); + public List getServerVersion(@JsonRpcParam("client_name") String clientName, @JsonRpcParam("protocol_version") String[] protocolVersion) throws UnsupportedVersionException { + String version = protocolVersion.length > 1 ? protocolVersion[1] : protocolVersion[0]; + Version clientVersion = new Version(version); if(clientVersion.compareTo(VERSION) < 0) { - throw new UnsupportedVersionException(protocolVersion); + throw new UnsupportedVersionException(version); } return List.of(Cormorant.SERVER_NAME + " " + SparrowWallet.APP_VERSION, VERSION.get()); diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java index 967d2386..4fd6b76e 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java @@ -1559,6 +1559,23 @@ public class HeadersController extends TransactionFormController implements Init signButtonBox.setVisible(false); broadcastButtonBox.setVisible(true); + + if(Config.get().hasServer() && !AppServices.isConnected() && !AppServices.isConnecting()) { + if(Config.get().getConnectToBroadcast() == null) { + Platform.runLater(() -> { + ConfirmationAlert confirmationAlert = new ConfirmationAlert("Connect to broadcast?", "Connect to the configured server to broadcast the transaction?", ButtonType.NO, ButtonType.YES); + Optional optType = confirmationAlert.showAndWait(); + if(confirmationAlert.isDontAskAgain() && optType.isPresent()) { + Config.get().setConnectToBroadcast(optType.get() == ButtonType.YES); + } + if(optType.isPresent() && optType.get() == ButtonType.YES) { + EventManager.get().post(new RequestConnectEvent()); + } + }); + } else if(Config.get().getConnectToBroadcast()) { + Platform.runLater(() -> EventManager.get().post(new RequestConnectEvent())); + } + } } } diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java index 720c6b04..57f4d970 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java @@ -326,7 +326,7 @@ public class SendController extends WalletFormController implements Initializabl recentBlocksView.visibleProperty().bind(Bindings.equal(feeRatesSelectionProperty, FeeRatesSelection.RECENT_BLOCKS)); List blockSummaries = AppServices.getBlockSummaries().values().stream().sorted().toList(); if(!blockSummaries.isEmpty()) { - recentBlocksView.update(blockSummaries, AppServices.getDefaultFeeRate()); + recentBlocksView.update(blockSummaries, AppServices.getNextBlockMedianFeeRate()); } feeRatesSelectionProperty.addListener((_, oldValue, newValue) -> { @@ -491,11 +491,35 @@ public class SendController extends WalletFormController implements Initializabl validationSupport.setErrorDecorationEnabled(false); } - public Tab addPaymentTab() { + public void addPaymentTab() { + if(Config.get().getSuggestSendToMany() == null && openSendToMany()) { + return; + } + Tab tab = getPaymentTab(); paymentTabs.getTabs().add(tab); paymentTabs.getSelectionModel().select(tab); - return tab; + } + + private boolean openSendToMany() { + try { + List payments = getPayments(); + if(payments.size() == 3) { + ConfirmationAlert confirmationAlert = new ConfirmationAlert("Open Send To Many?", "Open the Tools > Send To Many dialog to add multiple payments?", ButtonType.NO, ButtonType.YES); + Optional optType = confirmationAlert.showAndWait(); + if(confirmationAlert.isDontAskAgain() && optType.isPresent()) { + Config.get().setSuggestSendToMany(optType.get() == ButtonType.YES); + } + if(optType.isPresent() && optType.get() == ButtonType.YES) { + Platform.runLater(() -> EventManager.get().post(new RequestSendToManyEvent(payments))); + return true; + } + } + } catch(Exception e) { + //ignore + } + + return false; } public Tab getPaymentTab() { @@ -1205,7 +1229,7 @@ public class SendController extends WalletFormController implements Initializabl public void broadcastNotification(Wallet decryptedWallet) { try { - PaymentCode paymentCode = decryptedWallet.getPaymentCode(); + PaymentCode paymentCode = decryptedWallet.isMasterWallet() ? decryptedWallet.getPaymentCode() : decryptedWallet.getMasterWallet().getPaymentCode(); PaymentCode externalPaymentCode = paymentCodeProperty.get(); WalletTransaction walletTransaction = walletTransactionProperty.get(); WalletNode input0Node = walletTransaction.getSelectedUtxos().entrySet().iterator().next().getValue(); @@ -1411,7 +1435,12 @@ public class SendController extends WalletFormController implements Initializabl setFeeRatePriority(getFeeRangeRate()); } feeRange.updateTrackHighlight(); - recentBlocksView.updateFeeRate(event.getTargetBlockFeeRates()); + + if(event.getNextBlockMedianFeeRate() != null) { + recentBlocksView.updateFeeRate(event.getNextBlockMedianFeeRate()); + } else { + recentBlocksView.updateFeeRate(event.getTargetBlockFeeRates()); + } if(updateDefaultFeeRate) { if(getFeeRate() != null && Long.valueOf((long)getFallbackFeeRate()).equals(getFeeRate().longValue())) { @@ -1435,7 +1464,7 @@ public class SendController extends WalletFormController implements Initializabl @Subscribe public void blockSummary(BlockSummaryEvent event) { - Platform.runLater(() -> recentBlocksView.update(AppServices.getBlockSummaries().values().stream().sorted().toList(), AppServices.getDefaultFeeRate())); + Platform.runLater(() -> recentBlocksView.update(AppServices.getBlockSummaries().values().stream().sorted().toList(), AppServices.getNextBlockMedianFeeRate())); } @Subscribe diff --git a/src/main/resources/image/walletmodel/specter-icon-invert.svg b/src/main/resources/image/walletmodel/specter-icon-invert.svg index 73eca817..3bf323da 100644 --- a/src/main/resources/image/walletmodel/specter-icon-invert.svg +++ b/src/main/resources/image/walletmodel/specter-icon-invert.svg @@ -5,11 +5,11 @@ - - - - - + + + + + diff --git a/src/main/resources/image/walletmodel/specter-icon.svg b/src/main/resources/image/walletmodel/specter-icon.svg index 27a5d7d2..53c43b51 100644 --- a/src/main/resources/image/walletmodel/specter-icon.svg +++ b/src/main/resources/image/walletmodel/specter-icon.svg @@ -5,11 +5,11 @@ - - - - - + + + + +