Compare commits

...
45 Commits
Author SHA1 Message Date
Craig Raw 67e1573329 improve reset instructions for trezor passphrase change 2026-03-09 13:44:32 +02:00
Craig Raw 23a9db5fc6 support loading v3 transactions in the transaction editor 2026-03-06 14:35:42 +02:00
Craig Raw e9108b85cd fix possible db corruption on password removal by reordering database file and row updates 2026-03-06 11:30:41 +02:00
Craig Raw 3766585474 fix psbtv2 and dst related transaction editor issues around tx version and locktime 2026-03-06 09:15:57 +02:00
Craig Raw 0e7aa34d07 fix non default split menu button text color on dark theme 2026-03-05 13:46:32 +02:00
Craig Raw 537c2ffef0 support qr and file methods for signing messages via psbt when bip322 is selected 2026-03-05 13:37:40 +02:00
Craig Raw 8b4f388eb7 add tests for descriptor import and export, and handle multiline descriptor 2026-03-05 09:39:33 +02:00
Craig Raw 6a07adfea5 upgrade jackson-databind to v2.21.1 2026-03-04 15:14:59 +02:00
Craig Raw eeade6a173 upgrade gson to v2.13.2 2026-03-04 14:36:29 +02:00
Craig Raw 4c2b8055ec upgrade protobuf to v4.34.0 2026-03-04 14:15:28 +02:00
Craig Raw 04d210ed4b upgrade bcprov to v1.82 and pgpainless to v1.7.7 2026-03-04 14:07:01 +02:00
Craig Raw 5b737be76d upgrade controlsfx to v11.2.3 2026-03-04 13:50:06 +02:00
Craig Raw 908fb80d2a upgrade kotlinx-coroutines-javafx to v1.10.2 2026-03-04 13:37:53 +02:00
Craig Raw eda7087bdd upgrade junit to v5.14.1 2026-03-04 13:05:19 +02:00
Craig Raw a5d61f1a9f upgrade jetbrains annotations to v26.0.2 2026-03-04 13:02:21 +02:00
Craig Raw 2621bb6504 upgrade thumbnailator to v0.4.21 2026-03-04 12:59:33 +02:00
Craig Raw 7dfc432bd2 remove streamsupport dependency 2026-03-04 12:54:16 +02:00
Craig Raw df5b5146ba upgrade jcommander to v3.0 2026-03-04 12:46:10 +02:00
Craig Raw 452584d87d upgrade zxing to v3.5.4 2026-03-04 12:37:24 +02:00
Craig Raw 5c823533d3 upgrade caffeine to v3.2.3 2026-03-04 12:34:52 +02:00
Craig Raw 6561971d4f upgrade dnsjava to v3.6.4 2026-03-04 12:32:05 +02:00
Craig Raw 8ebf8dc041 upgrade rxjava2 to v2.2.21 2026-03-04 12:21:29 +02:00
Craig Raw 419e8e1374 upgrade argon2-jvm to v2.12 2026-03-04 12:14:01 +02:00
Craig Raw 684965cf89 upgrade commons-codec to v1.21.0 2026-03-04 12:09:43 +02:00
Craig Raw 3498b5bab1 upgrade commons-compress to v1.28.0 2026-03-04 12:06:46 +02:00
Craig Raw 74635b1d1f upgrade commons-lang3 to v3.20.0 2026-03-04 12:02:25 +02:00
Craig Raw 229c6430ed upgrade jdbi to v3.51.0 2026-03-04 12:00:14 +02:00
Craig Raw c74abfeeb7 upgrade hikaricp to v7.0.2 2026-03-04 11:55:59 +02:00
Craig Raw c78bc7b1cf upgrade openpdf to v1.3.43 2026-03-04 11:49:54 +02:00
Craig Raw 87cd96a627 upgrade jna to v5.18.1 2026-03-04 11:36:08 +02:00
Craig Raw 7ae04a2564 upgrade slf4j-api and jul-to-slf4j to v2.0.17 2026-03-04 11:30:59 +02:00
Craig Raw d4c0df0df9 detect algorithm on provided certificate when checking for ca cert 2026-02-28 12:51:50 +02:00
Craig Raw dc88dc4ede add hostname verification for ca-validated tls connections 2026-02-28 12:38:58 +02:00
Craig Raw a478edfad7 use ca validation or tofu pinning for public servers depending on certificate type 2026-02-27 13:30:46 +02:00
Craig Raw 46d444615c implement tofu certificate pinning for tls bitcoin core connections 2026-02-19 15:35:03 +02:00
Craig Raw 78fe55787b escape backticks in schema name for drop schema statement 2026-02-18 16:16:37 +02:00
Craig Raw cd0be365de bind cormorant server to localhost only 2026-02-18 15:45:51 +02:00
Craig Raw b9d62c0b7b bump to v2.4.2 2026-02-17 10:41:24 +02:00
Craig Raw 53d1f196e4 suggest configuring a custom wallets directory when opening a wallet from a non-default location 2026-02-17 09:07:09 +02:00
Craig Raw 64b8da14a6 recognise renamed linux packages for file verify drag and drop 2026-02-16 12:57:28 +02:00
ottoschandGitHub 529cc3d6e7 handle import of samourai wallet backup file with extraneous appended data 2026-02-12 09:20:40 +02:00
Craig Raw e13fe897e1 add keepkey passphrase support 2026-02-12 09:17:02 +02:00
Craig Raw dac1a61601 check wallet file names on open 2026-02-12 09:12:13 +02:00
Craig Raw 227c7ae637 avoid triggering repeated layout passes in address chunking skins 2026-02-11 14:34:21 +02:00
Craig Raw e11675b9a8 bump to v2.4.1 2026-02-10 09:59:35 +02:00
39 changed files with 621 additions and 184 deletions
+19 -30
View File
@@ -20,7 +20,7 @@ if(System.getProperty("os.arch") == "aarch64") {
def headless = "true".equals(System.getProperty("java.awt.headless"))
group = 'com.sparrowwallet'
version = '2.4.0'
version = '2.4.2'
repositories {
mavenCentral()
@@ -45,24 +45,24 @@ dependencies {
implementation(project(':drongo'))
implementation(project(':lark'))
implementation('com.google.guava:guava:33.5.0-jre')
implementation('com.google.code.gson:gson:2.9.1')
implementation('com.google.code.gson:gson:2.13.2')
implementation('com.h2database:h2:2.1.214')
implementation('com.zaxxer:HikariCP:4.0.3') {
implementation('com.zaxxer:HikariCP:7.0.2') {
exclude group: 'org.slf4j'
}
implementation('org.jdbi:jdbi3-core:3.49.5') {
implementation('org.jdbi:jdbi3-core:3.51.0') {
exclude group: 'org.slf4j'
}
implementation('org.jdbi:jdbi3-sqlobject:3.49.5') {
implementation('org.jdbi:jdbi3-sqlobject:3.51.0') {
exclude group: 'org.slf4j'
}
implementation('org.flywaydb:flyway-core:9.22.3')
implementation('org.fxmisc.richtext:richtextfx:0.11.7')
implementation('no.tornado:tornadofx-controls:1.0.4')
implementation('com.google.zxing:javase:3.4.0') {
implementation('com.google.zxing:javase:3.5.4') {
exclude group: 'com.beust', module: 'jcommander'
}
implementation('org.jcommander:jcommander:2.0')
implementation('org.jcommander:jcommander:3.0')
implementation('com.github.arteam:simple-json-rpc-core:1.3')
implementation('com.github.arteam:simple-json-rpc-client:1.3') {
exclude group: 'com.github.arteam', module: 'simple-json-rpc-core'
@@ -70,19 +70,19 @@ dependencies {
implementation('com.github.arteam:simple-json-rpc-server:1.3') {
exclude group: 'org.slf4j'
}
implementation('com.fasterxml.jackson.core:jackson-databind:2.17.2')
implementation('com.fasterxml.jackson.core:jackson-databind:2.21.1')
implementation('com.sparrowwallet:hummingbird:1.7.4')
implementation('co.nstant.in:cbor:0.9')
implementation('org.openpnp:openpnp-capture-java:0.0.30-1')
implementation("io.matthewnelson.kmp-tor:runtime:2.5.0")
implementation("io.matthewnelson.kmp-tor:resource-exec-tor-gpl:408.21.0")
implementation('org.jetbrains.kotlinx:kotlinx-coroutines-javafx:1.10.1') {
implementation('org.jetbrains.kotlinx:kotlinx-coroutines-javafx:1.10.2') {
exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-common'
}
implementation('de.jangassen:nsmenufx:3.1.0') {
exclude group: 'net.java.dev.jna', module: 'jna'
}
implementation('org.controlsfx:controlsfx:11.1.0' ) {
implementation('org.controlsfx:controlsfx:11.2.3' ) {
exclude group: 'org.openjfx', module: 'javafx-base'
exclude group: 'org.openjfx', module: 'javafx-graphics'
exclude group: 'org.openjfx', module: 'javafx-controls'
@@ -93,26 +93,25 @@ dependencies {
}
implementation('dev.bwt:bwt-jni:0.1.8')
implementation('net.sourceforge.javacsv:javacsv:2.0')
implementation ('org.slf4j:slf4j-api:2.0.12')
implementation('org.slf4j:jul-to-slf4j:2.0.12') {
implementation ('org.slf4j:slf4j-api:2.0.17')
implementation('org.slf4j:jul-to-slf4j:2.0.17') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet.bokmakierie:bokmakierie:1.0')
implementation('com.sparrowwallet:tern:1.0.6')
implementation('io.reactivex.rxjava2:rxjava:2.2.15')
implementation('io.reactivex.rxjava2:rxjava:2.2.21')
implementation('io.reactivex.rxjava2:rxjavafx:2.2.2')
implementation('org.apache.commons:commons-lang3:3.19.0')
implementation('org.apache.commons:commons-compress:1.27.1')
implementation('net.sourceforge.streamsupport:streamsupport:1.7.0')
implementation('com.github.librepdf:openpdf:1.3.30')
implementation('org.apache.commons:commons-lang3:3.20.0')
implementation('org.apache.commons:commons-compress:1.28.0')
implementation('com.github.librepdf:openpdf:1.3.43')
implementation('com.googlecode.lanterna:lanterna:3.1.3')
implementation('net.coobird:thumbnailator:0.4.18')
implementation('net.coobird:thumbnailator:0.4.21')
implementation('com.github.hervegirod:fxsvgimage:1.1')
implementation('com.sparrowwallet:toucan:0.9.0')
implementation('com.jcraft:jzlib:1.1.3')
implementation('io.github.doblon8:jzbar:0.3.0')
testImplementation('org.junit.jupiter:junit-jupiter-api:5.10.0')
testRuntimeOnly('org.junit.jupiter:junit-jupiter-engine:5.10.0')
testImplementation('org.junit.jupiter:junit-jupiter-api:5.14.1')
testRuntimeOnly('org.junit.jupiter:junit-jupiter-engine:5.14.1')
testRuntimeOnly('org.junit.platform:junit-platform-launcher')
}
@@ -462,16 +461,6 @@ extraJavaModuleInfo {
exports('co.nstant.in.cbor.model')
exports('co.nstant.in.cbor.builder')
}
module('net.sourceforge.streamsupport:streamsupport', 'net.sourceforge.streamsupport') {
requires('jdk.unsupported')
exports('java8.util')
exports('java8.util.function')
exports('java8.util.stream')
}
module('net.coobird:thumbnailator', 'net.coobird.thumbnailator') {
exports('net.coobird.thumbnailator')
requires('java.desktop')
}
module('org.jcommander:jcommander', 'org.jcommander') {
exports('com.beust.jcommander')
}
+20 -47
View File
@@ -12,63 +12,36 @@ Work on resolving both of these issues is ongoing.
### Install Java
Because Sparrow bundles a Java runtime in the release binaries, it is essential to have the same version of Java installed when creating the release.
For v1.6.6 to v1.9.1, this was Eclipse Temurin 18.0.1+10. For v2.0.0 and later, Eclipse Temurin 22.0.2+9 is used.
For v1.6.6 to v1.9.1, this was Eclipse Temurin 18.0.1+10. For v2.0.0 to v2.3.1, this was Eclipse Temurin 22.0.2+9. For v2.4.0 and later, Eclipse Temurin 25.0.2+10 is used.
Note: Do not install Java using a system package manager (e.g. apt, dnf, rpm).
Linux packages replace the JDK's bundled `cacerts` file with a symlink to the system CA certificates, which differ from those in the release tarballs and will produce a non-reproducible build.
#### Java from Adoptium github repo
It is available for all supported platforms from [Eclipse Temurin 22.0.2+9](https://github.com/adoptium/temurin22-binaries/releases/tag/jdk-22.0.2%2B9).
It is available for all supported platforms from [Eclipse Temurin 25.0.2+10](https://github.com/adoptium/temurin25-binaries/releases/tag/jdk-25.0.2%2B10).
For reference, the downloads are as follows:
- [Linux x64](https://github.com/adoptium/temurin22-binaries/releases/download/jdk-22.0.2%2B9/OpenJDK22U-jdk_x64_linux_hotspot_22.0.2_9.tar.gz)
- [Linux aarch64](https://github.com/adoptium/temurin22-binaries/releases/download/jdk-22.0.2%2B9/OpenJDK22U-jdk_aarch64_linux_hotspot_22.0.2_9.tar.gz)
- [MacOS x64](https://github.com/adoptium/temurin22-binaries/releases/download/jdk-22.0.2%2B9/OpenJDK22U-jdk_x64_mac_hotspot_22.0.2_9.tar.gz)
- [MacOS aarch64](https://github.com/adoptium/temurin22-binaries/releases/download/jdk-22.0.2%2B9/OpenJDK22U-jdk_aarch64_mac_hotspot_22.0.2_9.tar.gz)
- [Windows x64](https://github.com/adoptium/temurin22-binaries/releases/download/jdk-22.0.2%2B9/OpenJDK22U-jdk_x64_windows_hotspot_22.0.2_9.zip)
- [Linux x64](https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.2%2B10/OpenJDK25U-jdk_x64_linux_hotspot_25.0.2_10.tar.gz)
- [Linux aarch64](https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.2%2B10/OpenJDK25U-jdk_aarch64_linux_hotspot_25.0.2_10.tar.gz)
- [MacOS x64](https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.2%2B10/OpenJDK25U-jdk_x64_mac_hotspot_25.0.2_10.tar.gz)
- [MacOS aarch64](https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.2%2B10/OpenJDK25U-jdk_aarch64_mac_hotspot_25.0.2_10.tar.gz)
- [Windows x64](https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.2%2B10/OpenJDK25U-jdk_x64_windows_hotspot_25.0.2_10.zip)
#### Java from Adoptium deb repo
It is also possible to install via a package manager on *nix systems. For example, on Debian/Ubuntu systems:
- Install dependencies:
```sh
sudo apt-get install -y wget curl apt-transport-https gnupg
On Linux, extract the tarball and set `JAVA_HOME` to use it for the build:
```shell
tar -xzf OpenJDK25U-jdk_x64_linux_hotspot_25.0.2_10.tar.gz
export JAVA_HOME=$PWD/jdk-25.0.2+10
export PATH=$JAVA_HOME/bin:$PATH
```
Download Adoptium public PGP key:
```sh
curl --tlsv1.2 --proto =https --location -o adoptium.asc https://packages.adoptium.net/artifactory/api/gpg/key/public
```
#### Java from SDKMAN
Check if key fingerprint matches: `3B04D753C9050D9A5D343F39843C48A565F8F04B`:
```
gpg --import --import-options show-only adoptium.asc
```
If key doesn't match, do not proceed.
Add Adoptium PGP key to a the keyring shared folder:
```sh
sudo cp adoptium.asc /usr/share/keyrings/
```
Add Adoptium debian repository:
```sh
echo "deb [signed-by=/usr/share/keyrings/adoptium.asc] https://packages.adoptium.net/artifactory/deb $(awk -F= '/^VERSION_CODENAME/{print$2}' /etc/os-release) main" | sudo tee /etc/apt/sources.list.d/adoptium.list
```
Update cache, install the desired temurin version and configure java to be linked to this same version:
```
sudo apt update -y
sudo apt-get install -y temurin-22-jdk=22.0.2+9
sudo update-alternatives --config java
```
#### Java from SDK
A alternative option for all platforms is to use the [sdkman.io](https://sdkman.io/) package manager ([Git Bash for Windows](https://git-scm.com/download/win) is a good choice on that platform).
An alternative option for all platforms is to use the [sdkman.io](https://sdkman.io/) package manager ([Git Bash for Windows](https://git-scm.com/download/win) is a good choice on that platform).
See the installation [instructions here](https://sdkman.io/install).
Once installed, run
```shell
sdk install java 22.0.2-tem
sdk install java 25.0.2-tem
```
### Other requirements
@@ -83,7 +56,7 @@ sudo apt install -y rpm fakeroot binutils
First, assign a temporary variable in your shell for the specific release you want to build. For the current one specify:
```shell
GIT_TAG="2.3.1"
GIT_TAG="2.4.1"
```
The project can then be initially cloned as follows:
@@ -101,7 +74,7 @@ git checkout "${GIT_TAG}"
```
Note - there is an additional step if you updated rather than initially cloned your repo at `GIT_TAG`.
This is due to the [drongo submodule](https://github.com/sparrowwallet/drongo/tree/master) which needs to be checked out to the commit state it had at the time of the release.
This is due to the Git submodules which need to be checked out to the commit state they had at the time of the release.
Only then your build will be comparable to the provided one in the release section of Github.
To checkout the submodule to the correct commit for `GIT_TAG`, additionally run:
+1 -1
Submodule drongo updated: 0a500ea002...4049ebcdda
+1 -1
Submodule lark updated: 36cf0c85dc...7f79ddee6b
+1 -1
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.4.0</string>
<string>2.4.2</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@@ -1140,12 +1140,37 @@ public class AppController implements Initializable {
AppServices.moveToActiveWindowScreen(window, 800, 450);
List<File> files = fileChooser.showOpenMultipleDialog(window);
if(files != null) {
configureWalletsDir(files);
for(File file : files) {
openWalletFile(file, forceSameWindow);
}
}
}
private static void configureWalletsDir(List<File> files) {
List<File> parentDirs = files.stream().map(File::getParentFile).distinct().collect(Collectors.toList());
if(parentDirs.size() == 1 && !Boolean.FALSE.equals(Config.get().getSuggestChangeWalletsDir())) {
File selectedDir = parentDirs.getFirst();
boolean sameDir;
try {
sameDir = Files.isSameFile(selectedDir.toPath(), Storage.getWalletsDir().toPath());
} catch(IOException e) {
sameDir = selectedDir.toPath().normalize().equals(Storage.getWalletsDir().toPath().normalize());
}
if(!sameDir) {
ConfirmationAlert alert = new ConfirmationAlert("Change wallets directory?",
"Do you want to configure Sparrow to use " + selectedDir + " as the default wallets directory?", ButtonType.NO, ButtonType.YES);
Optional<ButtonType> optType = alert.showAndWait();
if(optType.isPresent() && optType.get() == ButtonType.YES) {
Config.get().setWalletsDir(selectedDir);
Config.get().setSuggestChangeWalletsDir(null);
} else if(alert.isDontAskAgain()) {
Config.get().setSuggestChangeWalletsDir(Boolean.FALSE);
}
}
}
}
public void openWalletFile(File file, boolean forceSameWindow) {
try {
Storage storage = new Storage(file);
@@ -328,6 +328,9 @@ public class AppServices {
"\n\nChange the configured server certificate if you would like to proceed.");
} else {
crtFile = Storage.getCertificateFile(tlsServerException.getServer().getHost());
if(crtFile == null) {
crtFile = Storage.getCaCertificateFile(tlsServerException.getServer().getHost());
}
if(crtFile != null) {
Optional<ButtonType> optButton = AppServices.showErrorDialog("SSL Handshake Failed", "The certificate provided by the server at " + tlsServerException.getServer().getHost() + " appears to have changed." +
"\n\nThis may be simply due to a certificate renewal, or it may indicate a man-in-the-middle attack." +
@@ -18,7 +18,7 @@ import java.util.*;
public class SparrowWallet {
public static final String APP_ID = "sparrow";
public static final String APP_NAME = "Sparrow";
public static final String APP_VERSION = "2.4.0";
public static final String APP_VERSION = "2.4.2";
public static final String APP_VERSION_SUFFIX = "";
public static final String APP_HOME_PROPERTY = "sparrow.home";
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
@@ -28,6 +28,7 @@ public class AddressLabelSkin extends LabelSkin {
super(control);
displayFlow = new TextFlow();
displayFlow.setManaged(false);
displayFlow.setMouseTransparent(true);
getChildren().addFirst(displayFlow);
@@ -37,6 +37,7 @@ public class AddressTextFieldSkin extends CustomTextFieldSkin {
super(control);
displayFlow = new TextFlow();
displayFlow.setManaged(false);
displayFlow.setMouseTransparent(true);
clip = new Rectangle();
@@ -18,6 +18,7 @@ public class AddressTreeTableCellSkin<S, T> extends TreeTableCellSkin<S, T> {
super(cell);
displayFlow = new TextFlow();
displayFlow.setManaged(false);
displayFlow.setMouseTransparent(true);
displayFlow.setMinWidth(Region.USE_PREF_SIZE);
getChildren().add(displayFlow);
@@ -62,7 +62,7 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
private static final List<String> ARCHIVE_EXTENSIONS = List.of("zip", "tar.gz", "tar.bz2", "tar.xz", "rar", "7z");
private static final String SPARROW_RELEASE_PREFIX = "sparrow-";
private static final String SPARROW_RELEASE_ALT_PREFIX = "sparrow_";
private static final String[] SPARROW_RELEASE_ALT_PREFIXES = { "sparrowwallet-", "sparrowwallet_", "sparrowserver-", "sparrowserver_" };
private static final String SPARROW_MANIFEST_SUFFIX = "-manifest.txt";
private static final String SPARROW_SIGNATURE_SUFFIX = SPARROW_MANIFEST_SUFFIX + ".asc";
private static final Pattern SPARROW_RELEASE_VERSION = Pattern.compile("[0-9]+(\\.[0-9]+)*");
@@ -465,7 +465,7 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
}
String providedName = providedFile.getName().toLowerCase(Locale.ROOT);
if(providedName.startsWith(SPARROW_RELEASE_PREFIX) || providedName.startsWith(SPARROW_RELEASE_ALT_PREFIX)) {
if(providedName.startsWith(SPARROW_RELEASE_PREFIX) || Arrays.stream(SPARROW_RELEASE_ALT_PREFIXES).anyMatch(providedName::startsWith)) {
Matcher matcher = SPARROW_RELEASE_VERSION.matcher(providedFile.getName());
if(matcher.find()) {
String version = matcher.group();
@@ -591,7 +591,8 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
}
}
if((name.startsWith(SPARROW_RELEASE_PREFIX) || name.startsWith(SPARROW_RELEASE_ALT_PREFIX)) && file.length() >= MIN_VALID_SPARROW_RELEASE_SIZE) {
if((name.startsWith(SPARROW_RELEASE_PREFIX) || Arrays.stream(SPARROW_RELEASE_ALT_PREFIXES).anyMatch(name::startsWith))
&& file.length() >= MIN_VALID_SPARROW_RELEASE_SIZE) {
Matcher matcher = SPARROW_RELEASE_VERSION.matcher(name);
return matcher.find();
}
@@ -10,7 +10,12 @@ import com.sparrowwallet.drongo.crypto.Bip322;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.hummingbird.registry.CryptoPSBT;
import com.sparrowwallet.sparrow.io.bbqr.BBQR;
import com.sparrowwallet.sparrow.io.bbqr.BBQRType;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.*;
@@ -242,7 +247,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
if(wallet != null) {
setWalletNodeFromAddress(wallet, address);
if(walletNode != null) {
setFormatFromScriptType(getSigningScriptType(walletNode));
setFormatFromScriptType(walletNode.getWallet().getScriptType());
}
}
} catch(InvalidAddressException e) {
@@ -250,6 +255,13 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
}
}
});
formatGroup.selectedToggleProperty().addListener((_, _, newVal) -> {
if(wallet != null) {
boolean canSignSelectedFormat = canSignAllFormats(wallet) || newVal == formatElectrum;
signButton.setDisable(!isValidAddress() || !canSign || !canSignSelectedFormat);
}
});
}
EventManager.get().register(this);
@@ -277,7 +289,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
}
if(wallet != null && walletNode != null) {
setFormatFromScriptType(getSigningScriptType(walletNode));
setFormatFromScriptType(walletNode.getWallet().getScriptType());
} else {
formatGroup.selectToggle(formatElectrum);
}
@@ -296,7 +308,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|| wallet.getKeystores().getFirst().getWalletModel().isCard();
}
private boolean canSignBip322(Wallet wallet) {
private boolean canSignAllFormats(Wallet wallet) {
return wallet.getKeystores().getFirst().hasPrivateKey();
}
@@ -321,11 +333,6 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
walletNode = wallet.getWalletAddresses().get(address);
}
private ScriptType getSigningScriptType(WalletNode walletNode) {
ScriptType scriptType = walletNode.getWallet().getScriptType();
return canSign(walletNode.getWallet()) && !canSignBip322(walletNode.getWallet()) ? ScriptType.P2PKH : scriptType;
}
private void setFormatFromScriptType(ScriptType scriptType) {
formatElectrum.setDisable(scriptType == ScriptType.P2TR);
formatTrezor.setDisable(scriptType == ScriptType.P2TR || scriptType == ScriptType.P2PKH);
@@ -473,6 +480,11 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
return;
}
if(isBip322()) {
showBip322Qr();
return;
}
//Note we can expect a single keystore due to the check in the constructor
KeyDerivation firstDerivation = walletNode.getWallet().getKeystores().get(0).getKeyDerivation();
String derivationPath = KeyDerivation.writePath(firstDerivation.extend(walletNode.getDerivation()).getDerivation(), false);
@@ -486,13 +498,57 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
}
}
private void showBip322Qr() {
Wallet signingWallet = walletNode.getWallet();
ScriptType scriptType = signingWallet.getScriptType();
PSBT psbt = Bip322.getBip322Psbt(scriptType, walletNode.getAddress(), message.getText().trim());
addBip322DerivationInfo(psbt, signingWallet);
byte[] psbtBytes = psbt.serialize();
CryptoPSBT cryptoPSBT = new CryptoPSBT(psbtBytes);
BBQR bbqr = new BBQR(BBQRType.PSBT, psbtBytes);
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR(), bbqr, false, true, QREncoding.UR);
qrDisplayDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<ButtonType> optButtonType = qrDisplayDialog.showAndWait();
if(optButtonType.isPresent() && optButtonType.get().getButtonData() == ButtonBar.ButtonData.OK_DONE) {
scanQr();
}
}
private void addBip322DerivationInfo(PSBT psbt, Wallet signingWallet) {
ScriptType scriptType = signingWallet.getScriptType();
PSBTInput psbtInput = psbt.getPsbtInputs().get(0);
Keystore keystore = signingWallet.getKeystores().get(0);
ECKey pubKey = keystore.getPubKey(walletNode);
KeyDerivation fullDerivation = keystore.getKeyDerivation().extend(walletNode.getDerivation());
if(scriptType == ScriptType.P2TR) {
psbtInput.setTapInternalKey(pubKey);
psbtInput.getTapDerivedPublicKeys().put(ECKey.fromPublicOnly(pubKey.getPubKeyXCoord()), Map.of(fullDerivation, Collections.emptyList()));
} else {
psbtInput.getDerivedPublicKeys().put(scriptType.getOutputKey(pubKey), fullDerivation);
}
}
private void scanQr() {
QRScanDialog qrScanDialog = new QRScanDialog();
qrScanDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get();
if(result.payload != null) {
if(result.psbt != null) {
try {
Wallet signingWallet = walletNode.getWallet();
ECKey pubKey = signingWallet.getKeystores().get(0).getPubKey(walletNode);
String sig = Bip322.getBip322SignatureFromPsbt(signingWallet.getScriptType(), result.psbt, pubKey);
signature.clear();
signature.appendText(sig);
} catch(Exception e) {
log.error("Error extracting BIP-322 signature from PSBT", e);
AppServices.showErrorDialog("Error extracting signature", e.getMessage());
}
} else if(result.payload != null) {
signature.clear();
signature.appendText(result.payload);
} else if(result.exception != null) {
@@ -510,6 +566,11 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
return;
}
if(isBip322()) {
exportBip322File();
return;
}
StringJoiner joiner = new StringJoiner("\n");
joiner.add(message.getText().trim().replaceAll("\r*\n*", ""));
//Note we can expect a single keystore due to the check in the constructor
@@ -538,20 +599,66 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
}
}
private void exportBip322File() {
Wallet signingWallet = walletNode.getWallet();
ScriptType scriptType = signingWallet.getScriptType();
PSBT psbt = Bip322.getBip322Psbt(scriptType, walletNode.getAddress(), message.getText().trim());
addBip322DerivationInfo(psbt, signingWallet);
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Save PSBT File");
fileChooser.setInitialFileName("bip322-signmessage.psbt");
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
try(OutputStream os = new FileOutputStream(file)) {
os.write(psbt.serialize());
} catch(IOException e) {
log.error("Error saving BIP-322 PSBT", e);
AppServices.showErrorDialog("Error saving PSBT", "Cannot write to " + file.getAbsolutePath());
}
}
}
private void importFile() {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Open Signed Text File");
fileChooser.setTitle("Open Signed File");
fileChooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("All Files", OsType.getCurrent().equals(OsType.UNIX) ? "*" : "*.*"),
new FileChooser.ExtensionFilter("Text Files", "*.txt")
new FileChooser.ExtensionFilter("Text Files", "*.txt"),
new FileChooser.ExtensionFilter("PSBT Files", "*.psbt")
);
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showOpenDialog(window);
if(file != null) {
if(file.getName().toLowerCase(Locale.ROOT).endsWith(".psbt") || isBip322()) {
if(walletNode == null) {
AppServices.showErrorDialog("Address not in wallet", "The provided address is not present in the currently selected wallet.");
return;
}
try {
byte[] psbtBytes = Files.readAllBytes(file.toPath());
PSBT signedPsbt = new PSBT(psbtBytes, false);
ECKey pubKey = walletNode.getWallet().getKeystores().get(0).getPubKey(walletNode);
String sig = Bip322.getBip322SignatureFromPsbt(walletNode.getWallet().getScriptType(), signedPsbt, pubKey);
signature.clear();
signature.appendText(sig);
return;
} catch(Exception e) {
if(file.getName().toLowerCase(Locale.ROOT).endsWith(".psbt")) {
log.error("Error loading signed PSBT", e);
AppServices.showErrorDialog("Error loading signed PSBT", e.getMessage());
return;
}
//Fall through to text handling for non-.psbt files
}
}
try {
String content = Files.readString(file.toPath(), StandardCharsets.UTF_8);
Matcher matcher = signedMessagePattern.matcher(content);
@@ -37,7 +37,8 @@ public class UsbStatusButton extends MenuButton {
togglePassphraseService.setOnSucceeded(event1 -> {
EventManager.get().post(new RequestOpenWalletsEvent());
if(!device.getModel().externalPassphraseEntry()) {
AppServices.showAlertDialog("Reconnect device", "Reconnect your " + device.getModel().toDisplayString() + " to reset the passphrase.", Alert.AlertType.INFORMATION);
AppServices.showAlertDialog("Restart device", "Reconnect your " + device.getModel().toDisplayString() + " to reset the passphrase." +
"\n\nIf it has a battery, hold down the power button until it restarts.", Alert.AlertType.INFORMATION);
}
});
togglePassphraseService.setOnFailed(event1 -> {
@@ -73,7 +73,7 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
name = (CustomTextField)TextFields.createClearableTextField();
name.setText(initialName);
name.setTextFormatter(new TextFormatter<>((change) -> {
change.setText(change.getText().replaceAll("[\\\\/:*?\"<>|]", "_"));
change.setText(change.getText().replaceAll("[\\\\/:*?\"<>|;`]", "_"));
return change;
}));
content.getChildren().add(name);
@@ -59,6 +59,8 @@ public class Config {
private Boolean connectToBroadcast;
private Boolean connectToResolve;
private Boolean suggestSendToMany;
private Boolean suggestChangeWalletsDir;
private File walletsDir;
private List<File> recentWalletFiles;
private Integer keyDerivationPeriod;
private long dustAttackThreshold = DUST_ATTACK_THRESHOLD_SATS;
@@ -406,6 +408,24 @@ public class Config {
flush();
}
public Boolean getSuggestChangeWalletsDir() {
return suggestChangeWalletsDir;
}
public void setSuggestChangeWalletsDir(Boolean suggestChangeWalletsDir) {
this.suggestChangeWalletsDir = suggestChangeWalletsDir;
flush();
}
public File getWalletsDir() {
return walletsDir;
}
public void setWalletsDir(File walletsDir) {
this.walletsDir = walletsDir;
flush();
}
public List<File> getRecentWalletFiles() {
return recentWalletFiles;
}
@@ -114,26 +114,14 @@ public class Descriptor implements WalletImport, WalletExport {
private static List<String> getParagraphs(InputStream inputStream) {
List<String> paragraphs = new ArrayList<>();
StringBuilder paragraph = new StringBuilder();
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
for(String line : reader.lines().map(String::trim).toArray(String[]::new)) {
if(line.isEmpty()) {
if(!paragraph.isEmpty()) {
paragraphs.add(paragraph.toString());
paragraph.setLength(0);
}
} else if(line.startsWith("#")) {
continue;
} else {
paragraph.append(line.replaceFirst("^.+:", "").trim());
if(!line.isEmpty() && !line.startsWith("#")) {
paragraphs.add(line.replaceFirst("^.+:", "").trim());
}
}
if(!paragraph.isEmpty()) {
paragraphs.add(paragraph.toString());
}
return paragraphs;
}
@@ -29,10 +29,7 @@ public class Samourai implements KeystoreFileImport {
try {
String input = CharStreams.toString(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
Gson gson = new Gson();
Type stringStringMap = new TypeToken<Map<String, JsonElement>>() {
}.getType();
Map<String, JsonElement> map = gson.fromJson(input, stringStringMap);
Map<String, JsonElement> map = this.parseJsonInput(input);
String payload = input;
if(map.containsKey("payload")) {
@@ -53,7 +50,7 @@ public class Samourai implements KeystoreFileImport {
throw new ImportException("Unsupported backup version: " + version);
}
SamouraiBackup backup = gson.fromJson(decrypted, SamouraiBackup.class);
SamouraiBackup backup = new Gson().fromJson(decrypted, SamouraiBackup.class);
DeterministicSeed seed = new DeterministicSeed(Utils.hexToBytes(backup.wallet.seed), password, 0);
Keystore keystore = Keystore.fromSeed(seed, scriptType.getDefaultDerivation());
keystore.setLabel(getWalletModel().toDisplayString());
@@ -67,6 +64,24 @@ public class Samourai implements KeystoreFileImport {
}
}
private Map<String, JsonElement> parseJsonInput(String input) {
Gson gson = new Gson();
Type stringStringMap = new TypeToken<Map<String, JsonElement>>() {
}.getType();
try {
return gson.fromJson(input, stringStringMap);
} catch (JsonParseException e) {
int closingBracket = input.indexOf('}');
if (closingBracket < 0) {
throw e;
}
String fixedInput = input.substring(0, closingBracket + 1);
return gson.fromJson(fixedInput, stringStringMap);
}
}
@Override
public boolean isKeystoreImportScannable() {
return false;
@@ -7,6 +7,7 @@ import com.sparrowwallet.drongo.wallet.MnemonicException;
import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.net.ServerType;
import com.sparrowwallet.sparrow.SparrowWallet;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Service;
@@ -485,7 +486,16 @@ public class Storage {
}
public static File getWalletsDir() {
File walletsDir = new File(getSparrowDir(), WALLETS_DIR);
File walletsDir = Config.get().getWalletsDir();
if(walletsDir != null) {
if(!walletsDir.exists() && (walletsDir.getParentFile() == null || !walletsDir.getParentFile().exists() || !walletsDir.getParentFile().canWrite())) {
log.info("Configured wallets directory " + walletsDir.getAbsolutePath() + " is not reachable, reverting to default");
walletsDir = null;
}
}
if(walletsDir == null) {
walletsDir = new File(getSparrowDir(), WALLETS_DIR);
}
if(!walletsDir.exists()) {
createOwnerOnlyDirectory(walletsDir);
}
@@ -494,8 +504,23 @@ public class Storage {
}
public static File getCertificateFile(String host) {
File certsDir = getCertsDir();
File[] certs = certsDir.listFiles((dir, name) -> name.equals(host));
return findCertFile(getCertName(host));
}
public static void saveCertificate(String host, Certificate cert) {
writeCertPem(getCertName(host), cert);
}
public static File getCaCertificateFile(String host) {
return findCertFile(host + ".cacert");
}
public static void saveCaCertificate(String host, Certificate cert) {
writeCertPem(host + ".cacert", cert);
}
private static File findCertFile(String filename) {
File[] certs = getCertsDir().listFiles((dir, name) -> name.equals(filename));
if(certs != null && certs.length > 0) {
return certs[0];
}
@@ -503,8 +528,8 @@ public class Storage {
return null;
}
public static void saveCertificate(String host, Certificate cert) {
try(FileWriter writer = new FileWriter(new File(getCertsDir(), host))) {
private static void writeCertPem(String filename, Certificate cert) {
try(FileWriter writer = new FileWriter(new File(getCertsDir(), filename))) {
writer.write("-----BEGIN CERTIFICATE-----\n");
writer.write(Base64.getEncoder().encodeToString(cert.getEncoded()).replaceAll("(.{64})", "$1\n"));
writer.write("\n-----END CERTIFICATE-----\n");
@@ -515,6 +540,14 @@ public class Storage {
}
}
private static String getCertName(String host) {
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
return host + ".bitcoind";
}
return host;
}
static File getCertsDir() {
File certsDir = new File(getSparrowDir(), CERTS_DIR);
if(!certsDir.exists()) {
@@ -36,6 +36,7 @@ import java.nio.file.StandardCopyOption;
import java.security.SecureRandom;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.regex.Pattern;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@@ -54,6 +55,7 @@ public class DbPersistence implements Persistence {
private static final String H2_USER = "sa";
private static final String H2_PASSWORD = "";
public static final String MIGRATION_RESOURCES_DIR = "com/sparrowwallet/sparrow/sql/";
private static final Pattern JDBC_URL_INJECTION_PATTERN = Pattern.compile(";\\w+=");
private HikariDataSource dataSource;
private AsymmetricKeyDeriver keyDeriver;
@@ -81,6 +83,7 @@ public class DbPersistence implements Persistence {
ECKey encryptionKey = getEncryptionKey(password, storage.getWalletFile(), alreadyDerivedKey);
migrate(storage, MASTER_SCHEMA, encryptionKey);
validateSchema(storage, MASTER_SCHEMA, encryptionKey);
Jdbi jdbi = getJdbi(storage, getFilePassword(encryptionKey));
masterWallet = jdbi.withHandle(handle -> {
@@ -110,6 +113,7 @@ public class DbPersistence implements Persistence {
Map<WalletAndKey, Storage> childWallets = new TreeMap<>();
for(String schema : childSchemas) {
migrate(storage, schema, encryptionKey);
validateSchema(storage, schema, encryptionKey);
Jdbi childJdbi = getJdbi(storage, getFilePassword(encryptionKey));
Wallet wallet = childJdbi.withHandle(handle -> {
@@ -158,11 +162,20 @@ public class DbPersistence implements Persistence {
@Override
public void updateWallet(Storage storage, Wallet wallet, ECKey encryptionPubKey) throws StorageException {
updatePassword(storage, encryptionPubKey);
String newPassword = getFilePassword(encryptionPubKey);
String currentPassword = getDatasourcePassword();
updateExecutor.execute(() -> {
try {
update(storage, wallet, getFilePassword(encryptionPubKey));
if(dataSource != null && currentPassword != null && newPassword == null) {
//Removing encryption: write data first
update(storage, wallet, currentPassword);
updatePassword(storage, encryptionPubKey);
} else {
//Adding encryption or no change: change file first
updatePassword(storage, encryptionPubKey);
update(storage, wallet, newPassword);
}
} catch(Exception e) {
log.error("Error updating wallet db", e);
}
@@ -213,7 +226,7 @@ public class DbPersistence implements Persistence {
WalletDao walletDao = handle.attach(WalletDao.class);
try {
if(dirtyPersistables.deleteAccount && !wallet.isMasterWallet()) {
handle.execute("drop schema `" + getSchema(wallet) + "` cascade");
handle.execute("drop schema `" + getSchema(wallet).replace("`", "``") + "` cascade");
return;
}
@@ -407,6 +420,52 @@ public class DbPersistence implements Persistence {
}
}
private void validateSchema(Storage storage, String schema, ECKey encryptionKey) throws StorageException {
Jdbi jdbi = getJdbi(storage, getFilePassword(encryptionKey));
try {
jdbi.useHandle(handle -> {
List<String> routines = handle.createQuery("SELECT ROUTINE_NAME FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA <> 'INFORMATION_SCHEMA'").mapTo(String.class).list();
if(!routines.isEmpty()) {
throw new RuntimeException(new StorageException("Wallet file contains unexpected database routines: " + String.join(", ", routines) + "."));
}
List<String> triggers = handle.createQuery("SELECT TRIGGER_NAME FROM INFORMATION_SCHEMA.TRIGGERS WHERE TRIGGER_SCHEMA <> 'INFORMATION_SCHEMA'").mapTo(String.class).list();
if(!triggers.isEmpty()) {
throw new RuntimeException(new StorageException("Wallet file contains unexpected database triggers: " + String.join(", ", triggers) + "."));
}
List<String> checkConstraints = handle.createQuery("SELECT CHECK_CLAUSE FROM INFORMATION_SCHEMA.CHECK_CONSTRAINTS WHERE UPPER(CONSTRAINT_SCHEMA) = UPPER(:schema)")
.bind("schema", schema).mapTo(String.class).list();
if(!checkConstraints.isEmpty()) {
throw new RuntimeException(new StorageException("Wallet file contains unexpected check constraints: " + String.join(", ", checkConstraints) + "."));
}
List<Map<String, Object>> nonBaseTables = handle.createQuery("SELECT TABLE_NAME, TABLE_TYPE FROM INFORMATION_SCHEMA.TABLES WHERE UPPER(TABLE_SCHEMA) = UPPER(:schema) "
+ "AND TABLE_TYPE <> 'BASE TABLE' AND UPPER(TABLE_NAME) <> 'FLYWAY_SCHEMA_HISTORY'").bind("schema", schema).mapToMap().list();
if(!nonBaseTables.isEmpty()) {
String detail = nonBaseTables.stream().map(m -> m.get("TABLE_NAME") + " (" + m.get("TABLE_TYPE") + ")").collect(Collectors.joining(", "));
throw new RuntimeException(new StorageException("Wallet file contains unexpected database object types: " + detail + "."));
}
List<String> generatedColumns = handle.createQuery("SELECT TABLE_NAME || '.' || COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE UPPER(TABLE_SCHEMA) = UPPER(:schema) AND GENERATION_EXPRESSION IS NOT NULL")
.bind("schema", schema).mapTo(String.class).list();
if(!generatedColumns.isEmpty()) {
throw new RuntimeException(new StorageException("Wallet file contains unexpected generated columns: " + String.join(", ", generatedColumns) + "."));
}
List<String> domains = handle.createQuery("SELECT DOMAIN_NAME FROM INFORMATION_SCHEMA.DOMAINS WHERE DOMAIN_SCHEMA <> 'INFORMATION_SCHEMA'").mapTo(String.class).list();
if(!domains.isEmpty()) {
throw new RuntimeException(new StorageException("Wallet file contains unexpected database domains: " + String.join(", ", domains) + "."));
}
});
} catch(RuntimeException e) {
if(e.getCause() instanceof StorageException) {
throw new StorageException("This is not a valid wallet file.\n\n" + e.getCause().getMessage());
}
throw e;
}
}
private void cleanAndMigrate(Storage storage, String schema, String password) throws StorageException {
File migrationDir = getMigrationDir();
try {
@@ -695,7 +754,10 @@ public class DbPersistence implements Persistence {
}
}
private String getUrl(File walletFile, String password) {
private String getUrl(File walletFile, String password) throws StorageException {
if(JDBC_URL_INJECTION_PATTERN.matcher(walletFile.getAbsolutePath()).find()) {
throw new StorageException("Wallet file path contains invalid characters");
}
return "jdbc:h2:" + walletFile.getAbsolutePath().replace("." + getType().getExtension(), "") + ";INIT=SET TRACE_LEVEL_FILE=4;TRACE_LEVEL_FILE=4;DEFRAG_ALWAYS=true;MAX_COMPACT_TIME=5000;DATABASE_TO_UPPER=false" + (password == null ? "" : ";CIPHER=AES");
}
@@ -31,6 +31,7 @@ import javafx.util.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.net.ssl.SSLHandshakeException;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
@@ -1435,6 +1436,12 @@ public class ElectrumServer {
bwtStartLock.unlock();
}
}
} catch(IllegalStateException e) {
if(e.getCause() instanceof SSLHandshakeException) {
throw new TlsServerException(Config.get().getCoreServer().getHostAndPort(), e.getCause());
} else {
throw e;
}
}
}
@@ -33,7 +33,7 @@ public class ProxyTcpOverTlsTransport extends TcpOverTlsTransport {
InetSocketAddress proxyAddr = new InetSocketAddress(proxy.getHost(), proxy.getPortOrDefault(DEFAULT_PROXY_PORT));
socket = new Socket(new Proxy(Proxy.Type.SOCKS, proxyAddr));
socket.connect(InetSocketAddress.createUnresolved(server.getHost(), server.getPortOrDefault(getDefaultPort())));
socket = sslSocketFactory.createSocket(socket, proxy.getHost(), proxy.getPortOrDefault(DEFAULT_PROXY_PORT), true);
socket = sslSocketFactory.createSocket(socket, server.getHost(), server.getPortOrDefault(getDefaultPort()), true);
startHandshake((SSLSocket)socket);
}
}
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.net;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -19,11 +20,19 @@ public class TcpOverTlsTransport extends TcpTransport {
public static final int PAD_TO_MULTIPLE_OF_BYTES = 96;
protected final SSLSocketFactory sslSocketFactory;
protected final boolean usingCaTrust;
public TcpOverTlsTransport(HostAndPort server) throws NoSuchAlgorithmException, KeyManagementException, CertificateException, KeyStoreException, IOException {
super(server);
TrustManager[] trustManagers = getTrustManagers(Storage.getCertificateFile(server.getHost()));
TrustManager[] trustManagers;
if(Storage.getCaCertificateFile(server.getHost()) != null) {
trustManagers = getCaTrustManagers();
this.usingCaTrust = true;
} else {
trustManagers = getTrustManagers(Storage.getCertificateFile(server.getHost()), server.getHost());
this.usingCaTrust = false;
}
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustManagers, new SecureRandom());
@@ -34,7 +43,8 @@ public class TcpOverTlsTransport extends TcpTransport {
public TcpOverTlsTransport(HostAndPort server, File crtFile) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
super(server);
TrustManager[] trustManagers = getTrustManagers(crtFile);
this.usingCaTrust = false;
TrustManager[] trustManagers = getTrustManagers(crtFile, server.getHost());
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustManagers, null);
@@ -60,7 +70,7 @@ public class TcpOverTlsTransport extends TcpTransport {
}
}
private TrustManager[] getTrustManagers(File crtFile) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException {
public static TrustManager[] getTrustManagers(File crtFile, String host) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException {
if(crtFile == null) {
return new TrustManager[] {
new X509TrustManager() {
@@ -79,7 +89,7 @@ public class TcpOverTlsTransport extends TcpTransport {
try {
certs[0].checkValidity();
} catch(CertificateExpiredException e) {
if(Storage.getCertificateFile(server.getHost()) == null) {
if(Storage.getCertificateFile(host) == null) {
throw new UnknownCertificateExpiredException(e.getMessage(), certs[0]);
}
}
@@ -88,17 +98,23 @@ public class TcpOverTlsTransport extends TcpTransport {
};
}
Certificate certificate = CertificateFactory.getInstance("X.509").generateCertificate(new FileInputStream(crtFile));
Certificate certificate;
try(FileInputStream fis = new FileInputStream(crtFile)) {
certificate = CertificateFactory.getInstance("X.509").generateCertificate(fis);
}
if(certificate instanceof X509Certificate) {
try {
X509Certificate x509Certificate = (X509Certificate)certificate;
x509Certificate.checkValidity();
} catch(CertificateExpiredException e) {
//Allow expired certificates so long as they have been previously used or explicitly approved
//These will usually be self-signed certificates that users may not have the expertise to renew
if(Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER) {
crtFile.delete();
return getTrustManagers(null, host);
}
//Allow expired certificates for private servers where users may not have the expertise to renew
} catch(CertificateException e) {
crtFile.delete();
return getTrustManagers(null);
return getTrustManagers(null, host);
}
}
@@ -124,7 +140,11 @@ public class TcpOverTlsTransport extends TcpTransport {
try {
Certificate[] certs = event.getPeerCertificates();
if(certs.length > 0) {
Storage.saveCertificate(server.getHost(), certs[0]);
if(isCaSigned(certs)) {
Storage.saveCaCertificate(server.getHost(), certs[0]);
} else {
Storage.saveCertificate(server.getHost(), certs[0]);
}
}
} catch(SSLPeerUnverifiedException e) {
log.warn("Attempting to retrieve certificate for unverified peer", e);
@@ -132,18 +152,53 @@ public class TcpOverTlsTransport extends TcpTransport {
}
});
if(usingCaTrust && !Protocol.isOnionAddress(server)) {
SSLParameters sslParameters = sslSocket.getSSLParameters();
sslParameters.setEndpointIdentificationAlgorithm("HTTPS");
sslSocket.setSSLParameters(sslParameters);
}
sslSocket.startHandshake();
}
protected boolean shouldSaveCertificate() {
//Avoid saving the certificates for public servers - they change often, encourage approval complacency, and there is little a user can do to check
for(PublicElectrumServer publicElectrumServer : PublicElectrumServer.getServers()) {
if(publicElectrumServer.getServer().getHost().equals(server.getHost())) {
return Storage.getCertificateFile(server.getHost()) == null && Storage.getCaCertificateFile(server.getHost()) == null;
}
private static TrustManager[] getCaTrustManagers() throws NoSuchAlgorithmException, KeyStoreException {
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init((KeyStore)null);
return tmf.getTrustManagers();
}
private static boolean isCaSigned(Certificate[] certs) {
try {
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init((KeyStore)null);
X509TrustManager defaultTm = null;
for(TrustManager tm : tmf.getTrustManagers()) {
if(tm instanceof X509TrustManager) {
defaultTm = (X509TrustManager)tm;
break;
}
}
if(defaultTm == null) {
return false;
}
}
return Storage.getCertificateFile(server.getHost()) == null;
X509Certificate[] x509Certs = new X509Certificate[certs.length];
for(int i = 0; i < certs.length; i++) {
x509Certs[i] = (X509Certificate)certs[i];
}
String authType = x509Certs[0].getPublicKey().getAlgorithm().equals("EC") ? "ECDHE_ECDSA" : "RSA";
defaultTm.checkServerTrusted(x509Certs, authType);
return true;
} catch(Exception e) {
return false;
}
}
@Override
@@ -42,6 +42,8 @@ public class TlsServerException extends ServerException {
return "Provided server certificate from " + server.getHost() + " did not match configured certificate at " + configCrtFile.getAbsolutePath();
} else if(savedCrtFile != null) {
return "Provided server certificate from " + server.getHost() + " did not match previously saved certificate at " + savedCrtFile.getAbsolutePath();
} else if(Storage.getCaCertificateFile(server.getHost()) != null) {
return "Provided server certificate from " + server.getHost() + " failed CA validation";
}
return "Provided server certificate from " + server.getHost() + " was invalid: " + (cause.getCause() != null ? cause.getCause().getMessage() : cause.getMessage());
@@ -4,7 +4,9 @@ import com.github.arteam.simplejsonrpc.client.Transport;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.io.Server;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.Protocol;
import com.sparrowwallet.sparrow.net.TcpOverTlsTransport;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -14,8 +16,7 @@ import java.net.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.security.cert.Certificate;
import java.util.Base64;
public class BitcoindTransport implements Transport {
@@ -27,6 +28,7 @@ public class BitcoindTransport implements Transport {
private File cookieFile;
private Long cookieFileTimestamp;
private String bitcoindAuthEncoded;
private SSLSocketFactory sslSocketFactory;
public BitcoindTransport(Server bitcoindServer, String bitcoindWallet, String bitcoindAuth) {
this(bitcoindServer, bitcoindWallet);
@@ -57,9 +59,10 @@ public class BitcoindTransport implements Transport {
HttpURLConnection connection = proxy != null && Protocol.isOnionAddress(bitcoindServer) ? (HttpURLConnection)bitcoindUrl.openConnection(proxy) : (HttpURLConnection)bitcoindUrl.openConnection();
if(connection instanceof HttpsURLConnection httpsURLConnection) {
SSLSocketFactory sslSocketFactory = getTrustAllSocketFactory();
SSLSocketFactory sslSocketFactory = getSSLSocketFactory();
if(sslSocketFactory != null) {
httpsURLConnection.setSSLSocketFactory(sslSocketFactory);
httpsURLConnection.setHostnameVerifier((_, _) -> true);
}
}
@@ -81,6 +84,19 @@ public class BitcoindTransport implements Transport {
}
int statusCode = connection.getResponseCode();
if(connection instanceof HttpsURLConnection httpsConn && Storage.getCertificateFile(bitcoindServer.getHost()) == null) {
try {
Certificate[] certs = httpsConn.getServerCertificates();
if(certs.length > 0) {
Storage.saveCertificate(bitcoindServer.getHost(), certs[0]);
sslSocketFactory = null;
}
} catch(SSLPeerUnverifiedException e) {
log.warn("Could not retrieve certificate for saving", e);
}
}
if(statusCode == 401) {
throw new IOException((cookieFile == null ? "User/pass" : "Cookie file") + " authentication failed");
}
@@ -138,29 +154,24 @@ public class BitcoindTransport implements Transport {
return bitcoindDir;
}
private SSLSocketFactory getTrustAllSocketFactory() {
TrustManager[] trustAllCerts = new TrustManager[] {
new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
public void checkClientTrusted(X509Certificate[] certs, String authType) throws CertificateException {
}
public void checkServerTrusted(X509Certificate[] certs, String authType) throws CertificateException {
}
}
};
try {
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAllCerts, null);
return sslContext.getSocketFactory();
} catch (Exception e) {
log.error("Error creating SSL socket factory", e);
private SSLSocketFactory getSSLSocketFactory() {
if(sslSocketFactory == null) {
sslSocketFactory = createSSLSocketFactory();
}
return null;
return sslSocketFactory;
}
private SSLSocketFactory createSSLSocketFactory() {
try {
String host = bitcoindServer.getHost();
TrustManager[] trustManagers = TcpOverTlsTransport.getTrustManagers(Storage.getCertificateFile(host), host);
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustManagers, null);
return sslContext.getSocketFactory();
} catch(Exception e) {
log.error("Error creating SSL socket factory", e);
return null;
}
}
}
@@ -1,10 +1,12 @@
package com.sparrowwallet.sparrow.net.cormorant.electrum;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.cormorant.bitcoind.BitcoindClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
@@ -69,7 +71,7 @@ public class ElectrumServerRunnable implements Runnable {
private void openServerSocket() {
try {
serverSocket = new ServerSocket(0);
serverSocket = new ServerSocket(0, 50, InetAddress.getByName(ElectrumServer.CORE_ELECTRUM_HOST));
} catch(IOException e) {
throw new RuntimeException("Cannot open electrum server port", e);
}
@@ -11,7 +11,6 @@ import com.sparrowwallet.sparrow.AppServices;
import io.reactivex.Observable;
import io.reactivex.rxjavafx.schedulers.JavaFxScheduler;
import io.reactivex.schedulers.Schedulers;
import java8.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -670,6 +670,9 @@ public class ServerSettingsController extends SettingsDetailController {
if(exception.getCause().getMessage().contains("PKIX path building failed")) {
File configCrtFile = Config.get().getElectrumServerCert();
File savedCrtFile = Storage.getCertificateFile(tlsServerException.getServer().getHost());
if(savedCrtFile == null) {
savedCrtFile = Storage.getCaCertificateFile(tlsServerException.getServer().getHost());
}
if(configCrtFile == null && savedCrtFile != null) {
Optional<ButtonType> optButton = AppServices.showErrorDialog("SSL Handshake Failed", "The certificate provided by the server at " + tlsServerException.getServer().getHost() + " appears to have changed." +
"\n\nThis may indicate a man-in-the-middle attack!" +
@@ -185,6 +185,9 @@ public class ServerTestDialog extends DialogWindow {
if(exception.getCause().getMessage().contains("PKIX path building failed")) {
File configCrtFile = Config.get().getElectrumServerCert();
File savedCrtFile = Storage.getCertificateFile(tlsServerException.getServer().getHost());
if(savedCrtFile == null) {
savedCrtFile = Storage.getCaCertificateFile(tlsServerException.getServer().getHost());
}
if(configCrtFile == null && savedCrtFile != null) {
Optional<ButtonType> optButton = AppServices.showErrorDialog("SSL Handshake Failed", "The certificate provided by the server at " + tlsServerException.getServer().getHost() + " appears to have changed." +
"\n\nThis may indicate a man-in-the-middle attack!" +
@@ -268,18 +268,28 @@ public class HeadersController extends TransactionFormController implements Init
return headersForm;
}
private void setTransactionLocktime(Transaction tx, long locktime) {
tx.setLocktime(locktime);
if(headersForm.getPsbt() != null) {
headersForm.getPsbt().setFallbackLocktime(locktime);
}
}
private void initializeView() {
Transaction tx = headersForm.getTransaction();
updateTxId();
version.setValueFactory(new IntegerSpinner.ValueFactory(1, 2, (int)tx.getVersion()));
version.setValueFactory(new IntegerSpinner.ValueFactory(1, 3, (int)tx.getVersion()));
version.valueProperty().addListener((obs, oldValue, newValue) -> {
if(newValue == null || newValue < 1 || newValue > 2) {
if(newValue == null || newValue < 1 || newValue > 3) {
return;
}
tx.setVersion(newValue);
if(headersForm.getPsbt() != null) {
headersForm.getPsbt().setTxVersion((long)newValue);
}
if(oldValue != null) {
EventManager.get().post(new TransactionChangedEvent(tx));
}
@@ -296,7 +306,7 @@ public class HeadersController extends TransactionFormController implements Init
locktimeFieldset.getChildren().remove(locktimeBlockField);
locktimeFieldset.getChildren().remove(locktimeNoneField);
locktimeFieldset.getChildren().add(locktimeNoneField);
tx.setLocktime(0);
setTransactionLocktime(tx, 0);
if(old_toggle != null) {
EventManager.get().post(new TransactionChangedEvent(tx));
}
@@ -309,7 +319,7 @@ public class HeadersController extends TransactionFormController implements Init
if(block != null) {
locktimeCurrentHeight.setVisible(headersForm.isEditable() && AppServices.getCurrentBlockHeight() != null && block < AppServices.getCurrentBlockHeight());
futureBlockWarning.setVisible(AppServices.getCurrentBlockHeight() != null && block > AppServices.getCurrentBlockHeight());
tx.setLocktime(block);
setTransactionLocktime(tx, block);
if(old_toggle != null) {
EventManager.get().post(new TransactionChangedEvent(tx));
}
@@ -323,7 +333,7 @@ public class HeadersController extends TransactionFormController implements Init
if(date != null) {
locktimeDate.setDateTimeValue(date);
futureDateWarning.setVisible(date.isAfter(LocalDateTime.now()));
tx.setLocktime(date.toEpochSecond(OffsetDateTime.now(ZoneId.systemDefault()).getOffset()));
setTransactionLocktime(tx, date.atZone(ZoneId.systemDefault()).toEpochSecond());
if(old_toggle != null) {
EventManager.get().post(new TransactionChangedEvent(tx));
}
@@ -361,7 +371,7 @@ public class HeadersController extends TransactionFormController implements Init
return;
}
tx.setLocktime(newValue);
setTransactionLocktime(tx, newValue);
locktimeCurrentHeight.setVisible(headersForm.isEditable() && AppServices.getCurrentBlockHeight() != null && newValue < AppServices.getCurrentBlockHeight());
futureBlockWarning.setVisible(AppServices.getCurrentBlockHeight() != null && newValue > AppServices.getCurrentBlockHeight());
if(oldValue != null) {
@@ -386,7 +396,7 @@ public class HeadersController extends TransactionFormController implements Init
int caret = locktimeDate.getEditor().getCaretPosition();
locktimeDate.getEditor().setText(newValue.format(DateTimeFormatter.ofPattern(locktimeDate.getFormat())));
locktimeDate.getEditor().positionCaret(caret);
tx.setLocktime(newValue.toEpochSecond(OffsetDateTime.now(ZoneId.systemDefault()).getOffset()));
setTransactionLocktime(tx, newValue.atZone(ZoneId.systemDefault()).toEpochSecond());
futureDateWarning.setVisible(newValue.isAfter(LocalDateTime.now()));
if(oldValue != null) {
EventManager.get().post(new TransactionChangedEvent(tx));
@@ -1441,7 +1451,7 @@ public class HeadersController extends TransactionFormController implements Init
public void transactionChanged(TransactionChangedEvent event) {
if(headersForm.getTransaction().equals(event.getTransaction())) {
updateTxId();
boolean locktimeEnabled = headersForm.getTransaction().isLocktimeSequenceEnabled();
boolean locktimeEnabled = headersForm.isEditable() && headersForm.getTransaction().isLocktimeSequenceEnabled();
locktimeNoneType.setDisable(!locktimeEnabled);
locktimeBlockType.setDisable(!locktimeEnabled);
locktimeBlock.setDisable(!locktimeEnabled);
@@ -330,14 +330,14 @@ public class InputController extends TransactionFormController implements Initia
if(txInput.isAbsoluteTimeLockDisabled()) {
locktimeToggleGroup.selectToggle(locktimeAbsoluteType);
} else if(txInput.isAbsoluteTimeLocked()) {
txInput.setSequenceNumber(TransactionInput.SEQUENCE_RBF_ENABLED);
setInputSequenceNumber(txInput, TransactionInput.SEQUENCE_RBF_ENABLED);
if(oldValue != null) {
EventManager.get().post(new TransactionChangedEvent(transaction));
}
}
} else {
if(txInput.isAbsoluteTimeLocked()) {
txInput.setSequenceNumber(TransactionInput.SEQUENCE_RBF_DISABLED);
setInputSequenceNumber(txInput, TransactionInput.SEQUENCE_RBF_DISABLED);
if(oldValue != null) {
EventManager.get().post(new TransactionChangedEvent(transaction));
}
@@ -366,6 +366,13 @@ public class InputController extends TransactionFormController implements Initia
}
}
private void setInputSequenceNumber(TransactionInput txInput, long sequence) {
txInput.setSequenceNumber(sequence);
if(inputForm.getPsbtInput() != null) {
inputForm.getPsbtInput().setSequence(sequence);
}
}
private void initializeLocktimeFields(TransactionInput txInput) {
Transaction transaction = inputForm.getTransaction();
locktimeToggleGroup.selectedToggleProperty().addListener((ov, old_toggle, new_toggle) -> {
@@ -376,7 +383,7 @@ public class InputController extends TransactionFormController implements Initia
locktimeFieldset.getChildren().add(locktimeAbsoluteField);
updateAbsoluteLocktimeField(transaction);
locktimeAbsoluteField.setDisable(true);
txInput.setSequenceNumber(TransactionInput.SEQUENCE_LOCKTIME_DISABLED);
setInputSequenceNumber(txInput, TransactionInput.SEQUENCE_LOCKTIME_DISABLED);
rbf.setSelected(false);
if(old_toggle != null) {
EventManager.get().post(new TransactionChangedEvent(transaction));
@@ -387,9 +394,9 @@ public class InputController extends TransactionFormController implements Initia
updateAbsoluteLocktimeField(transaction);
locktimeAbsoluteField.setDisable(false);
if(rbf.selectedProperty().getValue()) {
txInput.setSequenceNumber(TransactionInput.SEQUENCE_RBF_ENABLED);
setInputSequenceNumber(txInput, TransactionInput.SEQUENCE_RBF_ENABLED);
} else {
txInput.setSequenceNumber(TransactionInput.SEQUENCE_RBF_DISABLED);
setInputSequenceNumber(txInput, TransactionInput.SEQUENCE_RBF_DISABLED);
}
if(old_toggle != null) {
EventManager.get().post(new TransactionChangedEvent(transaction));
@@ -468,10 +475,10 @@ public class InputController extends TransactionFormController implements Initia
String relativeSelection = locktimeRelativeCombo.getValue();
if(relativeSelection.equals("blocks")) {
Integer value = locktimeRelativeBlocks.getValue();
txInput.setSequenceNumber(value & TransactionInput.RELATIVE_TIMELOCK_VALUE_MASK);
setInputSequenceNumber(txInput, value & TransactionInput.RELATIVE_TIMELOCK_VALUE_MASK);
} else {
long value = locktimeRelativeSeconds.getValue().toSeconds() / TransactionInput.RELATIVE_TIMELOCK_SECONDS_INCREMENT;
txInput.setSequenceNumber((value & TransactionInput.RELATIVE_TIMELOCK_VALUE_MASK) | TransactionInput.RELATIVE_TIMELOCK_TYPE_FLAG);
setInputSequenceNumber(txInput, (value & TransactionInput.RELATIVE_TIMELOCK_VALUE_MASK) | TransactionInput.RELATIVE_TIMELOCK_TYPE_FLAG);
}
if(changed) {
EventManager.get().post(new TransactionChangedEvent(transaction));
-1
View File
@@ -44,7 +44,6 @@ open module com.sparrowwallet.sparrow {
requires io.reactivex.rxjava2fx;
requires org.apache.commons.lang3;
requires org.apache.commons.compress;
requires net.sourceforge.streamsupport;
requires co.nstant.in.cbor;
requires com.github.librepdf.openpdf;
requires com.googlecode.lanterna;
@@ -38,7 +38,7 @@
-fx-base: -fx-default-button;
}
.split-menu-button.default-button > .label, .menu-item.default-button > .label {
.split-menu-button > .label, .menu-item.default-button > .label {
-fx-text-fill: white;
}
@@ -0,0 +1,111 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
public class DescriptorTest extends IoTest {
@Test
public void testImport() throws ImportException {
Descriptor descriptor = new Descriptor();
Wallet wallet = descriptor.importWallet(getInputStream("descriptor-receive.txt"), null);
Assertions.assertEquals(ScriptType.P2WPKH, wallet.getScriptType());
Keystore keystore = wallet.getKeystores().getFirst();
Assertions.assertEquals("m/84'/0'/0'", keystore.getKeyDerivation().getDerivationPath());
Assertions.assertEquals("a262308d", keystore.getKeyDerivation().getMasterFingerprint());
Assertions.assertEquals(ExtendedKey.fromDescriptor("xpub6DM7CYgaTMdMbhTcLTUWmNUE5WLXK5hx8ZMa4sRw8qYJPqtqKYiKnwsmT8A6AijDVAUZRivdBnXdR8QE7Y9vVnqvzPL3fXCmu1WtCRLdAoz"), keystore.getExtendedPublicKey());
Assertions.assertTrue(keystore.isValid());
}
@Test
public void testImportMultipath() throws ImportException {
Descriptor descriptor = new Descriptor();
Wallet wallet = descriptor.importWallet(getInputStream("descriptor-multipath.txt"), null);
Assertions.assertEquals(ScriptType.P2WPKH, wallet.getScriptType());
Keystore keystore = wallet.getKeystores().getFirst();
Assertions.assertEquals("m/84'/0'/0'", keystore.getKeyDerivation().getDerivationPath());
Assertions.assertEquals("a262308d", keystore.getKeyDerivation().getMasterFingerprint());
Assertions.assertEquals(ExtendedKey.fromDescriptor("xpub6DM7CYgaTMdMbhTcLTUWmNUE5WLXK5hx8ZMa4sRw8qYJPqtqKYiKnwsmT8A6AijDVAUZRivdBnXdR8QE7Y9vVnqvzPL3fXCmu1WtCRLdAoz"), keystore.getExtendedPublicKey());
Assertions.assertTrue(keystore.isValid());
}
@Test
public void testImportSeparateDescriptors() throws ImportException {
Descriptor descriptor = new Descriptor();
Wallet wallet = descriptor.importWallet(getInputStream("descriptor-receive-change1.txt"), null);
Assertions.assertEquals(ScriptType.P2WPKH, wallet.getScriptType());
Keystore keystore = wallet.getKeystores().getFirst();
Assertions.assertEquals("m/84'/0'/0'", keystore.getKeyDerivation().getDerivationPath());
Assertions.assertEquals("a262308d", keystore.getKeyDerivation().getMasterFingerprint());
Assertions.assertEquals(ExtendedKey.fromDescriptor("xpub6DM7CYgaTMdMbhTcLTUWmNUE5WLXK5hx8ZMa4sRw8qYJPqtqKYiKnwsmT8A6AijDVAUZRivdBnXdR8QE7Y9vVnqvzPL3fXCmu1WtCRLdAoz"), keystore.getExtendedPublicKey());
Assertions.assertTrue(keystore.isValid());
}
@Test
public void testExport() throws ImportException, ExportException {
Descriptor descriptor = new Descriptor();
Wallet wallet = descriptor.importWallet(getInputStream("descriptor-multipath.txt"), null);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
descriptor.exportWallet(wallet, baos, null);
String export = baos.toString();
Assertions.assertTrue(export.contains("wpkh([a262308d/84h/0h/0h]xpub6DM7CYgaTMdMbhTcLTUWmNUE5WLXK5hx8ZMa4sRw8qYJPqtqKYiKnwsmT8A6AijDVAUZRivdBnXdR8QE7Y9vVnqvzPL3fXCmu1WtCRLdAoz/<0;1>/*)#cpx4ean7"));
Assertions.assertTrue(export.contains("wpkh([a262308d/84h/0h/0h]xpub6DM7CYgaTMdMbhTcLTUWmNUE5WLXK5hx8ZMa4sRw8qYJPqtqKYiKnwsmT8A6AijDVAUZRivdBnXdR8QE7Y9vVnqvzPL3fXCmu1WtCRLdAoz/0/*)#s5x06kda"));
Assertions.assertTrue(export.contains("wpkh([a262308d/84h/0h/0h]xpub6DM7CYgaTMdMbhTcLTUWmNUE5WLXK5hx8ZMa4sRw8qYJPqtqKYiKnwsmT8A6AijDVAUZRivdBnXdR8QE7Y9vVnqvzPL3fXCmu1WtCRLdAoz/1/*)#pqrw8ra9"));
}
@Test
public void testImportExport() throws ImportException, ExportException {
Descriptor descriptor = new Descriptor();
Wallet wallet = descriptor.importWallet(getInputStream("descriptor-multipath.txt"), null);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
descriptor.exportWallet(wallet, baos, null);
Wallet reimported = descriptor.importWallet(new ByteArrayInputStream(baos.toByteArray()), null);
Assertions.assertEquals(wallet.getScriptType(), reimported.getScriptType());
Keystore keystore = wallet.getKeystores().getFirst();
Keystore reimportedKeystore = reimported.getKeystores().getFirst();
Assertions.assertEquals(keystore.getKeyDerivation().getDerivationPath(), reimportedKeystore.getKeyDerivation().getDerivationPath());
Assertions.assertEquals(keystore.getKeyDerivation().getMasterFingerprint(), reimportedKeystore.getKeyDerivation().getMasterFingerprint());
Assertions.assertEquals(keystore.getExtendedPublicKey(), reimportedKeystore.getExtendedPublicKey());
Assertions.assertTrue(reimportedKeystore.isValid());
}
@Test
public void testImportLabelled() throws ImportException {
Descriptor descriptor = new Descriptor();
Wallet wallet = descriptor.importWallet(getInputStream("descriptor-labelled.txt"), null);
Assertions.assertEquals(ScriptType.P2WPKH, wallet.getScriptType());
Keystore keystore = wallet.getKeystores().getFirst();
Assertions.assertEquals("m/84'/0'/0'", keystore.getKeyDerivation().getDerivationPath());
Assertions.assertEquals("a262308d", keystore.getKeyDerivation().getMasterFingerprint());
Assertions.assertEquals(ExtendedKey.fromDescriptor("xpub6DM7CYgaTMdMbhTcLTUWmNUE5WLXK5hx8ZMa4sRw8qYJPqtqKYiKnwsmT8A6AijDVAUZRivdBnXdR8QE7Y9vVnqvzPL3fXCmu1WtCRLdAoz"), keystore.getExtendedPublicKey());
Assertions.assertTrue(keystore.isValid());
}
@Test
public void testImportSeparateDescriptorsNoBlankLine() throws ImportException {
Descriptor descriptor = new Descriptor();
Wallet wallet = descriptor.importWallet(getInputStream("descriptor-receive-change2.txt"), null);
Assertions.assertEquals(ScriptType.P2WPKH, wallet.getScriptType());
Keystore keystore = wallet.getKeystores().getFirst();
Assertions.assertEquals("m/84'/0'/0'", keystore.getKeyDerivation().getDerivationPath());
Assertions.assertEquals("a262308d", keystore.getKeyDerivation().getMasterFingerprint());
Assertions.assertEquals(ExtendedKey.fromDescriptor("xpub6DM7CYgaTMdMbhTcLTUWmNUE5WLXK5hx8ZMa4sRw8qYJPqtqKYiKnwsmT8A6AijDVAUZRivdBnXdR8QE7Y9vVnqvzPL3fXCmu1WtCRLdAoz"), keystore.getExtendedPublicKey());
Assertions.assertTrue(keystore.isValid());
}
}
@@ -0,0 +1 @@
Receive: wpkh([a262308d/84h/0h/0h]xpub6DM7CYgaTMdMbhTcLTUWmNUE5WLXK5hx8ZMa4sRw8qYJPqtqKYiKnwsmT8A6AijDVAUZRivdBnXdR8QE7Y9vVnqvzPL3fXCmu1WtCRLdAoz/0/*)#s5x06kda
@@ -0,0 +1 @@
wpkh([a262308d/84h/0h/0h]xpub6DM7CYgaTMdMbhTcLTUWmNUE5WLXK5hx8ZMa4sRw8qYJPqtqKYiKnwsmT8A6AijDVAUZRivdBnXdR8QE7Y9vVnqvzPL3fXCmu1WtCRLdAoz/<0;1>/*)#cpx4ean7
@@ -0,0 +1,3 @@
wpkh([a262308d/84h/0h/0h]xpub6DM7CYgaTMdMbhTcLTUWmNUE5WLXK5hx8ZMa4sRw8qYJPqtqKYiKnwsmT8A6AijDVAUZRivdBnXdR8QE7Y9vVnqvzPL3fXCmu1WtCRLdAoz/0/*)#s5x06kda
wpkh([a262308d/84h/0h/0h]xpub6DM7CYgaTMdMbhTcLTUWmNUE5WLXK5hx8ZMa4sRw8qYJPqtqKYiKnwsmT8A6AijDVAUZRivdBnXdR8QE7Y9vVnqvzPL3fXCmu1WtCRLdAoz/1/*)#pqrw8ra9
@@ -0,0 +1,2 @@
wpkh([a262308d/84h/0h/0h]xpub6DM7CYgaTMdMbhTcLTUWmNUE5WLXK5hx8ZMa4sRw8qYJPqtqKYiKnwsmT8A6AijDVAUZRivdBnXdR8QE7Y9vVnqvzPL3fXCmu1WtCRLdAoz/0/*)#s5x06kda
wpkh([a262308d/84h/0h/0h]xpub6DM7CYgaTMdMbhTcLTUWmNUE5WLXK5hx8ZMa4sRw8qYJPqtqKYiKnwsmT8A6AijDVAUZRivdBnXdR8QE7Y9vVnqvzPL3fXCmu1WtCRLdAoz/1/*)#pqrw8ra9
@@ -0,0 +1 @@
wpkh([a262308d/84h/0h/0h]xpub6DM7CYgaTMdMbhTcLTUWmNUE5WLXK5hx8ZMa4sRw8qYJPqtqKYiKnwsmT8A6AijDVAUZRivdBnXdR8QE7Y9vVnqvzPL3fXCmu1WtCRLdAoz/0/*)#s5x06kda