Merge branch 'sparrowwallet:master' into master

This commit is contained in:
QcMrHyde 2025-05-17 14:45:07 -04:00 committed by GitHub
commit 891a4c3ff4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 1053 additions and 395 deletions

View file

@ -30,7 +30,7 @@ jobs:
- name: Package tar distribution
if: ${{ runner.os == 'Linux' }}
run: ./gradlew packageTarDistribution
- name: Upload Artifacts
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: Sparrow Build - ${{ runner.os }} ${{ runner.arch }}
@ -43,9 +43,6 @@ jobs:
- name: Package headless tar distribution
if: ${{ runner.os == 'Linux' }}
run: ./gradlew -Djava.awt.headless=true packageTarDistribution
- name: Rename Headless Artifacts
if: ${{ runner.os == 'Linux' }}
run: for f in build/jpackage/sparrow*; do mv -v "$f" "${f/sparrow/sparrow-server}"; done;
- name: Upload Headless Artifact
if: ${{ runner.os == 'Linux' }}
uses: actions/upload-artifact@v4

View file

@ -3,10 +3,9 @@ plugins {
id 'org-openjfx-javafxplugin'
id 'org.beryx.jlink' version '3.1.1'
id 'org.gradlex.extra-java-module-info' version '1.9'
id 'com.sparrowwallet.filterjar'
id 'io.matthewnelson.kmp.tor.resource-filterjar' version '408.16.2'
}
def sparrowVersion = '2.1.4'
def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName()
if(os.macOsX) {
@ -20,8 +19,8 @@ if(System.getProperty("os.arch") == "aarch64") {
}
def headless = "true".equals(System.getProperty("java.awt.headless"))
group "com.sparrowwallet"
version "${sparrowVersion}"
group 'com.sparrowwallet'
version '2.1.4'
repositories {
mavenCentral()
@ -77,7 +76,7 @@ dependencies {
implementation('co.nstant.in:cbor:0.9')
implementation('org.openpnp:openpnp-capture-java:0.0.28-5')
implementation("io.matthewnelson.kmp-tor:runtime:2.2.1")
implementation("io.matthewnelson.kmp-tor:resource-exec-tor-gpl:408.16.0")
implementation("io.matthewnelson.kmp-tor:resource-exec-tor-gpl:408.16.2")
implementation('org.jetbrains.kotlinx:kotlinx-coroutines-javafx:1.10.1') {
exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-common'
}
@ -239,7 +238,7 @@ jlink {
jpackage {
imageName = "Sparrow"
installerName = "Sparrow"
appVersion = "${sparrowVersion}"
appVersion = "${version}"
skipInstaller = os.macOsX || properties.skipInstallers
imageOptions = []
installerOptions = ['--file-associations', 'src/main/deploy/psbt.properties', '--file-associations', 'src/main/deploy/txn.properties', '--file-associations', 'src/main/deploy/asc.properties', '--file-associations', 'src/main/deploy/bitcoin.properties', '--file-associations', 'src/main/deploy/auth47.properties', '--file-associations', 'src/main/deploy/lightning.properties', '--license-file', 'LICENSE']
@ -250,11 +249,13 @@ jlink {
}
if(os.linux) {
if(headless) {
installerOptions = ['--license-file', 'LICENSE', '--resource-dir', "src/main/deploy/package/linux-headless/${osArch}"]
installerName = "sparrowserver"
installerOptions = ['--license-file', 'LICENSE']
} else {
installerOptions += ['--resource-dir', 'src/main/deploy/package/linux/', '--linux-shortcut', '--linux-menu-group', 'Sparrow']
installerName = "sparrowwallet"
installerOptions += ['--linux-shortcut', '--linux-menu-group', 'Sparrow']
}
installerOptions += ['--linux-app-category', 'utils', '--linux-app-release', '1', '--linux-rpm-license-type', 'ASL 2.0', '--linux-deb-maintainer', 'mail@sparrowwallet.com']
installerOptions += ['--resource-dir', layout.buildDirectory.dir('deploy/package').get().asFile.toString(), '--linux-app-category', 'utils', '--linux-app-release', '1', '--linux-rpm-license-type', 'ASL 2.0', '--linux-deb-maintainer', 'mail@sparrowwallet.com']
imageOptions += ['--icon', 'src/main/deploy/package/linux/Sparrow.png', '--resource-dir', 'src/main/deploy/package/linux/']
}
if(os.macOsX) {
@ -272,6 +273,7 @@ jlink {
if(os.linux) {
tasks.jlink.finalizedBy('addUserWritePermission', 'copyUdevRules')
tasks.jpackageImage.finalizedBy('prepareResourceDir')
} else {
tasks.jlink.finalizedBy('addUserWritePermission')
}
@ -290,12 +292,42 @@ tasks.register('copyUdevRules', Copy) {
include('*')
}
tasks.register('prepareResourceDir', Copy) {
from("src/main/deploy/package/linux${headless ? '-headless' : ''}")
into(layout.buildDirectory.dir('deploy/package'))
include('*')
eachFile { file ->
if(file.name.equals('control') || file.name.endsWith('.spec')) {
filter { line ->
if(line.contains('${size}')) {
line = line.replace('${size}', getDirectorySize(layout.buildDirectory.dir('jpackage/Sparrow').get().asFile))
}
return line.replace('${version}', "${version}").replace('${arch}', osArch == 'aarch64' ? 'arm64' : 'amd64')
}
}
}
}
static def getDirectorySize(File directory) {
long size = 0
if(directory.isFile()) {
size = directory.length()
} else if(directory.isDirectory()) {
directory.eachFileRecurse { file ->
if(file.isFile()) {
size += file.length()
}
}
}
return Long.toString(size/1024 as long)
}
tasks.register('removeGroupWritePermission', Exec) {
commandLine 'chmod', '-R', 'g-w', "$buildDir/jpackage/Sparrow"
}
tasks.register('packageZipDistribution', Zip) {
archiveFileName = "Sparrow-${sparrowVersion}.zip"
archiveFileName = "Sparrow-${version}.zip"
destinationDirectory = file("$buildDir/jpackage")
preserveFileTimestamps = os.macOsX
from("$buildDir/jpackage/") {
@ -306,7 +338,7 @@ tasks.register('packageZipDistribution', Zip) {
tasks.register('packageTarDistribution', Tar) {
dependsOn removeGroupWritePermission
archiveFileName = "sparrow-${sparrowVersion}-${releaseArch}.tar.gz"
archiveFileName = "sparrow${headless ? 'server': 'wallet'}-${version}-${releaseArch}.tar.gz"
destinationDirectory = file("$buildDir/jpackage")
compression = Compression.GZIP
from("$buildDir/jpackage/") {
@ -460,10 +492,6 @@ extraJavaModuleInfo {
}
}
String torOs = os.macOsX ? "macos" : (os.windows ? "mingw" : "linux-libc")
filterInfo {
filter('io.matthewnelson.kmp-tor', 'resource-lib-tor-gpl-jvm') {
include("io/matthewnelson/kmp/tor/resource/lib/tor/native/${torOs}/${releaseArch}")
exclude('io/matthewnelson/kmp/tor/resource/lib/tor/native/')
}
kmpTorResourceFilterJar {
keepTorCompilation("current","current")
}

View file

@ -20,9 +20,5 @@ gradlePlugin {
id = "org-openjfx-javafxplugin"
implementationClass = "org.openjfx.gradle.JavaFXPlugin"
}
register("com.sparrowwallet.filterjar") {
id = "com.sparrowwallet.filterjar"
implementationClass = "com.sparrowwallet.filterjar.FilterJarPlugin"
}
}
}

View file

@ -1,69 +0,0 @@
package com.sparrowwallet.filterjar;
import org.gradle.api.Action;
import org.gradle.api.artifacts.Configuration;
import org.gradle.api.artifacts.ConfigurationContainer;
import org.gradle.api.attributes.Attribute;
import org.gradle.api.model.ObjectFactory;
import org.gradle.api.provider.MapProperty;
import org.gradle.api.tasks.SourceSet;
import javax.inject.Inject;
public abstract class FilterJarExtension {
static Attribute<Boolean> FILTERED_ATTRIBUTE = Attribute.of("filtered", Boolean.class);
public abstract MapProperty<String, JarFilterConfigImpl> getFilterConfigs();
@Inject
protected abstract ObjectFactory getObjects();
@Inject
protected abstract ConfigurationContainer getConfigurations();
public void filter(String group, String artifact, Action<? super JarFilterConfigImpl> action) {
String name = group + ":" + artifact;
JarFilterConfigImpl config = new JarFilterConfigImpl(name, getObjects());
config.setGroup(group);
config.setArtifact(artifact);
action.execute(config);
getFilterConfigs().put(name, config);
}
/**
* Activate the plugin's functionality for dependencies of all scopes of the given source set
* (runtimeClasspath, compileClasspath, annotationProcessor).
* Note that the plugin activates the functionality for all source sets by default.
* Therefore, this method only has an effect for source sets for which a {@link #deactivate(Configuration)}
* has been performed.
*
* @param sourceSet the Source Set to activate (e.g. sourceSets.test)
*/
public void activate(SourceSet sourceSet) {
Configuration runtimeClasspath = getConfigurations().getByName(sourceSet.getRuntimeClasspathConfigurationName());
Configuration compileClasspath = getConfigurations().getByName(sourceSet.getCompileClasspathConfigurationName());
Configuration annotationProcessor = getConfigurations().getByName(sourceSet.getAnnotationProcessorConfigurationName());
activate(runtimeClasspath);
activate(compileClasspath);
activate(annotationProcessor);
}
/**
* Activate the plugin's functionality for a single resolvable Configuration.
*
* @param resolvable a resolvable Configuration (e.g. configurations["customClasspath"])
*/
public void activate(Configuration resolvable) {
resolvable.getAttributes().attribute(FILTERED_ATTRIBUTE, true);
}
/**
* Deactivate the plugin's functionality for a single resolvable Configuration.
*
* @param resolvable a resolvable Configuration (e.g. configurations.annotationProcessor)
*/
public void deactivate(Configuration resolvable) {
resolvable.getAttributes().attribute(FILTERED_ATTRIBUTE, false);
}
}

View file

@ -1,10 +0,0 @@
package com.sparrowwallet.filterjar;
import org.gradle.api.artifacts.transform.TransformParameters;
import org.gradle.api.provider.MapProperty;
import org.gradle.api.tasks.Input;
public interface FilterJarParameters extends TransformParameters {
@Input
MapProperty<String, JarFilterConfig> getFilterConfigs();
}

View file

@ -1,33 +0,0 @@
package com.sparrowwallet.filterjar;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.plugins.JavaPlugin;
import org.gradle.api.tasks.SourceSetContainer;
import static com.sparrowwallet.filterjar.FilterJarExtension.FILTERED_ATTRIBUTE;
public class FilterJarPlugin implements Plugin<Project> {
@Override
public void apply(Project project) {
// Register the extension
FilterJarExtension extension = project.getExtensions().create("filterInfo", FilterJarExtension.class);
project.getPlugins().withType(JavaPlugin.class).configureEach(_ -> {
// By default, activate plugin for all source sets
project.getExtensions().getByType(SourceSetContainer.class).all(extension::activate);
// All jars have a filtered=false attribute by default
project.getDependencies().getArtifactTypes().maybeCreate("jar").getAttributes().attribute(FILTERED_ATTRIBUTE, false);
// Register the transform
project.getDependencies().registerTransform(FilterJarTransform.class, transform -> {
transform.getFrom().attribute(FILTERED_ATTRIBUTE, false);
transform.getTo().attribute(FILTERED_ATTRIBUTE, true);
transform.parameters(params -> {
params.getFilterConfigs().putAll(extension.getFilterConfigs());
});
});
});
}
}

View file

@ -1,79 +0,0 @@
package com.sparrowwallet.filterjar;
import org.gradle.api.artifacts.transform.InputArtifact;
import org.gradle.api.artifacts.transform.TransformAction;
import org.gradle.api.artifacts.transform.TransformOutputs;
import org.gradle.api.file.FileSystemLocation;
import org.gradle.api.provider.Provider;
import org.gradle.api.tasks.PathSensitive;
import org.gradle.api.tasks.PathSensitivity;
import java.io.File;
import java.util.Map;
import java.util.Set;
import java.util.HashSet;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.JarOutputStream;
import java.nio.file.Files;
public abstract class FilterJarTransform implements TransformAction<FilterJarParameters> {
@InputArtifact
@PathSensitive(PathSensitivity.NAME_ONLY)
public abstract Provider<FileSystemLocation> getInputArtifact();
@Override
public void transform(TransformOutputs outputs) {
File originalJar = getInputArtifact().get().getAsFile();
String jarName = originalJar.getName();
// Get filter configurations from parameters
Map<String, JarFilterConfig> filterConfigs = getParameters().getFilterConfigs().get();
//Inclusions are prioritised ahead of exclusions
Set<String> inclusions = new HashSet<>();
Set<String> exclusions = new HashSet<>();
// Check if this JAR matches any configured filters (simplified matching based on artifact name)
filterConfigs.forEach((key, config) -> {
if(jarName.contains(config.getArtifact())) {
inclusions.addAll(config.getInclusions());
exclusions.addAll(config.getExclusions());
}
});
try {
if(!exclusions.isEmpty()) {
filterJar(originalJar, getFilterJar(outputs, originalJar), inclusions, exclusions);
} else {
outputs.file(originalJar);
}
} catch(Exception e) {
throw new RuntimeException("Failed to transform jar: " + jarName, e);
}
}
private void filterJar(File inputFile, File outputFile, Set<String> inclusions, Set<String> exclusions) throws Exception {
try(JarFile jarFile = new JarFile(inputFile); JarOutputStream jarOut = new JarOutputStream(Files.newOutputStream(outputFile.toPath()))) {
jarFile.entries().asIterator().forEachRemaining(entry -> {
String entryName = entry.getName();
boolean shouldInclude = inclusions.stream().anyMatch(entryName::startsWith);
boolean shouldExclude = exclusions.stream().anyMatch(entryName::startsWith);
if(shouldInclude || !shouldExclude) {
try {
jarOut.putNextEntry(new JarEntry(entryName));
jarFile.getInputStream(entry).transferTo(jarOut);
jarOut.closeEntry();
} catch(Exception e) {
throw new RuntimeException("Error processing entry: " + entryName, e);
}
}
});
}
}
private File getFilterJar(TransformOutputs outputs, File originalJar) {
return outputs.file(originalJar.getName().substring(0, originalJar.getName().lastIndexOf('.')) + "-filtered.jar");
}
}

View file

@ -1,19 +0,0 @@
package com.sparrowwallet.filterjar;
import org.gradle.api.tasks.Input;
import java.util.List;
public interface JarFilterConfig {
@Input
String getGroup();
@Input
String getArtifact();
@Input
List<String> getInclusions();
@Input
List<String> getExclusions();
}

View file

@ -1,64 +0,0 @@
package com.sparrowwallet.filterjar;
import org.gradle.api.Named;
import org.gradle.api.model.ObjectFactory;
import javax.inject.Inject;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
public class JarFilterConfigImpl implements Named, JarFilterConfig, Serializable {
private final String name;
private String group;
private String artifact;
private final List<String> inclusions;
private final List<String> exclusions;
@Inject
public JarFilterConfigImpl(String name, ObjectFactory objectFactory) {
this.name = name;
this.inclusions = new ArrayList<>();
this.exclusions = new ArrayList<>();
}
@Override
public String getName() {
return name;
}
@Override
public String getGroup() {
return group;
}
public void setGroup(String group) {
this.group = group;
}
@Override
public String getArtifact() {
return artifact;
}
public void setArtifact(String artifact) {
this.artifact = artifact;
}
@Override
public List<String> getInclusions() {
return inclusions;
}
public void include(String path) {
inclusions.add(path);
}
@Override
public List<String> getExclusions() {
return exclusions;
}
public void exclude(String path) {
exclusions.add(path);
}
}

2
lark

@ -1 +1 @@
Subproject commit d3ed65b89e0b6273eac4e35b266986308a5e83a9
Subproject commit 5facb25ede49c30650a8460dc04982650edb397f

View file

@ -1,9 +0,0 @@
Package: sparrow
Version: 2.1.4-1
Section: utils
Maintainer: Craig Raw <mail@sparrowwallet.com>
Priority: optional
Architecture: arm64
Provides: sparrow
Description: Sparrow
Depends: libc6, zlib1g

View file

@ -0,0 +1,12 @@
Package: sparrowserver
Version: ${version}-1
Section: utils
Maintainer: Craig Raw <mail@sparrowwallet.com>
Priority: optional
Architecture: ${arch}
Conflicts: sparrow (<= 2.1.4)
Replaces: sparrow (<= 2.1.4)
Provides: sparrowserver
Description: Sparrow Server
Depends: libc6, zlib1g
Installed-Size: ${size}

View file

@ -0,0 +1,85 @@
Summary: Sparrow Server
Name: sparrowserver
Version: ${version}
Release: 1
License: ASL 2.0
Vendor: Unknown
%if "x" != "x"
URL: https://sparrowwallet.com
%endif
%if "x/opt" != "x"
Prefix: /opt
%endif
Provides: sparrowserver
Obsoletes: sparrow <= 2.1.4
%if "xutils" != "x"
Group: utils
%endif
Autoprov: 0
Autoreq: 0
#comment line below to enable effective jar compression
#it could easily get your package size from 40 to 15Mb but
#build time will substantially increase and it may require unpack200/system java to install
%define __jar_repack %{nil}
# on RHEL we got unwanted improved debugging enhancements
%define _build_id_links none
%define package_filelist %{_builddir}/%{name}.files
%define app_filelist %{_builddir}/%{name}.app.files
%define filesystem_filelist %{_builddir}/%{name}.filesystem.files
%define default_filesystem / /opt /usr /usr/bin /usr/lib /usr/local /usr/local/bin /usr/local/lib
%description
Sparrow Server
%global __os_install_post %{nil}
%prep
%build
%install
rm -rf %{buildroot}
install -d -m 755 %{buildroot}/opt/sparrowserver
cp -r %{_sourcedir}/opt/sparrowserver/* %{buildroot}/opt/sparrowserver
if [ "$(echo %{_sourcedir}/lib/systemd/system/*.service)" != '%{_sourcedir}/lib/systemd/system/*.service' ]; then
install -d -m 755 %{buildroot}/lib/systemd/system
cp %{_sourcedir}/lib/systemd/system/*.service %{buildroot}/lib/systemd/system
fi
%if "x%{_rpmdir}/../../LICENSE" != "x"
%define license_install_file %{_defaultlicensedir}/%{name}-%{version}/%{basename:%{_rpmdir}/../../LICENSE}
install -d -m 755 "%{buildroot}%{dirname:%{license_install_file}}"
install -m 644 "%{_rpmdir}/../../LICENSE" "%{buildroot}%{license_install_file}"
%endif
(cd %{buildroot} && find . -path ./lib/systemd -prune -o -type d -print) | sed -e 's/^\.//' -e '/^$/d' | sort > %{app_filelist}
{ rpm -ql filesystem || echo %{default_filesystem}; } | sort > %{filesystem_filelist}
comm -23 %{app_filelist} %{filesystem_filelist} > %{package_filelist}
sed -i -e 's/.*/%dir "&"/' %{package_filelist}
(cd %{buildroot} && find . -not -type d) | sed -e 's/^\.//' -e 's/.*/"&"/' >> %{package_filelist}
%if "x%{_rpmdir}/../../LICENSE" != "x"
sed -i -e 's|"%{license_install_file}"||' -e '/^$/d' %{package_filelist}
%endif
%files -f %{package_filelist}
%if "x%{_rpmdir}/../../LICENSE" != "x"
%license "%{license_install_file}"
%endif
%post
package_type=rpm
%pre
package_type=rpm
%preun
package_type=rpm
%clean

View file

@ -1,9 +0,0 @@
Package: sparrow
Version: 2.1.4-1
Section: utils
Maintainer: Craig Raw <mail@sparrowwallet.com>
Priority: optional
Architecture: amd64
Provides: sparrow
Description: Sparrow
Depends: libc6, zlib1g

View file

@ -1,8 +1,8 @@
[Desktop Entry]
Name=Sparrow
Comment=Sparrow
Exec=/opt/sparrow/bin/Sparrow %U
Icon=/opt/sparrow/lib/Sparrow.png
Exec=/opt/sparrowwallet/bin/Sparrow %U
Icon=/opt/sparrowwallet/lib/Sparrow.png
Terminal=false
Type=Application
Categories=Finance;Network;

View file

@ -0,0 +1,12 @@
Package: sparrowwallet
Version: ${version}-1
Section: utils
Maintainer: Craig Raw <mail@sparrowwallet.com>
Priority: optional
Architecture: ${arch}
Provides: sparrowwallet
Conflicts: sparrow (<= 2.1.4)
Replaces: sparrow (<= 2.1.4)
Description: Sparrow Wallet
Depends: libasound2, libbsd0, libc6, libmd0, libx11-6, libxau6, libxcb1, libxdmcp6, libxext6, libxi6, libxrender1, libxtst6, xdg-utils
Installed-Size: ${size}

View file

@ -1,5 +1,5 @@
#!/bin/sh
# postinst script for sparrow
# postinst script for sparrowwallet
#
# see: dh_installdeb(1)
@ -22,9 +22,9 @@ package_type=deb
case "$1" in
configure)
xdg-desktop-menu install /opt/sparrow/lib/sparrow-Sparrow.desktop
xdg-mime install /opt/sparrow/lib/sparrow-Sparrow-MimeInfo.xml
install -D -m 644 /opt/sparrow/lib/runtime/conf/udev/*.rules /etc/udev/rules.d
xdg-desktop-menu install /opt/sparrowwallet/lib/sparrowwallet-Sparrow.desktop
xdg-mime install /opt/sparrowwallet/lib/sparrowwallet-Sparrow-MimeInfo.xml
install -D -m 644 /opt/sparrowwallet/lib/runtime/conf/udev/*.rules /etc/udev/rules.d
if ! getent group plugdev > /dev/null; then
groupadd plugdev
fi

View file

@ -1,19 +1,20 @@
Summary: Sparrow
Name: sparrow
Version: 2.1.4
Name: sparrowwallet
Version: ${version}
Release: 1
License: ASL 2.0
Vendor: Unknown
%if "x" != "x"
URL:
URL: https://sparrowwallet.com
%endif
%if "x/opt" != "x"
Prefix: /opt
%endif
Provides: sparrow
Provides: sparrowwallet
Obsoletes: sparrow <= 2.1.4
%if "xutils" != "x"
Group: utils
@ -50,8 +51,8 @@ Sparrow Wallet
%install
rm -rf %{buildroot}
install -d -m 755 %{buildroot}/opt/sparrow
cp -r %{_sourcedir}/opt/sparrow/* %{buildroot}/opt/sparrow
install -d -m 755 %{buildroot}/opt/sparrowwallet
cp -r %{_sourcedir}/opt/sparrowwallet/* %{buildroot}/opt/sparrowwallet
if [ "$(echo %{_sourcedir}/lib/systemd/system/*.service)" != '%{_sourcedir}/lib/systemd/system/*.service' ]; then
install -d -m 755 %{buildroot}/lib/systemd/system
cp %{_sourcedir}/lib/systemd/system/*.service %{buildroot}/lib/systemd/system
@ -77,9 +78,9 @@ sed -i -e 's/.*/%dir "&"/' %{package_filelist}
%post
package_type=rpm
xdg-desktop-menu install /opt/sparrow/lib/sparrow-Sparrow.desktop
xdg-mime install /opt/sparrow/lib/sparrow-Sparrow-MimeInfo.xml
install -D -m 644 /opt/sparrow/lib/runtime/conf/udev/*.rules /etc/udev/rules.d
xdg-desktop-menu install /opt/sparrowwallet/lib/sparrowwallet-Sparrow.desktop
xdg-mime install /opt/sparrowwallet/lib/sparrowwallet-Sparrow-MimeInfo.xml
install -D -m 644 /opt/sparrowwallet/lib/runtime/conf/udev/*.rules /etc/udev/rules.d
if ! getent group plugdev > /dev/null; then
groupadd plugdev
fi
@ -251,9 +252,9 @@ desktop_trace ()
echo "$@"
}
do_if_file_belongs_to_single_package /opt/sparrow/lib/sparrow-Sparrow.desktop xdg-desktop-menu uninstall /opt/sparrow/lib/sparrow-Sparrow.desktop
do_if_file_belongs_to_single_package /opt/sparrow/lib/sparrow-Sparrow-MimeInfo.xml xdg-mime uninstall /opt/sparrow/lib/sparrow-Sparrow-MimeInfo.xml
do_if_file_belongs_to_single_package /opt/sparrow/lib/sparrow-Sparrow.desktop desktop_uninstall_default_mime_handler sparrow-Sparrow.desktop application/psbt application/bitcoin-transaction application/pgp-signature x-scheme-handler/bitcoin x-scheme-handler/auth47 x-scheme-handler/lightning
do_if_file_belongs_to_single_package /opt/sparrowwallet/lib/sparrowwallet-Sparrow.desktop xdg-desktop-menu uninstall /opt/sparrowwallet/lib/sparrowwallet-Sparrow.desktop
do_if_file_belongs_to_single_package /opt/sparrowwallet/lib/sparrowwallet-Sparrow-MimeInfo.xml xdg-mime uninstall /opt/sparrowwallet/lib/sparrowwallet-Sparrow-MimeInfo.xml
do_if_file_belongs_to_single_package /opt/sparrowwallet/lib/sparrowwallet-Sparrow.desktop desktop_uninstall_default_mime_handler sparrowwallet-Sparrow.desktop application/psbt application/bitcoin-transaction application/pgp-signature x-scheme-handler/bitcoin x-scheme-handler/auth47 x-scheme-handler/lightning
%clean

View file

@ -612,16 +612,16 @@ public class AppController implements Initializable {
public void installUdevRules(ActionEvent event) {
String commands = """
sudo install -m 644 /opt/sparrow/lib/runtime/conf/udev/*.rules /etc/udev/rules.d
sudo install -m 644 /opt/sparrowwallet/lib/runtime/conf/udev/*.rules /etc/udev/rules.d
sudo udevadm control --reload
sudo udevadm trigger
sudo groupadd -f plugdev
sudo usermod -aG plugdev `whoami`
""";
String home = System.getProperty(JPACKAGE_APP_PATH);
if(home != null && !home.startsWith("/opt/sparrow") && home.endsWith("bin/Sparrow")) {
if(home != null && !home.startsWith("/opt/sparrowwallet") && home.endsWith("bin/Sparrow")) {
home = home.replace("bin/Sparrow", "");
commands = commands.replace("/opt/sparrow/", home);
commands = commands.replace("/opt/sparrowwallet/", home);
}
TextAreaDialog dialog = new TextAreaDialog(commands, false);

View file

@ -305,12 +305,6 @@ public class AppServices {
if(event != null) {
EventManager.get().post(event);
}
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
if(event instanceof ConnectionEvent && feeRatesSource.supportsNetwork(Network.get()) && feeRatesSource.isExternal()) {
EventManager.get().post(new FeeRatesSourceChangedEvent(feeRatesSource));
}
});
connectionService.setOnFailed(failEvent -> {
//Close connection here to create a new transport next time we try
@ -494,6 +488,13 @@ public class AppServices {
}
}
private void fetchFeeRates() {
if(feeRatesService != null && !feeRatesService.isRunning() && Config.get().getMode() != Mode.OFFLINE) {
feeRatesService = createFeeRatesService();
feeRatesService.start();
}
}
private void fetchBlockSummaries(List<NewBlockEvent> newBlockEvents) {
if(isConnected()) {
ElectrumServer.BlockSummaryService blockSummaryService = new ElectrumServer.BlockSummaryService(newBlockEvents);
@ -1216,6 +1217,12 @@ public class AppServices {
latestBlockHeader = event.getBlockHeader();
Config.get().addRecentServer();
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
if(feeRatesSource.supportsNetwork(Network.get()) && feeRatesSource.isExternal()) {
fetchFeeRates();
}
if(!blockSummaries.containsKey(currentBlockHeight)) {
fetchBlockSummaries(Collections.emptyList());
}
@ -1259,10 +1266,8 @@ public class AppServices {
@Subscribe
public void feeRateSourceChanged(FeeRatesSourceChangedEvent event) {
//Perform once-off fee rates retrieval to immediately change displayed rates
if(feeRatesService != null && !feeRatesService.isRunning() && Config.get().getMode() != Mode.OFFLINE) {
feeRatesService = createFeeRatesService();
feeRatesService.start();
}
fetchFeeRates();
fetchBlockSummaries(Collections.emptyList());
}
@Subscribe

View file

@ -5,21 +5,23 @@ import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.Optional;
public class BlockSummary {
public class BlockSummary implements Comparable<BlockSummary> {
private final Integer height;
private final Date timestamp;
private final Double medianFee;
private final Integer transactionCount;
private final Integer weight;
public BlockSummary(Integer height, Date timestamp) {
this(height, timestamp, null, null);
this(height, timestamp, null, null, null);
}
public BlockSummary(Integer height, Date timestamp, Double medianFee, Integer transactionCount) {
public BlockSummary(Integer height, Date timestamp, Double medianFee, Integer transactionCount, Integer weight) {
this.height = height;
this.timestamp = timestamp;
this.medianFee = medianFee;
this.transactionCount = transactionCount;
this.weight = weight;
}
public Integer getHeight() {
@ -38,6 +40,10 @@ public class BlockSummary {
return transactionCount == null ? Optional.empty() : Optional.of(transactionCount);
}
public Optional<Integer> getWeight() {
return weight == null ? Optional.empty() : Optional.of(weight);
}
private static long calculateElapsedSeconds(long timestampUtc) {
Instant timestampInstant = Instant.ofEpochMilli(timestampUtc);
Instant nowInstant = Instant.now();
@ -62,4 +68,9 @@ public class BlockSummary {
public String toString() {
return getElapsed() + ":" + getMedianFee();
}
@Override
public int compareTo(BlockSummary o) {
return o.height - height;
}
}

View file

@ -0,0 +1,331 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.BlockSummary;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.property.*;
import javafx.scene.Group;
import javafx.scene.shape.Polygon;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Font;
import javafx.scene.text.FontWeight;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.util.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
public class BlockCube extends Group {
public static final List<Integer> MEMPOOL_FEE_RATES_INTERVALS = List.of(1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200, 250, 300, 350, 400, 500, 600, 700, 800, 900, 1000, 1200, 1400, 1600, 1800, 2000);
public static final double CUBE_SIZE = 60;
private final IntegerProperty weightProperty = new SimpleIntegerProperty(0);
private final DoubleProperty medianFeeProperty = new SimpleDoubleProperty(-1.0d);
private final IntegerProperty heightProperty = new SimpleIntegerProperty(0);
private final IntegerProperty txCountProperty = new SimpleIntegerProperty(0);
private final LongProperty timestampProperty = new SimpleLongProperty(System.currentTimeMillis());
private final StringProperty elapsedProperty = new SimpleStringProperty("");
private final BooleanProperty confirmedProperty = new SimpleBooleanProperty(false);
private Polygon front;
private Rectangle unusedArea;
private Rectangle usedArea;
private final Text heightText = new Text();
private final Text medianFeeText = new Text();
private final Text unitsText = new Text();
private final TextFlow medianFeeTextFlow = new TextFlow();
private final Text txCountText = new Text();
private final Text elapsedText = new Text();
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.weightProperty.addListener((_, _, _) -> {
if(front != null) {
updateFill();
}
});
this.medianFeeProperty.addListener((_, _, newValue) -> {
medianFeeText.setText("~" + Math.round(Math.max(newValue.doubleValue(), 1.0d)));
unitsText.setText(" s/vb");
double unitsWidth = TextUtils.computeTextWidth(unitsText.getFont(), unitsText.getText(), 0.0d);
medianFeeTextFlow.setTranslateX((CUBE_SIZE - (medianFeeText.getLayoutBounds().getWidth() + unitsWidth)) / 2);
});
this.txCountProperty.addListener((_, _, newValue) -> {
txCountText.setText(newValue.intValue() == 0 ? "" : newValue + " txes");
txCountText.setX((CUBE_SIZE - txCountText.getLayoutBounds().getWidth()) / 2);
});
this.timestampProperty.addListener((_, _, newValue) -> {
elapsedProperty.set(getElapsed(newValue.longValue()));
});
this.elapsedProperty.addListener((_, _, newValue) -> {
elapsedText.setText(isConfirmed() ? newValue : "In ~10m");
elapsedText.setX((CUBE_SIZE - elapsedText.getLayoutBounds().getWidth()) / 2);
});
this.heightProperty.addListener((_, _, newValue) -> {
heightText.setText(newValue.intValue() == 0 ? "" : String.valueOf(newValue));
heightText.setX(((CUBE_SIZE * 0.7) - heightText.getLayoutBounds().getWidth()) / 2);
});
this.confirmedProperty.addListener((_, _, _) -> {
if(front != null) {
updateFill();
}
});
this.medianFeeText.textProperty().addListener((_, _, _) -> {
pulse();
});
if(weight != null) {
this.weightProperty.set(weight);
}
if(medianFee != null) {
this.medianFeeProperty.set(medianFee);
}
if(height != null) {
this.heightProperty.set(height);
}
if(txCount != null) {
this.txCountProperty.set(txCount);
}
if(timestamp != null) {
this.timestampProperty.set(timestamp);
}
drawCube();
}
private void drawCube() {
double depth = CUBE_SIZE * 0.2;
double perspective = CUBE_SIZE * 0.04;
front = new Polygon(0, 0, CUBE_SIZE, 0, CUBE_SIZE, CUBE_SIZE, 0, CUBE_SIZE);
front.getStyleClass().add("block-front");
front.setFill(null);
unusedArea = new Rectangle(0, 0, CUBE_SIZE, CUBE_SIZE);
unusedArea.getStyleClass().add("block-unused");
usedArea = new Rectangle(0, 0, CUBE_SIZE, CUBE_SIZE);
usedArea.getStyleClass().add("block-used");
Group frontFaceGroup = new Group(front, unusedArea, usedArea);
Polygon top = new Polygon(0, 0, CUBE_SIZE, 0, CUBE_SIZE - depth - perspective, -depth, -depth, -depth);
top.getStyleClass().add("block-top");
top.setStroke(null);
Polygon left = new Polygon(0, 0, -depth, -depth, -depth, CUBE_SIZE - depth - perspective, 0, CUBE_SIZE);
left.getStyleClass().add("block-left");
left.setStroke(null);
updateFill();
heightText.getStyleClass().add("block-height");
heightText.setFont(new Font(11));
heightText.setX(((CUBE_SIZE * 0.7) - heightText.getLayoutBounds().getWidth()) / 2);
heightText.setY(-24);
medianFeeText.getStyleClass().add("block-text");
medianFeeText.setFont(Font.font(null, FontWeight.BOLD, 11));
unitsText.getStyleClass().add("block-text");
unitsText.setFont(new Font(10));
medianFeeTextFlow.getChildren().addAll(medianFeeText, unitsText);
medianFeeTextFlow.setTranslateX((CUBE_SIZE - (medianFeeText.getLayoutBounds().getWidth() + unitsText.getLayoutBounds().getWidth())) / 2);
medianFeeTextFlow.setTranslateY(7);
txCountText.getStyleClass().add("block-text");
txCountText.setFont(new Font(10));
txCountText.setOpacity(0.7);
txCountText.setX((CUBE_SIZE - txCountText.getLayoutBounds().getWidth()) / 2);
txCountText.setY(34);
elapsedText.getStyleClass().add("block-text");
elapsedText.setFont(new Font(10));
elapsedText.setX((CUBE_SIZE - elapsedText.getLayoutBounds().getWidth()) / 2);
elapsedText.setY(50);
getChildren().addAll(frontFaceGroup, top, left, heightText, medianFeeTextFlow, txCountText, elapsedText);
}
private void updateFill() {
if(isConfirmed()) {
getStyleClass().removeAll("block-unconfirmed");
if(!getStyleClass().contains("block-confirmed")) {
getStyleClass().add("block-confirmed");
}
double startY = 1 - weightProperty.doubleValue() / (Transaction.MAX_BLOCK_SIZE_VBYTES * Transaction.WITNESS_SCALE_FACTOR);
double startYAbsolute = startY * BlockCube.CUBE_SIZE;
unusedArea.setHeight(startYAbsolute);
unusedArea.setStyle(null);
usedArea.setY(startYAbsolute);
usedArea.setHeight(CUBE_SIZE - startYAbsolute);
usedArea.setVisible(true);
heightText.setVisible(true);
} else {
getStyleClass().removeAll("block-confirmed");
if(!getStyleClass().contains("block-unconfirmed")) {
getStyleClass().add("block-unconfirmed");
}
usedArea.setVisible(false);
unusedArea.setStyle("-fx-fill: " + getFeeRateStyleName() + ";");
heightText.setVisible(false);
}
}
public void pulse() {
if(isConfirmed()) {
return;
}
if(unusedArea != null) {
unusedArea.setStyle("-fx-fill: " + getFeeRateStyleName() + ";");
}
Timeline timeline = new Timeline(
new KeyFrame(Duration.ZERO, new KeyValue(opacityProperty(), 1.0)),
new KeyFrame(Duration.millis(500), new KeyValue(opacityProperty(), 0.7)),
new KeyFrame(Duration.millis(1000), new KeyValue(opacityProperty(), 1.0))
);
timeline.setCycleCount(1);
timeline.play();
}
private static long calculateElapsedSeconds(long timestampUtc) {
Instant timestampInstant = Instant.ofEpochMilli(timestampUtc);
Instant nowInstant = Instant.now();
return ChronoUnit.SECONDS.between(timestampInstant, nowInstant);
}
public static String getElapsed(long timestampUtc) {
long elapsed = calculateElapsedSeconds(timestampUtc);
if(elapsed < 60) {
return "Just now";
} else if(elapsed < 3600) {
return Math.round(elapsed / 60f) + "m ago";
} else if(elapsed < 86400) {
return Math.round(elapsed / 3600f) + "h ago";
} else {
return Math.round(elapsed / 86400d) + "d ago";
}
}
private String getFeeRateStyleName() {
double rate = getMedianFee();
int[] feeRateInterval = getFeeRateInterval(rate);
if(feeRateInterval[1] == Integer.MAX_VALUE) {
return "VSIZE2000-2200_COLOR";
}
int[] nextRateInterval = getFeeRateInterval(rate * 2);
String from = "VSIZE" + feeRateInterval[0] + "-" + feeRateInterval[1] + "_COLOR";
String to = "VSIZE" + nextRateInterval[0] + "-" + (nextRateInterval[1] == Integer.MAX_VALUE ? "2200" : nextRateInterval[1]) + "_COLOR";
return "linear-gradient(from 75% 0% to 100% 0%, " + from + " 0%, " + to + " 100%, " + from +")";
}
private int[] getFeeRateInterval(double medianFee) {
for(int i = 0; i < MEMPOOL_FEE_RATES_INTERVALS.size(); i++) {
int feeRate = MEMPOOL_FEE_RATES_INTERVALS.get(i);
int nextFeeRate = (i == MEMPOOL_FEE_RATES_INTERVALS.size() - 1 ? Integer.MAX_VALUE : MEMPOOL_FEE_RATES_INTERVALS.get(i + 1));
if(feeRate <= medianFee && nextFeeRate > medianFee) {
return new int[] { feeRate, nextFeeRate };
}
}
return new int[] { 1, 2 };
}
public int getWeight() {
return weightProperty.get();
}
public IntegerProperty weightProperty() {
return weightProperty;
}
public void setWeight(int weight) {
weightProperty.set(weight);
}
public double getMedianFee() {
return medianFeeProperty.get();
}
public DoubleProperty medianFee() {
return medianFeeProperty;
}
public void setMedianFee(double medianFee) {
medianFeeProperty.set(medianFee);
}
public int getHeight() {
return heightProperty.get();
}
public IntegerProperty heightProperty() {
return heightProperty;
}
public void setHeight(int height) {
heightProperty.set(height);
}
public int getTxCount() {
return txCountProperty.get();
}
public IntegerProperty txCountProperty() {
return txCountProperty;
}
public void setTxCount(int txCount) {
txCountProperty.set(txCount);
}
public long getTimestamp() {
return timestampProperty.get();
}
public LongProperty timestampProperty() {
return timestampProperty;
}
public void setTimestamp(long timestamp) {
timestampProperty.set(timestamp);
}
public String getElapsed() {
return elapsedProperty.get();
}
public StringProperty elapsedProperty() {
return elapsedProperty;
}
public void setElapsed(String elapsed) {
elapsedProperty.set(elapsed);
}
public boolean isConfirmed() {
return confirmedProperty.get();
}
public BooleanProperty confirmedProperty() {
return confirmedProperty;
}
public void setConfirmed(boolean confirmed) {
confirmedProperty.set(confirmed);
}
public static BlockCube fromBlockSummary(BlockSummary blockSummary) {
return new BlockCube(blockSummary.getWeight().orElse(0), blockSummary.getMedianFee().orElse(1.0d), blockSummary.getHeight(),
blockSummary.getTransactionCount().orElse(0), blockSummary.getTimestamp().getTime(), true);
}
}

View file

@ -0,0 +1,150 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.BlockSummary;
import io.reactivex.Observable;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.rxjavafx.schedulers.JavaFxScheduler;
import javafx.animation.TranslateTransition;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.layout.Pane;
import javafx.scene.shape.Line;
import javafx.scene.shape.Rectangle;
import javafx.util.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
public class RecentBlocksView extends Pane {
private static final double CUBE_SPACING = 100;
private static final double ANIMATION_DURATION_MILLIS = 1000;
private static final double SEPARATOR_X = 74;
private final CompositeDisposable disposables = new CompositeDisposable();
private final ObjectProperty<List<BlockCube>> cubesProperty = new SimpleObjectProperty<>(new ArrayList<>());
public RecentBlocksView() {
cubesProperty.addListener((_, _, newValue) -> {
if(newValue != null && newValue.size() == 3) {
drawView();
}
});
Rectangle clip = new Rectangle(-20, -40, CUBE_SPACING * 3 - 20, 100);
setClip(clip);
Observable<Long> intervalObservable = Observable.interval(1, TimeUnit.MINUTES);
disposables.add(intervalObservable.observeOn(JavaFxScheduler.platform()).subscribe(_ -> {
for(BlockCube cube : getCubes()) {
cube.setElapsed(BlockCube.getElapsed(cube.getTimestamp()));
}
}));
}
public void drawView() {
createSeparator();
for(int i = 0; i < 3; i++) {
BlockCube cube = getCubes().get(i);
cube.setTranslateX(i * CUBE_SPACING);
getChildren().add(cube);
}
}
private void createSeparator() {
Line separator = new Line(SEPARATOR_X, -9, SEPARATOR_X, 80);
separator.getStyleClass().add("blocks-separator");
separator.getStrokeDashArray().addAll(5.0, 5.0); // Create dotted line pattern
separator.setStrokeWidth(1.0);
getChildren().add(separator);
}
public void update(List<BlockSummary> latestBlocks, Double currentFeeRate) {
if(getCubes().isEmpty()) {
List<BlockCube> cubes = new ArrayList<>();
cubes.add(new BlockCube(null, currentFeeRate, null, null, 0L, false));
cubes.addAll(latestBlocks.stream().map(BlockCube::fromBlockSummary).limit(2).toList());
setCubes(cubes);
} else {
int knownTip = getCubes().stream().mapToInt(BlockCube::getHeight).max().orElse(0);
int latestTip = latestBlocks.stream().mapToInt(BlockSummary::getHeight).max().orElse(0);
if(latestTip > knownTip) {
addNewBlock(latestBlocks, currentFeeRate);
} else {
for(int i = 1; i < getCubes().size() && i < latestBlocks.size(); i++) {
BlockCube blockCube = getCubes().get(i);
BlockSummary latestBlock = latestBlocks.get(i);
blockCube.setConfirmed(true);
blockCube.setHeight(latestBlock.getHeight());
blockCube.setTimestamp(latestBlock.getTimestamp().getTime());
blockCube.setWeight(latestBlock.getWeight().orElse(0));
blockCube.setMedianFee(latestBlock.getMedianFee().orElse(0.0d));
blockCube.setTxCount(latestBlock.getTransactionCount().orElse(0));
}
updateFeeRate(currentFeeRate);
}
}
}
public void addNewBlock(List<BlockSummary> latestBlocks, Double currentFeeRate) {
if(getCubes().isEmpty()) {
return;
}
for(int i = 0; i < getCubes().size() && i < latestBlocks.size(); i++) {
BlockCube blockCube = getCubes().get(i);
BlockSummary latestBlock = latestBlocks.get(i);
blockCube.setConfirmed(true);
blockCube.setHeight(latestBlock.getHeight());
blockCube.setTimestamp(latestBlock.getTimestamp().getTime());
blockCube.setWeight(latestBlock.getWeight().orElse(0));
blockCube.setMedianFee(latestBlock.getMedianFee().orElse(0.0d));
blockCube.setTxCount(latestBlock.getTransactionCount().orElse(0));
}
add(new BlockCube(null, currentFeeRate, null, null, 0L, false));
}
public void add(BlockCube newCube) {
newCube.setTranslateX(-CUBE_SPACING);
getChildren().add(newCube);
getCubes().getFirst().setConfirmed(true);
getCubes().addFirst(newCube);
animateCubes();
if(getCubes().size() > 4) {
BlockCube lastCube = getCubes().getLast();
getChildren().remove(lastCube);
getCubes().remove(lastCube);
}
}
public void updateFeeRate(Double currentFeeRate) {
if(!getCubes().isEmpty()) {
BlockCube firstCube = getCubes().getFirst();
firstCube.setMedianFee(currentFeeRate);
}
}
private void animateCubes() {
for(int i = 0; i < getCubes().size(); i++) {
BlockCube cube = getCubes().get(i);
TranslateTransition transition = new TranslateTransition(Duration.millis(ANIMATION_DURATION_MILLIS), cube);
transition.setToX(i * CUBE_SPACING);
transition.play();
}
}
public List<BlockCube> getCubes() {
return cubesProperty.get();
}
public ObjectProperty<List<BlockCube>> cubesProperty() {
return cubesProperty;
}
public void setCubes(List<BlockCube> cubes) {
this.cubesProperty.set(cubes);
}
}

View file

@ -8,15 +8,13 @@ import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.*;
import com.sparrowwallet.sparrow.event.ExcludeUtxoEvent;
import com.sparrowwallet.sparrow.event.ReplaceChangeAddressEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.wallet.OptimizationStrategy;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
@ -229,6 +227,13 @@ public class TransactionDiagram extends GridPane {
getChildren().clear();
getChildren().addAll(inputsTypePane, inputsPane, inputsLinesPane, txPane, outputsLinesPane, outputsPane);
List<Payment> defaultPayments = getDefaultPayments();
if(!isFinal() && defaultPayments.size() > 1) {
Pane totalsPane = getTotalsPane(defaultPayments);
GridPane.setConstraints(totalsPane, 2, 0, 3, 1);
getChildren().add(totalsPane);
}
if(contextMenu == null) {
contextMenu = new ContextMenu();
MenuItem menuItem = new MenuItem("Save as Image...");
@ -616,6 +621,10 @@ public class TransactionDiagram extends GridPane {
}
}
private List<Payment> getDefaultPayments() {
return walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT).toList();
}
private Pane getOutputsLines(List<Payment> displayedPayments) {
VBox pane = new VBox();
Group group = new Group();
@ -839,6 +848,33 @@ public class TransactionDiagram extends GridPane {
return txPane;
}
private Pane getTotalsPane(List<Payment> defaultPayments) {
VBox totalsBox = new VBox();
totalsBox.setPadding(new Insets(0, 0, 15, 0));
totalsBox.setAlignment(Pos.CENTER);
long amount = defaultPayments.stream().mapToLong(Payment::getAmount).sum();
HBox coinLabelBox = new HBox();
coinLabelBox.setAlignment(Pos.CENTER);
CoinLabel totalCoinLabel = new CoinLabel();
totalCoinLabel.setValue(amount);
coinLabelBox.getChildren().addAll(totalCoinLabel, new Label(" in "), new Label(Long.toString(defaultPayments.size())), new Label(" payments"));
totalsBox.getChildren().addAll(createSpacer(), coinLabelBox);
CurrencyRate currencyRate = AppServices.getFiatCurrencyExchangeRate();
if(currencyRate != null && currencyRate.isAvailable() && Config.get().getExchangeSource() != ExchangeSource.NONE) {
HBox fiatLabelBox = new HBox();
fiatLabelBox.setAlignment(Pos.CENTER);
FiatLabel fiatLabel = new FiatLabel();
fiatLabel.set(currencyRate, amount);
fiatLabelBox.getChildren().add(fiatLabel);
totalsBox.getChildren().add(fiatLabelBox);
}
return totalsBox;
}
private void saveAsImage() {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();

View file

@ -17,6 +17,7 @@ import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import java.util.ArrayList;
@ -103,6 +104,7 @@ public class WalletSummaryDialog extends Dialog<Void> {
vBox.getChildren().add(table);
hBox.getChildren().add(vBox);
HBox.setHgrow(vBox, Priority.ALWAYS);
Wallet balanceWallet;
if(allOpenWallets) {

View file

@ -4,7 +4,6 @@ import com.github.arteam.simplejsonrpc.client.JsonRpcClient;
import com.github.arteam.simplejsonrpc.client.Transport;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcBatchException;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
import com.github.arteam.simplejsonrpc.core.domain.ErrorMessage;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
@ -162,12 +161,12 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
try {
return batchRequest.execute();
} catch(JsonRpcBatchException e) {
log.warn("Failed to unsubscribe from script hashes: " + e.getErrors().keySet(), e);
log.info("Failed to unsubscribe from script hashes: " + e.getErrors().keySet(), e);
Map<String, Boolean> unsubscribedScriptHashes = scriptHashes.stream().collect(Collectors.toMap(s -> s, _ -> true));
unsubscribedScriptHashes.keySet().removeIf(scriptHash -> e.getErrors().containsKey(scriptHash));
return unsubscribedScriptHashes;
} catch(Exception e) {
log.warn("Failed to unsubscribe from script hashes: " + scriptHashes, e);
log.info("Failed to unsubscribe from script hashes: " + scriptHashes, e);
return Collections.emptyMap();
}
}
@ -191,6 +190,24 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
}
}
@Override
@SuppressWarnings("unchecked")
public Map<Integer, BlockStats> getBlockStats(Transport transport, Set<Integer> blockHeights) {
PagedBatchRequestBuilder<Integer, BlockStats> batchRequest = PagedBatchRequestBuilder.create(transport, idCounter).keysType(Integer.class).returnType(BlockStats.class);
for(Integer height : blockHeights) {
batchRequest.add(height, "blockchain.block.stats", height);
}
try {
return batchRequest.execute();
} catch(JsonRpcBatchException e) {
return (Map<Integer, BlockStats>)e.getSuccesses();
} catch(Exception e) {
throw new ElectrumServerRpcException("Failed to retrieve block stats for block heights: " + blockHeights, e);
}
}
@Override
@SuppressWarnings("unchecked")
public Map<String, String> getTransactions(Transport transport, Wallet wallet, Set<String> txids) {

View file

@ -0,0 +1,14 @@
package com.sparrowwallet.sparrow.net;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.sparrowwallet.sparrow.BlockSummary;
import java.util.Date;
@JsonIgnoreProperties(ignoreUnknown = true)
public record BlockStats(int height, String blockhash, double[] feerate_percentiles, int total_weight, int txs, long time) {
public BlockSummary toBlockSummary() {
Double medianFee = feerate_percentiles != null && feerate_percentiles.length > 0 ? feerate_percentiles[feerate_percentiles.length / 2] : null;
return new BlockSummary(height, new Date(time * 1000), medianFee, txs, total_weight);
}
}

View file

@ -82,6 +82,8 @@ public class ElectrumServer {
private static Server coreElectrumServer;
private static ServerCapability serverCapability;
private static final Pattern RPC_WALLET_LOADING_PATTERN = Pattern.compile(".*\"(Wallet loading failed[:.][^\"]*)\".*");
private static synchronized CloseableTransport getTransport() throws ServerException {
@ -981,6 +983,21 @@ public class ElectrumServer {
}
public Map<Integer, BlockSummary> getBlockSummaryMap(Integer height, BlockHeader blockHeader) throws ServerException {
if(serverCapability.supportsBlockStats()) {
if(height == null) {
Integer current = AppServices.getCurrentBlockHeight();
if(current == null) {
return Collections.emptyMap();
}
Set<Integer> heights = IntStream.range(current - 1, current + 1).boxed().collect(Collectors.toSet());
Map<Integer, BlockStats> blockStats = electrumServerRpc.getBlockStats(getTransport(), heights);
return blockStats.keySet().stream().collect(Collectors.toMap(java.util.function.Function.identity(), v -> blockStats.get(v).toBlockSummary()));
} else {
Map<Integer, BlockStats> blockStats = electrumServerRpc.getBlockStats(getTransport(), Set.of(height));
return blockStats.keySet().stream().collect(Collectors.toMap(java.util.function.Function.identity(), v -> blockStats.get(v).toBlockSummary()));
}
}
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
@ -1010,7 +1027,7 @@ public class ElectrumServer {
if(current == null) {
return Collections.emptyMap();
}
Set<BlockTransactionHash> references = IntStream.range(current - 4, current + 1)
Set<BlockTransactionHash> references = IntStream.range(current - 1, current + 1)
.mapToObj(i -> new BlockTransaction(null, i, null, null, null)).collect(Collectors.toSet());
Map<Integer, BlockHeader> blockHeaders = getBlockHeaders(null, references);
return blockHeaders.keySet().stream()
@ -1219,7 +1236,7 @@ public class ElectrumServer {
}
if(server.startsWith("cormorant")) {
return new ServerCapability(true);
return new ServerCapability(true, false, true);
}
if(server.startsWith("electrs/")) {
@ -1405,7 +1422,7 @@ public class ElectrumServer {
firstCall = false;
//If electrumx is detected, we can upgrade to batched RPC. Electrs/EPS do not support batching.
ServerCapability serverCapability = getServerCapability(serverVersion);
serverCapability = getServerCapability(serverVersion);
if(serverCapability.supportsBatching()) {
log.debug("Upgrading to batched JSON-RPC");
electrumServerRpc = new BatchedElectrumServerRpc(electrumServerRpc.getIdCounterValue(), serverCapability.getMaxTargetBlocks());
@ -1945,13 +1962,18 @@ public class ElectrumServer {
if(startHeight == 0 || totalBlocks > 1 || startHeight > maxHeight + 1) {
if(isBlockstorm(totalBlocks)) {
for(int height = maxHeight + 1; height < endHeight; height++) {
blockSummaryMap.put(height, new BlockSummary(height, new Date()));
int start = Math.max(maxHeight + 1, endHeight - 15);
for(int height = start; height <= endHeight; height++) {
blockSummaryMap.put(height, new BlockSummary(height, new Date(), 1.0d, 0, 0));
}
} else {
blockSummaryMap.putAll(electrumServer.getRecentBlockSummaryMap());
}
} else {
}
List<NewBlockEvent> events = new ArrayList<>(newBlockEvents);
events.removeIf(event -> blockSummaryMap.containsKey(event.getHeight()));
if(!events.isEmpty()) {
for(NewBlockEvent event : newBlockEvents) {
blockSummaryMap.putAll(electrumServer.getBlockSummaryMap(event.getHeight(), event.getBlockHeader()));
}

View file

@ -26,6 +26,8 @@ public interface ElectrumServerRpc {
Map<Integer, String> getBlockHeaders(Transport transport, Wallet wallet, Set<Integer> blockHeights);
Map<Integer, BlockStats> getBlockStats(Transport transport, Set<Integer> blockHeights);
Map<String, String> getTransactions(Transport transport, Wallet wallet, Set<String> txids);
Map<String, VerboseTransaction> getVerboseTransactions(Transport transport, Set<String> txids, String scriptHash);

View file

@ -285,7 +285,7 @@ public enum FeeRatesSource {
}
}
protected record MempoolBlockSummary(String id, Integer height, Long timestamp, Integer tx_count, MempoolBlockSummaryExtras extras) {
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();
}
@ -294,7 +294,7 @@ public enum FeeRatesSource {
if(height == null || timestamp == null) {
throw new IllegalStateException("Height = " + height + ", timestamp = " + timestamp + ": both must be specified");
}
return new BlockSummary(height, new Date(timestamp * 1000), getMedianFee(), tx_count);
return new BlockSummary(height, new Date(timestamp * 1000), getMedianFee(), tx_count, weight);
}
}

View file

@ -4,7 +4,7 @@ import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
class ScriptHashTx {
public class ScriptHashTx {
public static final ScriptHashTx ERROR_TX = new ScriptHashTx() {
@Override
public BlockTransactionHash getBlockchainTransactionHash() {

View file

@ -5,15 +5,29 @@ import com.sparrowwallet.sparrow.AppServices;
public class ServerCapability {
private final boolean supportsBatching;
private final int maxTargetBlocks;
private final boolean supportsRecentMempool;
private final boolean supportsBlockStats;
public ServerCapability(boolean supportsBatching) {
this.supportsBatching = supportsBatching;
this.maxTargetBlocks = AppServices.TARGET_BLOCKS_RANGE.getLast();
this(supportsBatching, AppServices.TARGET_BLOCKS_RANGE.getLast());
}
public ServerCapability(boolean supportsBatching, int maxTargetBlocks) {
this.supportsBatching = supportsBatching;
this.maxTargetBlocks = maxTargetBlocks;
this.supportsRecentMempool = false;
this.supportsBlockStats = false;
}
public ServerCapability(boolean supportsBatching, boolean supportsRecentMempool, boolean supportsBlockStats) {
this(supportsBatching, AppServices.TARGET_BLOCKS_RANGE.getLast(), supportsRecentMempool, supportsBlockStats);
}
public ServerCapability(boolean supportsBatching, int maxTargetBlocks, boolean supportsRecentMempool, boolean supportsBlockStats) {
this.supportsBatching = supportsBatching;
this.maxTargetBlocks = maxTargetBlocks;
this.supportsRecentMempool = supportsRecentMempool;
this.supportsBlockStats = supportsBlockStats;
}
public boolean supportsBatching() {
@ -23,4 +37,12 @@ public class ServerCapability {
public int getMaxTargetBlocks() {
return maxTargetBlocks;
}
public boolean supportsRecentMempool() {
return supportsRecentMempool;
}
public boolean supportsBlockStats() {
return supportsBlockStats;
}
}

View file

@ -177,6 +177,29 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
return result;
}
@Override
public Map<Integer, BlockStats> getBlockStats(Transport transport, Set<Integer> blockHeights) {
JsonRpcClient client = new JsonRpcClient(transport);
Map<Integer, BlockStats> result = new LinkedHashMap<>();
for(Integer blockHeight : blockHeights) {
try {
BlockStats blockStats = new RetryLogic<BlockStats>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
client.createRequest().returnAs(BlockStats.class).method("blockchain.block.stats").id(idCounter.incrementAndGet()).params(blockHeight).execute());
result.put(blockHeight, blockStats);
} catch(ServerException e) {
//If there is an error with the server connection, don't keep trying - this may take too long given many blocks
throw new ElectrumServerRpcException("Failed to retrieve block stats for block height: " + blockHeight, e);
} catch(JsonRpcException e) {
log.warn("Failed to retrieve block stats for block height: " + blockHeight + (e.getErrorMessage() != null ? " (" + e.getErrorMessage().getMessage() + ")" : ""));
} catch(Exception e) {
log.warn("Failed to retrieve block stats for block height: " + blockHeight + " (" + e.getMessage() + ")");
}
}
return result;
}
@Override
public Map<String, String> getTransactions(Transport transport, Wallet wallet, Set<String> txids) {
JsonRpcClient client = new JsonRpcClient(transport);

View file

@ -10,7 +10,7 @@ import com.sparrowwallet.sparrow.AppServices;
import java.util.Date;
@JsonIgnoreProperties(ignoreUnknown = true)
class VerboseTransaction {
public class VerboseTransaction {
public String blockhash;
public long blocktime;
public int confirmations;

View file

@ -7,6 +7,7 @@ import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcOptional;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcParam;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcService;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.sparrow.net.BlockStats;
import java.util.List;
import java.util.Map;
@ -48,6 +49,9 @@ public interface BitcoindClientService {
@JsonRpcMethod("getblockheader")
VerboseBlockHeader getBlockHeader(@JsonRpcParam("blockhash") String blockhash);
@JsonRpcMethod("getblockstats")
BlockStats getBlockStats(@JsonRpcParam("blockhash") int hash_or_height);
@JsonRpcMethod("getrawtransaction")
Object getRawTransaction(@JsonRpcParam("txid") String txid, @JsonRpcParam("verbose") boolean verbose);

View file

@ -10,6 +10,7 @@ import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.SparrowWallet;
import com.sparrowwallet.sparrow.event.MempoolEntriesInitializedEvent;
import com.sparrowwallet.drongo.Version;
import com.sparrowwallet.sparrow.net.BlockStats;
import com.sparrowwallet.sparrow.net.cormorant.Cormorant;
import com.sparrowwallet.sparrow.net.cormorant.bitcoind.*;
import com.sparrowwallet.sparrow.net.cormorant.index.TxEntry;
@ -157,6 +158,17 @@ public class ElectrumServerService {
}
}
@JsonRpcMethod("blockchain.block.stats")
public BlockStats getBlockStats(@JsonRpcParam("height") int height) throws BitcoindIOException, BlockNotFoundException {
try {
return bitcoindClient.getBitcoindService().getBlockStats(height);
} catch(JsonRpcException e) {
throw new BlockNotFoundException(e.getErrorMessage());
} catch(IllegalStateException e) {
throw new BitcoindIOException(e);
}
}
@JsonRpcMethod("blockchain.transaction.get")
@SuppressWarnings("unchecked")
public Object getTransaction(@JsonRpcParam("tx_hash") String tx_hash, @JsonRpcParam("verbose") @JsonRpcOptional boolean verbose) throws BitcoindIOException, TransactionNotFoundException {

View file

@ -1,7 +1,7 @@
package com.sparrowwallet.sparrow.wallet;
public enum FeeRatesSelection {
BLOCK_TARGET("Block Target"), MEMPOOL_SIZE("Mempool Size");
BLOCK_TARGET("Block Target"), MEMPOOL_SIZE("Mempool Size"), RECENT_BLOCKS("Recent Blocks");
private final String name;

View file

@ -13,10 +13,7 @@ import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.CurrencyRate;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.*;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
@ -28,6 +25,7 @@ import com.sparrowwallet.sparrow.paynym.PayNymService;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
@ -78,6 +76,9 @@ public class SendController extends WalletFormController implements Initializabl
@FXML
private ToggleButton mempoolSizeToggle;
@FXML
private ToggleButton recentBlocksToggle;
@FXML
private Field targetBlocksField;
@ -117,6 +118,9 @@ public class SendController extends WalletFormController implements Initializabl
@FXML
private MempoolSizeFeeRatesChart mempoolSizeFeeRatesChart;
@FXML
private RecentBlocksView recentBlocksView;
@FXML
private TransactionDiagram transactionDiagram;
@ -162,6 +166,8 @@ public class SendController extends WalletFormController implements Initializabl
private final ObjectProperty<BlockTransaction> replacedTransactionProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<FeeRatesSelection> feeRatesSelectionProperty = new SimpleObjectProperty<>(null);
private final List<byte[]> opReturnsList = new ArrayList<>();
private final Set<WalletNode> excludedChangeNodes = new HashSet<>();
@ -299,6 +305,7 @@ public class SendController extends WalletFormController implements Initializabl
feeRange.valueProperty().addListener(feeRangeListener);
blockTargetFeeRatesChart.managedProperty().bind(blockTargetFeeRatesChart.visibleProperty());
blockTargetFeeRatesChart.visibleProperty().bind(Bindings.equal(feeRatesSelectionProperty, FeeRatesSelection.BLOCK_TARGET));
blockTargetFeeRatesChart.initialize();
Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
if(targetBlocksFeeRates != null) {
@ -308,20 +315,41 @@ public class SendController extends WalletFormController implements Initializabl
}
mempoolSizeFeeRatesChart.managedProperty().bind(mempoolSizeFeeRatesChart.visibleProperty());
mempoolSizeFeeRatesChart.visibleProperty().bind(blockTargetFeeRatesChart.visibleProperty().not());
mempoolSizeFeeRatesChart.visibleProperty().bind(Bindings.equal(feeRatesSelectionProperty, FeeRatesSelection.MEMPOOL_SIZE));
mempoolSizeFeeRatesChart.initialize();
Map<Date, Set<MempoolRateSize>> mempoolHistogram = getMempoolHistogram();
if(mempoolHistogram != null) {
mempoolSizeFeeRatesChart.update(mempoolHistogram);
}
recentBlocksView.managedProperty().bind(recentBlocksView.visibleProperty());
recentBlocksView.visibleProperty().bind(Bindings.equal(feeRatesSelectionProperty, FeeRatesSelection.RECENT_BLOCKS));
List<BlockSummary> blockSummaries = AppServices.getBlockSummaries().values().stream().sorted().toList();
if(!blockSummaries.isEmpty()) {
recentBlocksView.update(blockSummaries, AppServices.getDefaultFeeRate());
}
feeRatesSelectionProperty.addListener((_, oldValue, newValue) -> {
boolean isBlockTargetSelection = (newValue == FeeRatesSelection.BLOCK_TARGET);
boolean wasBlockTargetSelection = (oldValue == FeeRatesSelection.BLOCK_TARGET || oldValue == null);
targetBlocksField.setVisible(isBlockTargetSelection);
if(isBlockTargetSelection) {
setTargetBlocks(getTargetBlocks(getFeeRangeRate()));
updateTransaction();
} else if(wasBlockTargetSelection) {
setFeeRangeRate(getTargetBlocksFeeRates().get(getTargetBlocks()));
updateTransaction();
}
});
FeeRatesSelection feeRatesSelection = Config.get().getFeeRatesSelection();
feeRatesSelection = (feeRatesSelection == null ? FeeRatesSelection.MEMPOOL_SIZE : feeRatesSelection);
feeRatesSelection = (feeRatesSelection == null ? FeeRatesSelection.RECENT_BLOCKS : feeRatesSelection);
cpfpFeeRate.managedProperty().bind(cpfpFeeRate.visibleProperty());
cpfpFeeRate.setVisible(false);
setDefaultFeeRate();
updateFeeRateSelection(feeRatesSelection);
feeSelectionToggleGroup.selectToggle(feeRatesSelection == FeeRatesSelection.BLOCK_TARGET ? targetBlocksToggle : mempoolSizeToggle);
feeRatesSelectionProperty.set(feeRatesSelection);
feeSelectionToggleGroup.selectToggle(feeRatesSelection == FeeRatesSelection.BLOCK_TARGET ? targetBlocksToggle :
(feeRatesSelection == FeeRatesSelection.MEMPOOL_SIZE ? mempoolSizeToggle : recentBlocksToggle));
feeSelectionToggleGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null) {
FeeRatesSelection newFeeRatesSelection = (FeeRatesSelection)newValue.getUserData();
@ -723,24 +751,13 @@ public class SendController extends WalletFormController implements Initializabl
return List.of(spentTxoFilter, new FrozenTxoFilter(), new CoinbaseTxoFilter(getWalletForm().getWallet()));
}
private void updateFeeRateSelection(FeeRatesSelection feeRatesSelection) {
boolean blockTargetSelection = (feeRatesSelection == FeeRatesSelection.BLOCK_TARGET);
targetBlocksField.setVisible(blockTargetSelection);
blockTargetFeeRatesChart.setVisible(blockTargetSelection);
if(blockTargetSelection) {
setTargetBlocks(getTargetBlocks(getFeeRangeRate()));
} else {
setFeeRangeRate(getTargetBlocksFeeRates().get(getTargetBlocks()));
}
updateTransaction();
}
private void setDefaultFeeRate() {
int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1);
int index = TARGET_BLOCKS_RANGE.indexOf(defaultTarget);
Double defaultRate = getTargetBlocksFeeRates().get(defaultTarget);
targetBlocks.setValue(index);
blockTargetFeeRatesChart.select(defaultTarget);
recentBlocksView.updateFeeRate(defaultRate);
setFeeRangeRate(defaultRate);
setFeeRate(getFeeRangeRate());
if(Network.get().equals(Network.MAINNET) && defaultRate == getFallbackFeeRate()) {
@ -964,7 +981,7 @@ public class SendController extends WalletFormController implements Initializabl
}
private void setFiatFeeAmount(CurrencyRate currencyRate, Long amount) {
if(amount != null && currencyRate != null && currencyRate.isAvailable()) {
if(amount != null && currencyRate != null && currencyRate.isAvailable() && Config.get().getExchangeSource() != ExchangeSource.NONE) {
fiatFeeAmount.set(currencyRate, amount);
}
}
@ -1411,10 +1428,15 @@ public class SendController extends WalletFormController implements Initializabl
@Subscribe
public void feeRateSelectionChanged(FeeRatesSelectionChangedEvent event) {
if(event.getWallet() == getWalletForm().getWallet()) {
updateFeeRateSelection(event.getFeeRateSelection());
feeRatesSelectionProperty.set(event.getFeeRateSelection());
}
}
@Subscribe
public void blockSummary(BlockSummaryEvent event) {
Platform.runLater(() -> recentBlocksView.update(AppServices.getBlockSummaries().values().stream().sorted().toList(), AppServices.getDefaultFeeRate()));
}
@Subscribe
public void spendUtxos(SpendUtxoEvent event) {
if((event.getUtxos() == null || !event.getUtxos().isEmpty()) && event.getWallet().equals(getWalletForm().getWallet())) {
@ -1497,12 +1519,18 @@ public class SendController extends WalletFormController implements Initializabl
if(event.getExchangeSource() == ExchangeSource.NONE) {
fiatFeeAmount.setCurrency(null);
fiatFeeAmount.setBtcRate(0.0);
if(paymentTabs.getTabs().size() > 1) {
updateTransaction();
}
}
}
@Subscribe
public void exchangeRatesUpdated(ExchangeRatesUpdatedEvent event) {
setFiatFeeAmount(event.getCurrencyRate(), getFeeValueSats());
if(paymentTabs.getTabs().size() > 1) {
updateTransaction();
}
}
@Subscribe
@ -1597,7 +1625,7 @@ public class SendController extends WalletFormController implements Initializabl
} else if(payjoinPresent) {
addLabel("Cannot fake coinjoin due to payjoin", getInfoGlyph());
} else {
if(utxoSelectorProperty().get() != null) {
if(utxoSelectorProperty().get() != null && !(utxoSelectorProperty().get() instanceof MaxUtxoSelector)) {
addLabel("Cannot fake coinjoin due to coin control", getInfoGlyph());
} else {
addLabel("Cannot fake coinjoin due to insufficient funds", getInfoGlyph());

View file

@ -343,4 +343,32 @@ HorizontalHeaderColumn > TableColumnHeader.column-header.table-column{
#grid .spreadsheet-cell.selection {
-fx-text-fill: -fx-base;
}
.root .block-height {
-fx-fill: derive(lightgray, -20%);
}
.root .blocks-separator {
-fx-stroke: derive(lightgray, -20%);
}
.root .block-confirmed .block-unused {
-fx-fill: #5a5a65;
}
.root .block-confirmed .block-top {
-fx-fill: #474c5e;
}
.root .block-confirmed .block-left {
-fx-fill: #3c4055;
}
.root .block-unconfirmed .block-top {
-fx-fill: #635b57;
}
.root .block-unconfirmed .block-left {
-fx-fill: #4e4846;
}

View file

@ -132,6 +132,66 @@
-fx-text-fill: -fx-accent;
}
.block-used {
-fx-fill: linear-gradient(from 0% 0% to 0% 100%, -top 0%, -bottom 100%);
}
.block-mainnet {
-top: rgb(155, 79, 174);
-bottom: rgb(77, 96, 154);
}
.block-testnet {
-top: rgb(30, 136, 229);
-bottom: rgba(57, 73, 171);
}
.block-signet {
-top: rgb(136, 14, 79);
-bottom: rgb(64, 7, 39);
}
.block-regtest {
-top: rgb(0, 137, 123);
-bottom: rgb(0, 96, 100);
}
.block-confirmed .block-unused {
-fx-fill: #8c8c98;
}
.block-confirmed .block-top {
-fx-fill: #696d7c;
}
.block-confirmed .block-left {
-fx-fill: #616475;
}
.block-unconfirmed .block-top {
-fx-fill: #807976;
}
.block-unconfirmed .block-left {
-fx-fill: #6f6a69;
}
.block-unconfirmed .block-unused {
-fx-opacity: 70%;
}
.block-height {
-fx-fill: derive(-fx-text-background-color, 40%);
}
.block-text {
-fx-fill: #ffffff;
}
.blocks-separator {
-fx-stroke: -fx-text-background-color;
}
.vsizeChart {
VSIZE1-2_COLOR: rgb(216, 27, 96);
VSIZE2-3_COLOR: rgb(142, 36, 170);
@ -164,3 +224,45 @@
VSIZE600-700_COLOR: rgb(51, 105, 30);
VSIZE700-800_COLOR: rgb(130, 119, 23);
}
.block-cube {
VSIZE1-2_COLOR: #557d00;
VSIZE2-3_COLOR: #5d7d01;
VSIZE3-4_COLOR: #637d02;
VSIZE4-5_COLOR: #6d7d04;
VSIZE5-6_COLOR: #757d05;
VSIZE6-8_COLOR: #7d7d06;
VSIZE8-10_COLOR: #867d08;
VSIZE10-12_COLOR: #8c7d09;
VSIZE12-15_COLOR: #957d0b;
VSIZE15-20_COLOR: #9b7d0c;
VSIZE20-30_COLOR: #a67d0e;
VSIZE30-40_COLOR: #aa7d0f;
VSIZE40-50_COLOR: #b27d10;
VSIZE50-60_COLOR: #bb7d11;
VSIZE60-70_COLOR: #bf7d12;
VSIZE70-80_COLOR: #bf7815;
VSIZE80-90_COLOR: #bf7319;
VSIZE90-100_COLOR: #be6c1e;
VSIZE100-125_COLOR: #be6820;
VSIZE125-150_COLOR: #bd6125;
VSIZE150-175_COLOR: #bd5c28;
VSIZE175-200_COLOR: #bc552d;
VSIZE200-250_COLOR: #bc4f30;
VSIZE250-300_COLOR: #bc4a34;
VSIZE300-350_COLOR: #bb4339;
VSIZE350-400_COLOR: #bb3d3c;
VSIZE400-500_COLOR: #bb373f;
VSIZE500-600_COLOR: #ba3243;
VSIZE600-700_COLOR: #b92b48;
VSIZE700-800_COLOR: #b9254b;
VSIZE800-900_COLOR: #b8214d;
VSIZE900-1000_COLOR: #b71d4f;
VSIZE1000-1200_COLOR: #b61951;
VSIZE1200-1400_COLOR: #b41453;
VSIZE1400-1600_COLOR: #b30e55;
VSIZE1600-1800_COLOR: #b10857;
VSIZE1800-2000_COLOR: #b00259;
VSIZE2000-2200_COLOR: #ae005b;
}

View file

@ -26,6 +26,7 @@
<?import com.sparrowwallet.sparrow.wallet.OptimizationStrategy?>
<?import com.sparrowwallet.sparrow.control.HelpLabel?>
<?import com.sparrowwallet.sparrow.control.FeeRangeSlider?>
<?import com.sparrowwallet.sparrow.control.RecentBlocksView?>
<BorderPane stylesheets="@send.css, @wallet.css, @../script.css, @../general.css" styleClass="wallet-pane" xmlns="http://javafx.com/javafx/10.0.2-internal" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.sparrowwallet.sparrow.wallet.SendController">
<center>
@ -80,6 +81,14 @@
<FeeRatesSelection fx:constant="MEMPOOL_SIZE"/>
</userData>
</ToggleButton>
<ToggleButton fx:id="recentBlocksToggle" text="Recent Blocks" toggleGroup="$feeSelectionToggleGroup">
<tooltip>
<Tooltip text="Show recent and upcoming blocks"/>
</tooltip>
<userData>
<FeeRatesSelection fx:constant="RECENT_BLOCKS"/>
</userData>
</ToggleButton>
</buttons>
</SegmentedButton>
</HBox>
@ -140,6 +149,7 @@
<NumberAxis side="LEFT" />
</yAxis>
</MempoolSizeFeeRatesChart>
<RecentBlocksView fx:id="recentBlocksView" styleClass="feeRatesChart" AnchorPane.topAnchor="10" AnchorPane.leftAnchor="74" translateY="30" minHeight="135"/>
</AnchorPane>
</GridPane>
<AnchorPane>