Merge branch 'sparrowwallet:master' into master

This commit is contained in:
QcMrHyde 2025-06-10 12:12:11 -04:00 committed by GitHub
commit 4498bad7e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 514 additions and 102 deletions

View file

@ -20,7 +20,7 @@ if(System.getProperty("os.arch") == "aarch64") {
def headless = "true".equals(System.getProperty("java.awt.headless")) def headless = "true".equals(System.getProperty("java.awt.headless"))
group 'com.sparrowwallet' group 'com.sparrowwallet'
version '2.2.2' version '2.2.4'
repositories { repositories {
mavenCentral() mavenCentral()
@ -78,7 +78,7 @@ dependencies {
implementation('com.fasterxml.jackson.core:jackson-databind:2.17.2') implementation('com.fasterxml.jackson.core:jackson-databind:2.17.2')
implementation('com.sparrowwallet:hummingbird:1.7.4') implementation('com.sparrowwallet:hummingbird:1.7.4')
implementation('co.nstant.in:cbor:0.9') 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:runtime:2.2.1")
implementation("io.matthewnelson.kmp-tor:resource-exec-tor-gpl:408.16.3") implementation("io.matthewnelson.kmp-tor:resource-exec-tor-gpl:408.16.3")
implementation('org.jetbrains.kotlinx:kotlinx-coroutines-javafx:1.10.1') { implementation('org.jetbrains.kotlinx:kotlinx-coroutines-javafx:1.10.1') {

View file

@ -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: First, assign a temporary variable in your shell for the specific release you want to build. For the current one specify:
```shell ```shell
GIT_TAG="2.2.1" GIT_TAG="2.2.3"
``` ```
The project can then be initially cloned as follows: The project can then be initially cloned as follows:

2
drongo

@ -1 +1 @@
Subproject commit abb598d3b041a9d0b3d0ba41b5fb9785e2100193 Subproject commit 13e1fafbe8892d7005a043ae561e09ed66f7cea6

View file

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

View file

@ -1462,6 +1462,10 @@ public class AppController implements Initializable {
} }
public void sendToMany(ActionEvent event) { public void sendToMany(ActionEvent event) {
sendToMany(Collections.emptyList());
}
private void sendToMany(List<Payment> initialPayments) {
if(sendToManyDialog != null) { if(sendToManyDialog != null) {
Stage stage = (Stage)sendToManyDialog.getDialogPane().getScene().getWindow(); Stage stage = (Stage)sendToManyDialog.getDialogPane().getScene().getWindow();
stage.setAlwaysOnTop(true); stage.setAlwaysOnTop(true);
@ -1477,7 +1481,7 @@ public class AppController implements Initializable {
bitcoinUnit = wallet.getAutoUnit(); bitcoinUnit = wallet.getAutoUnit();
} }
sendToManyDialog = new SendToManyDialog(bitcoinUnit); sendToManyDialog = new SendToManyDialog(bitcoinUnit, initialPayments);
sendToManyDialog.initModality(Modality.NONE); sendToManyDialog.initModality(Modality.NONE);
Optional<List<Payment>> optPayments = sendToManyDialog.showAndWait(); Optional<List<Payment>> optPayments = sendToManyDialog.showAndWait();
sendToManyDialog = null; sendToManyDialog = null;
@ -3147,6 +3151,11 @@ public class AppController implements Initializable {
} }
} }
@Subscribe
public void requestSendToMany(RequestSendToManyEvent event) {
sendToMany(event.getPayments());
}
@Subscribe @Subscribe
public void functionAction(FunctionActionEvent event) { public void functionAction(FunctionActionEvent event) {
selectTab(event.getWallet()); selectTab(event.getWallet());

View file

@ -136,6 +136,8 @@ public class AppServices {
private static Map<Integer, Double> targetBlockFeeRates; private static Map<Integer, Double> targetBlockFeeRates;
private static Double nextBlockMedianFeeRate;
private static final TreeMap<Date, Set<MempoolRateSize>> mempoolHistogram = new TreeMap<>(); private static final TreeMap<Date, Set<MempoolRateSize>> mempoolHistogram = new TreeMap<>();
private static Double minimumRelayFeeRate; private static Double minimumRelayFeeRate;
@ -748,6 +750,10 @@ public class AppServices {
return Math.max(minRate, Transaction.DUST_RELAY_TX_FEE); return Math.max(minRate, Transaction.DUST_RELAY_TX_FEE);
} }
public static Double getNextBlockMedianFeeRate() {
return nextBlockMedianFeeRate == null ? getDefaultFeeRate() : nextBlockMedianFeeRate;
}
public static double getFallbackFeeRate() { public static double getFallbackFeeRate() {
return Network.get() == Network.MAINNET ? FALLBACK_FEE_RATE : TESTNET_FALLBACK_FEE_RATE; return Network.get() == Network.MAINNET ? FALLBACK_FEE_RATE : TESTNET_FALLBACK_FEE_RATE;
} }
@ -1249,11 +1255,13 @@ public class AppServices {
if(AppServices.currentBlockHeight != null) { if(AppServices.currentBlockHeight != null) {
blockSummaries.keySet().removeIf(height -> AppServices.currentBlockHeight - height > 5); blockSummaries.keySet().removeIf(height -> AppServices.currentBlockHeight - height > 5);
} }
nextBlockMedianFeeRate = event.getNextBlockMedianFeeRate();
} }
@Subscribe @Subscribe
public void feesUpdated(FeeRatesUpdatedEvent event) { public void feesUpdated(FeeRatesUpdatedEvent event) {
targetBlockFeeRates = event.getTargetBlockFeeRates(); targetBlockFeeRates = event.getTargetBlockFeeRates();
nextBlockMedianFeeRate = event.getNextBlockMedianFeeRate();
} }
@Subscribe @Subscribe

View file

@ -18,7 +18,7 @@ import java.util.*;
public class SparrowWallet { public class SparrowWallet {
public static final String APP_ID = "sparrow"; public static final String APP_ID = "sparrow";
public static final String APP_NAME = "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_VERSION_SUFFIX = "";
public static final String APP_HOME_PROPERTY = "sparrow.home"; public static final String APP_HOME_PROPERTY = "sparrow.home";
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK"; public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";

View file

@ -52,7 +52,10 @@ public class BlockCube extends Group {
public BlockCube(Integer weight, Double medianFee, Integer height, Integer txCount, Long timestamp, boolean confirmed) { public BlockCube(Integer weight, Double medianFee, Integer height, Integer txCount, Long timestamp, boolean confirmed) {
getStyleClass().addAll("block-" + Network.getCanonical().getName(), "block-cube"); getStyleClass().addAll("block-" + Network.getCanonical().getName(), "block-cube");
this.confirmedProperty.set(confirmed); 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((_, _, _) -> { this.weightProperty.addListener((_, _, _) -> {
if(front != null) { if(front != null) {
@ -198,6 +201,8 @@ public class BlockCube extends Group {
} else { } else {
feeRateIcon.getChildren().clear(); feeRateIcon.getChildren().clear();
} }
} else {
feeRateIcon.getChildren().clear();
} }
} }
} }

View file

@ -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();
}
}

View file

@ -240,6 +240,9 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
setFormatFromScriptType(address.getScriptType()); setFormatFromScriptType(address.getScriptType());
if(wallet != null) { if(wallet != null) {
setWalletNodeFromAddress(wallet, address); setWalletNodeFromAddress(wallet, address);
if(walletNode != null) {
setFormatFromScriptType(getSigningScriptType(walletNode));
}
} }
} catch(InvalidAddressException e) { } catch(InvalidAddressException e) {
//can't happen //can't happen
@ -273,7 +276,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
} }
if(wallet != null && walletNode != null) { if(wallet != null && walletNode != null) {
setFormatFromScriptType(wallet.getScriptType()); setFormatFromScriptType(getSigningScriptType(walletNode));
} else { } else {
formatGroup.selectToggle(formatElectrum); formatGroup.selectToggle(formatElectrum);
} }
@ -287,9 +290,13 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
} }
private boolean canSign(Wallet wallet) { private boolean canSign(Wallet wallet) {
return wallet.getKeystores().get(0).hasPrivateKey() return wallet.getKeystores().getFirst().hasPrivateKey()
|| wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB || wallet.getKeystores().getFirst().getSource() == KeystoreSource.HW_USB
|| wallet.getKeystores().get(0).getWalletModel().isCard(); || wallet.getKeystores().getFirst().getWalletModel().isCard();
}
private boolean canSignBip322(Wallet wallet) {
return wallet.getKeystores().getFirst().hasPrivateKey();
} }
private Address getAddress()throws InvalidAddressException { private Address getAddress()throws InvalidAddressException {
@ -313,6 +320,11 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
walletNode = wallet.getWalletAddresses().get(address); 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) { private void setFormatFromScriptType(ScriptType scriptType) {
formatElectrum.setDisable(scriptType == ScriptType.P2TR); formatElectrum.setDisable(scriptType == ScriptType.P2TR);
formatTrezor.setDisable(scriptType == ScriptType.P2TR || scriptType == ScriptType.P2PKH); formatTrezor.setDisable(scriptType == ScriptType.P2TR || scriptType == ScriptType.P2PKH);
@ -345,7 +357,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
//Note we can expect a single keystore due to the check in the constructor //Note we can expect a single keystore due to the check in the constructor
Wallet signingWallet = walletNode.getWallet(); Wallet signingWallet = walletNode.getWallet();
if(signingWallet.getKeystores().get(0).hasPrivateKey()) { if(signingWallet.getKeystores().getFirst().hasPrivateKey()) {
if(signingWallet.isEncrypted()) { if(signingWallet.isEncrypted()) {
EventManager.get().post(new RequestOpenWalletsEvent()); EventManager.get().post(new RequestOpenWalletsEvent());
} else { } else {
@ -358,7 +370,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
private void signUnencryptedKeystore(Wallet decryptedWallet) { private void signUnencryptedKeystore(Wallet decryptedWallet) {
try { try {
Keystore keystore = decryptedWallet.getKeystores().get(0); Keystore keystore = decryptedWallet.getKeystores().getFirst();
ECKey privKey = keystore.getKey(walletNode); ECKey privKey = keystore.getKey(walletNode);
String signatureText; String signatureText;
if(isBip322()) { if(isBip322()) {
@ -378,8 +390,8 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
} }
private void signDeviceKeystore(Wallet deviceWallet) { private void signDeviceKeystore(Wallet deviceWallet) {
List<String> fingerprints = List.of(deviceWallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint()); List<String> fingerprints = List.of(deviceWallet.getKeystores().getFirst().getKeyDerivation().getMasterFingerprint());
KeyDerivation fullDerivation = deviceWallet.getKeystores().get(0).getKeyDerivation().extend(walletNode.getDerivation()); KeyDerivation fullDerivation = deviceWallet.getKeystores().getFirst().getKeyDerivation().extend(walletNode.getDerivation());
DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(fingerprints, deviceWallet, message.getText().trim(), fullDerivation); DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(fingerprints, deviceWallet, message.getText().trim(), fullDerivation);
deviceSignMessageDialog.initOwner(getDialogPane().getScene().getWindow()); deviceSignMessageDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<String> optSignature = deviceSignMessageDialog.showAndWait(); Optional<String> optSignature = deviceSignMessageDialog.showAndWait();

View file

@ -122,19 +122,21 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
if(percentComplete.get() <= 0.0) { if(percentComplete.get() <= 0.0) {
Platform.runLater(() -> percentComplete.set(opening ? 0.0 : -1.0)); Platform.runLater(() -> percentComplete.set(opening ? 0.0 : -1.0));
} }
});
if(opening) { webcamService.openedProperty().addListener((_, _, opened) -> {
if(opened) {
Platform.runLater(() -> { Platform.runLater(() -> {
try { try {
postOpenUpdate = true; postOpenUpdate = true;
List<CaptureDevice> newDevices = new ArrayList<>(webcamService.getDevices()); List<CaptureDevice> newDevices = new ArrayList<>(webcamService.getAvailableDevices());
newDevices.removeAll(foundDevices); newDevices.removeAll(foundDevices);
foundDevices.addAll(newDevices); foundDevices.addAll(newDevices);
foundDevices.removeIf(device -> !webcamService.getDevices().contains(device)); foundDevices.removeIf(device -> !webcamService.getDevices().contains(device));
if(Config.get().getWebcamDevice() != null && webcamDeviceProperty.get() == null) { if(webcamService.getDevice() != null) {
for(CaptureDevice device : foundDevices) { for(CaptureDevice device : foundDevices) {
if(device.getName().equals(Config.get().getWebcamDevice())) { if(device.equals(webcamService.getDevice())) {
webcamDeviceProperty.set(device); webcamDeviceProperty.set(device);
} }
} }
@ -146,10 +148,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
postOpenUpdate = false; postOpenUpdate = false;
} }
}); });
} } else if(webcamResolutionProperty.get() != null) {
});
webcamService.closedProperty().addListener((_, _, closed) -> {
if(closed && webcamResolutionProperty.get() != null) {
webcamService.setResolution(webcamResolutionProperty.get()); webcamService.setResolution(webcamResolutionProperty.get());
webcamService.setDevice(webcamDeviceProperty.get()); webcamService.setDevice(webcamDeviceProperty.get());
Platform.runLater(() -> { Platform.runLater(() -> {

View file

@ -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); Tooltip.install(this, tooltip);
} }
@ -106,7 +108,7 @@ public class RecentBlocksView extends Pane {
} }
} }
public void addNewBlock(List<BlockSummary> latestBlocks, Double currentFeeRate) { private void addNewBlock(List<BlockSummary> latestBlocks, Double currentFeeRate) {
if(getCubes().isEmpty()) { if(getCubes().isEmpty()) {
return; return;
} }
@ -140,8 +142,10 @@ public class RecentBlocksView extends Pane {
public void updateFeeRate(Map<Integer, Double> targetBlockFeeRates) { public void updateFeeRate(Map<Integer, Double> targetBlockFeeRates) {
int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1); int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1);
Double defaultRate = targetBlockFeeRates.get(defaultTarget); if(targetBlockFeeRates.get(defaultTarget) != null) {
updateFeeRate(defaultRate); Double defaultRate = targetBlockFeeRates.get(defaultTarget);
updateFeeRate(defaultRate);
}
} }
public void updateFeeRate(Double currentFeeRate) { public void updateFeeRate(Double currentFeeRate) {

View file

@ -13,8 +13,6 @@ import javafx.collections.FXCollections;
import javafx.collections.ObservableList; import javafx.collections.ObservableList;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.*; import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.Clipboard; import javafx.scene.input.Clipboard;
import javafx.scene.layout.StackPane; import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser; import javafx.stage.FileChooser;
@ -34,7 +32,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
private final SpreadsheetView spreadsheetView; private final SpreadsheetView spreadsheetView;
public static final AddressCellType ADDRESS = new AddressCellType(); public static final AddressCellType ADDRESS = new AddressCellType();
public SendToManyDialog(BitcoinUnit bitcoinUnit) { public SendToManyDialog(BitcoinUnit bitcoinUnit, List<Payment> payments) {
this.bitcoinUnit = bitcoinUnit; this.bitcoinUnit = bitcoinUnit;
final DialogPane dialogPane = new SendToManyDialogPane(); final DialogPane dialogPane = new SendToManyDialogPane();
@ -44,7 +42,8 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
dialogPane.setHeaderText("Send to many recipients by specifying addresses and amounts.\nOnly the first row's label is necessary."); 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)); dialogPane.setGraphic(new DialogImage(DialogImage.Type.SPARROW));
List<Payment> initialPayments = IntStream.range(0, 100).mapToObj(i -> new Payment(null, null, -1, false)).collect(Collectors.toList()); List<Payment> 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); Grid grid = getGrid(initialPayments);
spreadsheetView = new SpreadsheetView(grid) { spreadsheetView = new SpreadsheetView(grid) {

View file

@ -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 //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_RGB24("RGB3", true),
PIX_FMT_YUYV("YUYV", true), PIX_FMT_YUYV("YUYV", true),
PIX_FMT_MJPG("MJPG", true), PIX_FMT_NV12("NV12", true),
PIX_FMT_NV12("NV12", false); PIX_FMT_MJPG("MJPG", true);
private final String name; private final String name;
private final boolean supported; private final boolean supported;
@ -25,6 +25,14 @@ public enum WebcamPixelFormat {
return supported; 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() { public String toString() {
return name; return name;
} }

View file

@ -27,6 +27,9 @@ import java.awt.image.BufferedImage;
import java.awt.image.WritableRaster; import java.awt.image.WritableRaster;
import java.util.*; import java.util.*;
import java.util.List; 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.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@ -34,13 +37,18 @@ import java.util.stream.Stream;
public class WebcamService extends ScheduledService<Image> { public class WebcamService extends ScheduledService<Image> {
private static final Logger log = LoggerFactory.getLogger(WebcamService.class); 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<CaptureDevice> devices; private List<CaptureDevice> devices;
private List<CaptureDevice> availableDevices;
private Set<WebcamResolution> resolutions; private Set<WebcamResolution> resolutions;
private WebcamResolution resolution; private WebcamResolution resolution;
private CaptureDevice device; private CaptureDevice device;
private final BooleanProperty opening = new SimpleBooleanProperty(false); private final BooleanProperty opening = new SimpleBooleanProperty(false);
private final BooleanProperty closed = new SimpleBooleanProperty(false); private final BooleanProperty opened = new SimpleBooleanProperty(false);
private final ObjectProperty<Result> resultProperty = new SimpleObjectProperty<>(null); private final ObjectProperty<Result> resultProperty = new SimpleObjectProperty<>(null);
@ -105,24 +113,36 @@ public class WebcamService extends ScheduledService<Image> {
return new Task<>() { return new Task<>() {
@Override @Override
protected Image call() throws Exception { 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 { try {
if(stream == null) { if(devices == null) {
devices = capture.getDevices(); devices = capture.getDevices();
availableDevices = new ArrayList<>(devices);
if(devices.isEmpty()) { if(devices.isEmpty()) {
throw new UnsupportedOperationException("No cameras available"); 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) { if(device != null) {
for(CaptureDevice webcam : devices) { for(CaptureDevice webcam : availableDevices) {
if(webcam.getName().equals(device.getName())) { if(webcam.getName().equals(device.getName())) {
selectedDevice = webcam; selectedDevice = webcam;
} }
} }
} else if(Config.get().getWebcamDevice() != null) { } else if(Config.get().getWebcamDevice() != null) {
for(CaptureDevice webcam : devices) { for(CaptureDevice webcam : availableDevices) {
if(webcam.getName().equals(Config.get().getWebcamDevice())) { if(webcam.getName().equals(Config.get().getWebcamDevice())) {
selectedDevice = webcam; selectedDevice = webcam;
} }
@ -163,22 +183,35 @@ public class WebcamService extends ScheduledService<Image> {
} }
} }
//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()) { 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); opening.set(true);
stream = device.openStream(format); stream = device.openStream(format);
opening.set(false); opening.set(false);
closed.set(false);
try { try {
zoomLimits = stream.getPropertyLimits(CaptureProperty.Zoom); zoomLimits = stream.getPropertyLimits(CaptureProperty.Zoom);
} catch(Throwable e) { } catch(Throwable e) {
log.debug("Error getting zoom limits on " + device + ", assuming no zoom function"); 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(); BufferedImage originalImage = stream.capture();
CroppedDimension cropped = getCroppedDimension(originalImage); CroppedDimension cropped = getCroppedDimension(originalImage);
BufferedImage croppedImage = originalImage.getSubimage(cropped.x, cropped.y, cropped.length, cropped.length); BufferedImage croppedImage = originalImage.getSubimage(cropped.x, cropped.y, cropped.length, cropped.length);
@ -195,6 +228,7 @@ public class WebcamService extends ScheduledService<Image> {
return image; return image;
} finally { } finally {
opening.set(false); opening.set(false);
taskSemaphore.release();
} }
} }
}; };
@ -204,21 +238,38 @@ public class WebcamService extends ScheduledService<Image> {
public void reset() { public void reset() {
stream = null; stream = null;
zoomLimits = null; zoomLimits = null;
cancelRequested.set(false);
super.reset(); super.reset();
} }
@Override @Override
public boolean cancel() { public boolean cancel() {
if(stream != null) { cancelRequested.set(true);
stream.close(); boolean cancelled = super.cancel();
closed.set(true);
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() { public synchronized void close() {
capture.close(); if(!captureClosed.get()) {
captureClosed.set(true);
capture.close();
}
} }
public PropertyLimits getZoomLimits() { public PropertyLimits getZoomLimits() {
@ -336,6 +387,10 @@ public class WebcamService extends ScheduledService<Image> {
return devices; return devices;
} }
public List<CaptureDevice> getAvailableDevices() {
return availableDevices;
}
public Set<WebcamResolution> getResolutions() { public Set<WebcamResolution> getResolutions() {
return resolutions; return resolutions;
} }
@ -376,8 +431,12 @@ public class WebcamService extends ScheduledService<Image> {
return opening; return opening;
} }
public BooleanProperty closedProperty() { public BooleanProperty openedProperty() {
return closed; return opened;
}
public boolean getCancelRequested() {
return cancelRequested.get();
} }
public static <T extends Enum<T>> T getNearestEnum(T target) { public static <T extends Enum<T>> T getNearestEnum(T target) {

View file

@ -61,7 +61,7 @@ public class WebcamView {
}); });
service.valueProperty().addListener((observable, oldValue, newValue) -> { service.valueProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null) { if(newValue != null && !service.getCancelRequested()) {
imageProperty.set(newValue); imageProperty.set(newValue);
} }
}); });

View file

@ -6,12 +6,18 @@ import java.util.Map;
public class BlockSummaryEvent { public class BlockSummaryEvent {
private final Map<Integer, BlockSummary> blockSummaryMap; private final Map<Integer, BlockSummary> blockSummaryMap;
private final Double nextBlockMedianFeeRate;
public BlockSummaryEvent(Map<Integer, BlockSummary> blockSummaryMap) { public BlockSummaryEvent(Map<Integer, BlockSummary> blockSummaryMap, Double nextBlockMedianFeeRate) {
this.blockSummaryMap = blockSummaryMap; this.blockSummaryMap = blockSummaryMap;
this.nextBlockMedianFeeRate = nextBlockMedianFeeRate;
} }
public Map<Integer, BlockSummary> getBlockSummaryMap() { public Map<Integer, BlockSummary> getBlockSummaryMap() {
return blockSummaryMap; return blockSummaryMap;
} }
public Double getNextBlockMedianFeeRate() {
return nextBlockMedianFeeRate;
}
} }

View file

@ -7,13 +7,23 @@ import java.util.Set;
public class FeeRatesUpdatedEvent extends MempoolRateSizesUpdatedEvent { public class FeeRatesUpdatedEvent extends MempoolRateSizesUpdatedEvent {
private final Map<Integer, Double> targetBlockFeeRates; private final Map<Integer, Double> targetBlockFeeRates;
private final Double nextBlockMedianFeeRate;
public FeeRatesUpdatedEvent(Map<Integer, Double> targetBlockFeeRates, Set<MempoolRateSize> mempoolRateSizes) { public FeeRatesUpdatedEvent(Map<Integer, Double> targetBlockFeeRates, Set<MempoolRateSize> mempoolRateSizes) {
this(targetBlockFeeRates, mempoolRateSizes, null);
}
public FeeRatesUpdatedEvent(Map<Integer, Double> targetBlockFeeRates, Set<MempoolRateSize> mempoolRateSizes, Double nextBlockMedianFeeRate) {
super(mempoolRateSizes); super(mempoolRateSizes);
this.targetBlockFeeRates = targetBlockFeeRates; this.targetBlockFeeRates = targetBlockFeeRates;
this.nextBlockMedianFeeRate = nextBlockMedianFeeRate;
} }
public Map<Integer, Double> getTargetBlockFeeRates() { public Map<Integer, Double> getTargetBlockFeeRates() {
return targetBlockFeeRates; return targetBlockFeeRates;
} }
public Double getNextBlockMedianFeeRate() {
return nextBlockMedianFeeRate;
}
} }

View file

@ -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<Payment> payments;
public RequestSendToManyEvent(List<Payment> payments) {
this.payments = payments;
}
public List<Payment> getPayments() {
return payments;
}
}

View file

@ -14,9 +14,16 @@ import java.util.List;
*/ */
public class WalletNodeHistoryChangedEvent { public class WalletNodeHistoryChangedEvent {
private final String scriptHash; private final String scriptHash;
private final String status;
public WalletNodeHistoryChangedEvent(String scriptHash) { public WalletNodeHistoryChangedEvent(String scriptHash) {
this.scriptHash = scriptHash; this.scriptHash = scriptHash;
this.status = null;
}
public WalletNodeHistoryChangedEvent(String scriptHash, String status) {
this.scriptHash = scriptHash;
this.status = status;
} }
public WalletNode getWalletNode(Wallet wallet) { public WalletNode getWalletNode(Wallet wallet) {
@ -70,4 +77,8 @@ public class WalletNodeHistoryChangedEvent {
public String getScriptHash() { public String getScriptHash() {
return scriptHash; return scriptHash;
} }
public String getStatus() {
return status;
}
} }

View file

@ -52,6 +52,8 @@ public class Config {
private boolean showDeprecatedImportExport = false; private boolean showDeprecatedImportExport = false;
private boolean signBsmsExports = false; private boolean signBsmsExports = false;
private boolean preventSleep = false; private boolean preventSleep = false;
private Boolean connectToBroadcast;
private Boolean suggestSendToMany;
private List<File> recentWalletFiles; private List<File> recentWalletFiles;
private Integer keyDerivationPeriod; private Integer keyDerivationPeriod;
private long dustAttackThreshold = DUST_ATTACK_THRESHOLD_SATS; private long dustAttackThreshold = DUST_ATTACK_THRESHOLD_SATS;
@ -69,6 +71,7 @@ public class Config {
private File coreDataDir; private File coreDataDir;
private String coreAuth; private String coreAuth;
private boolean useLegacyCoreWallet; private boolean useLegacyCoreWallet;
private boolean legacyServer;
private Server electrumServer; private Server electrumServer;
private List<Server> recentElectrumServers; private List<Server> recentElectrumServers;
private File electrumServerCert; private File electrumServerCert;
@ -355,6 +358,25 @@ public class Config {
public void setPreventSleep(boolean preventSleep) { public void setPreventSleep(boolean preventSleep) {
this.preventSleep = 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<File> getRecentWalletFiles() { public List<File> getRecentWalletFiles() {
@ -557,6 +579,15 @@ public class Config {
flush(); flush();
} }
public boolean isLegacyServer() {
return legacyServer;
}
public void setLegacyServer(boolean legacyServer) {
this.legacyServer = legacyServer;
flush();
}
public Server getElectrumServer() { public Server getElectrumServer() {
return electrumServer; return electrumServer;
} }

View file

@ -126,7 +126,7 @@ public class Descriptor implements WalletImport, WalletExport {
} else if(line.startsWith("#")) { } else if(line.startsWith("#")) {
continue; continue;
} else { } else {
paragraph.append(line); paragraph.append(line.replaceFirst("^.+:", "").trim());
} }
} }

View file

@ -37,7 +37,8 @@ public class ElectrumPersonalServer implements WalletExport {
try { try {
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)); BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
writer.write("# Electrum Personal Server configuration file fragments\n"); 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"); writer.write("# Copy into [master-public-keys] section\n");
Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet(); Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet();
writeWalletXpub(masterWallet, writer); writeWalletXpub(masterWallet, writer);

View file

@ -76,6 +76,10 @@ public class ElectrumServer {
private static final Set<String> sameHeightTxioScriptHashes = ConcurrentHashMap.newKeySet(); private static final Set<String> sameHeightTxioScriptHashes = ConcurrentHashMap.newKeySet();
private final static Map<String, Integer> subscribedRecent = new ConcurrentHashMap<>();
private final static Map<String, String> broadcastRecent = new ConcurrentHashMap<>();
private static ElectrumServerRpc electrumServerRpc = new SimpleElectrumServerRpc(); private static ElectrumServerRpc electrumServerRpc = new SimpleElectrumServerRpc();
private static Cormorant cormorant; private static Cormorant cormorant;
@ -936,6 +940,20 @@ public class ElectrumServer {
return targetBlocksFeeRatesSats; 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<Integer, Double> getDefaultFeeEstimates(List<Integer> targetBlocks) throws ServerException { public Map<Integer, Double> getDefaultFeeEstimates(List<Integer> targetBlocks) throws ServerException {
try { try {
Map<Integer, Double> targetBlocksFeeRatesBtcKb = electrumServerRpc.getFeeEstimates(getTransport(), targetBlocks); Map<Integer, Double> targetBlocksFeeRatesBtcKb = electrumServerRpc.getFeeEstimates(getTransport(), targetBlocks);
@ -1048,6 +1066,11 @@ public class ElectrumServer {
List<BlockTransactionHash> recentTransactions = feeRatesSource.getRecentMempoolTransactions(); List<BlockTransactionHash> recentTransactions = feeRatesSource.getRecentMempoolTransactions();
Map<BlockTransactionHash, Transaction> setReferences = new HashMap<>(); Map<BlockTransactionHash, Transaction> setReferences = new HashMap<>();
setReferences.put(recentTransactions.getFirst(), null); 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<Sha256Hash, BlockTransaction> transactions = getTransactions(null, setReferences, Collections.emptyMap()); Map<Sha256Hash, BlockTransaction> transactions = getTransactions(null, setReferences, Collections.emptyMap());
return transactions.values().stream().filter(blxTx -> blxTx.getTransaction() != null).toList(); return transactions.values().stream().filter(blxTx -> blxTx.getTransaction() != null).toList();
} catch(Exception e) { } catch(Exception e) {
@ -1232,11 +1255,11 @@ public class ElectrumServer {
if(!serverVersion.isEmpty()) { if(!serverVersion.isEmpty()) {
String server = serverVersion.getFirst().toLowerCase(Locale.ROOT); String server = serverVersion.getFirst().toLowerCase(Locale.ROOT);
if(server.contains("electrumx")) { if(server.contains("electrumx")) {
return new ServerCapability(true); return new ServerCapability(true, true);
} }
if(server.startsWith("cormorant")) { if(server.startsWith("cormorant")) {
return new ServerCapability(true, false, true); return new ServerCapability(true, false, true, false);
} }
if(server.startsWith("electrs/")) { if(server.startsWith("electrs/")) {
@ -1248,7 +1271,7 @@ public class ElectrumServer {
try { try {
Version version = new Version(electrsVersion); Version version = new Version(electrsVersion);
if(version.compareTo(ELECTRS_MIN_BATCHING_VERSION) >= 0) { if(version.compareTo(ELECTRS_MIN_BATCHING_VERSION) >= 0) {
return new ServerCapability(true); return new ServerCapability(true, true);
} }
} catch(Exception e) { } catch(Exception e) {
//ignore //ignore
@ -1264,7 +1287,7 @@ public class ElectrumServer {
try { try {
Version version = new Version(fulcrumVersion); Version version = new Version(fulcrumVersion);
if(version.compareTo(FULCRUM_MIN_BATCHING_VERSION) >= 0) { if(version.compareTo(FULCRUM_MIN_BATCHING_VERSION) >= 0) {
return new ServerCapability(true); return new ServerCapability(true, true);
} }
} catch(Exception e) { } catch(Exception e) {
//ignore //ignore
@ -1283,15 +1306,19 @@ public class ElectrumServer {
Version version = new Version(mempoolElectrsVersion); Version version = new Version(mempoolElectrsVersion);
if(version.compareTo(MEMPOOL_ELECTRS_MIN_BATCHING_VERSION) > 0 || if(version.compareTo(MEMPOOL_ELECTRS_MIN_BATCHING_VERSION) > 0 ||
(version.compareTo(MEMPOOL_ELECTRS_MIN_BATCHING_VERSION) == 0 && (!mempoolElectrsSuffix.contains("dev") || mempoolElectrsSuffix.contains("dev-249848d")))) { (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) { } catch(Exception e) {
//ignore //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<List<String>> { public static class ServerVersionService extends Service<List<String>> {
@ -1456,8 +1483,9 @@ public class ElectrumServer {
if(elapsed > FEE_RATES_PERIOD) { if(elapsed > FEE_RATES_PERIOD) {
Map<Integer, Double> blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE, false); Map<Integer, Double> blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE, false);
Set<MempoolRateSize> mempoolRateSizes = electrumServer.getMempoolRateSizes(); Set<MempoolRateSize> mempoolRateSizes = electrumServer.getMempoolRateSizes();
Double nextBlockMedianFeeRate = electrumServer.getNextBlockMedianFeeRate();
feeRatesRetrievedAt = System.currentTimeMillis(); feeRatesRetrievedAt = System.currentTimeMillis();
return new FeeRatesUpdatedEvent(blockTargetFeeRates, mempoolRateSizes); return new FeeRatesUpdatedEvent(blockTargetFeeRates, mempoolRateSizes, nextBlockMedianFeeRate);
} }
} else { } else {
closeConnection(); closeConnection();
@ -1583,6 +1611,31 @@ public class ElectrumServer {
Set<MempoolRateSize> mempoolRateSizes = electrumServer.getMempoolRateSizes(); Set<MempoolRateSize> mempoolRateSizes = electrumServer.getMempoolRateSizes();
EventManager.get().post(new MempoolRateSizesUpdatedEvent(mempoolRateSizes)); 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<String, String> 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 { public static class ReadRunnable implements Runnable {
@ -1935,7 +1988,8 @@ public class ElectrumServer {
protected FeeRatesUpdatedEvent call() throws ServerException { protected FeeRatesUpdatedEvent call() throws ServerException {
ElectrumServer electrumServer = new ElectrumServer(); ElectrumServer electrumServer = new ElectrumServer();
Map<Integer, Double> blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE, false); Map<Integer, Double> 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(); Config config = Config.get();
if(!isBlockstorm(totalBlocks) && !AppServices.isUsingProxy() && config.getServer().getProtocol().equals(Protocol.SSL) if(!isBlockstorm(totalBlocks) && !AppServices.isUsingProxy() && config.getServer().getProtocol().equals(Protocol.SSL)
&& (config.getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER || config.getServerType() == ServerType.ELECTRUM_SERVER)) { && (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; return Network.get() != Network.MAINNET && totalBlocks > 2;
} }
private final static Set<String> subscribedRecent = Collections.newSetFromMap(new ConcurrentHashMap<>()); private void subscribeRecent(ElectrumServer electrumServer, int currentHeight) {
Set<String> unsubscribeScriptHashes = subscribedRecent.entrySet().stream().filter(entry -> entry.getValue() == null || entry.getValue() <= currentHeight - 3)
private void subscribeRecent(ElectrumServer electrumServer) { .map(Map.Entry::getKey).collect(Collectors.toSet());
Set<String> unsubscribeScriptHashes = new HashSet<>(subscribedRecent);
unsubscribeScriptHashes.removeIf(subscribedScriptHashes::containsKey); unsubscribeScriptHashes.removeIf(subscribedScriptHashes::containsKey);
electrumServerRpc.unsubscribeScriptHashes(transport, unsubscribeScriptHashes); if(!unsubscribeScriptHashes.isEmpty() && serverCapability.supportsUnsubscribe()) {
subscribedRecent.removeAll(unsubscribeScriptHashes); electrumServerRpc.unsubscribeScriptHashes(transport, unsubscribeScriptHashes);
}
subscribedRecent.keySet().removeAll(unsubscribeScriptHashes);
broadcastRecent.keySet().removeAll(unsubscribeScriptHashes);
Map<String, String> subscribeScriptHashes = new HashMap<>(); Map<String, String> subscribeScriptHashes = new HashMap<>();
List<BlockTransaction> recentTransactions = electrumServer.getRecentMempoolTransactions(); List<BlockTransaction> recentTransactions = electrumServer.getRecentMempoolTransactions();
for(BlockTransaction blkTx : recentTransactions) { 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); TransactionOutput txOutput = blkTx.getTransaction().getOutputs().get(i);
String scriptHash = getScriptHash(txOutput); String scriptHash = getScriptHash(txOutput);
if(!subscribedScriptHashes.containsKey(scriptHash)) { 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()) { 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 { try {
electrumServerRpc.subscribeScriptHashes(transport, null, subscribeScriptHashes); electrumServerRpc.subscribeScriptHashes(transport, null, subscribeScriptHashes);
subscribedRecent.addAll(subscribeScriptHashes.values()); subscribeScriptHashes.values().forEach(scriptHash -> subscribedRecent.put(scriptHash, currentHeight));
} catch(ElectrumServerRpcException e) { } catch(ElectrumServerRpcException e) {
log.debug("Error subscribing to recent mempool transactions", e); log.debug("Error subscribing to recent mempool transactions", e);
} }
} }
if(!recentTransactions.isEmpty()) {
broadcastRecent(electrumServer, recentTransactions);
}
}
private void broadcastRecent(ElectrumServer electrumServer, List<BlockTransaction> recentTransactions) {
ScheduledService<Void> broadcastService = new ScheduledService<>() { ScheduledService<Void> broadcastService = new ScheduledService<>() {
@Override @Override
protected Task<Void> createTask() { protected Task<Void> createTask() {
return new Task<>() { return new Task<>() {
@Override @Override
protected Void call() throws Exception { protected Void call() throws Exception {
for(BlockTransaction blkTx : recentTransactions) { if(!recentTransactions.isEmpty()) {
electrumServer.broadcastTransaction(blkTx.getTransaction()); 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; return null;
} }

View file

@ -34,6 +34,12 @@ public enum FeeRatesSource {
return getThreeTierFeeRates(this, defaultblockTargetFeeRates, url); return getThreeTierFeeRates(this, defaultblockTargetFeeRates, url);
} }
@Override
public Double getNextBlockMedianFeeRate() throws Exception {
String url = getApiUrl() + "v1/fees/mempool-blocks";
return requestNextBlockMedianFeeRate(this, url);
}
@Override @Override
public BlockSummary getBlockSummary(Sha256Hash blockId) throws Exception { public BlockSummary getBlockSummary(Sha256Hash blockId) throws Exception {
String url = getApiUrl() + "v1/block/" + Utils.bytesToHex(blockId.getReversedBytes()); String url = getApiUrl() + "v1/block/" + Utils.bytesToHex(blockId.getReversedBytes());
@ -130,6 +136,10 @@ public enum FeeRatesSource {
public abstract Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates); public abstract Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> 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 { public BlockSummary getBlockSummary(Sha256Hash blockId) throws Exception {
throw new UnsupportedOperationException(name + " does not support block summaries"); throw new UnsupportedOperationException(name + " does not support block summaries");
} }
@ -199,6 +209,30 @@ public enum FeeRatesSource {
return httpClientService.requestJson(url, ThreeTierRates.class, null); 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 { protected static BlockSummary requestBlockSummary(FeeRatesSource feeRatesSource, String url) throws Exception {
if(log.isInfoEnabled()) { if(log.isInfoEnabled()) {
log.info("Requesting block summary from " + url); 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) { protected record MempoolBlockSummary(String id, Integer height, Long timestamp, Integer tx_count, Integer weight, MempoolBlockSummaryExtras extras) {
public Double getMedianFee() { public Double getMedianFee() {
return extras == null ? null : extras.medianFee(); return extras == null ? null : extras.medianFee();

View file

@ -7,27 +7,30 @@ public class ServerCapability {
private final int maxTargetBlocks; private final int maxTargetBlocks;
private final boolean supportsRecentMempool; private final boolean supportsRecentMempool;
private final boolean supportsBlockStats; private final boolean supportsBlockStats;
private final boolean supportsUnsubscribe;
public ServerCapability(boolean supportsBatching) { public ServerCapability(boolean supportsBatching, boolean supportsUnsubscribe) {
this(supportsBatching, AppServices.TARGET_BLOCKS_RANGE.getLast()); 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.supportsBatching = supportsBatching;
this.maxTargetBlocks = maxTargetBlocks; this.maxTargetBlocks = maxTargetBlocks;
this.supportsRecentMempool = false; this.supportsRecentMempool = false;
this.supportsBlockStats = false; this.supportsBlockStats = false;
this.supportsUnsubscribe = supportsUnsubscribe;
} }
public ServerCapability(boolean supportsBatching, boolean supportsRecentMempool, boolean supportsBlockStats) { public ServerCapability(boolean supportsBatching, boolean supportsRecentMempool, boolean supportsBlockStats, boolean supportsUnsubscribe) {
this(supportsBatching, AppServices.TARGET_BLOCKS_RANGE.getLast(), supportsRecentMempool, supportsBlockStats); 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.supportsBatching = supportsBatching;
this.maxTargetBlocks = maxTargetBlocks; this.maxTargetBlocks = maxTargetBlocks;
this.supportsRecentMempool = supportsRecentMempool; this.supportsRecentMempool = supportsRecentMempool;
this.supportsBlockStats = supportsBlockStats; this.supportsBlockStats = supportsBlockStats;
this.supportsUnsubscribe = supportsUnsubscribe;
} }
public boolean supportsBatching() { public boolean supportsBatching() {
@ -45,4 +48,8 @@ public class ServerCapability {
public boolean supportsBlockStats() { public boolean supportsBlockStats() {
return supportsBlockStats; return supportsBlockStats;
} }
public boolean supportsUnsubscribe() {
return supportsUnsubscribe;
}
} }

View file

@ -10,6 +10,7 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent; import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
import com.sparrowwallet.sparrow.io.Config;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -38,16 +39,32 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
@Override @Override
public List<String> getServerVersion(Transport transport, String clientName, String[] supportedVersions) { public List<String> getServerVersion(Transport transport, String clientName, String[] supportedVersions) {
if(Config.get().getServerType() == ServerType.ELECTRUM_SERVER && Config.get().isLegacyServer()) {
return getLegacyServerVersion(transport, clientName);
}
try { try {
JsonRpcClient client = new JsonRpcClient(transport); 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<List<String>>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() -> return new RetryLogic<List<String>>(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) { } catch(Exception e) {
throw new ElectrumServerRpcException("Error getting server version", e); throw new ElectrumServerRpcException("Error getting server version", e);
} }
} }
private List<String> 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<List<String>>(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 @Override
public String getServerBanner(Transport transport) { public String getServerBanner(Transport transport) {
try { try {

View file

@ -38,6 +38,6 @@ public class SubscriptionService {
existingStatuses.add(status); existingStatuses.add(status);
} }
Platform.runLater(() -> EventManager.get().post(new WalletNodeHistoryChangedEvent(scriptHash))); Platform.runLater(() -> EventManager.get().post(new WalletNodeHistoryChangedEvent(scriptHash, status)));
} }
} }

View file

@ -35,10 +35,11 @@ public class ElectrumServerService {
} }
@JsonRpcMethod("server.version") @JsonRpcMethod("server.version")
public List<String> getServerVersion(@JsonRpcParam("client_name") String clientName, @JsonRpcParam("protocol_version") String protocolVersion) throws UnsupportedVersionException { public List<String> getServerVersion(@JsonRpcParam("client_name") String clientName, @JsonRpcParam("protocol_version") String[] protocolVersion) throws UnsupportedVersionException {
Version clientVersion = new Version(protocolVersion); String version = protocolVersion.length > 1 ? protocolVersion[1] : protocolVersion[0];
Version clientVersion = new Version(version);
if(clientVersion.compareTo(VERSION) < 0) { if(clientVersion.compareTo(VERSION) < 0) {
throw new UnsupportedVersionException(protocolVersion); throw new UnsupportedVersionException(version);
} }
return List.of(Cormorant.SERVER_NAME + " " + SparrowWallet.APP_VERSION, VERSION.get()); return List.of(Cormorant.SERVER_NAME + " " + SparrowWallet.APP_VERSION, VERSION.get());

View file

@ -1559,6 +1559,23 @@ public class HeadersController extends TransactionFormController implements Init
signButtonBox.setVisible(false); signButtonBox.setVisible(false);
broadcastButtonBox.setVisible(true); 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<ButtonType> 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()));
}
}
} }
} }

View file

@ -326,7 +326,7 @@ public class SendController extends WalletFormController implements Initializabl
recentBlocksView.visibleProperty().bind(Bindings.equal(feeRatesSelectionProperty, FeeRatesSelection.RECENT_BLOCKS)); recentBlocksView.visibleProperty().bind(Bindings.equal(feeRatesSelectionProperty, FeeRatesSelection.RECENT_BLOCKS));
List<BlockSummary> blockSummaries = AppServices.getBlockSummaries().values().stream().sorted().toList(); List<BlockSummary> blockSummaries = AppServices.getBlockSummaries().values().stream().sorted().toList();
if(!blockSummaries.isEmpty()) { if(!blockSummaries.isEmpty()) {
recentBlocksView.update(blockSummaries, AppServices.getDefaultFeeRate()); recentBlocksView.update(blockSummaries, AppServices.getNextBlockMedianFeeRate());
} }
feeRatesSelectionProperty.addListener((_, oldValue, newValue) -> { feeRatesSelectionProperty.addListener((_, oldValue, newValue) -> {
@ -491,11 +491,35 @@ public class SendController extends WalletFormController implements Initializabl
validationSupport.setErrorDecorationEnabled(false); validationSupport.setErrorDecorationEnabled(false);
} }
public Tab addPaymentTab() { public void addPaymentTab() {
if(Config.get().getSuggestSendToMany() == null && openSendToMany()) {
return;
}
Tab tab = getPaymentTab(); Tab tab = getPaymentTab();
paymentTabs.getTabs().add(tab); paymentTabs.getTabs().add(tab);
paymentTabs.getSelectionModel().select(tab); paymentTabs.getSelectionModel().select(tab);
return tab; }
private boolean openSendToMany() {
try {
List<Payment> 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<ButtonType> 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() { public Tab getPaymentTab() {
@ -1205,7 +1229,7 @@ public class SendController extends WalletFormController implements Initializabl
public void broadcastNotification(Wallet decryptedWallet) { public void broadcastNotification(Wallet decryptedWallet) {
try { try {
PaymentCode paymentCode = decryptedWallet.getPaymentCode(); PaymentCode paymentCode = decryptedWallet.isMasterWallet() ? decryptedWallet.getPaymentCode() : decryptedWallet.getMasterWallet().getPaymentCode();
PaymentCode externalPaymentCode = paymentCodeProperty.get(); PaymentCode externalPaymentCode = paymentCodeProperty.get();
WalletTransaction walletTransaction = walletTransactionProperty.get(); WalletTransaction walletTransaction = walletTransactionProperty.get();
WalletNode input0Node = walletTransaction.getSelectedUtxos().entrySet().iterator().next().getValue(); WalletNode input0Node = walletTransaction.getSelectedUtxos().entrySet().iterator().next().getValue();
@ -1411,7 +1435,12 @@ public class SendController extends WalletFormController implements Initializabl
setFeeRatePriority(getFeeRangeRate()); setFeeRatePriority(getFeeRangeRate());
} }
feeRange.updateTrackHighlight(); feeRange.updateTrackHighlight();
recentBlocksView.updateFeeRate(event.getTargetBlockFeeRates());
if(event.getNextBlockMedianFeeRate() != null) {
recentBlocksView.updateFeeRate(event.getNextBlockMedianFeeRate());
} else {
recentBlocksView.updateFeeRate(event.getTargetBlockFeeRates());
}
if(updateDefaultFeeRate) { if(updateDefaultFeeRate) {
if(getFeeRate() != null && Long.valueOf((long)getFallbackFeeRate()).equals(getFeeRate().longValue())) { if(getFeeRate() != null && Long.valueOf((long)getFallbackFeeRate()).equals(getFeeRate().longValue())) {
@ -1435,7 +1464,7 @@ public class SendController extends WalletFormController implements Initializabl
@Subscribe @Subscribe
public void blockSummary(BlockSummaryEvent event) { 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 @Subscribe

View file

@ -5,11 +5,11 @@
<defs/> <defs/>
<g id="Layer-1"> <g id="Layer-1">
<g opacity="1"> <g opacity="1">
<path d="M11.1092 6.38238C11.7874 6.49541 12.6924 6.75737 13.0291 7.43079C13.3676 8.10768 12.7646 8.86191 12.0528 8.81338C11.6576 8.78644 11.4097 8.41273 11.1551 8.15812C10.9809 10.1088 10.2877 12.136 8.44235 13.0988C7.90851 13.3773 7.15886 13.6243 6.55521 13.4329C5.9407 13.2381 5.84716 12.4781 5.33643 12.1486C4.98351 11.9209 4.55155 11.9498 4.15042 11.9324C3.96346 11.9243 3.77148 11.9124 3.60655 11.8145C3.06601 11.4935 3.37994 10.7658 3.09545 10.3139C2.99212 10.1498 2.79588 10.0734 2.64333 9.96663C2.3666 9.77293 1.83235 9.33348 1.87667 8.96409C1.90244 8.74941 2.25019 8.74007 2.38778 8.72164C2.54701 8.70032 2.70699 8.68477 2.86611 8.66267C3.25833 8.6082 3.72578 8.49071 3.9866 8.16468C3.30258 8.77269 2.31035 8.12428 2.44675 7.26042C2.54818 6.618 3.3035 6.29432 3.849 6.13993C4.17298 6.04824 4.52084 5.97983 4.85809 6.02199C4.85809 5.54542 4.74612 5.08577 4.70738 4.61318C4.66874 4.14168 4.74205 3.65871 4.91051 3.21749C5.67321 1.21995 8.32474 0.966847 9.7201 2.44428C10.2289 2.98306 10.4928 3.66365 10.703 4.36419C10.9014 5.02542 11.0222 5.69867 11.1092 6.38238Z" fill="#989898" fill-opacity="0.996078" fill-rule="nonzero" opacity="1" stroke="none"/> <path d="M10.8078 6.47573C11.4294 6.57932 12.2588 6.8194 12.5674 7.43658C12.8776 8.05695 12.325 8.74819 11.6726 8.70371C11.3104 8.67902 11.0832 8.33652 10.8499 8.10317C10.6902 9.89095 10.0549 11.7489 8.36366 12.6312C7.8744 12.8865 7.18736 13.1129 6.63412 12.9374C6.07093 12.7589 5.9852 12.0624 5.51712 11.7604C5.19368 11.5517 4.79779 11.5782 4.43016 11.5623C4.25881 11.5548 4.08287 11.5439 3.93171 11.4542C3.43631 11.16 3.72402 10.4931 3.46329 10.0789C3.36859 9.92852 3.18874 9.8585 3.04893 9.76065C2.79531 9.58313 2.30568 9.18038 2.3463 8.84184C2.36991 8.64508 2.68862 8.63652 2.81472 8.61963C2.96065 8.60009 3.10727 8.58584 3.25311 8.56559C3.61257 8.51567 4.04098 8.40799 4.28002 8.10919C3.65313 8.66642 2.74376 8.07216 2.86877 7.28044C2.96173 6.69167 3.65397 6.39502 4.15391 6.25353C4.45084 6.16949 4.76965 6.1068 5.07873 6.14544C5.07873 5.70867 4.97611 5.2874 4.94061 4.85428C4.90519 4.42216 4.97238 3.97952 5.12677 3.57515C5.82578 1.74442 8.25587 1.51246 9.5347 2.86651C10.001 3.36029 10.2429 3.98405 10.4355 4.62608C10.6174 5.23209 10.7281 5.84912 10.8078 6.47573Z" fill="#989898" fill-opacity="0.996078" fill-rule="nonzero" opacity="1" stroke="none"/>
<path d="M7.57741 4.35763C7.52047 4.27222 7.50984 4.17594 7.43325 4.09553C7.20899 3.86005 6.91345 4.08501 6.88939 4.35763C6.86774 4.60298 7.01074 4.97283 7.31531 4.92771C7.35869 4.92128 7.43451 4.88713 7.46602 4.85563C7.53563 4.78601 7.5612 4.67147 7.58396 4.58042C7.62201 5.15107 7.1347 5.78519 6.53555 5.4257C6.19393 5.22073 6.0003 4.77722 5.98514 4.3904C5.97127 4.03683 6.06143 3.5982 6.39795 3.40751C6.97483 3.08061 7.57741 3.82267 7.57741 4.35763Z" fill="#fefefe" fill-rule="nonzero" opacity="1" stroke="none"/> <path d="M7.57096 4.62007C7.51877 4.54179 7.50903 4.45355 7.43883 4.37986C7.2333 4.16404 6.96244 4.37022 6.94039 4.62007C6.92055 4.84493 7.05161 5.18389 7.33074 5.14254C7.3705 5.13665 7.43999 5.10535 7.46887 5.07648C7.53266 5.01268 7.5561 4.9077 7.57696 4.82425C7.61183 5.34725 7.16522 5.92841 6.6161 5.59894C6.30301 5.41109 6.12555 5.00462 6.11166 4.6501C6.09895 4.32606 6.18158 3.92406 6.48999 3.7493C7.0187 3.4497 7.57096 4.12979 7.57096 4.62007Z" fill="#fefefe" fill-rule="nonzero" opacity="1" stroke="none"/>
<path d="M9.70699 4.25279C9.67353 4.1524 9.63791 4.03833 9.55629 3.96448C9.24722 3.68485 8.99444 4.04682 9.01897 4.35763C9.03907 4.61216 9.30486 4.96596 9.57594 4.73768C9.66318 4.66422 9.68613 4.55355 9.71355 4.44937C9.75056 4.9675 9.30222 5.61975 8.73066 5.33397C8.34543 5.14135 8.13656 4.68734 8.11472 4.27245C8.10412 4.071 8.12355 3.87066 8.1999 3.68272C8.24175 3.57971 8.29741 3.4913 8.37027 3.40751C8.91987 2.77547 9.70699 3.64998 9.70699 4.25279Z" fill="#fefefe" fill-rule="nonzero" opacity="1" stroke="none"/> <path d="M9.52269 4.52399C9.49202 4.43198 9.45938 4.32744 9.38457 4.25975C9.10132 4.00348 8.86965 4.33522 8.89213 4.62007C8.91055 4.85334 9.15414 5.1776 9.40258 4.96838C9.48254 4.90106 9.50357 4.79963 9.5287 4.70415C9.56262 5.17901 9.15172 5.77679 8.62789 5.51487C8.27484 5.33834 8.08341 4.92225 8.06339 4.542C8.05368 4.35738 8.07149 4.17377 8.14146 4.00152C8.17981 3.90712 8.23083 3.82609 8.2976 3.7493C8.8013 3.17004 9.52269 3.97152 9.52269 4.52399Z" fill="#fefefe" fill-rule="nonzero" opacity="1" stroke="none"/>
<path d="M9.28763 3.86619C9.63393 3.81072 9.79871 4.27401 9.68734 4.57387C9.64772 4.68053 9.56254 4.79847 9.43834 4.80976C8.96026 4.85322 8.82813 3.93979 9.28763 3.86619Z" fill="#000000" fill-rule="nonzero" opacity="1" stroke="none"/> <path d="M9.13835 4.16967C9.45573 4.11883 9.60675 4.54343 9.50468 4.81825C9.46837 4.916 9.3903 5.0241 9.27648 5.03444C8.83832 5.07427 8.71722 4.23713 9.13835 4.16967Z" fill="#000000" fill-rule="nonzero" opacity="1" stroke="none"/>
<path d="M7.1777 3.99069C7.52459 3.9464 7.66131 4.42013 7.55775 4.70492C7.52103 4.80592 7.43318 4.92354 7.31531 4.93426C6.8277 4.97859 6.6978 4.05196 7.1777 3.99069Z" fill="#000000" fill-rule="nonzero" opacity="1" stroke="none"/> <path d="M7.20463 4.28377C7.52255 4.24318 7.64785 4.67735 7.55294 4.93836C7.51928 5.03092 7.43877 5.13872 7.33074 5.14855C6.88386 5.18917 6.7648 4.33993 7.20463 4.28377Z" fill="#000000" fill-rule="nonzero" opacity="1" stroke="none"/>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -5,11 +5,11 @@
<defs/> <defs/>
<g id="Layer-1"> <g id="Layer-1">
<g opacity="1"> <g opacity="1">
<path d="M11.1099 6.38217C11.7882 6.49522 12.6934 6.75723 13.0302 7.43078C13.3687 8.10779 12.7656 8.86217 12.0537 8.81363C11.6584 8.78668 11.4105 8.41291 11.1558 8.15825C10.9816 10.1093 10.2882 12.1368 8.44253 13.0998C7.90859 13.3784 7.15879 13.6255 6.55503 13.4341C5.94041 13.2392 5.84685 12.4791 5.33602 12.1495C4.98304 11.9218 4.55099 11.9507 4.14978 11.9332C3.96279 11.9251 3.77077 11.9132 3.60582 11.8153C3.06517 11.4943 3.37916 10.7664 3.09462 10.3145C2.99126 10.1503 2.79498 10.0739 2.64241 9.9671C2.36563 9.77336 1.83127 9.33383 1.87561 8.96437C1.90138 8.74965 2.2492 8.74031 2.38681 8.72188C2.54607 8.70055 2.70608 8.685 2.86523 8.66289C3.25752 8.60841 3.72506 8.4909 3.98594 8.1648C3.30179 8.77293 2.30937 8.1244 2.44579 7.26038C2.54725 6.61783 3.3027 6.29409 3.84831 6.13967C4.17235 6.04797 4.52028 5.97954 4.85759 6.02171C4.85759 5.54505 4.7456 5.08532 4.70686 4.61264C4.6682 4.14104 4.74153 3.65798 4.91002 3.21668C5.67286 1.21876 8.3249 0.965609 9.72052 2.44333C10.2295 2.98221 10.4934 3.66293 10.7036 4.36359C10.902 5.02495 11.0229 5.69833 11.1099 6.38217Z" fill="#242424" fill-opacity="0.996078" fill-rule="nonzero" opacity="1" stroke="none"/> <path d="M10.8091 6.47531C11.4309 6.57894 12.2606 6.81912 12.5694 7.43654C12.8797 8.05714 12.3268 8.74865 11.6742 8.70416C11.3119 8.67945 11.0846 8.33683 10.8512 8.10339C10.6915 9.89186 10.0559 11.7504 8.36399 12.6332C7.87454 12.8885 7.18722 13.1151 6.63377 12.9396C6.07037 12.7609 5.98461 12.0642 5.51634 11.762C5.19278 11.5533 4.79673 11.5798 4.42895 11.5638C4.25754 11.5563 4.08153 11.5454 3.93032 11.4557C3.43472 11.1614 3.72255 10.4942 3.46172 10.08C3.36697 9.92944 3.18705 9.85941 3.04719 9.76151C2.79348 9.58391 2.30365 9.18101 2.34429 8.84234C2.36791 8.64551 2.68675 8.63695 2.81289 8.62005C2.95888 8.6005 3.10556 8.58625 3.25144 8.56598C3.61105 8.51604 4.03962 8.40832 4.27877 8.10939C3.65163 8.66685 2.7419 8.07236 2.86696 7.28034C2.95996 6.69133 3.65246 6.39457 4.1526 6.25302C4.44964 6.16896 4.76858 6.10623 5.07778 6.14489C5.07778 5.70795 4.97512 5.28653 4.93961 4.85324C4.90417 4.42094 4.97139 3.97813 5.12584 3.5736C5.82511 1.74217 8.25616 1.51012 9.53548 2.8647C10.002 3.35867 10.244 3.98267 10.4366 4.62494C10.6185 5.23119 10.7293 5.84846 10.8091 6.47531Z" fill="#242424" fill-opacity="0.996078" fill-rule="nonzero" opacity="1" stroke="none"/>
<path d="M7.57742 4.35704C7.52047 4.27161 7.50984 4.17531 7.43324 4.09489C7.20893 3.85937 6.91333 4.08436 6.88927 4.35704C6.86762 4.60243 7.01064 4.97235 7.31527 4.92722C7.35866 4.92079 7.4345 4.88664 7.46601 4.85513C7.53564 4.7855 7.56121 4.67093 7.58398 4.57987C7.62203 5.15062 7.13463 5.78487 6.53537 5.42531C6.19368 5.2203 6.00002 4.7767 5.98485 4.38981C5.97098 4.03617 6.06116 3.59746 6.39774 3.40674C6.97473 3.07977 7.57742 3.82198 7.57742 4.35704Z" fill="#fefefe" fill-rule="nonzero" opacity="1" stroke="none"/> <path d="M7.57097 4.61894C7.51876 4.54063 7.50902 4.45235 7.4388 4.37863C7.23318 4.16274 6.96221 4.36898 6.94016 4.61894C6.92031 4.84388 7.05142 5.18297 7.33066 5.1416C7.37044 5.13571 7.43996 5.10441 7.46884 5.07552C7.53267 5.01169 7.55611 4.90667 7.57698 4.8232C7.61186 5.34639 7.16507 5.92779 6.61575 5.59819C6.30253 5.41026 6.12501 5.00363 6.11111 4.64898C6.09839 4.32481 6.18106 3.92265 6.48959 3.74783C7.0185 3.4481 7.57097 4.12846 7.57097 4.61894Z" fill="#fefefe" fill-rule="nonzero" opacity="1" stroke="none"/>
<path d="M9.70741 4.25218C9.67394 4.15177 9.63832 4.03767 9.55667 3.96381C9.24755 3.68413 8.99472 4.04616 9.01926 4.35704C9.03936 4.61161 9.3052 4.96548 9.57634 4.73716C9.66359 4.66368 9.68655 4.55299 9.71397 4.44879C9.75098 4.96702 9.30256 5.61939 8.73089 5.33356C8.34559 5.1409 8.13668 4.68681 8.11484 4.27184C8.10423 4.07035 8.12367 3.86997 8.20004 3.682C8.24189 3.57897 8.29756 3.49054 8.37044 3.40674C8.92014 2.77458 9.70741 3.64925 9.70741 4.25218Z" fill="#fefefe" fill-rule="nonzero" opacity="1" stroke="none"/> <path d="M9.52346 4.52282C9.49278 4.43077 9.46013 4.32618 9.38528 4.25848C9.10192 4.0021 8.87016 4.33396 8.89266 4.61894C8.91108 4.85229 9.15477 5.17668 9.40331 4.96738C9.48329 4.90002 9.50434 4.79856 9.52948 4.70304C9.5634 5.17809 9.15235 5.77609 8.62832 5.51408C8.27512 5.33748 8.08362 4.92123 8.0636 4.54084C8.05388 4.35614 8.0717 4.17245 8.1417 4.00015C8.18007 3.9057 8.2311 3.82464 8.2979 3.74783C8.8018 3.16834 9.52346 3.97013 9.52346 4.52282Z" fill="#fefefe" fill-rule="nonzero" opacity="1" stroke="none"/>
<path d="M9.28797 3.8655C9.63434 3.81002 9.79915 4.2734 9.68775 4.57331C9.64813 4.67999 9.56293 4.79796 9.43871 4.80925C8.96053 4.85272 8.82838 3.93912 9.28797 3.8655Z" fill="#000000" fill-rule="nonzero" opacity="1" stroke="none"/> <path d="M9.13897 4.16836C9.45648 4.1175 9.60756 4.54227 9.50544 4.81719C9.46912 4.91498 9.39102 5.02312 9.27715 5.03346C8.83882 5.07331 8.71768 4.23584 9.13897 4.16836Z" fill="#000000" fill-rule="nonzero" opacity="1" stroke="none"/>
<path d="M7.17764 3.99002C7.5246 3.94573 7.66134 4.41955 7.55776 4.70439C7.52103 4.80541 7.43317 4.92306 7.31527 4.93377C6.82757 4.97811 6.69764 4.05131 7.17764 3.99002Z" fill="#000000" fill-rule="nonzero" opacity="1" stroke="none"/> <path d="M7.2045 4.2825C7.52255 4.2419 7.64789 4.67624 7.55294 4.93734C7.51927 5.02994 7.43874 5.13779 7.33066 5.14761C6.8836 5.18825 6.7645 4.33868 7.2045 4.2825Z" fill="#000000" fill-rule="nonzero" opacity="1" stroke="none"/>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB