Compare commits

...
68 Commits
Author SHA1 Message Date
Craig Raw 146e6cbdc4 improve context menu item text fill on dark theme for default split menu buttons 2026-02-09 13:56:36 +02:00
Craig Raw e4fa0c9356 bump to v2.4.0 2026-02-09 12:42:16 +02:00
Craig Raw 30719c5a7f final address chunk contrast tweaking 2026-02-09 12:04:32 +02:00
Craig Raw a120d08ea1 remove extension-less file associations which are no longer supported by jpackage 2026-02-06 17:22:13 +02:00
Craig Raw b51bd90261 add workaround for jpackage issue where mime-info is not generated on linux 2026-02-06 16:28:33 +02:00
Craig Raw a765105d34 reduce contrast on address chunks 2026-02-06 13:34:06 +02:00
Craig Raw 2cd7d0bdf3 respect configured bitcoin unit on transaction diagram and label 2026-02-06 12:07:02 +02:00
Craig Raw c6c24028d9 followup 2026-02-06 11:18:18 +02:00
Craig Raw efb81555bd add view menu item to toggle address chunk display formatting 2026-02-05 15:38:41 +02:00
Craig Raw b11afdf2bb format display of addresses in tables only when the table is selected 2026-02-05 12:48:23 +02:00
Craig Raw 779a55c494 update readme to reflect java 25 requirement 2026-02-05 12:34:51 +02:00
Craig Raw b06d858794 followup 2026-02-05 12:01:53 +02:00
Craig Raw 306c025cbf clear tool cache, revert to setup-java action for reproducibility 2026-02-05 11:59:09 +02:00
Craig Raw b145bddf07 combine sdkman and setup-java actions 2026-02-05 11:22:24 +02:00
Craig Raw edee72d97d followup 2 2026-02-05 11:05:27 +02:00
Craig Raw b87650a740 followup 2026-02-05 10:58:42 +02:00
Craig Raw 0c9bbc2550 define sdkmanrc and reference it in github action 2026-02-05 10:55:08 +02:00
Craig Raw 088f8e302c upgrade kmp-tor to fix file deletion issue on windows 2026-02-04 16:59:42 +02:00
Craig Raw 4309216ad7 fix file association exception on windows 2026-02-04 14:31:05 +02:00
Craig Raw dffaf806cd remove warnings on gradle test task 2026-02-04 12:57:19 +02:00
Craig Raw 448067db54 upgrade to java 25.0.2 and javafx 25.0.2 2026-02-04 12:18:25 +02:00
Craig Raw 6b498c28c2 format display of addresses in 4 character chunks 2026-02-03 15:11:29 +02:00
Craig Raw 4724dc7700 further minor qr display dialog updates 2026-01-28 15:12:18 +02:00
Craig Raw 883558fd9c updates for java 25 compatibility 2026-01-28 13:47:33 +02:00
Craig Raw 9d576bc45d avoid scanning for legacy addresses on bitbox02 with wallet discovery 2026-01-23 09:34:19 +02:00
Craig Raw 49d807f31b fix trezor change detection on signing 2026-01-22 15:42:25 +02:00
Craig Raw 0c2ee6690f add connected device wallet discovery with configurable number of accounts to scan 2026-01-22 13:09:39 +02:00
Liz LightningandGitHub 85c81adc09 add user agent to coingecko historical rates call 2026-01-21 11:36:46 +02:00
Craig Raw 04de83706e improve qr encoding ui, save previous selection and add raw encoding for tx hex 2026-01-20 14:43:47 +02:00
Craig Raw ab99f1d392 fix handling of non-standard key derivations when writing output descriptors 2026-01-15 10:32:40 +02:00
Craig Raw 53857389b7 use precise fee rates estimate from mempool.space 2026-01-15 09:48:22 +02:00
Craig Raw 98576d40c6 convert from tilepane to gridpane to resolve mnemonic words layout issue 2026-01-14 16:16:54 +02:00
Craig Raw 96f7d4bdb3 add additional fee rate limit check for high fee transactions 2026-01-13 16:30:57 +02:00
Michele BalistreriandGitHub 0c67962730 add support for keycard via smart card interface 2026-01-13 15:11:54 +02:00
Craig Raw 0515d8056b add user agent to coingecko exchange source request 2026-01-13 14:49:25 +02:00
Craig Raw cf15760d34 avoid extraneous error logging on card enumeration 2026-01-13 14:07:52 +02:00
Craig Raw f28f15df66 remove unnecessary lark .gitmodules 2026-01-13 11:18:17 +02:00
Craig Raw 34900d2909 add any missing key path information to psbts once signing wallet is chosen 2026-01-03 14:21:44 +02:00
Craig Raw 430f2ee320 add warning when entering outdated slip132 values into a watch-only wallet of a different script type 2025-12-23 09:03:12 +02:00
Craig Raw 74160ba35f minor trezor v2 fixes 2025-12-20 10:41:35 +02:00
Craig Raw 59d85cdd05 improve thp pairing flow, and add passphrase session support 2025-12-18 13:12:54 +02:00
Craig Raw 0406421918 add trezor safe 7 noise config implementation 2025-12-17 14:21:38 +02:00
Craig Raw 4ddd09fe22 update to macos-15-intel runner 2025-12-11 11:59:52 +02:00
Craig Raw a49edb7c45 add ledger nano gen5 support 2025-12-11 11:08:51 +02:00
Craig Raw 5275cab436 update drongo for bip93 package export 2025-12-09 12:05:55 +02:00
Ian McKenzieandGitHub 4dcd463d92 add codex32 importer to software keystore import workflow 2025-12-09 12:04:35 +02:00
Craig Raw f900f6dcb2 minor ui changes to master private key import 2025-12-04 10:24:45 +02:00
Craig Raw b0ab6c9d42 ensure plugdev is added as a system group 2025-12-03 08:25:23 +02:00
Craig Raw b8a703add2 improve calculation of taproot tweaked keys 2025-12-01 11:38:25 +02:00
Craig Raw 4185a8dced update drongo with bech32 refactor 2025-11-27 07:38:49 +02:00
Craig Raw a608197038 followup to display address for watch-only wallets 2025-11-26 07:07:12 +02:00
Craig Raw b4630043eb update block logo fill for dark theme 2025-11-25 10:38:48 +02:00
Liz LightningandGitHub 6452bf8184 add block.xyz augur fee estimator 2025-11-25 10:32:33 +02:00
Craig Raw 71cb5852b3 always show display address for watch-only wallets 2025-11-25 09:51:13 +02:00
Craig Raw 8efee4eaae pass default derivation to usb device and card import panes 2025-11-25 09:43:43 +02:00
Craig Raw ac044c6f71 update drongo for bip32 tests 2025-11-24 11:44:27 +02:00
Craig Raw d2d45e5432 improve psbt/tx matching and ensure incoming psbt signatures are always verified 2025-11-24 11:18:29 +02:00
Craig Raw c16997eacc handle errors if silent payments psbt validation fails 2025-11-19 16:06:55 +02:00
Craig Raw 21543de007 minor updates to handle psbtv2 as the default internal representation 2025-11-19 11:57:58 +02:00
Craig Raw 6f772cb193 update to support bip375 2025-11-18 10:37:02 +02:00
Craig Raw a4634f6a3d update drongo for keycard wallet models 2025-11-12 16:58:35 +02:00
Michele BalistreriandGitHub a794dafdf9 add keycard shell importers for singlesig and multisig 2025-11-12 16:57:38 +02:00
Craig Raw 9fb19ebe8b add bech32m tests 2025-11-12 11:12:51 +02:00
Craig Raw 46a39225bd update to configurable app name in codesign action for macos 2025-11-11 16:37:23 +02:00
Craig Raw a0f7e2e65d use sparrowwallet action for macos codesigning 2025-11-10 12:50:22 +02:00
Craig Raw 39cb946e08 add frigate to detected electrum server capabilities list 2025-11-10 10:10:23 +02:00
Craig Raw 0894407b0b delete home folder on exit if empty 2025-11-07 08:10:13 +02:00
Craig Raw c76acb8d82 bump to v2.3.2 2025-11-06 13:00:07 +02:00
111 changed files with 5532 additions and 330 deletions
+27 -13
View File
@@ -10,48 +10,62 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [windows-2022, ubuntu-22.04, ubuntu-22.04-arm, macos-13, macos-14]
os: [windows-2022, ubuntu-22.04, ubuntu-22.04-arm, macos-15-intel, macos-14]
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
submodules: true
- name: Set up JDK 22.0.2
submodules: recursive
- name: Clear Java tool-cache for reproducibility
shell: bash
run: rm -rf "$RUNNER_TOOL_CACHE"/Java_*
- name: Set up JDK 25.0.2
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '22.0.2'
java-version: '25.0.2'
- name: Show Build Versions
run: ./gradlew -v
- name: Build with Gradle
run: ./gradlew jpackage
- name: Package zip distribution
if: ${{ runner.os == 'Windows' || runner.os == 'macOS' }}
- name: Codesign, package and notarize macOS distribution
if: ${{ runner.os == 'macOS' }}
uses: sparrowwallet/github-actions/codesign-macos@v1
with:
app-name: Sparrow
certificate: ${{ secrets.MACOS_CERTIFICATE }}
certificate-password: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
apple-id: ${{ secrets.MACOS_NOTARIZATION_APPLE_ID }}
team-id: ${{ secrets.MACOS_NOTARIZATION_TEAM_ID }}
notarization-password: ${{ secrets.MACOS_NOTARIZATION_PASSWORD }}
- name: Package Windows zip distribution
if: ${{ runner.os == 'Windows' }}
run: ./gradlew packageZipDistribution
- name: Package tar distribution
- name: Package Linux tar distribution
if: ${{ runner.os == 'Linux' }}
run: ./gradlew packageTarDistribution
- name: Repackage deb distribution
- name: Repackage Linux deb distribution
if: ${{ runner.os == 'Linux' }}
run: ./repackage.sh
- name: Upload Artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: Sparrow Build - ${{ runner.os }} ${{ runner.arch }}
path: |
build/jpackage/*
!build/jpackage/Sparrow/
!build/jpackage/Sparrow.app/
- name: Headless build with Gradle
if: ${{ runner.os == 'Linux' }}
run: ./gradlew -Djava.awt.headless=true clean jpackage
- name: Package headless tar distribution
- name: Package Linux headless tar distribution
if: ${{ runner.os == 'Linux' }}
run: ./gradlew -Djava.awt.headless=true packageTarDistribution
- name: Repackage headless deb distribution
- name: Repackage Linux headless deb distribution
if: ${{ runner.os == 'Linux' }}
run: ./repackage.sh
- name: Upload Headless Artifact
if: ${{ runner.os == 'Linux' }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: Sparrow Build - ${{ runner.os }} ${{ runner.arch }} Headless
path: |
+3
View File
@@ -0,0 +1,3 @@
# Enable auto-env through the sdkman_auto_env config
# Add key=value pairs of SDKs to use below
java=25.0.2-tem
+4 -4
View File
@@ -16,14 +16,14 @@ or for those without SSH credentials:
`git clone --recursive https://github.com/sparrowwallet/sparrow.git`
In order to build, Sparrow requires Java 22 or higher to be installed.
The release binaries are built with [Eclipse Temurin 22.0.2+9](https://github.com/adoptium/temurin22-binaries/releases/tag/jdk-22.0.2%2B9).
In order to build, Sparrow requires Java 25 or higher to be installed.
The release binaries are built with [Eclipse Temurin 25.0.2+10](https://github.com/adoptium/temurin25-binaries/releases/tag/jdk-25.0.2%2B10).
If you are using [SDKMAN](https://sdkman.io/), you can use `sdk env install` to ensure you have the correct version.
Other packages may also be necessary to build depending on the platform. On Debian/Ubuntu systems:
`sudo apt install -y rpm fakeroot binutils`
The Sparrow binaries can be built from source using
`./gradlew jpackage`
@@ -44,7 +44,7 @@ If you prefer to run Sparrow directly from source, it can be launched from withi
`./sparrow`
Java 22 or higher must be installed.
Java 25 or higher must be installed.
## Configuration
+23 -12
View File
@@ -1,9 +1,9 @@
plugins {
id 'application'
id 'org-openjfx-javafxplugin'
id 'org.beryx.jlink' version '3.1.3'
id 'org.gradlex.extra-java-module-info' version '1.13'
id 'io.matthewnelson.kmp.tor.resource-filterjar' version '408.16.3'
id 'org.beryx.jlink' version '3.2.1'
id 'org.gradlex.extra-java-module-info' version '1.13.1'
id 'io.matthewnelson.kmp.tor.resource-filterjar' version '408.21.0'
}
def os = org.gradle.internal.os.OperatingSystem.current()
@@ -20,7 +20,7 @@ if(System.getProperty("os.arch") == "aarch64") {
def headless = "true".equals(System.getProperty("java.awt.headless"))
group = 'com.sparrowwallet'
version = '2.3.1'
version = '2.4.0'
repositories {
mavenCentral()
@@ -32,7 +32,7 @@ tasks.withType(AbstractArchiveTask).configureEach {
}
javafx {
version = headless ? "18" : "23.0.2"
version = headless ? "18" : "25.0.2"
modules = [ 'javafx.controls', 'javafx.fxml', 'javafx.swing', 'javafx.graphics' ]
}
@@ -57,7 +57,7 @@ dependencies {
exclude group: 'org.slf4j'
}
implementation('org.flywaydb:flyway-core:9.22.3')
implementation('org.fxmisc.richtext:richtextfx:0.11.6')
implementation('org.fxmisc.richtext:richtextfx:0.11.7')
implementation('no.tornado:tornadofx-controls:1.0.4')
implementation('com.google.zxing:javase:3.4.0') {
exclude group: 'com.beust', module: 'jcommander'
@@ -74,8 +74,8 @@ dependencies {
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.2.1")
implementation("io.matthewnelson.kmp-tor:resource-exec-tor-gpl:408.16.3")
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') {
exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-common'
}
@@ -110,7 +110,7 @@ dependencies {
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.2.1')
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')
testRuntimeOnly('org.junit.platform:junit-platform-launcher')
@@ -135,7 +135,7 @@ processResources {
test {
useJUnitPlatform()
jvmArgs = ["--add-opens=java.base/java.io=ALL-UNNAMED", "--add-opens=java.base/java.io=com.google.gson", "--add-reads=org.flywaydb.core=java.desktop"]
jvmArgs = ["--add-opens=java.base/java.io=ALL-UNNAMED", "--enable-native-access=ALL-UNNAMED"]
}
application {
@@ -185,6 +185,7 @@ jlink {
requires 'jdk.crypto.cryptoki'
requires 'java.management'
requires 'io.leangen.geantyref'
requires 'static jdk.jfr'
uses 'org.eclipse.jetty.http.HttpFieldPreEncoder'
}
@@ -246,7 +247,7 @@ jlink {
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']
installerOptions = ['--file-associations', 'src/main/deploy/psbt.properties', '--file-associations', 'src/main/deploy/txn.properties', '--file-associations', 'src/main/deploy/asc.properties', '--license-file', 'LICENSE']
if(os.windows) {
installerOptions += ['--win-per-user-install', '--win-dir-chooser', '--win-menu', '--win-menu-group', 'Sparrow', '--win-shortcut', '--resource-dir', 'src/main/deploy/package/windows/']
imageOptions += ['--icon', 'src/main/deploy/package/windows/sparrow.ico']
@@ -265,7 +266,7 @@ jlink {
}
if(os.macOsX) {
installerOptions += ['--mac-sign', '--mac-signing-key-user-name', 'Craig Raw (UPLVMSK9D7)']
imageOptions += ['--icon', 'src/main/deploy/package/osx/sparrow.icns', '--resource-dir', 'src/main/deploy/package/osx/']
imageOptions += ['--icon', 'src/main/deploy/package/macos/sparrow.icns', '--resource-dir', 'src/main/deploy/package/macos/']
installerType = "dmg"
}
}
@@ -279,6 +280,9 @@ jlink {
if(os.linux) {
tasks.jlink.finalizedBy('addUserWritePermission', 'copyUdevRules')
tasks.jpackageImage.finalizedBy('prepareResourceDir')
if(!headless) {
tasks.jpackage.dependsOn('copyMimeInfo')
}
} else {
tasks.jlink.finalizedBy('addUserWritePermission')
}
@@ -314,6 +318,13 @@ tasks.register('prepareResourceDir', Copy) {
}
}
tasks.register('copyMimeInfo', Copy) {
mustRunAfter tasks.jpackageImage
from('src/main/deploy/package/linux')
into(layout.buildDirectory.dir('jpackage/Sparrow/lib'))
include('sparrowwallet-Sparrow-MimeInfo.xml')
}
static def getDirectorySize(File directory) {
long size = 0
if(directory.isFile()) {
+1 -1
View File
@@ -83,7 +83,7 @@ sudo apt install -y rpm fakeroot binutils
First, assign a temporary variable in your shell for the specific release you want to build. For the current one specify:
```shell
GIT_TAG="2.3.0"
GIT_TAG="2.3.1"
```
The project can then be initially cloned as follows:
+1 -1
Submodule drongo updated: e975cbe6f8...0a500ea002
+1 -1
Submodule lark updated: 10e8d9cd4b...36cf0c85dc
-2
View File
@@ -1,2 +0,0 @@
mime-type=x-scheme-handler/auth47
description=Auth47 Authentication URI
-2
View File
@@ -1,2 +0,0 @@
mime-type=x-scheme-handler/bitcoin
description=Bitcoin Scheme URI
-2
View File
@@ -1,2 +0,0 @@
mime-type=x-scheme-handler/lightning
description=LNURL URI
+1 -1
View File
@@ -26,7 +26,7 @@ case "$1" in
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
groupadd -r plugdev
fi
if ! groups "${SUDO_USER:-$(whoami)}" | grep -q plugdev; then
usermod -aG plugdev "${SUDO_USER:-$(whoami)}"
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
<mime-type type="application/psbt">
<comment>Partially Signed Bitcoin Transaction</comment>
<glob pattern="*.psbt"/>
</mime-type>
<mime-type type="application/bitcoin-transaction">
<comment>Bitcoin Transaction</comment>
<glob pattern="*.txn"/>
</mime-type>
<mime-type type="application/pgp-signature">
<comment>ASCII Armored File</comment>
<glob pattern="*.asc"/>
</mime-type>
<mime-type type="x-scheme-handler/bitcoin">
<comment>Bitcoin Scheme URI</comment>
</mime-type>
<mime-type type="x-scheme-handler/auth47">
<comment>Auth47 Authentication URI</comment>
</mime-type>
<mime-type type="x-scheme-handler/lightning">
<comment>LNURL URI</comment>
</mime-type>
</mime-info>
@@ -82,7 +82,7 @@ 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
groupadd -r plugdev
fi
if ! groups "${SUDO_USER:-$(whoami)}" | grep -q plugdev; then
usermod -aG plugdev "${SUDO_USER:-$(whoami)}"
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.3.1</string>
<string>2.4.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

@@ -90,6 +90,9 @@ public class AppController implements Initializable {
public static final String TRYING_ANOTHER_SERVER_MESSAGE = "trying another server...";
public static final String JPACKAGE_APP_PATH = "jpackage.app-path";
@FXML
private VBox rootBox;
@FXML
private MenuItem saveTransaction;
@@ -138,6 +141,13 @@ public class AppController implements Initializable {
@FXML
private ToggleGroup unitFormat;
@FXML
private CheckMenuItem chunkAddresses;
@FXML
private CheckMenuItem hideEmptyUsedAddresses;
private static final BooleanProperty hideEmptyUsedAddressesProperty = new SimpleBooleanProperty();
@FXML
private ToggleGroup theme;
@@ -145,10 +155,6 @@ public class AppController implements Initializable {
private CheckMenuItem openWalletsInNewWindows;
private static final BooleanProperty openWalletsInNewWindowsProperty = new SimpleBooleanProperty();
@FXML
private CheckMenuItem hideEmptyUsedAddresses;
private static final BooleanProperty hideEmptyUsedAddressesProperty = new SimpleBooleanProperty();
@FXML
private CheckMenuItem hideAmounts;
@@ -373,6 +379,13 @@ public class AppController implements Initializable {
Optional<Toggle> selectedFormatToggle = unitFormat.getToggles().stream().filter(toggle -> selectedFormat.equals(toggle.getUserData())).findFirst();
selectedFormatToggle.ifPresent(toggle -> unitFormat.selectToggle(toggle));
chunkAddresses.setSelected(Config.get().isChunkAddresses());
if(Config.get().isChunkAddresses()) {
rootBox.getStyleClass().add("chunk-addresses");
}
hideEmptyUsedAddressesProperty.set(Config.get().isHideEmptyUsedAddresses());
hideEmptyUsedAddresses.selectedProperty().bindBidirectional(hideEmptyUsedAddressesProperty);
Theme configTheme = Config.get().getTheme();
if(configTheme == null) {
configTheme = Theme.LIGHT;
@@ -385,8 +398,6 @@ public class AppController implements Initializable {
openWalletsInNewWindowsProperty.set(Config.get().isOpenWalletsInNewWindows());
openWalletsInNewWindows.selectedProperty().bindBidirectional(openWalletsInNewWindowsProperty);
hideEmptyUsedAddressesProperty.set(Config.get().isHideEmptyUsedAddresses());
hideEmptyUsedAddresses.selectedProperty().bindBidirectional(hideEmptyUsedAddressesProperty);
hideAmounts.setSelected(Config.get().isHideAmounts());
useHdCameraResolutionProperty.set(Config.get().getWebcamResolution() == null || Config.get().getWebcamResolution().isWidescreenAspect());
useHdCameraResolution.selectedProperty().bindBidirectional(useHdCameraResolutionProperty);
@@ -583,7 +594,7 @@ public class AppController implements Initializable {
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 groupadd -f -r plugdev
sudo usermod -aG plugdev `whoami`
""";
String home = System.getProperty(JPACKAGE_APP_PATH);
@@ -771,7 +782,8 @@ public class AppController implements Initializable {
byte[] txBytes = transaction.bitcoinSerialize();
UR ur = UR.fromBytes(txBytes);
BBQR bbqr = new BBQR(BBQRType.TXN, txBytes);
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, false, false);
String raw = Utils.bytesToHex(txBytes);
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(ur, bbqr, raw, false, false, QREncoding.UR);
qrDisplayDialog.initOwner(rootStack.getScene().getWindow());
qrDisplayDialog.showAndWait();
} catch(Exception e) {
@@ -873,7 +885,7 @@ public class AppController implements Initializable {
byte[] psbtBytes = transactionTabData.getPsbt().getForExport().serialize();
CryptoPSBT cryptoPSBT = new CryptoPSBT(psbtBytes);
BBQR bbqr = new BBQR(BBQRType.PSBT, psbtBytes);
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR(), bbqr, false, true, false);
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR(), bbqr, false, true, QREncoding.UR);
qrDisplayDialog.initOwner(rootStack.getScene().getWindow());
qrDisplayDialog.show();
}
@@ -945,6 +957,16 @@ public class AppController implements Initializable {
EventManager.get().post(new OpenWalletsNewWindowsStatusEvent(item.isSelected()));
}
public void chunkAddresses(ActionEvent event) {
CheckMenuItem item = (CheckMenuItem)event.getSource();
Config.get().setChunkAddresses(item.isSelected());
if(item.isSelected() && !rootBox.getStyleClass().contains("chunk-addresses")) {
rootBox.getStyleClass().add("chunk-addresses");
} else {
rootBox.getStyleClass().remove("chunk-addresses");
}
}
public void hideEmptyUsedAddresses(ActionEvent event) {
CheckMenuItem item = (CheckMenuItem)event.getSource();
Config.get().setHideEmptyUsedAddresses(item.isSelected());
@@ -1216,22 +1238,24 @@ public class AppController implements Initializable {
List<WalletForm> selectedWalletForms = getSelectedWalletForms();
WalletImportDialog dlg = new WalletImportDialog(selectedWalletForms);
dlg.initOwner(rootStack.getScene().getWindow());
Optional<Wallet> optionalWallet = dlg.showAndWait();
if(optionalWallet.isPresent()) {
Wallet wallet = optionalWallet.get();
Optional<List<Wallet>> optionalWallets = dlg.showAndWait();
if(optionalWallets.isPresent()) {
List<Wallet> wallets = optionalWallets.get();
List<WalletTabData> walletTabData = getOpenWalletTabData();
List<ExtendedKey> xpubs = wallet.getKeystores().stream().map(Keystore::getExtendedPublicKey).collect(Collectors.toList());
Optional<WalletForm> optNewWalletForm = walletTabData.stream()
.map(WalletTabData::getWalletForm)
.filter(wf -> wf.getSettingsWalletForm() != null && wf.getSettingsWalletForm().getWallet().getPolicyType() == PolicyType.MULTI &&
wf.getSettingsWalletForm().getWallet().getScriptType() == wallet.getScriptType() && !wf.getSettingsWalletForm().getWallet().isValid() &&
wf.getSettingsWalletForm().getWallet().getKeystores().stream().map(Keystore::getExtendedPublicKey).anyMatch(xpubs::contains)).findFirst();
if(optNewWalletForm.isPresent()) {
EventManager.get().post(new ExistingWalletImportedEvent(optNewWalletForm.get().getWalletId(), wallet));
selectTab(optNewWalletForm.get().getWallet());
} else if(selectedWalletForms.isEmpty() || wallet != selectedWalletForms.get(0).getWallet()) {
addImportedWallet(wallet);
for(Wallet wallet : wallets) {
List<WalletTabData> walletTabData = getOpenWalletTabData();
List<ExtendedKey> xpubs = wallet.getKeystores().stream().map(Keystore::getExtendedPublicKey).collect(Collectors.toList());
Optional<WalletForm> optNewWalletForm = walletTabData.stream()
.map(WalletTabData::getWalletForm)
.filter(wf -> wf.getSettingsWalletForm() != null && wf.getSettingsWalletForm().getWallet().getPolicyType() == PolicyType.MULTI &&
wf.getSettingsWalletForm().getWallet().getScriptType() == wallet.getScriptType() && !wf.getSettingsWalletForm().getWallet().isValid() &&
wf.getSettingsWalletForm().getWallet().getKeystores().stream().map(Keystore::getExtendedPublicKey).anyMatch(xpubs::contains)).findFirst();
if(optNewWalletForm.isPresent()) {
EventManager.get().post(new ExistingWalletImportedEvent(optNewWalletForm.get().getWalletId(), wallet));
selectTab(optNewWalletForm.get().getWallet());
} else if(selectedWalletForms.isEmpty() || wallet != selectedWalletForms.get(0).getWallet()) {
addImportedWallet(wallet);
}
}
}
}
@@ -1911,11 +1935,6 @@ public class AppController implements Initializable {
}
private void addTransactionTab(String name, File file, PSBT psbt) {
//Convert to PSBTv0 first
if(psbt.getVersion() != null && psbt.getVersion() >= 2) {
psbt.convertVersion(0);
}
//Add any missing previous outputs if available in open wallets
for(PSBTInput psbtInput : psbt.getPsbtInputs()) {
if(psbtInput.getUtxo() == null) {
@@ -1991,39 +2010,13 @@ public class AppController implements Initializable {
private void addTransactionTab(String name, File file, Transaction transaction, PSBT psbt, BlockTransaction blockTransaction, TransactionView initialView, Integer initialIndex) {
for(Tab tab : tabs.getTabs()) {
TabData tabData = (TabData)tab.getUserData();
if(tabData instanceof TransactionTabData) {
TransactionTabData transactionTabData = (TransactionTabData)tabData;
if(tabData instanceof TransactionTabData transactionTabData) {
if(isExistingTransaction(transactionTabData, transaction, psbt, getTabName(tab))) {
handleTransactionMerge(transactionTabData, psbt, name, tab);
return;
}
//If an exact match bytewise of an existing tab, return that tab
if(Arrays.equals(transactionTabData.getTransaction().bitcoinSerialize(), transaction.bitcoinSerialize())) {
if(transactionTabData.getPsbt() != null && psbt != null && !transactionTabData.getPsbt().isFinalized()) {
if(!psbt.isFinalized()) {
//As per BIP174, combine PSBTs with matching transactions so long as they are not yet finalized
transactionTabData.getPsbt().combine(psbt);
if(name != null && !name.isEmpty()) {
((Label)tab.getGraphic()).setText(name);
}
EventManager.get().post(new PSBTCombinedEvent(transactionTabData.getPsbt()));
} else {
//If the new PSBT is finalized, copy the finalized fields to the existing unfinalized PSBT
for(int i = 0; i < transactionTabData.getPsbt().getPsbtInputs().size(); i++) {
PSBTInput existingInput = transactionTabData.getPsbt().getPsbtInputs().get(i);
PSBTInput finalizedInput = psbt.getPsbtInputs().get(i);
existingInput.setFinalScriptSig(finalizedInput.getFinalScriptSig());
existingInput.setFinalScriptWitness(finalizedInput.getFinalScriptWitness());
existingInput.clearNonFinalFields();
}
if(name != null && !name.isEmpty()) {
((Label)tab.getGraphic()).setText(name);
}
EventManager.get().post(new PSBTFinalizedEvent(transactionTabData.getPsbt()));
}
}
tabs.getSelectionModel().select(tab);
if(transactionTabData.getPsbt() != null && transactionTabData.getPsbt().possibleUnverifiableSilentPaymentsTransaction(transaction) && !openUnverifiableTransaction(getTabName(tab))) {
return;
}
}
@@ -2090,6 +2083,69 @@ public class AppController implements Initializable {
}
}
private boolean isExistingTransaction(TransactionTabData transactionTabData, Transaction transaction, PSBT psbt, String tabName) {
PSBT currentPsbt = transactionTabData.getPsbt();
Transaction currentTransaction = transactionTabData.getTransaction();
if(currentPsbt != null && psbt != null && currentPsbt.matches(psbt)) {
return true;
} else if(currentTransaction.getTxId().equals(transaction.getTxId())) {
if(currentTransaction.getWTxId().equals(transaction.getWTxId())) {
return true;
} else if(currentPsbt == null) {
AppServices.showWarningDialog("Suspicious Transaction",
"This transaction has the same txid as the transaction in tab " + tabName + ", but contains different witnesses. It will be opened in a separate tab.");
}
}
return false;
}
private void handleTransactionMerge(TransactionTabData transactionTabData, PSBT psbt, String name, Tab tab) {
PSBT currentPsbt = transactionTabData.getPsbt();
if(currentPsbt != null && psbt != null && !currentPsbt.isFinalized()) {
if(!psbt.isFinalized()) {
//As per BIP174, combine PSBTs with matching transactions so long as they are not yet finalized
try {
currentPsbt.verifyCombinedSignatures(psbt);
currentPsbt.combine(psbt);
setTabName(tab, name);
EventManager.get().post(new PSBTCombinedEvent(currentPsbt));
} catch(PSBTSignatureException e) {
AppServices.showErrorDialog("Invalid PSBT", e.getMessage());
}
} else {
//If the new PSBT is finalized, copy the finalized fields to the existing unfinalized PSBT
currentPsbt.copyFinalizedFields(psbt);
setTabName(tab, name);
EventManager.get().post(new PSBTFinalizedEvent(currentPsbt));
}
}
tabs.getSelectionModel().select(tab);
}
private boolean openUnverifiableTransaction(String tabName) {
Optional<ButtonType> result = AppServices.showWarningDialog(
"Unverifiable Silent Payments Transaction",
"This transaction contains an unverifiable silent payments output.\n\n" +
"The tab " + tabName + " contains a similar transaction spending to a silent payments address, " +
"but this transaction does not contain enough information to determine if the recipient address is correct.\n\n" +
"Open the transaction in another tab?", ButtonType.YES, ButtonType.NO);
return result.isPresent() && result.get() == ButtonType.YES;
}
private String getTabName(Tab tab) {
return ((Label)tab.getGraphic()).getText();
}
private void setTabName(Tab tab, String name){
if(name != null && !name.isEmpty()) {
((Label)tab.getGraphic()).setText(name);
}
}
private ContextMenu getTabContextMenu(Tab tab) {
ContextMenu contextMenu = new ContextMenu();
@@ -2565,7 +2621,7 @@ public class AppController implements Initializable {
if(event instanceof TransactionTabSelectedEvent) {
TransactionTabSelectedEvent txTabEvent = (TransactionTabSelectedEvent)event;
TransactionTabData transactionTabData = txTabEvent.getTransactionTabData();
if(transactionTabData.getPsbt() == null || transactionTabData.getPsbt().getTransaction() != transactionTabData.getTransaction()) {
if(transactionTabData.getPsbt() == null || !transactionTabData.getPsbt().getTransaction().getTxId().equals(transactionTabData.getTransaction().getTxId())) {
saveTransaction.setVisible(true);
saveTransaction.setDisable(false);
} else {
@@ -887,8 +887,13 @@ public class AppServices {
Stage stage = (Stage)window;
stage.getIcons().add(getWindowIcon());
if(stage.getScene() != null && Config.get().getTheme() == Theme.DARK) {
stage.getScene().getStylesheets().add(AppServices.class.getResource("darktheme.css").toExternalForm());
if(stage.getScene() != null) {
if(Config.get().getTheme() == Theme.DARK) {
stage.getScene().getStylesheets().add(AppServices.class.getResource("darktheme.css").toExternalForm());
}
if(Config.get().isChunkAddresses()) {
stage.getScene().getRoot().getStyleClass().add("chunk-addresses");
}
}
}
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptChunk;
import com.sparrowwallet.drongo.wallet.Keystore;
@@ -72,8 +73,7 @@ public abstract class BaseController {
StringBuilder builder = new StringBuilder();
builder.append("[");
builder.append(keystore.getKeyDerivation().getMasterFingerprint());
builder.append("/");
builder.append(keystore.getKeyDerivation().getDerivationPath().replaceFirst("^m?/", ""));
builder.append(KeyDerivation.writePath(KeyDerivation.parsePath(keystore.getKeyDerivation().getDerivationPath())).substring(1));
builder.append("]");
builder.append(keystore.getExtendedPublicKey().toString());
@@ -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.3.1";
public static final String APP_VERSION = "2.4.0";
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";
@@ -22,6 +22,7 @@ public class AddressLabel extends IdLabel {
public AddressLabel(String text) {
super(text);
setSkin(new AddressTextFieldSkin(this));
addressProperty().addListener((observable, oldValue, newValue) -> {
setAddressAsText(newValue);
contextMenu.copyHex.setText("Copy " + newValue.getOutputScriptDataType());
@@ -0,0 +1,147 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.Network;
import javafx.beans.value.ChangeListener;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Label;
import javafx.scene.control.skin.LabelSkin;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class AddressLabelSkin extends LabelSkin {
public static final int CHUNK_SIZE = 4;
public static final Pattern CHUNK_PATTERN = Pattern.compile("(?<=\\G.{" + CHUNK_SIZE + "})");
private final TextFlow displayFlow;
private final ChangeListener<String> textListener;
private final ChangeListener<Font> fontListener;
public AddressLabelSkin(Label control) {
super(control);
displayFlow = new TextFlow();
displayFlow.setMouseTransparent(true);
getChildren().addFirst(displayFlow);
textListener = (_, _, newText) -> updateDisplay(newText);
fontListener = (_, _, _) -> updateDisplay(control.getText());
control.textProperty().addListener(textListener);
control.fontProperty().addListener(fontListener);
updateDisplay(control.getText());
control.setStyle("-fx-text-fill: transparent;");
}
@Override
public void dispose() {
getSkinnable().textProperty().removeListener(textListener);
getSkinnable().fontProperty().removeListener(fontListener);
super.dispose();
}
private void updateDisplay(String text) {
displayFlow.getChildren().clear();
if(text == null || text.isEmpty()) {
return;
}
List<AddressSpan> addresses = findAddresses(text);
int pos = 0;
for(AddressSpan span : addresses) {
if(span.start > pos) {
Text normalText = createText(text.substring(pos, span.start), false);
displayFlow.getChildren().add(normalText);
}
addChunkedAddress(text.substring(span.start, span.end));
pos = span.end;
}
if(pos < text.length()) {
Text normalText = createText(text.substring(pos), false);
displayFlow.getChildren().add(normalText);
}
}
private void addChunkedAddress(String address) {
String[] chunks = CHUNK_PATTERN.split(address);
for(int i = 0; i < chunks.length; i++) {
Text chunk = createText(chunks[i], i % 2 != 0);
displayFlow.getChildren().add(chunk);
}
}
private Text createText(String content, boolean alternate) {
Text text = new Text(content);
text.setFont(getSkinnable().getFont());
text.getStyleClass().add("address-chunk");
if(alternate) {
text.getStyleClass().add("alternate");
}
return text;
}
private List<AddressSpan> findAddresses(String text) {
List<AddressSpan> spans = new ArrayList<>();
Pattern wordBoundary = Pattern.compile("\\S+");
Matcher matcher = wordBoundary.matcher(text);
while(matcher.find()) {
String candidate = matcher.group();
if(isValidAddress(candidate)) {
spans.add(new AddressSpan(matcher.start(), matcher.end()));
}
}
return spans;
}
private boolean isValidAddress(String candidate) {
Network network = Network.get();
return network.hasP2PKHAddressPrefix(candidate) || network.hasP2SHAddressPrefix(candidate) ||
candidate.startsWith(network.getBech32AddressHRP()) || candidate.startsWith(network.getSilentPaymentsAddressHrp());
}
@Override
protected void updateChildren() {
super.updateChildren();
if(displayFlow != null && !getChildren().contains(displayFlow)) {
getChildren().addFirst(displayFlow);
}
}
@Override
protected void layoutChildren(double x, double y, double w, double h) {
super.layoutChildren(x, y, w, h);
// Position TextFlow to align with the label's text area
Label label = getSkinnable();
Insets padding = label.getPadding();
Node graphic = label.getGraphic();
double graphicOffset = 0;
if(graphic != null && label.getContentDisplay() == ContentDisplay.LEFT) {
graphicOffset = graphic.getLayoutBounds().getWidth() + label.getGraphicTextGap();
}
displayFlow.resizeRelocate(
x + padding.getLeft() + graphicOffset,
y + padding.getTop(),
w - padding.getLeft() - padding.getRight() - graphicOffset,
h - padding.getTop() - padding.getBottom()
);
}
private record AddressSpan(int start, int end) {}
}
@@ -0,0 +1,273 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.protocol.Base58;
import com.sparrowwallet.drongo.protocol.Bech32;
import impl.org.controlsfx.skin.CustomTextFieldSkin;
import javafx.beans.property.ObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.TextField;
import javafx.scene.layout.Region;
import javafx.scene.shape.Path;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import org.controlsfx.control.textfield.CustomTextField;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class AddressTextFieldSkin extends CustomTextFieldSkin {
private static final boolean[] BASE58_OK = buildOkTable(new String(Base58.ALPHABET));
private static final boolean[] BECH32_DATA_OK = buildOkTable(Bech32.CHARSET);
private final TextFlow displayFlow;
private final Rectangle clip;
private final ChangeListener<String> textListener;
private final ChangeListener<Font> fontListener;
public AddressTextFieldSkin(TextField control) {
super(control);
displayFlow = new TextFlow();
displayFlow.setMouseTransparent(true);
clip = new Rectangle();
displayFlow.setClip(clip);
getChildren().addFirst(displayFlow);
textListener = (_, _, newText) -> updateDisplay(newText);
fontListener = (_, _, _) -> updateDisplay(control.getText());
control.textProperty().addListener(textListener);
control.fontProperty().addListener(fontListener);
updateDisplay(control.getText());
control.setStyle("-fx-text-fill: transparent;");
// Unbind caret color since it's normally bound to textFill
unbindCaretColor(getChildren());
}
@Override
public void dispose() {
getSkinnable().textProperty().removeListener(textListener);
getSkinnable().fontProperty().removeListener(fontListener);
super.dispose();
}
private void unbindCaretColor(javafx.collections.ObservableList<Node> children) {
for(Node node : children) {
if(node instanceof Path path && path.getStroke() != null) {
path.fillProperty().unbind();
path.strokeProperty().unbind();
path.getStyleClass().add("address-field-caret");
} else if(node instanceof javafx.scene.Parent parent) {
unbindCaretColor(parent.getChildrenUnmodifiable());
}
}
}
@Override
public ObjectProperty<Node> leftProperty() {
if(getSkinnable() instanceof CustomTextField customTextField) {
return customTextField.leftProperty();
}
return new SimpleObjectProperty<>();
}
@Override
public ObjectProperty<Node> rightProperty() {
if(getSkinnable() instanceof CustomTextField customTextField) {
return customTextField.rightProperty();
}
return new SimpleObjectProperty<>();
}
private void updateDisplay(String text) {
displayFlow.getChildren().clear();
if(text == null || text.isEmpty()) {
return;
}
List<AddressSpan> addresses = findAddresses(text);
int pos = 0;
for(AddressSpan span : addresses) {
if(span.start > pos) {
Text normalText = createText(text.substring(pos, span.start), false);
displayFlow.getChildren().add(normalText);
}
addChunkedAddress(text.substring(span.start, span.end));
pos = span.end;
}
if(pos < text.length()) {
Text normalText = createText(text.substring(pos), false);
displayFlow.getChildren().add(normalText);
}
}
private void addChunkedAddress(String address) {
String[] chunks = AddressLabelSkin.CHUNK_PATTERN.split(address);
for(int i = 0; i < chunks.length; i++) {
Text chunk = createText(chunks[i], i % 2 != 0);
displayFlow.getChildren().add(chunk);
}
}
private Text createText(String content, boolean alternate) {
Text text = new Text(content);
text.setFont(getSkinnable().getFont());
text.getStyleClass().add("address-chunk");
if(alternate) {
text.getStyleClass().add("alternate");
}
return text;
}
private List<AddressSpan> findAddresses(String text) {
List<AddressSpan> spans = new ArrayList<>();
Pattern wordBoundary = Pattern.compile("\\S+");
Matcher matcher = wordBoundary.matcher(text);
while(matcher.find()) {
String candidate = matcher.group();
if(isValidAddress(candidate)) {
spans.add(new AddressSpan(matcher.start(), matcher.end()));
}
}
return spans;
}
private boolean isValidAddress(String candidate) {
if(candidate == null || candidate.isEmpty()) {
return false;
}
Network network = Network.get();
// Base58 (legacy) partial: must start with a legacy prefix and contain only base58 chars.
if(network.hasP2PKHAddressPrefix(candidate) || network.hasP2SHAddressPrefix(candidate)) {
return containsOnlyAscii(candidate, BASE58_OK);
}
String lower = candidate.toLowerCase(Locale.ROOT);
// Bech32 (segwit v0/v1) partial: starts with HRP, then optional '1', then bech32 data charset.
if(lower.startsWith(network.getBech32AddressHRP())) {
return isBech32LikePartial(lower);
}
// Silent payments partial (bech32-like): starts with its HRP, then optional '1', then bech32 data charset.
if(lower.startsWith(network.getSilentPaymentsAddressHrp())) {
return isBech32LikePartial(lower);
}
return false;
}
private static boolean isBech32LikePartial(String lower) {
int sep = lower.indexOf(Bech32.BECH32_SEPARATOR);
if(sep < 0) {
return containsOnlyHrpChars(lower);
}
String hrp = lower.substring(0, sep);
String dataPart = lower.substring(sep + 1);
if(hrp.isEmpty()) {
return false;
}
return containsOnlyHrpChars(hrp) && containsOnlyAscii(dataPart, BECH32_DATA_OK);
}
private static boolean containsOnlyHrpChars(String s) {
for(int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
boolean ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9');
if(!ok) {
return false;
}
}
return true;
}
private static boolean[] buildOkTable(String allowed) {
boolean[] ok = new boolean[128];
for(int i = 0; i < allowed.length(); i++) {
char c = allowed.charAt(i);
if(c < ok.length) {
ok[c] = true;
} else {
throw new IllegalArgumentException("Non-ASCII allowed char: " + c);
}
}
return ok;
}
private static boolean containsOnlyAscii(String s, boolean[] ok) {
for(int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if(c >= ok.length || !ok[c]) {
return false;
}
}
return true;
}
@Override
protected void layoutChildren(double x, double y, double w, double h) {
super.layoutChildren(x, y, w, h);
Insets padding = getSkinnable().getPadding();
double leftWidth = 0;
double rightWidth = 0;
if(getSkinnable() instanceof CustomTextField customTextField) {
Node left = customTextField.getLeft();
Node right = customTextField.getRight();
if(left != null) {
leftWidth = left.getLayoutBounds().getWidth();
if(left instanceof Region leftRegion) {
leftWidth += leftRegion.getPadding().getLeft() + leftRegion.getPadding().getRight() + 1;
}
}
if(right != null) {
rightWidth = right.getLayoutBounds().getWidth();
if(right instanceof Region rightRegion) {
rightWidth += rightRegion.getPadding().getLeft() + rightRegion.getPadding().getRight();
}
}
}
double availableWidth = w - padding.getLeft() - padding.getRight() - leftWidth - rightWidth;
clip.setWidth(availableWidth);
clip.setHeight(h);
double topOffset = getSkinnable().getBaselineOffset() - displayFlow.getBaselineOffset();
displayFlow.resizeRelocate(
padding.getLeft() + leftWidth,
topOffset,
displayFlow.prefWidth(-1),
h - padding.getTop() - padding.getBottom()
);
}
private record AddressSpan(int start, int end) {}
}
@@ -0,0 +1,111 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import javafx.beans.value.ChangeListener;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class AddressTooltipSkin implements Skin<Tooltip> {
private final Tooltip tooltip;
private final TextFlow textFlow;
private final ChangeListener<String> textListener;
public AddressTooltipSkin(Tooltip tooltip) {
this.tooltip = tooltip;
textFlow = new TextFlow();
textFlow.getStyleClass().addAll(tooltip.getStyleClass());
textListener = (_, _, newText) -> updateDisplay(newText);
tooltip.textProperty().addListener(textListener);
updateDisplay(tooltip.getText());
}
@Override
public Tooltip getSkinnable() {
return tooltip;
}
@Override
public Node getNode() {
return textFlow;
}
@Override
public void dispose() {
tooltip.textProperty().removeListener(textListener);
}
private void updateDisplay(String text) {
textFlow.getChildren().clear();
if(text == null || text.isEmpty()) {
return;
}
List<AddressSpan> addresses = findAddresses(text);
int pos = 0;
for(AddressSpan span : addresses) {
if(span.start > pos) {
textFlow.getChildren().add(createText(text.substring(pos, span.start), false));
}
addChunkedAddress(text.substring(span.start, span.end));
pos = span.end;
}
if(pos < text.length()) {
textFlow.getChildren().add(createText(text.substring(pos), false));
}
}
private void addChunkedAddress(String address) {
String[] chunks = AddressLabelSkin.CHUNK_PATTERN.split(address);
for(int i = 0; i < chunks.length; i++) {
textFlow.getChildren().add(createText(chunks[i], i % 2 != 0));
}
}
private Text createText(String content, boolean alternate) {
Text text = new Text(content);
text.getStyleClass().add("address-chunk");
if(alternate) {
text.getStyleClass().add("alternate");
}
return text;
}
private List<AddressSpan> findAddresses(String text) {
List<AddressSpan> spans = new ArrayList<>();
Pattern wordBoundary = Pattern.compile("\\S+");
Matcher matcher = wordBoundary.matcher(text);
while(matcher.find()) {
String candidate = matcher.group();
if(isValidAddress(candidate)) {
spans.add(new AddressSpan(matcher.start(), matcher.end()));
}
}
return spans;
}
private boolean isValidAddress(String candidate) {
try {
Address.fromString(candidate);
return true;
} catch(InvalidAddressException e) {
return false;
}
}
private record AddressSpan(int start, int end) {}
}
@@ -30,7 +30,11 @@ public class AddressTreeTable extends CoinTreeTable {
addressCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Entry> param) -> {
return new ReadOnlyObjectWrapper<>(param.getValue().getValue());
});
addressCol.setCellFactory(p -> new EntryCell());
addressCol.setCellFactory(p -> {
EntryCell entryCell = new EntryCell();
entryCell.setSkin(new AddressTreeTableCellSkin<>(entryCell));
return entryCell;
});
addressCol.setSortable(false);
getColumns().add(addressCol);
@@ -0,0 +1,127 @@
package com.sparrowwallet.sparrow.control;
import javafx.beans.value.ChangeListener;
import javafx.geometry.Insets;
import javafx.scene.control.TreeTableCell;
import javafx.scene.control.skin.TreeTableCellSkin;
import javafx.scene.layout.Region;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
public class AddressTreeTableCellSkin<S, T> extends TreeTableCellSkin<S, T> {
private final TextFlow displayFlow;
private final ChangeListener<String> textListener;
private final Text ellipsisText;
private String currentDisplayedText;
public AddressTreeTableCellSkin(TreeTableCell<S, T> cell) {
super(cell);
displayFlow = new TextFlow();
displayFlow.setMouseTransparent(true);
displayFlow.setMinWidth(Region.USE_PREF_SIZE);
getChildren().add(displayFlow);
ellipsisText = new Text("...");
ellipsisText.fontProperty().bind(cell.fontProperty());
ellipsisText.getStyleClass().add("address-chunk");
textListener = (_, _, newText) -> updateDisplay(newText);
cell.textProperty().addListener(textListener);
updateDisplay(cell.getText());
cell.setStyle("-fx-text-fill: transparent;");
}
private void updateDisplay(String text) {
currentDisplayedText = text;
buildDisplay(text, false);
}
private void buildDisplay(String text, boolean truncated) {
displayFlow.getChildren().clear();
if(text == null || text.isEmpty()) {
return;
}
if(getSkinnable().getStyleClass().contains("address-cell")) {
String[] chunks = AddressLabelSkin.CHUNK_PATTERN.split(text);
for(int i = 0; i < chunks.length; i++) {
displayFlow.getChildren().add(createText(chunks[i], i % 2 != 0));
}
} else {
Text normalText = createText(text, false);
displayFlow.getChildren().add(normalText);
}
if(truncated) {
displayFlow.getChildren().add(ellipsisText);
}
}
private Text createText(String content, boolean alternate) {
Text text = new Text(content);
text.fontProperty().bind(getSkinnable().fontProperty());
text.getStyleClass().add("address-chunk");
if(alternate) {
text.getStyleClass().add("alternate");
}
return text;
}
@Override
protected void layoutChildren(double x, double y, double w, double h) {
super.layoutChildren(x, y, w, h);
TreeTableCell<S, T> cell = getSkinnable();
Insets padding = cell.getPadding();
double leftOffset = 0;
double topOffset = y + padding.getTop();
Text labeledText = (Text)getChildren().stream().filter(n -> n instanceof Text).findFirst().orElse(null);
if(labeledText != null) {
leftOffset = labeledText.getLayoutX();
topOffset = labeledText.getLayoutY() - labeledText.getBaselineOffset();
String fullText = cell.getText();
String displayedText = labeledText.getText();
if(fullText != null && displayedText != null && !fullText.equals(displayedText)) {
String ellipsis = cell.getEllipsisString();
if(displayedText.endsWith(ellipsis)) {
String truncatedText = displayedText.substring(0, displayedText.length() - ellipsis.length());
if(!truncatedText.equals(currentDisplayedText)) {
currentDisplayedText = truncatedText;
buildDisplay(truncatedText, true);
}
}
} else if(fullText != null && !fullText.equals(currentDisplayedText)) {
currentDisplayedText = fullText;
buildDisplay(fullText, false);
}
}
displayFlow.resizeRelocate(
leftOffset,
topOffset,
w - padding.getLeft() - padding.getRight(),
h - padding.getTop() - padding.getBottom()
);
}
@Override
protected void updateChildren() {
super.updateChildren();
if(displayFlow != null && !getChildren().contains(displayFlow)) {
getChildren().add(displayFlow);
}
}
@Override
public void dispose() {
getSkinnable().textProperty().removeListener(textListener);
super.dispose();
}
}
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.control;
import com.google.common.base.Throwables;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
@@ -47,10 +48,18 @@ public class CardImportPane extends TitledDescriptionPane {
protected Button importButton;
private final SimpleStringProperty pin = new SimpleStringProperty("");
public CardImportPane(Wallet wallet, KeystoreCardImport importer, KeyDerivation requiredDerivation) {
public CardImportPane(Wallet wallet, KeystoreCardImport importer, KeyDerivation defaultDerivation, KeyDerivation requiredDerivation) {
super(importer.getName(), "Place card on reader", importer.getKeystoreImportDescription(getAccount(wallet, requiredDerivation)), importer.getWalletModel());
this.importer = importer;
this.derivation = requiredDerivation == null ? wallet.getScriptType().getDefaultDerivation() : requiredDerivation.getDerivation();
this.derivation = requiredDerivation == null ? getDefaultDerivation(wallet, defaultDerivation) : requiredDerivation.getDerivation();
}
private static List<ChildNumber> getDefaultDerivation(Wallet wallet, KeyDerivation defaultDerivation) {
if(defaultDerivation != null && !defaultDerivation.getDerivation().isEmpty()) {
return defaultDerivation.getDerivation();
}
return wallet == null || wallet.getScriptType() == null ? ScriptType.P2WPKH.getDefaultDerivation() : wallet.getScriptType().getDefaultDerivation();
}
@Override
@@ -0,0 +1,251 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
import com.sparrowwallet.sparrow.io.ImportException;
import com.sparrowwallet.sparrow.io.KeystoreCodexImport;
import javafx.application.Platform;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.value.ChangeListener;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
import javafx.scene.control.SplitMenuButton;
import javafx.scene.control.TextField;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import java.util.List;
public class CodexKeystoreImportPane extends TitledDescriptionPane {
protected final Wallet wallet;
private final KeystoreCodexImport importer;
private final KeyDerivation defaultDerivation;
private SplitMenuButton importButton;
private Button enterCodexButton;
private Button calculateButton;
protected Label validLabel;
protected Label invalidLabel;
protected final SimpleStringProperty secretShareProperty = new SimpleStringProperty("");
public CodexKeystoreImportPane(Wallet wallet, KeystoreCodexImport importer, KeyDerivation defaultDerivation) {
super(importer.getName(), "Enter secret share", importer.getKeystoreImportDescription(), importer.getWalletModel());
this.wallet = wallet;
this.importer = importer;
this.defaultDerivation = defaultDerivation;
createImportButton();
buttonBox.getChildren().add(importButton);
}
@Override
protected Control createButton() {
enterCodexButton = new Button("Enter Secret Share");
enterCodexButton.managedProperty().bind(enterCodexButton.visibleProperty());
enterCodexButton.setOnAction(event -> {
enterCodex();
});
return enterCodexButton;
}
private void enterCodex() {
setDescription("Enter secret share");
showHideLink.setVisible(false);
setContent(getSecretShareEntry());
setExpanded(true);
}
private void importKeystore(List<ChildNumber> derivation) {
importButton.setDisable(true);
try {
Keystore keystore = importer.getKeystore(derivation, secretShareProperty.get());
EventManager.get().post(new KeystoreImportEvent(keystore));
} catch(ImportException e) {
String errorMessage = e.getMessage();
if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) {
errorMessage = e.getCause().getMessage();
}
setError("Import Error", errorMessage);
importButton.setDisable(false);
}
}
private void createImportButton() {
importButton = new SplitMenuButton();
importButton.setAlignment(Pos.CENTER_RIGHT);
importButton.setText("Import Keystore");
setDefaultButton(importButton);
importButton.setOnAction(event -> {
importButton.setDisable(true);
importKeystore(getDefaultDerivation());
});
String[] accounts = new String[]{"Import Default Account #0", "Import Account #1", "Import Account #2", "Import Account #3", "Import Account #4", "Import Account #5", "Import Account #6", "Import Account #7", "Import Account #8", "Import Account #9"};
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
for(int i = 0; i < scriptAccountsLength; i++) {
MenuItem item = new MenuItem(accounts[i]);
final List<ChildNumber> derivation = wallet.getScriptType().getDefaultDerivation(i);
item.setOnAction(event -> {
importButton.setDisable(true);
importKeystore(derivation);
});
importButton.getItems().add(item);
}
importButton.managedProperty().bind(importButton.visibleProperty());
importButton.setVisible(false);
}
private List<ChildNumber> getDefaultDerivation() {
return defaultDerivation == null || defaultDerivation.getDerivation().isEmpty() ? wallet.getScriptType().getDefaultDerivation() : defaultDerivation.getDerivation();
}
private void onInputChange(boolean empty, boolean validChecksum) {
if(!empty) {
try {
importer.getKeystore(ScriptType.P2WPKH.getDefaultDerivation(), secretShareProperty.get());
validChecksum = true;
} catch(ImportException e) {
invalidLabel.setText("Invalid checksum");
invalidLabel.setTooltip(null);
}
}
calculateButton.setDisable(!validChecksum);
validLabel.setVisible(validChecksum);
invalidLabel.setVisible(!validChecksum && !empty);
}
private Node getSecretShareEntry() {
VBox vBox = new VBox(20);
vBox.setPadding(new Insets(10, 30, 10, 30));
HBox shareEntry = new HBox(10);
shareEntry.setAlignment(Pos.CENTER_LEFT);
Label shareLabel = new Label("Secret:");
TextField shareField = new TextField();
HBox.setHgrow(shareField, Priority.ALWAYS);
shareField.setPromptText("ms...");
shareField.textProperty().addListener((observable, oldValue, newValue) -> {
secretShareProperty.set(newValue);
});
shareEntry.getChildren().addAll(shareLabel, shareField);
vBox.getChildren().add(shareEntry);
AnchorPane buttonPane = new AnchorPane();
validLabel = new Label("Valid checksum", GlyphUtils.getSuccessGlyph());
validLabel.setContentDisplay(ContentDisplay.LEFT);
validLabel.setGraphicTextGap(5.0);
validLabel.managedProperty().bind(validLabel.visibleProperty());
validLabel.setVisible(false);
buttonPane.getChildren().add(validLabel);
AnchorPane.setTopAnchor(validLabel, 5.0);
AnchorPane.setLeftAnchor(validLabel, 0.0);
invalidLabel = new Label("Invalid checksum", GlyphUtils.getInvalidGlyph());
invalidLabel.setContentDisplay(ContentDisplay.LEFT);
invalidLabel.setGraphicTextGap(5.0);
invalidLabel.managedProperty().bind(invalidLabel.visibleProperty());
invalidLabel.setVisible(false);
buttonPane.getChildren().add(invalidLabel);
AnchorPane.setTopAnchor(invalidLabel, 5.0);
AnchorPane.setLeftAnchor(invalidLabel, 0.0);
secretShareProperty.addListener((ChangeListener<String>) (c, oldval, newval) -> {
boolean empty = secretShareProperty.isEmpty().get();
boolean validChecksum = false;
onInputChange(empty, validChecksum);
});
HBox rightBox = new HBox();
rightBox.setSpacing(10);
calculateButton = new Button("Create Keystore");
calculateButton.setDisable(true);
calculateButton.setDefaultButton(true);
calculateButton.managedProperty().bind(calculateButton.visibleProperty());
calculateButton.setTooltip(new Tooltip("Create the keystore from the provided secret share"));
calculateButton.setOnAction(event -> {
setExpanded(true);
enterCodexButton.setVisible(false);
importButton.setVisible(true);
importButton.setDisable(false);
setDescription("Ready to import");
showHideLink.setText("Show Derivation...");
showHideLink.setVisible(false);
setContent(getDerivationEntry(getDefaultDerivation()));
});
rightBox.getChildren().add(calculateButton);
buttonPane.getChildren().add(rightBox);
AnchorPane.setRightAnchor(rightBox, 0.0);
vBox.getChildren().add(buttonPane);
Platform.runLater(shareField::requestFocus);
return vBox;
}
private Node getDerivationEntry(List<ChildNumber> derivation) {
TextField derivationField = new TextField();
derivationField.setPromptText("Derivation path");
derivationField.setText(KeyDerivation.writePath(derivation));
HBox.setHgrow(derivationField, Priority.ALWAYS);
ValidationSupport validationSupport = new ValidationSupport();
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(derivationField, Validator.combine(
Validator.createEmptyValidator("Derivation is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Invalid derivation", !KeyDerivation.isValid(newValue))
));
Button importDerivationButton = new Button("Import Custom Derivation Keystore");
importDerivationButton.setDisable(true);
importDerivationButton.setOnAction(event -> {
showHideLink.setVisible(true);
setExpanded(false);
List<ChildNumber> importDerivation = KeyDerivation.parsePath(derivationField.getText());
importKeystore(importDerivation);
});
derivationField.textProperty().addListener((observable, oldValue, newValue) -> {
importButton.setDisable(newValue.isEmpty() || !KeyDerivation.isValid(newValue) || !KeyDerivation.parsePath(newValue).equals(derivation));
importDerivationButton.setDisable(newValue.isEmpty() || !KeyDerivation.isValid(newValue) || KeyDerivation.parsePath(newValue).equals(derivation));
});
HBox contentBox = new HBox();
contentBox.setAlignment(Pos.TOP_RIGHT);
contentBox.setSpacing(20);
contentBox.getChildren().add(derivationField);
contentBox.getChildren().add(importDerivationButton);
contentBox.setPadding(new Insets(10, 30, 10, 30));
contentBox.setPrefHeight(60);
return contentBox;
}
}
@@ -9,8 +9,8 @@ import javafx.scene.control.*;
import javafx.scene.control.Button;
public class DescriptorQRDisplayDialog extends QRDisplayDialog {
public DescriptorQRDisplayDialog(String walletName, String outputDescriptor, UR ur, BBQR bbqr, boolean selectBbqrButton) {
super(ur, bbqr, false, false, selectBbqrButton);
public DescriptorQRDisplayDialog(String walletName, String outputDescriptor, UR ur, BBQR bbqr, QREncoding encoding) {
super(ur, bbqr, false, false, encoding);
DialogPane dialogPane = getDialogPane();
final ButtonType pdfButtonType = new javafx.scene.control.ButtonType("Save PDF...", ButtonBar.ButtonData.HELP_2);
@@ -20,7 +20,7 @@ public class DescriptorQRDisplayDialog extends QRDisplayDialog {
pdfButton.setGraphicTextGap(5);
pdfButton.setGraphic(getGlyph(FontAwesome5.Glyph.FILE_PDF));
pdfButton.addEventFilter(ActionEvent.ACTION, event -> {
PdfUtils.saveOutputDescriptor(walletName, outputDescriptor, ur, isUseBbqrEncoding() ? bbqr : null);
PdfUtils.saveOutputDescriptor(walletName, outputDescriptor, ur, getEncoding() == QREncoding.BBQR ? bbqr : null);
event.consume();
});
}
@@ -19,6 +19,7 @@ import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.ServerType;
import javafx.application.Platform;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
@@ -51,7 +52,8 @@ public class DevicePane extends TitledDescriptionPane {
private final Wallet wallet;
private final PSBT psbt;
private final OutputDescriptor outputDescriptor;
private final KeyDerivation keyDerivation;
private final KeyDerivation defaultDerivation;
private final KeyDerivation requiredDerivation;
private final String message;
private final List<StandardAccount> availableAccounts;
private final Device device;
@@ -74,13 +76,14 @@ public class DevicePane extends TitledDescriptionPane {
private boolean defaultDevice;
public DevicePane(Wallet wallet, Device device, boolean defaultDevice, KeyDerivation requiredDerivation) {
public DevicePane(Wallet wallet, Device device, boolean defaultDevice, KeyDerivation defaultDerivation, KeyDerivation requiredDerivation) {
super(device.getModel().toDisplayString(), "", "", device.getModel());
this.deviceOperation = DeviceOperation.IMPORT;
this.wallet = wallet;
this.psbt = null;
this.outputDescriptor = null;
this.keyDerivation = requiredDerivation;
this.defaultDerivation = defaultDerivation;
this.requiredDerivation = requiredDerivation;
this.message = null;
this.availableAccounts = null;
this.device = device;
@@ -107,7 +110,8 @@ public class DevicePane extends TitledDescriptionPane {
this.wallet = wallet;
this.psbt = psbt;
this.outputDescriptor = null;
this.keyDerivation = null;
this.defaultDerivation = null;
this.requiredDerivation = null;
this.message = null;
this.availableAccounts = null;
this.device = device;
@@ -134,7 +138,8 @@ public class DevicePane extends TitledDescriptionPane {
this.wallet = wallet;
this.psbt = null;
this.outputDescriptor = outputDescriptor;
this.keyDerivation = null;
this.defaultDerivation = null;
this.requiredDerivation = null;
this.message = null;
this.availableAccounts = null;
this.device = device;
@@ -151,13 +156,14 @@ public class DevicePane extends TitledDescriptionPane {
buttonBox.getChildren().addAll(setPassphraseButton, displayAddressButton);
}
public DevicePane(Wallet wallet, String message, KeyDerivation keyDerivation, Device device, boolean defaultDevice) {
public DevicePane(Wallet wallet, String message, KeyDerivation requiredDerivation, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", device.getModel());
this.deviceOperation = DeviceOperation.SIGN_MESSAGE;
this.wallet = wallet;
this.psbt = null;
this.outputDescriptor = null;
this.keyDerivation = keyDerivation;
this.defaultDerivation = requiredDerivation;
this.requiredDerivation = requiredDerivation;
this.message = message;
this.availableAccounts = null;
this.device = device;
@@ -184,7 +190,8 @@ public class DevicePane extends TitledDescriptionPane {
this.wallet = wallet;
this.psbt = null;
this.outputDescriptor = null;
this.keyDerivation = null;
this.defaultDerivation = null;
this.requiredDerivation = null;
this.message = null;
this.device = device;
this.defaultDevice = defaultDevice;
@@ -207,7 +214,8 @@ public class DevicePane extends TitledDescriptionPane {
this.wallet = null;
this.psbt = null;
this.outputDescriptor = null;
this.keyDerivation = null;
this.defaultDerivation = null;
this.requiredDerivation = null;
this.message = null;
this.device = device;
this.defaultDevice = defaultDevice;
@@ -286,13 +294,12 @@ public class DevicePane extends TitledDescriptionPane {
}
private void createImportButton() {
importButton = keyDerivation == null ? new SplitMenuButton() : new Button();
importButton = requiredDerivation == null ? new SplitMenuButton() : new Button();
importButton.setAlignment(Pos.CENTER_RIGHT);
importButton.setText("Import Keystore");
importButton.setOnAction(event -> {
importButton.setDisable(true);
List<ChildNumber> defaultDerivation = wallet.getScriptType() == null ? ScriptType.P2WPKH.getDefaultDerivation() : wallet.getScriptType().getDefaultDerivation();
importKeystore(keyDerivation == null ? defaultDerivation : keyDerivation.getDerivation());
importKeystore(requiredDerivation == null ? getDefaultDerivation() : requiredDerivation.getDerivation());
});
if(importButton instanceof SplitMenuButton importMenuButton) {
@@ -307,6 +314,11 @@ public class DevicePane extends TitledDescriptionPane {
});
importMenuButton.getItems().add(item);
}
importMenuButton.getItems().add(new SeparatorMenuItem());
MenuItem discoverItem = new MenuItem("Discover Wallet...");
discoverItem.setDisable(!AppServices.isConnected());
discoverItem.setOnAction(_ -> discoverWallet());
importMenuButton.getItems().add(discoverItem);
} else {
String[] accounts = new String[] {"Default Account #0", "Account #1", "Account #2", "Account #3", "Account #4", "Account #5", "Account #6", "Account #7", "Account #8", "Account #9"};
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
@@ -363,7 +375,7 @@ public class DevicePane extends TitledDescriptionPane {
signMessageButton.managedProperty().bind(signMessageButton.visibleProperty());
signMessageButton.setVisible(false);
if(device.getFingerprint() != null && !device.getFingerprint().equals(keyDerivation.getMasterFingerprint())) {
if(device.getFingerprint() != null && !device.getFingerprint().equals(requiredDerivation.getMasterFingerprint())) {
signMessageButton.setDisable(true);
}
}
@@ -372,7 +384,6 @@ public class DevicePane extends TitledDescriptionPane {
discoverKeystoresButton = new Button("Discover");
discoverKeystoresButton.setAlignment(Pos.CENTER_RIGHT);
discoverKeystoresButton.setOnAction(event -> {
discoverKeystoresButton.setDisable(true);
discoverKeystores();
});
discoverKeystoresButton.managedProperty().bind(discoverKeystoresButton.visibleProperty());
@@ -433,6 +444,14 @@ public class DevicePane extends TitledDescriptionPane {
getAddressButton.setVisible(false);
}
private List<ChildNumber> getDefaultDerivation() {
if(defaultDerivation != null && !defaultDerivation.getDerivation().isEmpty()) {
return defaultDerivation.getDerivation();
}
return wallet == null || wallet.getScriptType() == null ? ScriptType.P2WPKH.getDefaultDerivation() : wallet.getScriptType().getDefaultDerivation();
}
private void unlock(Device device) {
if(device.getModel().requiresPinPrompt()) {
promptPin();
@@ -500,7 +519,7 @@ public class DevicePane extends TitledDescriptionPane {
SplitMenuButton sendPassphraseButton = new SplitMenuButton();
sendPassphraseButton.setText("Send Passphrase");
sendPassphraseButton.getStyleClass().add("default-button");
setDefaultButton(sendPassphraseButton);
sendPassphraseButton.setOnAction(event -> {
setExpanded(false);
setDescription("Confirm passphrase on device...");
@@ -864,7 +883,7 @@ public class DevicePane extends TitledDescriptionPane {
if(device.isCard()) {
try {
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
Service<String> signMessageService = cardApi.getSignMessageService(message, wallet.getScriptType(), keyDerivation.getDerivation(), messageProperty);
Service<String> signMessageService = cardApi.getSignMessageService(message, wallet.getScriptType(), requiredDerivation.getDerivation(), messageProperty);
handleCardOperation(signMessageService, signMessageButton, "Signing", true, event -> {
String signature = signMessageService.getValue();
EventManager.get().post(new MessageSignedEvent(wallet, signature));
@@ -875,7 +894,7 @@ public class DevicePane extends TitledDescriptionPane {
signButton.setDisable(false);
}
} else {
Hwi.SignMessageService signMessageService = new Hwi.SignMessageService(device, passphrase.get(), message, keyDerivation.getDerivationPath());
Hwi.SignMessageService signMessageService = new Hwi.SignMessageService(device, passphrase.get(), message, requiredDerivation.getDerivationPath());
signMessageService.setOnSucceeded(successEvent -> {
String signature = signMessageService.getValue();
EventManager.get().post(new MessageSignedEvent(wallet, signature));
@@ -889,29 +908,133 @@ public class DevicePane extends TitledDescriptionPane {
}
}
private void discoverWallet() {
importButton.setDisable(true);
importButton.setMaxHeight(importButton.getHeight());
ProgressIndicator progressIndicator = new ProgressIndicator(0);
progressIndicator.getStyleClass().add("button-progress");
importButton.setGraphic(progressIndicator);
List<Wallet> wallets = new ArrayList<>();
RangeInputDialog rangeInputDialog = new RangeInputDialog(StandardAccount.ACCOUNT_0.getAccountNumber(), StandardAccount.ACCOUNT_30.getAccountNumber(), StandardAccount.ACCOUNT_10.getAccountNumber());
rangeInputDialog.setTitle("Choose number of accounts");
rangeInputDialog.setHeaderText("Enter the number of additional accounts to scan for existing funds.\n\nThis may take a few minutes depending on how many accounts are selected.");
Optional<Integer> optRange = rangeInputDialog.showAndWait();
if(optRange.isEmpty()) {
return;
}
List<StandardAccount> discoveryAccounts = new ArrayList<>(Arrays.asList(StandardAccount.values()).subList(0, optRange.get() + 1));
Map<Hwi.WalletType, String> derivationPaths = new LinkedHashMap<>();
List<ScriptType> scriptTypes = new ArrayList<>(ScriptType.getAddressableScriptTypes(PolicyType.SINGLE));
if(device.getModel() == WalletModel.BITBOX_02) {
scriptTypes.remove(ScriptType.P2PKH);
}
for(ScriptType scriptType : scriptTypes) {
for(StandardAccount discoveryAccount : discoveryAccounts) {
derivationPaths.put(new Hwi.WalletType(scriptType, discoveryAccount), KeyDerivation.writePath(scriptType.getDefaultDerivation(discoveryAccount.getAccountNumber())));
}
}
Hwi.GetXpubsService getXpubsService = new Hwi.GetXpubsService(device, passphrase.get(), derivationPaths);
getXpubsService.setOnSucceeded(_ -> {
Map<Hwi.WalletType, String> accountXpubs = getXpubsService.getValue();
for(Map.Entry<Hwi.WalletType, String> entry : accountXpubs.entrySet()) {
try {
Wallet wallet = new Wallet(device.getModel().toDisplayString());
wallet.setPolicyType(PolicyType.SINGLE);
wallet.setScriptType(entry.getKey().scriptType());
Keystore keystore = new Keystore();
keystore.setLabel(device.getModel().toDisplayString());
keystore.setSource(KeystoreSource.HW_USB);
keystore.setWalletModel(device.getModel());
keystore.setKeyDerivation(new KeyDerivation(device.getFingerprint(), derivationPaths.get(entry.getKey())));
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(entry.getValue()));
wallet.getKeystores().add(keystore);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, entry.getKey().scriptType(), wallet.getKeystores(), 1));
if(entry.getKey().standardAccount().equals(StandardAccount.ACCOUNT_0)) {
wallets.add(wallet);
} else {
Wallet masterWallet = wallets.getLast();
wallet.setName(entry.getKey().standardAccount().getName());
wallet.setMasterWallet(masterWallet);
masterWallet.getChildWallets().add(wallet);
}
} catch(Exception e) {
setError("Could not retrieve xpub", e.getMessage());
}
}
ElectrumServer.WalletDiscoveryService walletDiscoveryService = new ElectrumServer.WalletDiscoveryService(wallets);
walletDiscoveryService.setOnSucceeded(_ -> {
importButton.setGraphic(null);
Optional<List<Wallet>> optWallets = walletDiscoveryService.getValue();
if(optWallets.isPresent()) {
List<Wallet> discoveredWallets = optWallets.get();
if(discoveredWallets.size() > 1) {
for(Wallet wallet : discoveredWallets) {
wallet.setName(wallet.getName() + " " + wallet.getScriptType().getDescription());
}
}
EventManager.get().post(new WalletImportEvent(discoveredWallets));
} else {
AppServices.showErrorDialog("No existing wallet found",
Config.get().getServerType() == ServerType.BITCOIN_CORE ? "The configured server type is Bitcoin Core, which does not support wallet discovery.\n\n" +
"You can however import the " + device.getModel().toDisplayString() + " and scan the blockchain by supplying a start date." :
"Could not find a wallet with existing transactions using the " + device.getModel().toDisplayString() + ".");
setDefaultStatus();
importButton.setDisable(false);
}
});
walletDiscoveryService.setOnFailed(failedEvent -> {
log.error("Failed to discover wallets", failedEvent.getSource().getException());
setError("Failed to discover wallets", failedEvent.getSource().getException().getMessage());
importButton.setGraphic(null);
importButton.setDisable(false);
});
walletDiscoveryService.start();
});
getXpubsService.setOnFailed(_ -> {
setError("Could not retrieve xpub", getXpubsService.getException().getMessage());
importButton.setGraphic(null);
importButton.setDisable(false);
});
progressIndicator.progressProperty().bind(getXpubsService.progressProperty());
getXpubsService.progressProperty().addListener((_, _, newValue) -> setDescription("Discovering... (" + Math.round(newValue.doubleValue() * 100) + "%)"));
showHideLink.setVisible(false);
getXpubsService.start();
}
private void discoverKeystores() {
if(wallet.getKeystores().size() != 1) {
setError("Could not discover keystores", "Only single signature wallets are supported for keystore discovery");
return;
}
discoverKeystoresButton.setDisable(true);
discoverKeystoresButton.setMaxHeight(discoverKeystoresButton.getHeight());
ProgressIndicator progressIndicator = new ProgressIndicator(0);
progressIndicator.getStyleClass().add("button-progress");
discoverKeystoresButton.setGraphic(progressIndicator);
String masterFingerprint = wallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint();
Wallet copyWallet = wallet.copy();
Map<StandardAccount, String> accountDerivationPaths = new LinkedHashMap<>();
Map<Hwi.WalletType, String> accountDerivationPaths = new LinkedHashMap<>();
for(StandardAccount availableAccount : availableAccounts) {
Wallet availableWallet = copyWallet.addChildWallet(availableAccount);
Keystore availableKeystore = availableWallet.getKeystores().get(0);
String derivationPath = availableKeystore.getKeyDerivation().getDerivationPath();
accountDerivationPaths.put(availableAccount, derivationPath);
accountDerivationPaths.put(new Hwi.WalletType(wallet.getScriptType(), availableAccount), derivationPath);
}
Map<StandardAccount, Keystore> importedKeystores = new LinkedHashMap<>();
Hwi.GetXpubsService getXpubsService = new Hwi.GetXpubsService(device, passphrase.get(), accountDerivationPaths);
getXpubsService.setOnSucceeded(workerStateEvent -> {
Map<StandardAccount, String> accountXpubs = getXpubsService.getValue();
Map<Hwi.WalletType, String> accountXpubs = getXpubsService.getValue();
for(Map.Entry<StandardAccount, String> entry : accountXpubs.entrySet()) {
for(Map.Entry<Hwi.WalletType, String> entry : accountXpubs.entrySet()) {
try {
Keystore keystore = new Keystore();
keystore.setLabel(device.getModel().toDisplayString());
@@ -919,7 +1042,7 @@ public class DevicePane extends TitledDescriptionPane {
keystore.setWalletModel(device.getModel());
keystore.setKeyDerivation(new KeyDerivation(masterFingerprint, accountDerivationPaths.get(entry.getKey())));
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(entry.getValue()));
importedKeystores.put(entry.getKey(), keystore);
importedKeystores.put(entry.getKey().standardAccount(), keystore);
} catch(Exception e) {
setError("Could not retrieve xpub", e.getMessage());
}
@@ -933,15 +1056,18 @@ public class DevicePane extends TitledDescriptionPane {
accountDiscoveryService.setOnFailed(event -> {
log.error("Failed to discover accounts", event.getSource().getException());
setError("Failed to discover accounts", event.getSource().getException().getMessage());
discoverKeystoresButton.setGraphic(null);
discoverKeystoresButton.setDisable(false);
});
accountDiscoveryService.start();
});
getXpubsService.setOnFailed(workerStateEvent -> {
setError("Could not retrieve xpub", getXpubsService.getException().getMessage());
discoverKeystoresButton.setGraphic(null);
discoverKeystoresButton.setDisable(false);
});
setDescription("Discovering...");
progressIndicator.progressProperty().bind(getXpubsService.progressProperty());
getXpubsService.progressProperty().addListener((_, _, newValue) -> setDescription("Discovering... (" + Math.round(newValue.doubleValue() * 100) + "%)"));
showHideLink.setVisible(false);
getXpubsService.start();
}
@@ -998,13 +1124,12 @@ public class DevicePane extends TitledDescriptionPane {
private void showOperationButton() {
if(deviceOperation.equals(DeviceOperation.IMPORT)) {
if(defaultDevice) {
importButton.getStyleClass().add("default-button");
setDefaultButton(importButton);
}
importButton.setVisible(true);
showHideLink.setText("Show derivation...");
showHideLink.setVisible(!device.isCard());
List<ChildNumber> defaultDerivation = wallet.getScriptType() == null ? ScriptType.P2WPKH.getDefaultDerivation() : wallet.getScriptType().getDefaultDerivation();
setContent(getDerivationEntry(keyDerivation == null ? defaultDerivation : keyDerivation.getDerivation()));
setContent(getDerivationEntry(requiredDerivation == null ? getDefaultDerivation() : requiredDerivation.getDerivation()));
} else if(deviceOperation.equals(DeviceOperation.SIGN)) {
signButton.setDefaultButton(defaultDevice);
signButton.setVisible(true);
@@ -1023,7 +1148,7 @@ public class DevicePane extends TitledDescriptionPane {
showHideLink.setVisible(false);
} else if(deviceOperation.equals(DeviceOperation.GET_PRIVATE_KEY)) {
if(defaultDevice) {
getPrivateKeyButton.getStyleClass().add("default-button");
setDefaultButton(getPrivateKeyButton);
}
getPrivateKeyButton.setVisible(true);
showHideLink.setVisible(false);
@@ -1038,7 +1163,7 @@ public class DevicePane extends TitledDescriptionPane {
TextField derivationField = new TextField();
derivationField.setPromptText("Derivation path");
derivationField.setText(KeyDerivation.writePath(derivation));
derivationField.setDisable(device.isCard() || keyDerivation != null);
derivationField.setDisable(device.isCard() || requiredDerivation != null);
HBox.setHgrow(derivationField, Priority.ALWAYS);
ValidationSupport validationSupport = new ValidationSupport();
@@ -124,13 +124,13 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
setGraphic(actionBox);
} else if(entry instanceof NodeEntry nodeEntry) {
Address address = nodeEntry.getAddress();
getStyleClass().add("address-cell");
setText(address.toString());
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry, true, getTreeTableView()));
Tooltip tooltip = new Tooltip();
tooltip.setShowDelay(Duration.millis(250));
tooltip.setText(nodeEntry.getNode().toString());
setTooltip(tooltip);
getStyleClass().add("address-cell");
HBox actionBox = new HBox();
actionBox.getStyleClass().add("cell-actions");
@@ -157,7 +157,7 @@ public class FileKeystoreExportPane extends TitledDescriptionPane {
if(exporter instanceof Bip129) {
UR ur = UR.fromBytes(baos.toByteArray());
BBQR bbqr = new BBQR(BBQRType.UNICODE, baos.toByteArray());
qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, true, false);
qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, true, QREncoding.UR);
} else {
qrDisplayDialog = new QRDisplayDialog(baos.toString(StandardCharsets.UTF_8));
}
@@ -171,18 +171,18 @@ public class FileWalletExportPane extends TitledDescriptionPane {
} else if(exporter instanceof Bip129 || exporter instanceof WalletLabels) {
UR ur = UR.fromBytes(outputStream.toByteArray());
BBQR bbqr = new BBQR(BBQRType.UNICODE, outputStream.toByteArray());
qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, false, false);
qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, false, QREncoding.UR);
} else if(exporter instanceof Descriptor) {
boolean addBbqrOption = exportWallet.getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().showBbqr());
boolean selectBbqrOption = exportWallet.getKeystores().stream().allMatch(keystore -> keystore.getWalletModel().selectBbqr());
QREncoding encoding = exportWallet.getKeystores().stream().allMatch(keystore -> keystore.getWalletModel().selectBbqr()) ? QREncoding.BBQR : QREncoding.UR;
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(exportWallet, KeyPurpose.DEFAULT_PURPOSES, null);
CryptoOutput cryptoOutput = getCryptoOutput(exportWallet);
BBQR bbqr = addBbqrOption ? new BBQR(BBQRType.UNICODE, outputDescriptor.toString(true).getBytes(StandardCharsets.UTF_8)) : null;
qrDisplayDialog = new DescriptorQRDisplayDialog(exportWallet.getFullDisplayName(), outputDescriptor.toString(true), cryptoOutput.toUR(), bbqr, selectBbqrOption);
qrDisplayDialog = new DescriptorQRDisplayDialog(exportWallet.getFullDisplayName(), outputDescriptor.toString(true), cryptoOutput.toUR(), bbqr, encoding);
} else if(exporter.getClass().equals(ColdcardMultisig.class)) {
UR ur = UR.fromBytes(outputStream.toByteArray());
BBQR bbqr = new BBQR(BBQRType.UNICODE, outputStream.toByteArray());
qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, false, true);
qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, false, QREncoding.BBQR);
} else {
qrDisplayDialog = new QRDisplayDialog(outputStream.toString(StandardCharsets.UTF_8));
}
@@ -134,6 +134,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
address.getStyleClass().add("id");
address.setEditable(walletNode == null);
address.setTooltip(new Tooltip("Only singlesig addresses can sign"));
address.setSkin(new AddressTextFieldSkin(address));
addressField.getInputs().add(address);
if(walletNode != null) {
@@ -6,10 +6,9 @@ import com.sparrowwallet.drongo.wallet.WalletModel;
import javafx.beans.property.SimpleListProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Orientation;
import javafx.scene.Node;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.TilePane;
import javafx.scene.layout.VBox;
import java.util.ArrayList;
@@ -32,11 +31,9 @@ public class MnemonicKeystoreDisplayPane extends MnemonicKeystorePane {
VBox vBox = new VBox();
vBox.setSpacing(10);
wordsPane = new TilePane();
wordsPane.setPrefRows(Math.ceilDiv(numWords, 3));
wordsPane = new GridPane();
wordsPane.setHgap(10);
wordsPane.setVgap(10);
wordsPane.setOrientation(Orientation.VERTICAL);
List<String> words = new ArrayList<>();
for(int i = 0; i < numWords; i++) {
@@ -53,7 +50,14 @@ public class MnemonicKeystoreDisplayPane extends MnemonicKeystorePane {
wordEntries.get(i).setNextEntry(wordEntries.get(i + 1));
wordEntries.get(i).setNextField(wordEntries.get(i + 1).getEditor());
}
wordsPane.getChildren().addAll(wordEntries);
int numCols = 3;
int numRows = Math.ceilDiv(numWords, numCols);
for(int i = 0; i < wordEntries.size(); i++) {
int col = i / numRows;
int row = i % numRows;
wordsPane.add(wordEntries.get(i), col, row);
}
vBox.getChildren().add(wordsPane);
@@ -58,7 +58,7 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
importButton = new SplitMenuButton();
importButton.setAlignment(Pos.CENTER_RIGHT);
importButton.setText("Import Keystore");
importButton.getStyleClass().add("default-button");
setDefaultButton(importButton);
importButton.setOnAction(event -> {
importButton.setDisable(true);
importKeystore(getDefaultDerivation(), false);
@@ -15,20 +15,15 @@ import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
import javafx.scene.layout.*;
import javafx.util.Callback;
import javafx.util.Duration;
import org.controlsfx.control.textfield.AutoCompletionBinding;
import org.controlsfx.control.textfield.TextFields;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
@@ -44,7 +39,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
private static final Logger log = LoggerFactory.getLogger(MnemonicKeystorePane.class);
protected SplitMenuButton enterMnemonicButton;
protected TilePane wordsPane;
protected GridPane wordsPane;
protected Label validLabel;
protected Label invalidLabel;
@@ -167,11 +162,9 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
VBox vBox = new VBox();
vBox.setSpacing(10);
wordsPane = new TilePane();
wordsPane.setPrefRows(Math.ceilDiv(numWords, 3));
wordsPane = new GridPane();
wordsPane.setHgap(10);
wordsPane.setVgap(10);
wordsPane.setOrientation(Orientation.VERTICAL);
List<String> words = new ArrayList<>();
for(int i = 0; i < numWords; i++) {
@@ -188,7 +181,14 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
wordEntries.get(i).setNextEntry(wordEntries.get(i + 1));
wordEntries.get(i).setNextField(wordEntries.get(i + 1).getEditor());
}
wordsPane.getChildren().addAll(wordEntries);
int numCols = 3;
int numRows = Math.ceilDiv(numWords, numCols);
for(int i = 0; i < wordEntries.size(); i++) {
int col = i / numRows;
int row = i % numRows;
wordsPane.add(wordEntries.get(i), col, row);
}
vBox.getChildren().add(wordsPane);
@@ -196,7 +196,7 @@ public class MnemonicShareKeystoreImportPane extends MnemonicKeystorePane {
importButton = new SplitMenuButton();
importButton.setAlignment(Pos.CENTER_RIGHT);
importButton.setText("Import Keystore");
importButton.getStyleClass().add("default-button");
setDefaultButton(importButton);
importButton.setOnAction(event -> {
importButton.setDisable(true);
importKeystore(getDefaultDerivation(), false);
@@ -134,9 +134,15 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
progressIndicator.progressProperty().bind(walletDiscoveryService.progressProperty());
walletDiscoveryService.setOnSucceeded(successEvent -> {
discoverButton.setGraphic(null);
Optional<Wallet> optWallet = walletDiscoveryService.getValue();
if(optWallet.isPresent()) {
EventManager.get().post(new WalletImportEvent(optWallet.get()));
Optional<List<Wallet>> optWallets = walletDiscoveryService.getValue();
if(optWallets.isPresent()) {
List<Wallet> discoveredWallets = optWallets.get();
if(discoveredWallets.size() > 1) {
for(Wallet wallet : discoveredWallets) {
wallet.setName(wallet.getKeystores().getFirst().getLabel() + " " + wallet.getScriptType().getDescription());
}
}
EventManager.get().post(new WalletImportEvent(discoveredWallets));
} else {
discoverButton.setDisable(false);
Optional<ButtonType> optButtonType = AppServices.showErrorDialog("No existing wallet found",
@@ -13,6 +13,7 @@ import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.psbt.PSBTProofException;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppServices;
@@ -28,8 +29,6 @@ import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.event.ActionEvent;
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.StackPane;
@@ -141,6 +140,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
toAddressField.setText("Sweep to:");
toAddress = new ComboBoxTextField();
toAddress.getStyleClass().add("fixed-width");
toAddress.setSkin(new AddressTextFieldSkin(toAddress));
toWallet = new ComboBox<>();
toWallet.setItems(FXCollections.observableList(AppServices.get().getOpenWallets().keySet().stream()
.filter(w -> !w.isWhirlpoolChildWallet() && !w.isBip47()).collect(Collectors.toList())));
@@ -461,7 +461,11 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
psbtInput.setFinalScriptWitness(finalizedTxInput.getWitness());
}
setResult(psbt.extractTransaction());
try {
setResult(psbt.extractTransaction());
} catch(PSBTProofException e) {
AppServices.showErrorDialog("Invalid Silent Payments Transaction", e.getMessage());
}
}
public Glyph getGlyph(FontAwesome5.Glyph glyphEnum) {
@@ -57,7 +57,11 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
private final BBQR bbqr;
private BBQREncoder bbqrEncoder;
private boolean useBbqrEncoding;
private final String raw;
private final boolean rawEncodable;
private QREncoding encoding = QREncoding.UR;
private final ImageView qrImageView;
@@ -72,14 +76,18 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
private static boolean initialDensityChange;
public QRDisplayDialog(String type, byte[] data, boolean addLegacyEncodingOption) throws UR.URException {
this(UR.fromBytes(type, data), null, addLegacyEncodingOption, false, false);
this(UR.fromBytes(type, data), null, addLegacyEncodingOption, false, QREncoding.UR);
}
public QRDisplayDialog(UR ur) {
this(ur, null, false, false, false);
this(ur, null, false, false, QREncoding.UR);
}
public QRDisplayDialog(UR ur, BBQR bbqr, boolean addLegacyEncodingOption, boolean addScanButton, boolean selectBbqrButton) {
public QRDisplayDialog(UR ur, BBQR bbqr, boolean addLegacyEncodingOption, boolean addScanButton, QREncoding defaultEncoding) {
this(ur, bbqr, null, addLegacyEncodingOption, addScanButton, defaultEncoding);
}
public QRDisplayDialog(UR ur, BBQR bbqr, String raw, boolean addLegacyEncodingOption, boolean addScanButton, QREncoding defaultEncoding) {
this.ur = ur;
this.bbqr = bbqr;
this.addLegacyEncodingOption = bbqr == null && addLegacyEncodingOption;
@@ -88,11 +96,14 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
if(bbqr != null) {
this.bbqrEncoder = new BBQREncoder(bbqr.type(), DEFAULT_BBQR_ENCODING, bbqr.data(), Config.get().getQrDensity().getMaxBbqrFragmentLength(), 0);
if(selectBbqrButton) {
useBbqrEncoding = true;
if(defaultEncoding == QREncoding.BBQR || Config.get().getQrEncoding() == QREncoding.BBQR) {
encoding = QREncoding.BBQR;
}
}
this.raw = raw;
this.rawEncodable = raw != null && getQrCode(raw, true) != null;
final DialogPane dialogPane = new QRDisplayDialogPane();
setDialogPane(dialogPane);
AppServices.setStageIcon(dialogPane.getScene().getWindow());
@@ -141,6 +152,11 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
dialogPane.getButtonTypes().add(scanButtonType);
}
ButtonBar buttonBar = (ButtonBar)dialogPane.lookup(".button-bar");
if(buttonBar != null) {
buttonBar.setButtonOrder("L+B+C+O");
}
dialogPane.setPrefWidth(40 + qrSize + 40);
dialogPane.setPrefHeight(40 + qrSize + 85);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
@@ -156,8 +172,10 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
public QRDisplayDialog(String data, boolean addScanButton) {
this.ur = null;
this.bbqr = null;
this.raw = data;
this.urEncoder = null;
this.bbqrEncoder = null;
this.rawEncodable = true;
final DialogPane dialogPane = new QRDisplayDialogPane();
setDialogPane(dialogPane);
@@ -205,7 +223,9 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
}
private boolean isSinglePart() {
if(useBbqrEncoding) {
if(encoding == QREncoding.RAW) {
return true;
} else if(encoding == QREncoding.BBQR) {
return bbqrEncoder.isSinglePart();
} else if(!useLegacyEncoding) {
return urEncoder.isSinglePart();
@@ -215,7 +235,9 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
}
private void nextPart() {
if(useBbqrEncoding) {
if(encoding == QREncoding.RAW) {
currentPart = raw;
} else if(encoding == QREncoding.BBQR) {
String fragment = bbqrEncoder.nextPart();
currentPart = fragment.toUpperCase(Locale.ROOT);
} else if(!useLegacyEncoding) {
@@ -231,6 +253,10 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
}
protected Image getQrCode(String fragment) {
return getQrCode(fragment, false);
}
protected Image getQrCode(String fragment, boolean trial) {
try {
QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix qrMatrix = qrCodeWriter.encode(fragment, BarcodeFormat.QR_CODE, qrSize, qrSize, Map.of(EncodeHintType.MARGIN, "2"));
@@ -241,7 +267,9 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
return new Image(bais);
} catch(Exception e) {
log.error("Error generating QR", e);
if(!trial) {
log.error("Error generating QR", e);
}
}
return null;
@@ -265,18 +293,13 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
}
}
public boolean isUseBbqrEncoding() {
return useBbqrEncoding;
public QREncoding getEncoding() {
return encoding;
}
private void setUseBbqrEncoding(boolean useBbqrEncoding) {
if(useBbqrEncoding) {
this.useBbqrEncoding = true;
restartAnimation();
} else {
this.useBbqrEncoding = false;
restartAnimation();
}
private void setEncoding(QREncoding encoding) {
this.encoding = encoding;
restartAnimation();
}
private void changeQRDensity() {
@@ -373,18 +396,29 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
return scanButton;
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.BACK_PREVIOUS) {
ToggleButton bbqr = new ToggleButton(buttonType.getText());
bbqr.setGraphicTextGap(5);
bbqr.setGraphic(getGlyph(FontAwesome5.Glyph.QRCODE));
bbqr.setSelected(useBbqrEncoding);
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(bbqr, buttonData);
ComboBox<QREncoding> encodingComboBox = new ComboBox<>();
if(ur != null) {
encodingComboBox.getItems().add(QREncoding.UR);
}
if(bbqr != null) {
encodingComboBox.getItems().add(QREncoding.BBQR);
}
if(raw != null) {
encodingComboBox.getItems().add(QREncoding.RAW);
}
bbqr.selectedProperty().addListener((observable, oldValue, newValue) -> {
setUseBbqrEncoding(newValue);
encodingComboBox.setCellFactory(_ -> new QREncodingListCell());
encodingComboBox.setButtonCell(new QREncodingButtonCell());
encodingComboBox.setValue(encoding);
encodingComboBox.setOnAction(_ -> {
setEncoding(encodingComboBox.getValue());
Config.get().setQrEncoding(encodingComboBox.getValue());
});
return bbqr;
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(encodingComboBox, buttonData);
return encodingComboBox;
}
return super.createButton(buttonType);
@@ -399,7 +433,7 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
}
private void updateDensityButton(Button density) {
density.setText(Config.get().getQrDensity() == QRDensity.NORMAL ? "Decrease Density" : "Increase Density");
density.setText(Config.get().getQrDensity() == QRDensity.NORMAL ? "Less Dense" : "More Dense");
if(Config.get().getQrDensity() == QRDensity.NORMAL) {
density.setGraphic(getGlyph(FontAwesome5.Glyph.MAGNIFYING_GLASS_PLUS));
} else {
@@ -413,4 +447,38 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
glyph.setFontSize(11);
return glyph;
}
private class QREncodingListCell extends ListCell<QREncoding> {
@Override
protected void updateItem(QREncoding item, boolean empty) {
super.updateItem(item, empty);
if(empty || item == null) {
setText(null);
setGraphic(null);
setDisable(false);
setOpacity(1.0);
} else {
setText(item.getName() + " Encoding");
setGraphic(item.getSVGImage());
setGraphicTextGap(8.0d);
setDisable(item == QREncoding.RAW && !rawEncodable);
setOpacity(isDisabled() ? 0.5 : 1.0);
}
}
}
private static class QREncodingButtonCell extends ListCell<QREncoding> {
@Override
protected void updateItem(QREncoding item, boolean empty) {
super.updateItem(item, empty);
if(item == null || empty) {
setText("");
setGraphic(null);
} else {
setText(item.getName());
setGraphic(item.getSVGImage());
setGraphicTextGap(8.0d);
}
}
}
}
@@ -0,0 +1,49 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import javafx.geometry.Insets;
import javafx.scene.Node;
import org.controlsfx.glyphfont.Glyph;
import org.girod.javafx.svgimage.SVGLoader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URL;
import java.util.Locale;
public enum QREncoding {
UR("UR"), BBQR("BBQr"), RAW("Raw");
private static final Logger log = LoggerFactory.getLogger(QREncoding.class);
QREncoding(String name) {
this.name = name;
}
private final String name;
public String getName() {
return name;
}
public Node getSVGImage() {
try {
URL url = AppServices.class.getResource("/image/qrencoding/" + getName().toLowerCase(Locale.ROOT) + "-icon" + (Config.get().getTheme() == Theme.DARK ? "-invert" : "") + ".svg");
if(url != null) {
return SVGLoader.load(url);
} else {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.QRCODE);
glyph.setFontSize(12);
glyph.setPadding(new Insets(0, 2, 0, 0));
return glyph;
}
} catch(Exception e) {
log.error("Could not load QR encoding source image for " + name);
}
return null;
}
}
@@ -656,7 +656,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
List<ChildNumber> path = cryptoKeypath.getComponents().stream().map(comp -> (IndexPathComponent)comp)
.map(comp -> new ChildNumber(comp.getIndex(), comp.isHardened())).collect(Collectors.toList());
String fingerprint = cryptoKeypath.getSourceFingerprint() == null ? KeystoreController.DEFAULT_WATCH_ONLY_FINGERPRINT : Utils.bytesToHex(cryptoKeypath.getSourceFingerprint());
String fingerprint = cryptoKeypath.getSourceFingerprint() == null ? KeyDerivation.DEFAULT_WATCH_ONLY_FINGERPRINT : Utils.bytesToHex(cryptoKeypath.getSourceFingerprint());
return new KeyDerivation(fingerprint, KeyDerivation.writePath(path));
}
@@ -0,0 +1,61 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.scene.control.*;
import javafx.scene.layout.GridPane;
import org.controlsfx.glyphfont.Glyph;
public class RangeInputDialog extends Dialog<Integer> {
private final Spinner<Integer> spinner;
public RangeInputDialog(int min, int max, int initialValue) {
final DialogPane dialogPane = getDialogPane();
setTitle("Select a Value");
setHeaderText("Choose a value between " + min + " and " + max);
Glyph key = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SORT_NUMERIC_DOWN);
key.setFontSize(50);
key.setPadding(new Insets(0, 0, 0, 10));
dialogPane.setGraphic(key);
dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
spinner = new Spinner<>();
spinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(min, max, initialValue));
spinner.setPrefWidth(80);
GridPane grid = new GridPane();
grid.setHgap(10);
grid.setVgap(10);
grid.setPadding(new Insets(20, 20, 10, 20));
grid.add(new Label("Enter value between " + min + " and " + max + ":"), 0, 0);
grid.add(spinner, 1, 0);
dialogPane.setContent(grid);
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
Platform.runLater(spinner::requestFocus);
setResultConverter((dialogButton) -> {
ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
return data == ButtonBar.ButtonData.OK_DONE ? spinner.getValue() : null;
});
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(230);
AppServices.moveToActiveWindowScreen(this);
}
public void setValue(int value) {
spinner.getValueFactory().setValue(value);
}
public int getValue() {
return spinner.getValue();
}
}
@@ -74,6 +74,7 @@ public class SearchWalletDialog extends Dialog<Entry> {
searchField.setText("Search:");
search = TextFields.createClearableTextField();
search.setPromptText("Label, address, value or transaction ID");
search.setSkin(new AddressTextFieldSkin(search));
searchField.getInputs().add(search);
fieldset.getChildren().addAll(searchField);
@@ -113,7 +114,11 @@ public class SearchWalletDialog extends Dialog<Entry> {
entryCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Entry> param) -> {
return new ReadOnlyObjectWrapper<>(param.getValue().getValue());
});
entryCol.setCellFactory(p -> new SearchEntryCell());
entryCol.setCellFactory(p -> {
SearchEntryCell searchEntryCell = new SearchEntryCell();
searchEntryCell.setSkin(new AddressTreeTableCellSkin<>(searchEntryCell));
return searchEntryCell;
});
String address = walletForms.iterator().next().getNodeEntry(KeyPurpose.RECEIVE).getAddress().toString();
entryCol.setMinWidth(TextUtils.computeTextWidth(AppServices.getMonospaceFont(), address, 0.0));
results.getColumns().add(entryCol);
@@ -5,6 +5,7 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppServices;
import javafx.application.Platform;
import javafx.collections.ListChangeListener;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
@@ -182,6 +183,22 @@ public class TitledDescriptionPane extends TitledPane {
});
}
protected static void setDefaultButton(ButtonBase button) {
button.getStyleClass().add("default-button");
if(button instanceof SplitMenuButton splitMenuButton) {
for(MenuItem item : splitMenuButton.getItems()) {
item.getStyleClass().add("default-button");
}
splitMenuButton.getItems().addListener((ListChangeListener<MenuItem>) c -> {
while(c.next()) {
for(MenuItem item : c.getAddedSubList()) {
item.getStyleClass().add("default-button");
}
}
});
}
}
protected static int getAccount(Wallet wallet, KeyDerivation requiredDerivation) {
if(wallet == null || requiredDerivation == null) {
return 0;
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.address.Address;
@@ -474,7 +475,7 @@ public class TransactionDiagram extends GridPane {
inputValue = input.getValue();
Wallet nodeWallet = walletNode.getWallet();
StringJoiner joiner = new StringJoiner("\n");
joiner.add("Spending " + getSatsValue(inputValue) + " sats from " + (isFinal() ? nodeWallet.getFullDisplayName() : (nodeWallet.isNested() ? nodeWallet.getDisplayName() : "")) + " " + walletNode);
joiner.add("Spending " + getCoinValue(inputValue) + " from " + (isFinal() ? nodeWallet.getFullDisplayName() : (nodeWallet.isNested() ? nodeWallet.getDisplayName() : "")) + " " + walletNode);
joiner.add(input.getHashAsString() + ":" + input.getIndex());
joiner.add(walletNode.getAddress().toString());
if(input.getLabel() != null) {
@@ -500,7 +501,7 @@ public class TransactionDiagram extends GridPane {
} else if(input instanceof AdditionalBlockTransactionHashIndex additionalReference) {
inputValue = input.getValue();
StringJoiner joiner = new StringJoiner("\n");
joiner.add("Spending " + getSatsValue(inputValue) + " sats from" + (isExpanded() ? ":" : " (click to expand):"));
joiner.add("Spending " + getCoinValue(inputValue) + " from" + (isExpanded() ? ":" : " (click to expand):"));
for(BlockTransactionHashIndex additionalInput : additionalReference.getAdditionalInputs()) {
joiner.add(getInputDescription(additionalInput));
}
@@ -513,7 +514,7 @@ public class TransactionDiagram extends GridPane {
TransactionOutput txOutput = blockTransaction.getTransaction().getOutputs().get((int) input.getIndex());
Address fromAddress = txOutput.getScript().getToAddress();
inputValue = txOutput.getValue();
tooltip.setText("Input of " + getSatsValue(inputValue) + " sats\n" + input.getHashAsString() + ":" + input.getIndex() + (fromAddress != null ? "\n" + fromAddress : ""));
tooltip.setText("Input of " + getCoinValue(inputValue) + "\n" + input.getHashAsString() + ":" + input.getIndex() + (fromAddress != null ? "\n" + fromAddress : ""));
ContextMenu contextMenu = new LabelContextMenu(fromAddress, inputValue);
label.setContextMenu(contextMenu);
@@ -532,6 +533,7 @@ public class TransactionDiagram extends GridPane {
tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
tooltip.setShowDuration(Duration.INDEFINITE);
tooltip.setWrapText(true);
tooltip.setSkin(new AddressTooltipSkin(tooltip));
Window activeWindow = AppServices.getActiveWindow();
if(activeWindow != null) {
tooltip.setMaxWidth(activeWindow.getWidth());
@@ -571,13 +573,22 @@ public class TransactionDiagram extends GridPane {
return input.getLabel() != null && !input.getLabel().isEmpty() ? input.getLabel() : input.getHashAsString().substring(0, 8) + "..:" + input.getIndex();
}
String getSatsValue(long amount) {
String getCoinValue(long amount) {
if(Config.get().isHideAmounts()) {
return CoinLabel.HIDDEN_AMOUNT_TEXT;
}
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
return format.formatSatsValue(amount);
BitcoinUnit unit = Config.get().getBitcoinUnit();
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
unit = (amount >= BitcoinUnit.getAutoThreshold() ? BitcoinUnit.BTC : BitcoinUnit.SATOSHIS);
}
if(unit.equals(BitcoinUnit.BTC)) {
return format.formatBtcValue(amount) + " BTC";
}
return format.formatSatsValue(amount) + " sats";
}
private Pane getInputsLines(List<Map<BlockTransactionHashIndex, WalletNode>> displayedUtxoSets) {
@@ -727,21 +738,26 @@ public class TransactionDiagram extends GridPane {
for(Payment payment : displayedPayments) {
Glyph outputGlyph = GlyphUtils.getOutputGlyph(walletTx, payment);
boolean labelledPayment = outputGlyph.getStyleClass().stream().anyMatch(style -> List.of("premix-icon", "badbank-icon", "whirlpoolfee-icon", "anchor-icon").contains(style)) || payment instanceof AdditionalPayment || payment.getLabel() != null;
Label recipientLabel = new Label(payment.getLabel() == null || payment.getType() == Payment.Type.FAKE_MIX || payment.getType() == Payment.Type.MIX ? payment.toString().substring(0, 8) + "..." : payment.getLabel(), outputGlyph);
boolean addressLabel = payment.getLabel() == null || payment.getType() == Payment.Type.FAKE_MIX || payment.getType() == Payment.Type.MIX;
Label recipientLabel = new Label(addressLabel ? payment.toString().substring(0, 8) + "..." : payment.getLabel(), outputGlyph);
recipientLabel.getStyleClass().add("output-label");
recipientLabel.getStyleClass().add(labelledPayment ? "payment-label" : "recipient-label");
if(addressLabel) {
recipientLabel.setSkin(new AddressLabelSkin(recipientLabel));
}
Wallet toWallet = walletTx.getToWallet(AppServices.get().getOpenWallets().keySet(), payment);
WalletNode toNode = payment instanceof WalletNodePayment walletNodePayment ? walletNodePayment.getWalletNode() : null;
Wallet toBip47Wallet = getBip47SendWallet(payment);
DnsPayment dnsPayment = DnsPaymentCache.getDnsPayment(payment);
Tooltip recipientTooltip = new Tooltip((toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ")
+ getSatsValue(payment.getAmount()) + " sats to "
+ getCoinValue(payment.getAmount()) + " to "
+ (payment instanceof AdditionalPayment ? (isExpanded() ? "\n" : "(click to expand)\n") + payment : (toWallet == null ? (dnsPayment == null ? (payment.getLabel() == null ? (toNode != null ? toNode : (toBip47Wallet == null ? "external address" : toBip47Wallet.getDisplayName())) : payment.getLabel()) : dnsPayment.toString()) : toWallet.getFullDisplayName()) + "\n" + payment.getDisplayAddress())
+ (walletTx.isDuplicateAddress(payment) ? " (Duplicate)" : ""));
recipientTooltip.getStyleClass().add("recipient-label");
recipientTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
recipientTooltip.setShowDuration(Duration.INDEFINITE);
recipientTooltip.setWrapText(true);
recipientTooltip.setSkin(new AddressTooltipSkin(recipientTooltip));
Window activeWindow = AppServices.getActiveWindow();
if(activeWindow != null) {
recipientTooltip.setMaxWidth(activeWindow.getWidth());
@@ -782,10 +798,12 @@ public class TransactionDiagram extends GridPane {
String changeDesc = changeAddress.toString().substring(0, 8) + "...";
Label changeLabel = new Label(changeDesc, overGapLimit ? getChangeWarningGlyph() : getChangeGlyph());
changeLabel.getStyleClass().addAll("output-label", "change-label");
Tooltip changeTooltip = new Tooltip("Change of " + getSatsValue(changeEntry.getValue()) + " sats to " + changeNode + "\n" + walletTx.getChangeAddress(changeNode).toString() + (overGapLimit ? "\nAddress is beyond the gap limit!" : ""));
changeLabel.setSkin(new AddressLabelSkin(changeLabel));
Tooltip changeTooltip = new Tooltip("Change of " + getCoinValue(changeEntry.getValue()) + " to " + changeNode + "\n" + walletTx.getChangeAddress(changeNode).toString() + (overGapLimit ? "\nAddress is beyond the gap limit!" : ""));
changeTooltip.getStyleClass().add("change-label");
changeTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
changeTooltip.setShowDuration(Duration.INDEFINITE);
changeTooltip.setSkin(new AddressTooltipSkin(changeTooltip));
changeLabel.setTooltip(changeTooltip);
actionBox.getChildren().add(changeLabel);
@@ -841,7 +859,7 @@ public class TransactionDiagram extends GridPane {
Label feeLabel = highFee ? new Label("High Fee", getFeeWarningGlyph()) : new Label("Fee", getFeeGlyph());
feeLabel.getStyleClass().addAll("output-label", "fee-label");
String percentage = walletTx.getFeePercentage() < 0.0001d ? "<0.01" : String.format("%.2f", walletTx.getFeePercentage() * 100.0);
Tooltip feeTooltip = new Tooltip(walletTx.getFee() < 0 ? "Unknown fee" : "Fee of " + getSatsValue(walletTx.getFee()) + " sats (" + percentage + "%)");
Tooltip feeTooltip = new Tooltip(walletTx.getFee() < 0 ? "Unknown fee" : "Fee of " + getCoinValue(walletTx.getFee()) + " (" + percentage + "%)");
feeTooltip.getStyleClass().add("fee-tooltip");
feeTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
feeTooltip.setShowDuration(Duration.INDEFINITE);
@@ -147,10 +147,10 @@ public class TransactionDiagramLabel extends HBox {
Glyph glyph = GlyphUtils.getOutputGlyph(transactionDiagram.getWalletTransaction(), premixOutput);
String text;
if(premixOutputs.size() == 1) {
text = "Premix transaction with 1 output of " + transactionDiagram.getSatsValue(premixOutput.getAmount()) + " sats";
text = "Premix transaction with 1 output of " + transactionDiagram.getCoinValue(premixOutput.getAmount());
} else {
text = "Premix transaction with " + premixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(premixOutput.getAmount()) + " sats each ("
+ transactionDiagram.getSatsValue(total) + " sats)";
text = "Premix transaction with " + premixOutputs.size() + " outputs of " + transactionDiagram.getCoinValue(premixOutput.getAmount()) + " each ("
+ transactionDiagram.getCoinValue(total) + ")";
}
return getOutputLabel(glyph, text);
@@ -158,7 +158,7 @@ public class TransactionDiagramLabel extends HBox {
private OutputLabel getBadbankOutputLabel(TransactionDiagram transactionDiagram, Payment payment) {
Glyph glyph = GlyphUtils.getOutputGlyph(transactionDiagram.getWalletTransaction(), payment);
String text = "Badbank change of " + transactionDiagram.getSatsValue(payment.getAmount()) + " sats to " + payment.getAddress().toString();
String text = "Badbank change of " + transactionDiagram.getCoinValue(payment.getAmount()) + " to " + payment.getAddress().toString();
return getOutputLabel(glyph, text);
}
@@ -167,7 +167,7 @@ public class TransactionDiagramLabel extends HBox {
long total = premixOutputs.stream().mapToLong(Payment::getAmount).sum();
double feePercentage = (double)whirlpoolFee.getAmount() / (total - whirlpoolFee.getAmount());
Glyph glyph = GlyphUtils.getOutputGlyph(transactionDiagram.getWalletTransaction(), whirlpoolFee);
String text = "Whirlpool fee of " + transactionDiagram.getSatsValue(whirlpoolFee.getAmount()) + " sats (" + String.format("%.2f", feePercentage * 100.0) + "% of total premix value)";
String text = "Whirlpool fee of " + transactionDiagram.getCoinValue(whirlpoolFee.getAmount()) + " (" + String.format("%.2f", feePercentage * 100.0) + "% of total premix value)";
return getOutputLabel(glyph, text);
}
@@ -180,8 +180,8 @@ public class TransactionDiagramLabel extends HBox {
Payment remixOutput = mixOutputs.get(0);
long total = mixOutputs.stream().mapToLong(Payment::getAmount).sum();
Glyph glyph = GlyphUtils.getPremixGlyph();
String text = "Mix transaction with " + mixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(remixOutput.getAmount()) + " sats each ("
+ transactionDiagram.getSatsValue(total) + " sats)";
String text = "Mix transaction with " + mixOutputs.size() + " outputs of " + transactionDiagram.getCoinValue(remixOutput.getAmount()) + " each ("
+ transactionDiagram.getCoinValue(total) + ")";
return getOutputLabel(glyph, text);
}
@@ -194,8 +194,8 @@ public class TransactionDiagramLabel extends HBox {
Payment remixOutput = remixOutputs.get(0);
long total = remixOutputs.stream().mapToLong(Payment::getAmount).sum();
Glyph glyph = GlyphUtils.getPremixGlyph();
String text = "Remix transaction with " + remixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(remixOutput.getAmount()) + " sats each ("
+ transactionDiagram.getSatsValue(total) + " sats)";
String text = "Remix transaction with " + remixOutputs.size() + " outputs of " + transactionDiagram.getCoinValue(remixOutput.getAmount()) + " each ("
+ transactionDiagram.getCoinValue(total) + ")";
return getOutputLabel(glyph, text);
}
@@ -206,7 +206,7 @@ public class TransactionDiagramLabel extends HBox {
WalletNode toNode = payment instanceof WalletNodePayment walletNodePayment ? walletNodePayment.getWalletNode() : null;
Glyph glyph = GlyphUtils.getOutputGlyph(transactionDiagram.getWalletTransaction(), payment);
String text = (toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ") + transactionDiagram.getSatsValue(payment.getAmount()) + " sats to " + payment;
String text = (toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ") + transactionDiagram.getCoinValue(payment.getAmount()) + " to " + payment;
return getOutputLabel(glyph, text);
}
@@ -215,7 +215,7 @@ public class TransactionDiagramLabel extends HBox {
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
Glyph glyph = GlyphUtils.getChangeGlyph();
String text = "Change of " + transactionDiagram.getSatsValue(changeEntry.getValue()) + " sats to " + walletTx.getChangeAddress(changeEntry.getKey()).toString();
String text = "Change of " + transactionDiagram.getCoinValue(changeEntry.getValue()) + " to " + walletTx.getChangeAddress(changeEntry.getKey()).toString();
return getOutputLabel(glyph, text);
}
@@ -228,7 +228,7 @@ public class TransactionDiagramLabel extends HBox {
Glyph glyph = GlyphUtils.getFeeGlyph();
String percentage = walletTx.getFeePercentage() < 0.0001d ? "<0.01" : String.format("%.2f", walletTx.getFeePercentage() * 100.0);
String text = "Fee of " + transactionDiagram.getSatsValue(walletTx.getFee()) + " sats (" + percentage + "%)";
String text = "Fee of " + transactionDiagram.getCoinValue(walletTx.getFee()) + " (" + percentage + "%)";
return getOutputLabel(glyph, text);
}
@@ -240,6 +240,7 @@ public class TransactionDiagramLabel extends HBox {
icon.setGraphic(glyph);
CopyableLabel label = new CopyableLabel();
label.setSkin(new AddressTextFieldSkin(label));
label.setFont(Font.font("Fragment Mono Italic", 13));
label.setText(text);
@@ -28,7 +28,7 @@ public class UsbStatusButton extends MenuButton {
for(Device device : devices) {
MenuItem deviceItem = new MenuItem(device.getModel().toDisplayString());
if(!device.isNeedsPinSent() && (device.getModel() == WalletModel.TREZOR_1 || device.getModel() == WalletModel.TREZOR_T || device.getModel() == WalletModel.TREZOR_SAFE_3 ||
device.getModel() == WalletModel.TREZOR_SAFE_5 || device.getModel() == WalletModel.KEEPKEY || device.getModel() == WalletModel.BITBOX_02 ||
device.getModel() == WalletModel.TREZOR_SAFE_5 || device.getModel() == WalletModel.TREZOR_SAFE_7 || device.getModel() == WalletModel.KEEPKEY || device.getModel() == WalletModel.BITBOX_02 ||
device.getModel() == WalletModel.ONEKEY_CLASSIC_1S || device.getModel() == WalletModel.ONEKEY_PRO)) {
deviceItem = new Menu(device.getModel().toDisplayString());
MenuItem toggleItem = new MenuItem("Toggle Passphrase" + (!device.getModel().externalPassphraseEntry() ? "" : (device.isNeedsPassphraseSent() ? " Off" : " On")));
@@ -57,7 +57,11 @@ public class UtxosTreeTable extends CoinTreeTable {
addressCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, UtxoEntry.AddressStatus> param) -> {
return ((UtxoEntry)param.getValue().getValue()).addressStatusProperty();
});
addressCol.setCellFactory(p -> new AddressCell());
addressCol.setCellFactory(p -> {
AddressCell addressCell = new AddressCell();
addressCell.setSkin(new AddressTreeTableCellSkin<>(addressCell));
return addressCell;
});
addressCol.setSortable(true);
addressCol.setComparator(Comparator.comparing(o -> o.getAddress().toString()));
getColumns().add(addressCol);
@@ -21,8 +21,8 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class WalletImportDialog extends Dialog<Wallet> {
private Wallet wallet;
public class WalletImportDialog extends Dialog<List<Wallet>> {
private List<Wallet> wallets;
private final Accordion importAccordion;
private final Button scanButton;
@@ -52,7 +52,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
importAccordion = new Accordion();
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(),
new GordianSeedTool(), new SeedSigner(), new SpecterDIY(), new Krux(), new AirGapVault(), new Samourai());
new GordianSeedTool(), new SeedSigner(), new SpecterDIY(), new Krux(), new AirGapVault(), new Samourai(), new KeycardShellSinglesig());
for(KeystoreFileImport importer : keystoreImporters) {
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer);
@@ -95,13 +95,13 @@ public class WalletImportDialog extends Dialog<Wallet> {
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? wallet : null);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? wallets : null);
}
@Subscribe
public void walletImported(WalletImportEvent event) {
wallet = event.getWallet();
setResult(wallet);
wallets = event.getWallets();
setResult(wallets);
}
private void scan() {
@@ -112,7 +112,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
List<Device> devices = enumerateService.getValue();
importAccordion.getPanes().removeIf(titledPane -> titledPane instanceof DevicePane);
for(Device device : devices) {
DevicePane devicePane = new DevicePane(new Wallet(), device, devices.size() == 1, null);
DevicePane devicePane = new DevicePane(new Wallet(), device, devices.size() == 1, null, null);
importAccordion.getPanes().add(0, devicePane);
}
Platform.runLater(() -> EventManager.get().post(new UsbDeviceEvent(devices)));
@@ -36,7 +36,7 @@ public class XprvKeystoreImportPane extends TitledDescriptionPane {
private ExtendedKey xprv;
public XprvKeystoreImportPane(Wallet wallet, KeystoreXprvImport importer, KeyDerivation defaultDerivation) {
super(importer.getName(), "Extended key import", importer.getKeystoreImportDescription(), importer.getWalletModel());
super(importer.getName(), "Enter master private key", importer.getKeystoreImportDescription(), importer.getWalletModel());
this.wallet = wallet;
this.importer = importer;
this.defaultDerivation = defaultDerivation;
@@ -79,7 +79,7 @@ public class XprvKeystoreImportPane extends TitledDescriptionPane {
importButton = new SplitMenuButton();
importButton.setAlignment(Pos.CENTER_RIGHT);
importButton.setText("Import Keystore");
importButton.getStyleClass().add("default-button");
setDefaultButton(importButton);
importButton.setOnAction(event -> {
importButton.setDisable(true);
importKeystore(getDefaultDerivation());
@@ -151,6 +151,7 @@ public class XprvKeystoreImportPane extends TitledDescriptionPane {
Button importXprvButton = new Button("Import");
importXprvButton.setMinWidth(80);
importXprvButton.setDisable(true);
importXprvButton.setDefaultButton(true);
importXprvButton.setOnAction(event -> {
enterXprvButton.setVisible(false);
importButton.setVisible(true);
@@ -2,14 +2,20 @@ package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.util.List;
public class WalletImportEvent {
private Wallet wallet;
private List<Wallet> wallets;
public WalletImportEvent(Wallet wallet) {
this.wallet = wallet;
this.wallets = List.of(wallet);
}
public Wallet getWallet() {
return wallet;
public WalletImportEvent(List<Wallet> wallets) {
this.wallets = wallets;
}
public List<Wallet> getWallets() {
return wallets;
}
}
@@ -197,6 +197,7 @@ public abstract class Instance {
File sparrowHome = Storage.getSparrowHome(true);
if(!sparrowHome.exists()) {
Storage.createOwnerOnlyDirectory(sparrowHome);
sparrowHome.deleteOnExit(); //Will only delete on exit if empty
}
return sparrowHome.toPath().resolve(applicationId + ".default");
@@ -21,7 +21,7 @@ public class Bip32 implements KeystoreXprvImport {
@Override
public String getKeystoreImportDescription() {
return "Import an extended master private key (BIP 32 xprv)";
return "Import an extended master private key (BIP 32 xprv).";
}
@Override
@@ -0,0 +1,43 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.crypto.DeterministicKey;
import com.sparrowwallet.drongo.crypto.HDKeyDerivation;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.MasterPrivateExtendedKey;
import com.sparrowwallet.drongo.wallet.MnemonicException;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.drongo.wallet.bip93.Codex32;
import java.util.List;
public class Bip93 implements KeystoreCodexImport {
@Override
public String getName() {
return "Codex32 (BIP93)";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.SEED;
}
@Override
public String getKeystoreImportDescription() {
return "Import your Codex32 secret share. Only a single share is currently supported.";
}
@Override
public Keystore getKeystore(List<ChildNumber> derivation, String secretShare) throws ImportException {
try {
Codex32.Codex32Data secretData = Codex32.decode(secretShare);
DeterministicKey key = HDKeyDerivation.createMasterPrivateKey(secretData.payloadToBip32Secret());
MasterPrivateExtendedKey mpek = new MasterPrivateExtendedKey(key);
Keystore keystore = Keystore.fromMasterPrivateExtendedKey(mpek, derivation);
keystore.setLabel("BIP93");
return keystore;
} catch(MnemonicException e) {
throw new ImportException(e);
}
}
}
@@ -11,6 +11,7 @@ import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.io.ckcard.CkCardApi;
import com.sparrowwallet.sparrow.io.keycard.KeycardApi;
import com.sparrowwallet.sparrow.io.satochip.SatoCardApi;
import javafx.beans.property.StringProperty;
import javafx.concurrent.Service;
@@ -58,6 +59,13 @@ public abstract class CardApi {
//ignore
}
try {
KeycardApi keycardApi = new KeycardApi(null, null);
cards.add(keycardApi.getCardType());
} catch(Exception e) {
//ignore
}
return cards;
}
@@ -70,6 +78,10 @@ public abstract class CardApi {
return new SatoCardApi(walletModel, pin);
}
if(walletModel == WalletModel.KEYCARD) {
return new KeycardApi(walletModel, pin);
}
throw new IllegalArgumentException("Cannot create card API for " + walletModel.toDisplayString());
}
@@ -7,6 +7,7 @@ import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.Mode;
import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.control.QRDensity;
import com.sparrowwallet.sparrow.control.QREncoding;
import com.sparrowwallet.sparrow.control.WebcamResolution;
import com.sparrowwallet.sparrow.net.*;
import com.sparrowwallet.sparrow.wallet.FeeRatesSelection;
@@ -46,6 +47,7 @@ public class Config {
private boolean checkNewVersions = true;
private Theme theme;
private boolean openWalletsInNewWindows = false;
private boolean chunkAddresses = true;
private boolean hideEmptyUsedAddresses = false;
private boolean hideAmounts = false;
private boolean showTransactionHex = true;
@@ -62,6 +64,7 @@ public class Config {
private long dustAttackThreshold = DUST_ATTACK_THRESHOLD_SATS;
private int enumerateHwPeriod = ENUMERATE_HW_PERIOD_SECS;
private QRDensity qrDensity;
private QREncoding qrEncoding;
private WebcamResolution webcamResolution;
private boolean mirrorCapture = true;
private boolean useZbar = true;
@@ -295,6 +298,15 @@ public class Config {
flush();
}
public boolean isChunkAddresses() {
return chunkAddresses;
}
public void setChunkAddresses(boolean chunkAddresses) {
this.chunkAddresses = chunkAddresses;
flush();
}
public boolean isHideEmptyUsedAddresses() {
return hideEmptyUsedAddresses;
}
@@ -429,6 +441,15 @@ public class Config {
flush();
}
public QREncoding getQrEncoding() {
return qrEncoding;
}
public void setQrEncoding(QREncoding qrEncoding) {
this.qrEncoding = qrEncoding;
flush();
}
public WebcamResolution getWebcamResolution() {
return webcamResolution;
}
@@ -140,7 +140,7 @@ public class Descriptor implements WalletImport, WalletExport {
private static Wallet ensureKeyDerivations(Wallet wallet) {
for(Keystore keystore : wallet.getKeystores()) {
if(keystore.getKeyDerivation().getMasterFingerprint() == null || keystore.getKeyDerivation().getDerivationPath() == null) {
keystore.setKeyDerivation(new KeyDerivation(KeystoreController.DEFAULT_WATCH_ONLY_FINGERPRINT, wallet.getScriptType().getDefaultDerivationPath()));
keystore.setKeyDerivation(new KeyDerivation(KeyDerivation.DEFAULT_WATCH_ONLY_FINGERPRINT, wallet.getScriptType().getDefaultDerivationPath()));
}
}
@@ -11,13 +11,23 @@ import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.lark.DeviceException;
import com.sparrowwallet.lark.Lark;
import com.sparrowwallet.lark.bitbox02.BitBoxFileNoiseConfig;
import com.sparrowwallet.lark.trezor.TrezorFileNoiseConfig;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.SparrowWallet;
import com.sparrowwallet.sparrow.control.BitBoxPairingDialog;
import com.sparrowwallet.sparrow.control.TextfieldDialog;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.collections.MapChangeListener;
import javafx.collections.ObservableMap;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.geometry.Insets;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.scene.control.TextFormatter;
import javafx.scene.layout.HBox;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -26,7 +36,9 @@ import javax.smartcardio.CardNotPresentException;
import java.io.File;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicBoolean;
public class Hwi {
@@ -34,8 +46,9 @@ public class Hwi {
private static final String HWI_HOME_DIR = "hwi";
private static final String LARK_HOME_DIR = "lark";
private static final String BITBOX_FILENAME = "bitbox02.json";
private static final String TREZOR_FILENAME = "trezor.json";
private static boolean isPromptActive = false;
private static volatile boolean isPromptActive = false;
private final Set<byte[]> newDeviceRegistrations = new HashSet<>();
@@ -83,7 +96,7 @@ public class Hwi {
} catch(CardNotPresentException e) {
//ignore
} catch(CardException e) {
log.error("Error reading card", e);
log.info("Error reading card", e);
}
}
@@ -132,9 +145,8 @@ public class Hwi {
}
}
public Map<StandardAccount, String> getXpubs(Device device, String passphrase, Map<StandardAccount, String> accountDerivationPaths) throws ImportException {
Map<StandardAccount, String> accountXpubs = new LinkedHashMap<>();
for(Map.Entry<StandardAccount, String> entry : accountDerivationPaths.entrySet()) {
public Map<WalletType, String> getXpubs(Device device, String passphrase, Map<WalletType, String> accountDerivationPaths, Map<WalletType, String> accountXpubs) throws ImportException {
for(Map.Entry<WalletType, String> entry : accountDerivationPaths.entrySet()) {
accountXpubs.put(entry.getKey(), getXpub(device, passphrase, entry.getValue()));
}
@@ -223,6 +235,7 @@ public class Hwi {
private Lark getLark(String passphrase, OutputDescriptor walletDescriptor, String walletName, byte[] walletRegistration) {
Lark lark = new Lark(AppServices.getHttpClientService());
lark.setBitBoxNoiseConfig(new BitBoxFxNoiseConfig());
lark.setTrezorNoiseConfig(new TrezorFxNoiseConfig());
if(passphrase != null) {
lark.setPassphrase(passphrase);
}
@@ -430,23 +443,26 @@ public class Hwi {
}
}
public static class GetXpubsService extends Service<Map<StandardAccount, String>> {
public static class GetXpubsService extends Service<Map<WalletType, String>> {
private final Device device;
private final String passphrase;
private final Map<StandardAccount, String> accountDerivationPaths;
private final Map<WalletType, String> accountDerivationPaths;
public GetXpubsService(Device device, String passphrase, Map<StandardAccount, String> accountDerivationPaths) {
public GetXpubsService(Device device, String passphrase, Map<WalletType, String> accountDerivationPaths) {
this.device = device;
this.passphrase = passphrase;
this.accountDerivationPaths = accountDerivationPaths;
}
@Override
protected Task<Map<StandardAccount, String>> createTask() {
protected Task<Map<WalletType, String>> createTask() {
return new Task<>() {
protected Map<StandardAccount, String> call() throws ImportException {
protected Map<WalletType, String> call() throws ImportException {
Hwi hwi = new Hwi();
return hwi.getXpubs(device, passphrase, accountDerivationPaths);
updateProgress(0, accountDerivationPaths.size());
ObservableMap<WalletType, String> accountXpubs = FXCollections.observableMap(new LinkedHashMap<>());
accountXpubs.addListener((MapChangeListener<? super WalletType, ? super String>) _ -> updateProgress(accountXpubs.size(), accountDerivationPaths.size()));
return hwi.getXpubs(device, passphrase, accountDerivationPaths, accountXpubs);
}
};
}
@@ -536,4 +552,89 @@ public class Hwi {
return confirmedDevice.get();
}
}
private static final class TrezorFxNoiseConfig extends TrezorFileNoiseConfig {
private String deviceInfo;
public TrezorFxNoiseConfig() {
super(Path.of(Storage.getSparrowHome().getAbsolutePath(), LARK_HOME_DIR, TREZOR_FILENAME).toFile());
}
@Override
public String promptForPairingCode() {
CompletableFuture<String> future = new CompletableFuture<>();
Platform.runLater(() -> {
TextfieldDialog textfieldDialog = new TextfieldDialog();
textfieldDialog.initOwner(AppServices.getActiveWindow());
textfieldDialog.setTitle("Enter Pairing Code");
textfieldDialog.setHeaderText("Enter the code shown on the " + deviceInfo + ":");
textfieldDialog.getDialogPane().setPrefWidth(300);
textfieldDialog.getEditor().setOnAction(_ -> textfieldDialog.setResult(textfieldDialog.getEditor().getText()));
textfieldDialog.getEditor().setTextFormatter(new TextFormatter<>(change -> {
String newText = change.getControlNewText();
if(newText.matches("\\d*")) {
return change;
}
return null;
}));
textfieldDialog.getEditor().setStyle("-fx-font-size: 30px;");
HBox.setMargin(textfieldDialog.getEditor(), new Insets(0, 65, 0, 65));
textfieldDialog.getEditor().requestFocus();
textfieldDialog.showAndWait().ifPresentOrElse(future::complete, () -> future.complete(null));
});
try {
isPromptActive = true;
return future.get(); // Block until dialog is closed
} catch (InterruptedException | ExecutionException e) {
Thread.currentThread().interrupt();
return null;
} finally {
isPromptActive = false;
}
}
@Override
public boolean confirmPairing(String deviceInfo) {
this.deviceInfo = deviceInfo;
CompletableFuture<ButtonType> future = new CompletableFuture<>();
Platform.runLater(() -> {
AppServices.showAlertDialog("Pairing Required", "Pair the " + deviceInfo + " with " + SparrowWallet.APP_NAME + "?",
Alert.AlertType.CONFIRMATION, ButtonType.YES, ButtonType.NO).ifPresentOrElse(future::complete, () -> future.complete(null));
});
try {
isPromptActive = true;
return future.get() == ButtonType.YES; // Block until dialog is closed
} catch (InterruptedException | ExecutionException e) {
Thread.currentThread().interrupt();
return false;
} finally {
isPromptActive = false;
}
}
@Override
public void displayPairingCode(String code) {
super.displayPairingCode(code);
}
@Override
public String getAppName() {
return SparrowWallet.APP_NAME;
}
@Override
public void pairingFailed(String reason) {
Platform.runLater(() -> AppServices.showErrorDialog("Pairing Failed", "Pairing failed: " + reason));
}
@Override
public void pairingSuccessful(String deviceInfo) {
this.deviceInfo = deviceInfo;
Platform.runLater(() -> AppServices.showSuccessDialog("Pairing Successful", "The " + deviceInfo + " has been successfully paired."));
}
}
public record WalletType(ScriptType scriptType, StandardAccount standardAccount) {}
}
@@ -0,0 +1,53 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.WalletModel;
import java.io.InputStream;
public class KeycardShellMultisig extends ColdcardMultisig {
@Override
public String getName() {
return "Keycard Shell Multisig";
}
@Override
public String getKeystoreImportDescription(int account) {
return "Import QR created on your Keycard Shell by selecting Connect software wallet > Bitcoin MultiSig";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.KEYCARD_SHELL;
}
@Override
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
Keystore keystore = super.getKeystore(scriptType, inputStream, password);
keystore.setLabel("Keycard Shell");
keystore.setWalletModel(getWalletModel());
return keystore;
}
@Override
public boolean isFileFormatAvailable() {
return false;
}
@Override
public boolean isWalletImportFileFormatAvailable() {
return false;
}
@Override
public boolean isWalletImportScannable() {
return false;
}
@Override
public boolean isWalletExportScannable() {
return false;
}
}
@@ -0,0 +1,44 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.WalletModel;
import java.io.InputStream;
public class KeycardShellSinglesig extends KeystoneSinglesig {
@Override
public String getName() {
return "Keycard Shell";
}
@Override
public String getKeystoreImportDescription(int account) {
return "Import QR created on your Keycard Shell by selecting Connect software wallet > Bitcoin" + (Network.getCanonical() == Network.TESTNET ? " Testnet" : "");
}
@Override
public WalletModel getWalletModel() {
return WalletModel.KEYCARD_SHELL;
}
@Override
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
Keystore keystore = super.getKeystore(scriptType, inputStream, password);
keystore.setLabel("Keycard Shell");
keystore.setWalletModel(getWalletModel());
return keystore;
}
@Override
public boolean isFileFormatAvailable() {
return false;
}
@Override
public boolean isWalletImportFileFormatAvailable() {
return false;
}
}
@@ -0,0 +1,10 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.wallet.Keystore;
import java.util.List;
public interface KeystoreCodexImport extends KeystoreImport {
Keystore getKeystore(List<ChildNumber> derivation, String secretShare) throws ImportException;
}
@@ -140,7 +140,7 @@ public class CardTransport {
}
}
} catch(CborException e) {
log.error("CBOR encoding error", e);
throw new CardException("CBOR encoding error", e);
}
return new JsonObject();
@@ -0,0 +1,125 @@
package com.sparrowwallet.sparrow.io.keycard;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
/**
* ISO7816-4 APDU.
*/
public class APDUCommand {
protected int cla;
protected int ins;
protected int p1;
protected int p2;
protected int lc;
protected byte[] data;
protected boolean needsLE;
/**
* Constructs an APDU with no response data length field. The data field cannot be null, but can be a zero-length array.
*
* @param cla class byte
* @param ins instruction code
* @param p1 P1 parameter
* @param p2 P2 parameter
* @param data the APDU data
*/
public APDUCommand(int cla, int ins, int p1, int p2, byte[] data) {
this(cla, ins, p1, p2, data, false);
}
/**
* Constructs an APDU with an optional data length field. The data field cannot be null, but can be a zero-length array.
* The LE byte, if sent, is set to 0.
*
* @param cla class byte
* @param ins instruction code
* @param p1 P1 parameter
* @param p2 P2 parameter
* @param data the APDU data
* @param needsLE whether the LE byte should be sent or not
*/
public APDUCommand(int cla, int ins, int p1, int p2, byte[] data, boolean needsLE) {
this.cla = cla & 0xff;
this.ins = ins & 0xff;
this.p1 = p1 & 0xff;
this.p2 = p2 & 0xff;
this.data = data;
this.needsLE = needsLE;
}
/**
* Serializes the APDU in order to send it to the card.
*
* @return the byte array representation of the APDU
*/
public byte[] serialize() throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(this.cla);
out.write(this.ins);
out.write(this.p1);
out.write(this.p2);
out.write(this.data.length);
out.write(this.data);
if(this.needsLE) {
out.write(0); // Response length
}
return out.toByteArray();
}
/**
* Returns the CLA of the APDU
*
* @return the CLA of the APDU
*/
public int getCla() {
return cla;
}
/**
* Returns the INS of the APDU
*
* @return the INS of the APDU
*/
public int getIns() {
return ins;
}
/**
* Returns the P1 of the APDU
*
* @return the P1 of the APDU
*/
public int getP1() {
return p1;
}
/**
* Returns the P2 of the APDU
*
* @return the P2 of the APDU
*/
public int getP2() {
return p2;
}
/**
* Returns the data field of the APDU
*
* @return the data field of the APDU
*/
public byte[] getData() {
return data;
}
/**
* Returns whether LE is sent or not.
*
* @return whether LE is sent or not
*/
public boolean getNeedsLE() {
return this.needsLE;
}
}
@@ -0,0 +1,29 @@
package com.sparrowwallet.sparrow.io.keycard;
/**
* Exception thrown when the response APDU from the card contains unexpected SW or data.
*/
public class APDUException extends Exception {
public final int sw;
/**
* Creates an exception with SW and message.
*
* @param sw the status word
* @param message a descriptive message of the error
*/
public APDUException(int sw, String message) {
super(message + ", 0x" + String.format("%04X", sw));
this.sw = sw;
}
/**
* Creates an exception with a message.
*
* @param message a descriptive message of the error
*/
public APDUException(String message) {
super(message);
this.sw = 0;
}
}
@@ -0,0 +1,181 @@
package com.sparrowwallet.sparrow.io.keycard;
import com.sparrowwallet.sparrow.io.CardAuthorizationException;
/**
* ISO7816-4 APDU response.
*/
public class APDUResponse {
public static final int SW_OK = 0x9000;
public static final int SW_SECURITY_CONDITION_NOT_SATISFIED = 0x6982;
public static final int SW_AUTHENTICATION_METHOD_BLOCKED = 0x6983;
public static final int SW_CARD_LOCKED = 0x6283;
public static final int SW_REFERENCED_DATA_NOT_FOUND = 0x6A88;
public static final int SW_CONDITIONS_OF_USE_NOT_SATISFIED = 0x6985; // applet may be already installed
public static final int SW_WRONG_PIN_MASK = 0x63C0;
private byte[] apdu;
private byte[] data;
private int sw;
private int sw1;
private int sw2;
/**
* Creates an APDU object by parsing the raw response from the card.
*
* @param apdu the raw response from the card.
*/
public APDUResponse(byte[] apdu) {
if(apdu.length < 2) {
throw new IllegalArgumentException("APDU response must be at least 2 bytes");
}
this.apdu = apdu;
this.parse();
}
/**
* Parses the APDU response, separating the response data from SW.
*/
private void parse() {
int length = this.apdu.length;
this.sw1 = this.apdu[length - 2] & 0xff;
this.sw2 = this.apdu[length - 1] & 0xff;
this.sw = (this.sw1 << 8) | this.sw2;
this.data = new byte[length - 2];
System.arraycopy(this.apdu, 0, this.data, 0, length - 2);
}
/**
* Returns true if the SW is 0x9000.
*
* @return true if the SW is 0x9000.
*/
public boolean isOK() {
return this.sw == SW_OK;
}
/**
* Asserts that the SW is 0x9000. Throws an exception if it isn't
*
* @return this object, to simplify chaining
* @throws APDUException if the SW is not 0x9000
*/
public APDUResponse checkOK() throws APDUException {
return this.checkSW(SW_OK);
}
/**
* Asserts that the SW is contained in the given list. Throws an exception if it isn't.
*
* @param codes the list of SWs to match.
* @return this object, to simplify chaining
* @throws APDUException if the SW is not 0x9000
*/
public APDUResponse checkSW(int... codes) throws APDUException {
for(int code : codes) {
if(this.sw == code) {
return this;
}
}
switch(this.sw) {
case SW_SECURITY_CONDITION_NOT_SATISFIED:
throw new APDUException(this.sw, "security condition not satisfied");
case SW_AUTHENTICATION_METHOD_BLOCKED:
throw new APDUException(this.sw, "authentication method blocked");
default:
throw new APDUException(this.sw, "Unexpected error SW");
}
}
/**
* Asserts that the SW is 0x9000. Throws an exception with the given message if it isn't
*
* @param message the error message
* @return this object, to simplify chaining
* @throws APDUException if the SW is not 0x9000
*/
public APDUResponse checkOK(String message) throws APDUException {
return checkSW(message, SW_OK);
}
/**
* Asserts that the SW is contained in the given list. Throws an exception with the given message if it isn't.
*
* @param message the error message
* @param codes the list of SWs to match.
* @return this object, to simplify chaining
* @throws APDUException if the SW is not 0x9000
*/
public APDUResponse checkSW(String message, int... codes) throws APDUException {
for(int code : codes) {
if(this.sw == code) {
return this;
}
}
throw new APDUException(this.sw, message);
}
/**
* Checks response from an authentication command (VERIFY PIN, UNBLOCK PUK)
*
* @throws CardAuthorizationException wrong PIN
* @throws APDUException unexpected response
*/
public APDUResponse checkAuthOK() throws CardAuthorizationException, APDUException {
if((this.sw & SW_WRONG_PIN_MASK) == SW_WRONG_PIN_MASK) {
int retryAttempts = sw2 & 0x0F;
throw new CardAuthorizationException("Wrong PIN, remaining tries: " + retryAttempts);
} else {
return checkOK();
}
}
/**
* Returns the data field of this APDU.
*
* @return the data field of this APDU
*/
public byte[] getData() {
return this.data;
}
/**
* Returns the Status Word.
*
* @return the status word
*/
public int getSw() {
return this.sw;
}
/**
* Returns the SW1 byte
*
* @return SW1
*/
public int getSw1() {
return this.sw1;
}
/**
* Returns the SW2 byte
*
* @return SW2
*/
public int getSw2() {
return this.sw2;
}
/**
* Returns the raw unparsed response.
*
* @return raw APDU data
*/
public byte[] getBytes() {
return this.apdu;
}
}
@@ -0,0 +1,207 @@
package com.sparrowwallet.sparrow.io.keycard;
/**
* Parses the response from a SELECT command. If the card has not yet received the INIT command the isInitializedCard
* will return false and only the getSecureChannelPubKey method will return a valid value.
*/
public class ApplicationInfo {
private boolean initializedCard;
private byte[] instanceUID;
private byte[] secureChannelPubKey;
private short appVersion;
private byte freePairingSlots;
private byte[] keyUID;
private byte capabilities;
public static final byte TLV_APPLICATION_INFO_TEMPLATE = (byte) 0xA4;
public static final byte TLV_PUB_KEY = (byte) 0x80;
public static final byte TLV_UID = (byte) 0x8F;
public static final byte TLV_KEY_UID = (byte) 0x8E;
public static final byte TLV_CAPABILITIES = (byte) 0x8D;
static final byte CAPABILITY_SECURE_CHANNEL = (byte) 0x01;
static final byte CAPABILITY_KEY_MANAGEMENT = (byte) 0x02;
static final byte CAPABILITY_CREDENTIALS_MANAGEMENT = (byte) 0x04;
static final byte CAPABILITY_NDEF = (byte) 0x08;
static final byte CAPABILITY_FACTORY_RESET = (byte) 0x10;
static final byte CAPABILITIES_ALL = CAPABILITY_SECURE_CHANNEL | CAPABILITY_KEY_MANAGEMENT | CAPABILITY_CREDENTIALS_MANAGEMENT | CAPABILITY_NDEF | CAPABILITY_FACTORY_RESET;
/**
* Constructs an object by parsing the TLV data.
*
* @param tlvData the raw response data from the card
* @throws IllegalArgumentException the TLV does not follow the allowed format
*/
public ApplicationInfo(byte[] tlvData) throws IllegalArgumentException {
TinyBERTLV tlv = new TinyBERTLV(tlvData);
int topTag = tlv.readTag();
tlv.unreadLastTag();
if(topTag == TLV_PUB_KEY) {
secureChannelPubKey = tlv.readPrimitive(TLV_PUB_KEY);
initializedCard = false;
capabilities = CAPABILITY_CREDENTIALS_MANAGEMENT;
if(secureChannelPubKey.length > 0) {
capabilities |= CAPABILITY_SECURE_CHANNEL;
}
return;
}
tlv.enterConstructed(TLV_APPLICATION_INFO_TEMPLATE);
instanceUID = tlv.readPrimitive(TLV_UID);
secureChannelPubKey = tlv.readPrimitive(TLV_PUB_KEY);
appVersion = (short) tlv.readInt();
freePairingSlots = (byte) tlv.readInt();
keyUID = tlv.readPrimitive(TLV_KEY_UID);
if(tlv.readTag() != TinyBERTLV.END_OF_TLV) {
tlv.unreadLastTag();
capabilities = tlv.readPrimitive(TLV_CAPABILITIES)[0];
} else {
capabilities = CAPABILITIES_ALL;
}
initializedCard = true;
}
/**
* Returns if the card is initialized or not. If this method returns false, only the getSecureChannelPubKey method
* will return a valid value.
*
* @return true if initialized, false otherwise
*/
public boolean isInitializedCard() {
return initializedCard;
}
/**
* Utility method to discover if the card has a master key.
*
* @return true if the card has a master key, false otherwise
*/
public boolean hasMasterKey() {
return (keyUID != null) && (keyUID.length != 0);
}
/**
* The instance UID of the applet. This ID never changes for the lifetime of the applet.
*
* @return the instance UID
*/
public byte[] getInstanceUID() {
return instanceUID;
}
/**
* The public key to be used for secure channel opening. Usually handled internally by the KeycardCommandSet.
*
* @return the public key
*/
public byte[] getSecureChannelPubKey() {
return secureChannelPubKey;
}
/**
* The application version, encoded as a short. The msb is the major revision number and the lsb is the minor one.
*
* @return the application version
*/
public short getAppVersion() {
return appVersion;
}
/**
* A formatted application version.
*
* @return the string representation of the application version
*/
public String getAppVersionString() {
return getAppVersionString(appVersion);
}
/**
* A formatted application version.
*
* @return the string representation of the application version
*/
static String getAppVersionString(short appVersion) {
return (appVersion >> 8) + "." + (appVersion & 0xff);
}
/**
* The number of remaining pairing slots. If zero is returned, no further pairing is possible.
*
* @return the number of remaining pairing slots
*/
public byte getFreePairingSlots() {
return freePairingSlots;
}
/**
* The UID of the master key on this card. Changes every time a different master key is stored. It has zero length if
* no key is on the card.
*
* @return the Key UID.
*/
public byte[] getKeyUID() {
return keyUID;
}
/**
* Returns the capability descriptor for the device.
*
* @return the capability descriptor for the device.
*/
public byte getCapabilities() {
return capabilities;
}
/**
* Returns true if the device supports the Secure Channel capability.
*
* @return true or false
*/
public boolean hasSecureChannelCapability() {
return (capabilities & CAPABILITY_SECURE_CHANNEL) == CAPABILITY_SECURE_CHANNEL;
}
/**
* Returns true if the device supports the Key Management capability.
*
* @return true or false
*/
public boolean hasKeyManagementCapability() {
return (capabilities & CAPABILITY_KEY_MANAGEMENT) == CAPABILITY_KEY_MANAGEMENT;
}
/**
* Returns true if the device supports the Credentials Management capability.
*
* @return true or false
*/
public boolean hasCredentialsManagementCapability() {
return (capabilities & CAPABILITY_CREDENTIALS_MANAGEMENT) == CAPABILITY_CREDENTIALS_MANAGEMENT;
}
/**
* Returns true if the device supports the NDEF capability.
*
* @return true or false
*/
public boolean hasNDEFCapability() {
return (capabilities & CAPABILITY_NDEF) == CAPABILITY_NDEF;
}
/**
* Returns true if the device supports the Factory Reset capability.
*
* @return true or false
*/
public boolean hasFactoryResetCapability() {
return (capabilities & CAPABILITY_FACTORY_RESET) == CAPABILITY_FACTORY_RESET;
}
}
@@ -0,0 +1,188 @@
package com.sparrowwallet.sparrow.io.keycard;
import com.sparrowwallet.drongo.crypto.ECKey;
/**
* Represents a BIP32 keypair. This can be a master key or any other key in the path. Contains convenience method to
* read and write formats the the card understands.
*/
public class BIP32KeyPair {
private byte[] privateKey;
private byte[] chainCode;
private byte[] publicKey;
static final byte TLV_KEY_TEMPLATE = (byte) 0xA1;
static final byte TLV_PUB_KEY = (byte) 0x80;
static final byte TLV_PRIV_KEY = (byte) 0x81;
static final byte TLV_CHAIN_CODE = (byte) 0x82;
/**
* Constructs a BIP32 keypair from a KEY TEMPLATE TLV. Data can be the output of the EXPORT KEY command.
*
* @param tlvData the TLV data
* @return the BIP32 keypair
*/
public static BIP32KeyPair fromTLV(byte[] tlvData) {
TinyBERTLV tlv = new TinyBERTLV(tlvData);
tlv.enterConstructed(TLV_KEY_TEMPLATE);
byte[] pubKey = null;
byte[] privKey = null;
byte[] chainCode = null;
int tag = tlv.readTag();
if(tag == TLV_PUB_KEY) {
tlv.unreadLastTag();
pubKey = tlv.readPrimitive(TLV_PUB_KEY);
tag = tlv.readTag();
}
if(tag == TLV_PRIV_KEY) {
tlv.unreadLastTag();
privKey = tlv.readPrimitive(TLV_PRIV_KEY);
tag = tlv.readTag();
}
if(tag == TLV_CHAIN_CODE) {
tlv.unreadLastTag();
chainCode = tlv.readPrimitive(TLV_CHAIN_CODE);
}
return new BIP32KeyPair(privKey, chainCode, pubKey);
}
/**
* Low level constructor. If the private key is not null, the public key can be omitted and it will be calculated
* automatically.
*
* @param privateKey the private key
* @param chainCode the chain code
* @param publicKey the public key
*/
public BIP32KeyPair(byte[] privateKey, byte[] chainCode, byte[] publicKey) {
this.privateKey = privateKey;
this.chainCode = chainCode;
if(publicKey != null) {
this.publicKey = publicKey;
} else {
calculatePublicKey();
}
}
private void calculatePublicKey() {
ECKey key = ECKey.fromPrivate(this.privateKey, false);
this.publicKey = key.getPubKey();
}
/**
* Returns the TLV representation of this object.
*
* @return the TLV representation of this object.
*/
public byte[] toTLV() {
return toTLV(true);
}
/**
* Returns the TLV representation of this object, optionally omitting the public component.
*
* @return the TLV representation of this object.
*/
public byte[] toTLV(boolean includePublic) {
int privLen = privateKey.length;
int privOff = 0;
if(privateKey[0] == 0x00) {
privOff++;
privLen--;
}
int off = 0;
int totalLength = includePublic ? (publicKey.length + 2) : 0;
totalLength += (privLen + 2);
totalLength += isExtended() ? (chainCode.length + 2) : 0;
if(totalLength > 127) {
totalLength += 3;
} else {
totalLength += 2;
}
byte[] data = new byte[totalLength];
data[off++] = TLV_KEY_TEMPLATE;
if(totalLength > 127) {
data[off++] = (byte) 0x81;
data[off++] = (byte) (totalLength - 3);
} else {
data[off++] = (byte) (totalLength - 2);
}
if(includePublic) {
data[off++] = TLV_PUB_KEY;
data[off++] = (byte) publicKey.length;
System.arraycopy(publicKey, 0, data, off, publicKey.length);
off += publicKey.length;
}
data[off++] = TLV_PRIV_KEY;
data[off++] = (byte) privLen;
System.arraycopy(privateKey, privOff, data, off, privLen);
off += privLen;
if(isExtended()) {
data[off++] = (byte) TLV_CHAIN_CODE;
data[off++] = (byte) chainCode.length;
System.arraycopy(chainCode, 0, data, off, chainCode.length);
}
return data;
}
/**
* Returns the private key. Might be null.
*
* @return the private key
*/
public byte[] getPrivateKey() {
return privateKey;
}
/**
* Returns the chain code. Might be null.
*
* @return the chain code
*/
public byte[] getChainCode() {
return chainCode;
}
/**
* Returns the public key. Is never null.
*
* @return the public key
*/
public byte[] getPublicKey() {
return publicKey;
}
/**
* True if only the public key is contained, false otherwise.
*
* @return true or false
*/
public boolean isPublicOnly() {
return privateKey == null;
}
/**
* True if the chain code is contained, false otherwise.
*
* @return true or false
*/
public boolean isExtended() {
return chainCode != null;
}
}
@@ -0,0 +1,36 @@
package com.sparrowwallet.sparrow.io.keycard;
import java.io.IOException;
/**
* A channel to transcieve ISO7816-4 APDUs.
*/
public interface CardChannel {
/**
* Sends the given C-APDU and returns an R-APDU.
*
* @param cmd the command to send
* @return the card response
* @throws IOException communication error
*/
APDUResponse send(APDUCommand cmd) throws IOException;
/**
* True if connected, false otherwise
*
* @return true if connected, false otherwise
*/
boolean isConnected();
/**
* Returns the iteration count for deriving the pairing key from the pairing password. The default is 50000 and is
* should only be changed for devices where the PBKDF2 is calculated on-board and the resource do not permit a
* high iteration count. If a lower count is used other security mechanism should be used to prevent brute force
* attacks.
*
* @return the iteration count
*/
default int pairingPasswordPBKDF2IterationCount() {
return 50000;
}
}
@@ -0,0 +1,46 @@
package com.sparrowwallet.sparrow.io.keycard;
import com.sparrowwallet.drongo.Utils;
import java.util.Arrays;
public class Identifiers {
public static final byte[] PACKAGE_AID = Utils.hexToBytes("A0000008040001");
public static final byte[] KEYCARD_AID = Utils.hexToBytes("A000000804000101");
public static final int KEYCARD_DEFAULT_INSTANCE_IDX = 1;
public static final byte[] NDEF_AID = Utils.hexToBytes("A000000804000102");
public static final byte[] NDEF_INSTANCE_AID = Utils.hexToBytes("D2760000850101");
public static final byte[] CASH_AID = Utils.hexToBytes("A000000804000103");
public static final byte[] CASH_INSTANCE_AID = Utils.hexToBytes("A00000080400010301");
public static final byte[] IDENT_AID = Utils.hexToBytes("A000000804000104");
public static final byte[] IDENT_INSTANCE_AID = Utils.hexToBytes("A00000080400010401");
/**
* Gets the instance AID of the default instance of the Keycard applet.
*
* @return the instance AID of the Keycard applet
*/
public static byte[] getKeycardInstanceAID() {
return getKeycardInstanceAID(KEYCARD_DEFAULT_INSTANCE_IDX);
}
/**
* Gets the instance AID of the Keycard applet with the given index. Since multiple instances of the Keycard applet
* could be installed in parallel, this method allows selecting a specific instance. The index is between 01 and ff
*
* @return the instance AID of the Keycard applet
*/
public static byte[] getKeycardInstanceAID(int instanceIdx) {
if(instanceIdx < 0x01 || instanceIdx > 0xff) {
throw new IllegalArgumentException("The instance index must be between 1 and 255");
}
byte[] instanceAID = Arrays.copyOf(KEYCARD_AID, KEYCARD_AID.length + 1);
instanceAID[KEYCARD_AID.length] = (byte) instanceIdx;
return instanceAID;
}
}
@@ -0,0 +1,144 @@
package com.sparrowwallet.sparrow.io.keycard;
import java.util.StringTokenizer;
/**
* Keypath object to be used with the KeycardCommandSet
*/
public class KeyPath {
private int source;
private byte[] data;
/**
* Parses a keypath into a byte array and source parameter to be used with the KeycardCommandSet object.
* <p>
* A valid string is composed of a minimum of one and a maximum of 11 components separated by "/".
* <p>
* The first component can be either "m", indicating the master key, "..", indicating the parent of the current key,
* or "." indicating the current key. It can also be omitted, in which case it is considered the same as being ".".
* <p>
* All other components are positive integers fitting in 31 bit, eventually suffixed by an apostrophe (') sign,
* which indicates an hardened key.
* <p>
* An example of a valid path is "m/44'/0'/0'/0/0"
*
* @param keypath the keypath as a string
*/
public KeyPath(String keypath) {
StringTokenizer tokenizer = new StringTokenizer(keypath, "/");
String sourceOrFirstElement = tokenizer.nextToken();
switch(sourceOrFirstElement) {
case "m":
source = KeycardCommandSet.DERIVE_P1_SOURCE_MASTER;
break;
case "..":
source = KeycardCommandSet.DERIVE_P1_SOURCE_PARENT;
break;
case ".":
source = KeycardCommandSet.DERIVE_P1_SOURCE_CURRENT;
break;
default:
source = KeycardCommandSet.DERIVE_P1_SOURCE_CURRENT;
tokenizer = new StringTokenizer(keypath, "/"); // rewind
break;
}
int componentCount = tokenizer.countTokens();
if(componentCount > 10) {
throw new IllegalArgumentException("Too many components");
}
data = new byte[4 * componentCount];
for(int i = 0; i < componentCount; i++) {
long component = parseComponent(tokenizer.nextToken());
writeComponent(component, i);
}
}
public KeyPath(byte[] data, int source) {
this.data = data;
this.source = source;
}
public KeyPath(byte[] data) {
this(data, KeycardCommandSet.DERIVE_P1_SOURCE_MASTER);
}
private long parseComponent(String num) {
long sign;
if(num.endsWith("'")) {
sign = 0x80000000L;
num = num.substring(0, (num.length() - 1));
} else {
sign = 0L;
}
if(num.startsWith("+") || num.startsWith("-")) {
throw new NumberFormatException("No sign allowed");
}
return (sign | Long.parseLong(num));
}
private void writeComponent(long component, int i) {
int off = (i * 4);
data[off] = (byte) ((component >> 24) & 0xff);
data[off + 1] = (byte) ((component >> 16) & 0xff);
data[off + 2] = (byte) ((component >> 8) & 0xff);
data[off + 3] = (byte) (component & 0xff);
}
/**
* The source of the derive command.
*
* @return the source of the derive command
*/
public int getSource() {
return source;
}
/**
* The byte encoded key path.
*
* @return byte encoded key path
*/
public byte[] getData() {
return data;
}
@Override
public String toString() {
StringBuffer sb = new StringBuffer();
switch(source) {
case KeycardCommandSet.DERIVE_P1_SOURCE_MASTER:
sb.append('m');
break;
case KeycardCommandSet.DERIVE_P1_SOURCE_PARENT:
sb.append("..");
break;
case KeycardCommandSet.DERIVE_P1_SOURCE_CURRENT:
sb.append('.');
break;
}
for(int i = 0; i < this.data.length; i += 4) {
sb.append('/');
appendComponent(sb, i);
}
return sb.toString();
}
private void appendComponent(StringBuffer sb, int i) {
int num = ((this.data[i] & 0x7f) << 24) | ((this.data[i + 1] & 0xff) << 16) | ((this.data[i + 2] & 0xff) << 8) | (this.data[i + 3] & 0xff);
sb.append(num);
if((this.data[i] & 0x80) == 0x80) {
sb.append('\'');
}
}
}
@@ -0,0 +1,95 @@
package com.sparrowwallet.sparrow.io.keycard;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.io.ImportException;
import com.sparrowwallet.sparrow.io.KeystoreCardImport;
import javafx.beans.property.StringProperty;
import org.apache.commons.lang3.StringUtils;
import javax.smartcardio.CardException;
import java.util.List;
public class Keycard implements KeystoreCardImport {
@Override
public boolean isInitialized() throws CardException {
KeycardApi cardApi = null;
try {
cardApi = new KeycardApi(WalletModel.KEYCARD, null);
return cardApi.isInitialized();
} finally {
if(cardApi != null) {
cardApi.disconnect();
}
}
}
@Override
public void initialize(String pin, byte[] entropy, StringProperty messageProperty) throws CardException {
if(!StringUtils.isNumeric(pin)) {
throw new CardException("PIN must be all digits.");
}
if(pin.length() != 6) {
throw new CardException("PIN must be 6 digit longs.");
}
KeycardApi cardApi = null;
try {
cardApi = new KeycardApi(WalletModel.KEYCARD, pin);
if(cardApi.isInitialized()) {
throw new IllegalStateException("Card is already initialized.");
}
cardApi.initialize(0, entropy);
} finally {
if(cardApi != null) {
cardApi.disconnect();
}
}
}
@Override
public Keystore getKeystore(String pin, List<ChildNumber> derivation, StringProperty messageProperty) throws ImportException {
if(!StringUtils.isNumeric(pin)) {
throw new ImportException("PIN must be all digits.");
}
if(pin.length() != 6) {
throw new ImportException("PIN must be 6 digit longs.");
}
KeycardApi cardApi = null;
try {
cardApi = new KeycardApi(WalletModel.KEYCARD, pin);
if(!cardApi.isInitialized()) {
throw new IllegalStateException("Card is not initialized.");
}
cardApi.setDerivation(derivation);
return cardApi.getKeystore();
} catch(Exception e) {
throw new ImportException(e);
} finally {
if(cardApi != null) {
cardApi.disconnect();
}
}
}
@Override
public String getKeystoreImportDescription(int account) {
return "Import the keystore from your Keycard by inserting or placing it on the card reader.";
}
@Override
public String getName() {
return "Keycard";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.KEYCARD;
}
}
@@ -0,0 +1,426 @@
package com.sparrowwallet.sparrow.io.keycard;
import com.sparrowwallet.drongo.*;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.crypto.ECDSASignature;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.psbt.PSBTInputSigner;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.control.CardImportPane;
import com.sparrowwallet.sparrow.io.CardApi;
import com.sparrowwallet.sparrow.io.CardAuthorizationException;
import javafx.beans.property.StringProperty;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.smartcardio.CardException;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
public class KeycardApi extends CardApi {
private static final Logger log = LoggerFactory.getLogger(KeycardApi.class);
private final WalletModel cardType;
private final KeycardTransport cardTransport;
private final KeycardCommandSet cardProtocol;
private final String pin;
private String basePath = null;
public KeycardApi(WalletModel cardType, String pin) throws CardException {
this.cardType = cardType;
this.cardTransport = new KeycardTransport(Identifiers.getKeycardInstanceAID());
this.cardProtocol = new KeycardCommandSet(cardTransport);
this.pin = pin;
try {
this.cardProtocol.select().checkOK();
} catch(IOException | APDUException e) {
throw new CardException(e);
}
}
@Override
public boolean isInitialized() throws CardException {
return getStatus().hasMasterKey();
}
//TODO
@Override
public void initialize(int slot, byte[] seedBytes) throws CardException {
// TODO check device certificate
ApplicationInfo cardStatus = this.getStatus();
if(!cardStatus.isInitializedCard()) {
try {
String puk = String.format("%012d", new SecureRandom().nextLong(999999999999L));
this.cardProtocol.init(pin, puk, "KeycardDefaultPairing").checkOK();
this.cardProtocol.select().checkOK();
} catch(IOException | APDUException e) {
throw new CardException(e);
}
}
if(!cardStatus.hasMasterKey()) {
try {
this.authenticate();
this.cardProtocol.autoUnpair();
this.cardProtocol.loadKey(seedBytes).checkOK();
} catch(IOException | APDUException e) {
throw new CardException(e);
}
}
}
@Override
public WalletModel getCardType() throws CardException {
return WalletModel.KEYCARD;
}
@Override
public int getCurrentSlot() throws CardException {
throw new CardException("Keycard does not support slots");
}
@Override
public ScriptType getDefaultScriptType() {
return ScriptType.P2WPKH;
}
ApplicationInfo getStatus() {
return this.cardProtocol.getApplicationInfo();
}
void authenticate() throws IOException, APDUException, CardAuthorizationException {
this.cardProtocol.autoPair("KeycardDefaultPairing");
this.cardProtocol.autoOpenSecureChannel();
this.cardProtocol.verifyPIN(pin).checkAuthOK();
this.cardProtocol.autoUnpair();
}
@Override
public Service<Void> getAuthDelayService() throws CardException {
return null;
}
@Override
public boolean requiresBackup() throws CardException {
return false;
}
@Override
public Service<String> getBackupService() {
return null;
}
@Override
public boolean changePin(String newPin) throws CardException {
try {
this.authenticate();
this.cardProtocol.changePIN(newPin).checkOK();
} catch(IOException | APDUException e) {
throw new CardException(e);
}
return true;
}
void setDerivation(List<ChildNumber> derivation) throws CardException {
this.basePath = KeyDerivation.writePath(derivation);
}
@Override
public Service<Void> getInitializationService(byte[] seedBytes, StringProperty messageProperty) {
return new KeycardApi.CardInitializationService(seedBytes, messageProperty);
}
@Override
public Service<Keystore> getImportService(List<ChildNumber> derivation, StringProperty messageProperty) {
return new CardImportPane.CardImportService(new Keycard(), pin, derivation, messageProperty);
}
private byte[] compressedPub(byte[] uncompressedPub) {
byte[] compressed = Arrays.copyOfRange(uncompressedPub, 0, 33);
compressed[0] = (byte) (0x02 | (uncompressedPub[64] & 0x1));
return compressed;
}
private String cardBip32GetXpub(String stringPath, ExtendedKey.Header xtype) throws IOException, APDUException {
KeyPath keyPath = new KeyPath(stringPath);
int bytepathLen = keyPath.getData().length;
int depth = bytepathLen / 4;
APDUResponse rapdu = this.cardProtocol.exportKey(keyPath.getData(), keyPath.getSource(), false, KeycardCommandSet.EXPORT_KEY_P2_EXTENDED_PUBLIC).checkOK();
BIP32KeyPair extendedkey = BIP32KeyPair.fromTLV(rapdu.getData());
byte[] fingerprint = new byte[4];
byte[] childNumber = new byte[4];
if(depth == 0) { //masterkey
// fingerprint and childnumber set to all-zero bytes by default
//fingerprint= bytes([0,0,0,0])
//childNumber= bytes([0,0,0,0])
} else { //get parent info
APDUResponse rapdu2 = this.cardProtocol.exportKey(Arrays.copyOfRange(keyPath.getData(), 0, bytepathLen - 4), keyPath.getSource(), false, KeycardCommandSet.EXPORT_KEY_P2_PUBLIC_ONLY).checkOK();
BIP32KeyPair keyParent = BIP32KeyPair.fromTLV(rapdu2.getData());
byte[] identifier = Utils.sha256hash160(compressedPub(keyParent.getPublicKey()));
fingerprint = Arrays.copyOfRange(identifier, 0, 4);
childNumber = Arrays.copyOfRange(keyPath.getData(), bytepathLen - 4, bytepathLen);
}
ByteBuffer buffer = ByteBuffer.allocate(78);
buffer.putInt(xtype.getHeader());
buffer.put((byte) depth);
buffer.put(fingerprint);
buffer.put(childNumber);
buffer.put(extendedkey.getChainCode()); // chaincode
buffer.put(compressedPub(extendedkey.getPublicKey())); // pubkey (compressed)
byte[] xpubByte = buffer.array();
return Base58.encodeChecked(xpubByte);
}
/*
* Keycard derives BIP32 keys based on the fullPath (from masterseed to leaf), not the partial path from a given xpub.
* the basePath (from masterseed to xpub) is only provided in Keycard.java:getKeystore(String pin, List<ChildNumber> derivation, StringProperty messageProperty)
* In Keycard:getKeystore(), no derivation path (i.e. basePath from masterSeed to xpub or relative path) is given and no derivation is reliably available as a object field.
* currently, we try to get the path from this.basePath if available (or use a default value) but it's not reliable enough
*/
@Override
public Keystore getKeystore() throws CardException {
String keyDerivationString = (this.basePath != null ? this.basePath : getDefaultScriptType().getDefaultDerivationPath());
ExtendedKey.Header xtype = Network.get().getXpubHeader();
String xpub;
String masterXpub;
try {
this.authenticate();
xpub = this.cardBip32GetXpub(keyDerivationString, xtype);
masterXpub = this.cardBip32GetXpub("m", xtype);
} catch(IOException | APDUException e) {
throw new CardException(e);
}
ExtendedKey extendedKey = ExtendedKey.fromDescriptor(xpub);
ExtendedKey masterExtendedKey = ExtendedKey.fromDescriptor(masterXpub);
String masterFingerprint = Utils.bytesToHex(masterExtendedKey.getKey().getFingerprint());
KeyDerivation keyDerivation = new KeyDerivation(masterFingerprint, keyDerivationString, true);
Keystore keystore = new Keystore();
keystore.setLabel(WalletModel.KEYCARD.toDisplayString());
keystore.setKeyDerivation(keyDerivation);
keystore.setSource(KeystoreSource.HW_USB);
keystore.setExtendedPublicKey(extendedKey);
keystore.setWalletModel(WalletModel.KEYCARD);
return keystore;
}
@Override
public Service<PSBT> getSignService(Wallet wallet, PSBT psbt, StringProperty messageProperty) {
return new KeycardApi.SignService(wallet, psbt, messageProperty);
}
void sign(Wallet wallet, PSBT psbt) throws CardException {
Map<PSBTInput, WalletNode> signingNodes = wallet.getSigningNodes(psbt);
for(PSBTInput psbtInput : psbt.getPsbtInputs()) {
if(!psbtInput.isSigned()) {
WalletNode signingNode = signingNodes.get(psbtInput);
List<Keystore> keystores = wallet.getKeystores();
// recover derivation path from Keycard keystore
String fullPath = null;
for(int i = 0; i < keystores.size(); i++) {
Keystore keystore = keystores.get(i);
WalletModel walletModel = keystore.getWalletModel();
if(walletModel == WalletModel.KEYCARD) {
String basePath = keystore.getKeyDerivation().getDerivationPath();
String extendedPath = signingNode.getDerivationPath().substring(1);
fullPath = basePath + extendedPath;
break;
}
}
if(fullPath == null) {
// recover a default derivation path from first keystore
Keystore keystore = keystores.get(0);
String basePath = keystore.getKeyDerivation().getDerivationPath();
String extendedPath = signingNode.getDerivationPath().substring(1);
fullPath = basePath + extendedPath;
}
psbtInput.sign(new KeycardApi.CardPSBTInputSigner(signingNode, fullPath));
}
}
}
@Override
public Service<String> getSignMessageService(String message, ScriptType scriptType, List<ChildNumber> derivation, StringProperty messageProperty) {
return new KeycardApi.SignMessageService(message, scriptType, derivation, messageProperty);
}
String signMessage(String message, ScriptType scriptType, List<ChildNumber> derivation) throws CardException {
String fullpath = KeyDerivation.writePath(derivation);
ECKey pubkey;
try {
authenticate();
APDUResponse rapdu = cardProtocol.exportKey(fullpath, false, KeycardCommandSet.EXPORT_KEY_P2_PUBLIC_ONLY).checkOK();
BIP32KeyPair keys = BIP32KeyPair.fromTLV(rapdu.getData());
pubkey = ECKey.fromPublicOnly(compressedPub(keys.getPublicKey()));
} catch(IOException | APDUException e) {
throw new CardException(e);
}
// sign msg
return pubkey.signMessage(message, scriptType, hash -> {
try {
// do the signature with Keycard
APDUResponse rapdu2 = cardProtocol.signWithPath(hash.getBytes(), fullpath, false).checkOK();
RecoverableSignature sig = new RecoverableSignature(hash.getBytes(), rapdu2.getData());
return new ECDSASignature(new BigInteger(1, sig.getR()), new BigInteger(1, sig.getS()));
} catch(Exception e) {
throw new RuntimeException(e);
}
});
}
@Override
public Service<ECKey> getPrivateKeyService(Integer slot, StringProperty messageProperty) {
throw new UnsupportedOperationException("Keycard does not support private key export");
}
@Override
public Service<Address> getAddressService(StringProperty messageProperty) {
return null;
}
@Override
public void disconnect() {
try {
cardTransport.disconnect();
} catch(CardException e) {
log.error("Error disconnecting Keycard" + e);
}
}
public class CardInitializationService extends Service<Void> {
private final byte[] seedBytes;
private final StringProperty messageProperty;
public CardInitializationService(byte[] seedBytes, StringProperty messageProperty) {
this.seedBytes = seedBytes;
this.messageProperty = messageProperty;
}
@Override
protected Task<Void> createTask() {
return new Task<>() {
@Override
protected Void call() throws Exception {
if(seedBytes == null) {
throw new CardException("Failed to initialize Keycard - no seed provided");
}
initialize(0, seedBytes);
return null;
}
};
}
}
public class SignService extends Service<PSBT> {
private final Wallet wallet;
private final PSBT psbt;
private final StringProperty messageProperty;
public SignService(Wallet wallet, PSBT psbt, StringProperty messageProperty) {
this.wallet = wallet;
this.psbt = psbt;
this.messageProperty = messageProperty;
}
@Override
protected Task<PSBT> createTask() {
return new Task<>() {
@Override
protected PSBT call() throws Exception {
sign(wallet, psbt);
return psbt;
}
};
}
}
private class CardPSBTInputSigner implements PSBTInputSigner {
private final WalletNode signingNode;
private final String fullPath;
private ECKey pubkey;
// todo: provide derivationpath instead of WalletNode??
public CardPSBTInputSigner(WalletNode signingNode, String fullPath) {
this.signingNode = signingNode;
this.fullPath = fullPath;
}
@Override
public TransactionSignature sign(Sha256Hash hash, SigHash sigHash, TransactionSignature.Type signatureType) {
try {
// verify PIN
authenticate();
if(signatureType == TransactionSignature.Type.ECDSA) {
// do the signature with Keycard
APDUResponse rapdu = cardProtocol.signWithPath(hash.getBytes(), fullPath, false).checkOK();
RecoverableSignature sig = new RecoverableSignature(hash.getBytes(), rapdu.getData());
pubkey = ECKey.fromPublicOnly(compressedPub(sig.getPublicKey()));
ECDSASignature ecdsaSig = new ECDSASignature(new BigInteger(1, sig.getR()), new BigInteger(1, sig.getS())).toCanonicalised();
TransactionSignature txSig = new TransactionSignature(ecdsaSig, sigHash);
boolean isCorrect = pubkey.verify(hash, txSig);
return txSig;
} else {
throw new CardException(WalletModel.KEYCARD.toDisplayString() + " cannot sign Taproot transactions");
}
} catch(Exception e) {
throw new RuntimeException(e);
}
}
@Override
public ECKey getPubKey() {
return pubkey;
}
}
public class SignMessageService extends Service<String> {
private final String message;
private final ScriptType scriptType;
private final List<ChildNumber> derivation;
private final StringProperty messageProperty;
public SignMessageService(String message, ScriptType scriptType, List<ChildNumber> derivation, StringProperty messageProperty) {
this.message = message;
this.scriptType = scriptType;
this.derivation = derivation;
this.messageProperty = messageProperty;
}
@Override
protected Task<String> createTask() {
return new Task<>() {
@Override
protected String call() throws Exception {
return signMessage(message, scriptType, derivation);
}
};
}
}
}
@@ -0,0 +1,870 @@
package com.sparrowwallet.sparrow.io.keycard;
import com.sparrowwallet.drongo.Drongo;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.io.IOException;
import java.util.Arrays;
/**
* This class is used to send APDU to the applet. Each method corresponds to an APDU as defined in the APPLICATION.md
* file. Some APDUs map to multiple methods for the sake of convenience since their payload or response require some
* pre/post processing.
*/
public class KeycardCommandSet {
static final byte INS_INIT = (byte) 0xFE;
static final byte INS_FACTORY_RESET = (byte) 0xFD;
static final byte INS_GET_STATUS = (byte) 0xF2;
static final byte INS_SET_NDEF = (byte) 0xF3;
static final byte INS_IDENTIFY_CARD = (byte) 0x14;
static final byte INS_VERIFY_PIN = (byte) 0x20;
static final byte INS_CHANGE_PIN = (byte) 0x21;
static final byte INS_UNBLOCK_PIN = (byte) 0x22;
static final byte INS_LOAD_KEY = (byte) 0xD0;
static final byte INS_DERIVE_KEY = (byte) 0xD1;
static final byte INS_GENERATE_MNEMONIC = (byte) 0xD2;
static final byte INS_REMOVE_KEY = (byte) 0xD3;
static final byte INS_GENERATE_KEY = (byte) 0xD4;
static final byte INS_SIGN = (byte) 0xC0;
static final byte INS_SET_PINLESS_PATH = (byte) 0xC1;
static final byte INS_EXPORT_KEY = (byte) 0xC2;
static final byte INS_GET_DATA = (byte) 0xCA;
static final byte INS_STORE_DATA = (byte) 0xE2;
public static final byte CHANGE_PIN_P1_USER_PIN = 0x00;
public static final byte CHANGE_PIN_P1_PUK = 0x01;
public static final byte CHANGE_PIN_P1_PAIRING_SECRET = 0x02;
public static final byte GET_STATUS_P1_APPLICATION = 0x00;
public static final byte GET_STATUS_P1_KEY_PATH = 0x01;
public static final byte LOAD_KEY_P1_EC = 0x01;
public static final byte LOAD_KEY_P1_EXT_EC = 0x02;
public static final byte LOAD_KEY_P1_SEED = 0x03;
public static final byte DERIVE_P1_SOURCE_MASTER = (byte) 0x00;
public static final byte DERIVE_P1_SOURCE_PARENT = (byte) 0x40;
public static final byte DERIVE_P1_SOURCE_CURRENT = (byte) 0x80;
static final byte SIGN_P1_CURRENT_KEY = 0x00;
static final byte SIGN_P1_DERIVE = 0x01;
static final byte SIGN_P1_DERIVE_AND_MAKE_CURRENT = 0x02;
static final byte SIGN_P1_PINLESS = 0x03;
public static final byte SIGN_P2_ECDSA = 0x00;
public static final byte SIGN_P2_BLS12_381 = 0x01;
public static final byte STORE_DATA_P1_PUBLIC = 0x00;
public static final byte STORE_DATA_P1_NDEF = 0x01;
public static final byte STORE_DATA_P1_CASH = 0x02;
public static final int GENERATE_MNEMONIC_12_WORDS = 0x04;
public static final int GENERATE_MNEMONIC_15_WORDS = 0x05;
public static final int GENERATE_MNEMONIC_18_WORDS = 0x06;
public static final int GENERATE_MNEMONIC_21_WORDS = 0x07;
public static final int GENERATE_MNEMONIC_24_WORDS = 0x08;
static final byte EXPORT_KEY_P1_CURRENT = 0x00;
static final byte EXPORT_KEY_P1_DERIVE = 0x01;
static final byte EXPORT_KEY_P1_DERIVE_AND_MAKE_CURRENT = 0x02;
public static final byte EXPORT_KEY_P2_PRIVATE_AND_PUBLIC = 0x00;
public static final byte EXPORT_KEY_P2_PUBLIC_ONLY = 0x01;
public static final byte EXPORT_KEY_P2_EXTENDED_PUBLIC = 0x02;
static final byte FACTORY_RESET_P1_MAGIC = (byte) 0xAA;
static final byte FACTORY_RESET_P2_MAGIC = 0x55;
static final byte TLV_APPLICATION_INFO_TEMPLATE = (byte) 0xA4;
private final CardChannel apduChannel;
private SecureChannelSession secureChannel;
private ApplicationInfo info;
/**
* Creates a KeycardCommandSet using the given APDU Channel
*
* @param apduChannel APDU channel
*/
public KeycardCommandSet(CardChannel apduChannel) {
this.apduChannel = apduChannel;
this.secureChannel = new SecureChannelSession();
}
/**
* Returns the application info as stored from the last sent SELECT command. Returns null if no succesful SELECT
* command has been sent using this command set.
*
* @return the application info object
*/
public ApplicationInfo getApplicationInfo() {
return info;
}
/**
* Set the SecureChannel object
*
* @param secureChannel secure channel
*/
protected void setSecureChannel(SecureChannelSession secureChannel) {
this.secureChannel = secureChannel;
}
/**
* Returns the current pairing data.
*/
public Pairing getPairing() {
return secureChannel.getPairing();
}
/**
* Sets the pairing data.
*
* @param pairing data from an existing pairing
*/
public void setPairing(Pairing pairing) {
secureChannel.setPairing(pairing);
}
/**
* Selects the default instance of the Keycard applet. The applet is assumed to have been installed with its default
* AID. The returned data is a public key which must be used to initialize the secure channel.
*
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse select() throws IOException {
return select(Identifiers.KEYCARD_DEFAULT_INSTANCE_IDX);
}
/**
* Selects a Keycard instance. The applet is assumed to have been installed with its default AID. The returned data is
* a public key which must be used to initialize the secure channel.
*
* @param instanceIdx the instance index
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse select(int instanceIdx) throws IOException {
APDUCommand selectApplet = new APDUCommand(0x00, 0xA4, 4, 0, Identifiers.getKeycardInstanceAID(instanceIdx));
APDUResponse resp = apduChannel.send(selectApplet);
if(resp.getSw() == 0x9000) {
info = new ApplicationInfo(resp.getData());
if(info.hasSecureChannelCapability()) {
this.secureChannel.generateSecret(info.getSecureChannelPubKey());
this.secureChannel.reset();
}
}
return resp;
}
/**
* Opens the secure channel. Calls the corresponding method of the SecureChannel class.
*
* @throws IOException communication error
* @throws APDUException secure channel error
*/
public void autoOpenSecureChannel() throws IOException, APDUException {
secureChannel.autoOpenSecureChannel(apduChannel);
}
/**
* Automatically pairs. Derives the secret from the given password.
*
* @throws IOException communication error
* @throws APDUException pairing error
*/
public void autoPair(String pairingPassword) throws IOException, APDUException {
byte[] secret = pairingPasswordToSecret(pairingPassword);
secureChannel.autoPair(apduChannel, secret);
}
/**
* Converts a pairing password to a binary pairing secret.
*
* @param pairingPassword the pairing password
* @return the pairing secret
*/
public byte[] pairingPasswordToSecret(String pairingPassword) {
SecretKey key;
try {
SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256", Drongo.getProvider());
PBEKeySpec spec = new PBEKeySpec(pairingPassword.toCharArray(), "Keycard Pairing Password Salt".getBytes(), apduChannel.pairingPasswordPBKDF2IterationCount(), 32 * 8);
key = skf.generateSecret(spec);
} catch(Exception e) {
throw new RuntimeException(e);
}
return key.getEncoded();
}
/**
* Automatically pairs. Calls the corresponding method of the SecureChannel class.
*
* @throws IOException communication error
* @throws APDUException pairing error
*/
public void autoPair(byte[] sharedSecret) throws IOException, APDUException {
secureChannel.autoPair(apduChannel, sharedSecret);
}
/**
* Automatically unpairs. Calls the corresponding method of the SecureChannel class.
*
* @throws IOException communication error
* @throws APDUException unpairing error
*/
public void autoUnpair() throws IOException, APDUException {
secureChannel.autoUnpair(apduChannel);
}
/**
* Sends a OPEN SECURE CHANNEL APDU. Calls the corresponding method of the SecureChannel class.
*/
public APDUResponse openSecureChannel(byte index, byte[] data) throws IOException {
return secureChannel.openSecureChannel(apduChannel, index, data);
}
/**
* Sends a MUTUALLY AUTHENTICATE APDU. Calls the corresponding method of the SecureChannel class.
*/
public APDUResponse mutuallyAuthenticate() throws IOException {
return secureChannel.mutuallyAuthenticate(apduChannel);
}
/**
* Sends a MUTUALLY AUTHENTICATE APDU. Calls the corresponding method of the SecureChannel class.
*/
public APDUResponse mutuallyAuthenticate(byte[] data) throws IOException {
return secureChannel.mutuallyAuthenticate(apduChannel, data);
}
/**
* Sends a PAIR APDU. Calls the corresponding method of the SecureChannel class.
*/
public APDUResponse pair(byte p1, byte[] data) throws IOException {
return secureChannel.pair(apduChannel, p1, data);
}
/**
* Sends a UNPAIR APDU. Calls the corresponding method of the SecureChannel class.
*/
public APDUResponse unpair(byte p1) throws IOException {
return secureChannel.unpair(apduChannel, p1);
}
/**
* Unpair all other clients.
*
* @throws IOException communication error
* @throws APDUException unpairing error
*/
public void unpairOthers() throws IOException, APDUException {
secureChannel.unpairOthers(apduChannel);
}
/**
* Sends an IDENTIFY CARD APDU. The challenge is sent as APDU data as-is. It must be 32 bytes long
*
* @param challenge the data of the APDU
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse identifyCard(byte[] challenge) throws IOException {
APDUCommand identifyCard = secureChannel.protectedCommand(0x80, INS_IDENTIFY_CARD, 0, 0, challenge);
return secureChannel.transmit(apduChannel, identifyCard);
}
/**
* Sends a GET STATUS APDU. The info byte is the P1 parameter of the command, valid constants are defined in the applet
* class itself.
*
* @param info the P1 of the APDU
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse getStatus(byte info) throws IOException {
APDUCommand getStatus = secureChannel.protectedCommand(0x80, INS_GET_STATUS, info, 0, new byte[0]);
return secureChannel.transmit(apduChannel, getStatus);
}
/**
* Sends a VERIFY PIN APDU. The raw bytes of the given string are encrypted using the secure channel and used as APDU
* data.
*
* @param pin the PIN
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse verifyPIN(String pin) throws IOException {
APDUCommand verifyPIN = secureChannel.protectedCommand(0x80, INS_VERIFY_PIN, 0, 0, pin.getBytes());
return secureChannel.transmit(apduChannel, verifyPIN);
}
/**
* Sends a CHANGE PIN APDU to change the user PIN.
*
* @param pin the new PIN
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse changePIN(String pin) throws IOException {
return changePIN(CHANGE_PIN_P1_USER_PIN, pin.getBytes());
}
/**
* Sends a CHANGE PIN APDU to change the PUK.
*
* @param puk the new PUK
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse changePUK(String puk) throws IOException {
return changePIN(CHANGE_PIN_P1_PUK, puk.getBytes());
}
/**
* Sends a CHANGE PIN APDU to change the pairing password. This does not break existing pairings, but new pairings
* will be made using the new password.
*
* @param pairingPassword the new pairing password
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse changePairingPassword(String pairingPassword) throws IOException {
return changePIN(CHANGE_PIN_P1_PAIRING_SECRET, pairingPasswordToSecret(pairingPassword));
}
/**
* Sends a CHANGE PIN APDU. The raw bytes of the given string are encrypted using the secure channel and used as APDU
* data.
*
* @param pinType the PIN type
* @param pin the new PIN
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse changePIN(int pinType, String pin) throws IOException {
return changePIN(pinType, pin.getBytes());
}
/**
* Sends a CHANGE PIN APDU. The raw bytes of the given string are encrypted using the secure channel and used as APDU
* data.
*
* @param pinType the PIN type
* @param pin the new PIN
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse changePIN(int pinType, byte[] pin) throws IOException {
APDUCommand changePIN = secureChannel.protectedCommand(0x80, INS_CHANGE_PIN, pinType, 0, pin);
return secureChannel.transmit(apduChannel, changePIN);
}
/**
* Sends an UNBLOCK PIN APDU. The PUK and PIN are concatenated and the raw bytes are encrypted using the secure
* channel and used as APDU data.
*
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse unblockPIN(String puk, String newPin) throws IOException {
APDUCommand unblockPIN = secureChannel.protectedCommand(0x80, INS_UNBLOCK_PIN, 0, 0, (puk + newPin).getBytes());
return secureChannel.transmit(apduChannel, unblockPIN);
}
/**
* Sends a LOAD KEY APDU. The given seed is sent as-is and the P1 of the command is set to LOAD_KEY_P1_SEED (0x03).
* This works on cards which support public key derivation. The loaded keyset is extended and support further
* key derivation.
*
* @param seed the binary seed
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse loadKey(byte[] seed) throws IOException {
return loadKey(seed, LOAD_KEY_P1_SEED);
}
/**
* Sends a LOAD KEY APDU. The key is sent in TLV format. The public key is included if not null. The chain code is
* included if not null. P1 is set automatically to either LOAD_KEY_P1_EC or
* LOAD_KEY_P1_EXT_EC depending on the presence of the chainCode.
*
* @param publicKey a raw public key
* @param privateKey a raw private key
* @param chainCode the chain code
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse loadKey(byte[] publicKey, byte[] privateKey, byte[] chainCode) throws IOException {
return loadKey(new BIP32KeyPair(privateKey, chainCode, publicKey), publicKey == null);
}
public APDUResponse loadKey(BIP32KeyPair keyPair) throws IOException {
return loadKey(keyPair, false);
}
public APDUResponse loadKey(BIP32KeyPair keyPair, boolean omitPublic) throws IOException {
byte p1;
if(keyPair.isExtended()) {
p1 = LOAD_KEY_P1_EXT_EC;
} else {
p1 = LOAD_KEY_P1_EC;
}
return loadKey(keyPair.toTLV(!omitPublic), p1);
}
/**
* Sends a LOAD KEY APDU. The data is encrypted and sent as-is. The keyType parameter is used as P1.
*
* @param data key data
* @param keyType the P1 parameter
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse loadKey(byte[] data, byte keyType) throws IOException {
APDUCommand loadKey = secureChannel.protectedCommand(0x80, INS_LOAD_KEY, keyType, 0, data);
return secureChannel.transmit(apduChannel, loadKey);
}
/**
* Sends a GENERATE MNEMONIC APDU. The cs parameter is the length of the checksum and is used as P1.
*
* @param cs the P1 parameter
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse generateMnemonic(int cs) throws IOException {
APDUCommand generateMnemonic = secureChannel.protectedCommand(0x80, INS_GENERATE_MNEMONIC, cs, 0, new byte[0]);
return secureChannel.transmit(apduChannel, generateMnemonic);
}
/**
* Sends a REMOVE KEY APDU.
*
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse removeKey() throws IOException {
APDUCommand removeKey = secureChannel.protectedCommand(0x80, INS_REMOVE_KEY, 0, 0, new byte[0]);
return secureChannel.transmit(apduChannel, removeKey);
}
/**
* Sends a GENERATE KEY APDU.
*
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse generateKey() throws IOException {
APDUCommand generateKey = secureChannel.protectedCommand(0x80, INS_GENERATE_KEY, 0, 0, new byte[0]);
return secureChannel.transmit(apduChannel, generateKey);
}
/**
* Sends a SIGN APDU. This signs a precomputed hash that must be exactly 32-bytes long.
*
* @param hash the hash to sign
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse sign(byte[] hash) throws IOException {
return sign(hash, SIGN_P1_CURRENT_KEY);
}
/**
* Sends a SIGN APDU. This signs a precomputed hash that must be exactly 32-bytes long. The key used to sign is given
* as a parameter.
*
* @param hash the hash to sign
* @param makeCurrent ture if the key used to sign should become the current key, false otherwise
* @return the raw card response
* @throws IOException communication error
* @params path the path of the key to use
*/
public APDUResponse signWithPath(byte[] hash, String path, boolean makeCurrent) throws IOException {
KeyPath keyPath = new KeyPath(path);
byte[] pathData = keyPath.getData();
byte[] data = Arrays.copyOf(hash, hash.length + pathData.length);
System.arraycopy(pathData, 0, data, hash.length, pathData.length);
return sign(data, keyPath.getSource() | (makeCurrent ? SIGN_P1_DERIVE_AND_MAKE_CURRENT : SIGN_P1_DERIVE));
}
/**
* Sends a SIGN APDU. This signs a precomputed hash that must be exactly 32-bytes long. The pinless path will be used
* to sign. This command is the only variant of SIGN which can also be executed without a Secure Channel.
*
* @param hash the hash to sign
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse signPinless(byte[] hash) throws IOException {
return sign(hash, SIGN_P1_PINLESS);
}
/**
* Sends a SIGN APDU. This signs a precomputed hash so the input must be exactly 32-bytes long, eventually followed by
* a derivation path.
*
* @param p1 the p1 parameter
* @param data the data to sign
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse sign(byte[] data, int p1) throws IOException {
APDUCommand sign = secureChannel.protectedCommand(0x80, INS_SIGN, p1, 0x01, data);
return secureChannel.transmit(apduChannel, sign);
}
/**
* Sends a DERIVE KEY APDU with the given key path.
*
* @param keypath the string key path
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse deriveKey(String keypath) throws IOException {
KeyPath path = new KeyPath(keypath);
return deriveKey(path.getData(), path.getSource());
}
/**
* Sends a DERIVE KEY APDU. The data is encrypted and sent as-is. The P1 is forced to 0, meaning that the derivation
* starts from the master key.
*
* @param data the raw key path
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse deriveKey(byte[] data) throws IOException {
return deriveKey(data, DERIVE_P1_SOURCE_MASTER);
}
/**
* Sends a DERIVE KEY APDU. The data is encrypted and sent as-is. The source parameter is used as P1.
*
* @param data the raw key path or a public key
* @param source the source to start derivation
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse deriveKey(byte[] data, int source) throws IOException {
APDUCommand deriveKey = secureChannel.protectedCommand(0x80, INS_DERIVE_KEY, source, 0x00, data);
return secureChannel.transmit(apduChannel, deriveKey);
}
/**
* Sends a SET PINLESS PATH APDU. The path must be absolute, that is starting from the master key.
*
* @param path the path. Must be an absolute path (i.e: starting from the master key)
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse setPinlessPath(String path) throws IOException {
KeyPath keyPath = new KeyPath(path);
if(keyPath.getSource() != DERIVE_P1_SOURCE_MASTER) {
throw new IllegalArgumentException("Only absolute paths can be set as PINLESS path");
}
return setPinlessPath(keyPath.getData());
}
/**
* Sends an empty SET PINLESS PATH APDU, resetting it. After this command the card does not have a PINless path until
* a new one is set.
*
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse resetPinlessPath() throws IOException {
return setPinlessPath(new byte[]{});
}
/**
* Sends a SET PINLESS PATH APDU. The data is encrypted and sent as-is.
*
* @param data the raw key path
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse setPinlessPath(byte[] data) throws IOException {
APDUCommand setPinlessPath = secureChannel.protectedCommand(0x80, INS_SET_PINLESS_PATH, 0x00, 0x00, data);
return secureChannel.transmit(apduChannel, setPinlessPath);
}
private byte poToP2(boolean publicOnly) {
return publicOnly ? EXPORT_KEY_P2_PUBLIC_ONLY : EXPORT_KEY_P2_PRIVATE_AND_PUBLIC;
}
/**
* Sends an EXPORT KEY APDU to export the current key.
*
* @param publicOnly exports only the public key
* @return the raw card reponse
* @throws IOException communication error
*/
public APDUResponse exportCurrentKey(boolean publicOnly) throws IOException {
return exportCurrentKey(poToP2(publicOnly));
}
/**
* Sends an EXPORT KEY APDU to export the current key.
*
* @param p2 the p2 parameter
* @return the raw card reponse
* @throws IOException communication error
*/
public APDUResponse exportCurrentKey(byte p2) throws IOException {
return exportKey(EXPORT_KEY_P1_CURRENT, p2, new byte[0]);
}
/**
* Sends an EXPORT KEY APDU. Performs derivation of the given keypath and optionally makes it the current key.
*
* @param keyPath the keypath to export
* @param makeCurrent if the key should be made current or not
* @param publicOnly the P2 parameter
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse exportKey(String keyPath, boolean makeCurrent, boolean publicOnly) throws IOException {
return exportKey(keyPath, makeCurrent, poToP2(publicOnly));
}
/**
* Sends an EXPORT KEY APDU. Performs derivation of the given keypath and optionally makes it the current key.
*
* @param keyPath the keypath to export
* @param makeCurrent if the key should be made current or not
* @param p2 the P2 parameter
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse exportKey(String keyPath, boolean makeCurrent, byte p2) throws IOException {
KeyPath path = new KeyPath(keyPath);
return exportKey(path.getData(), path.getSource(), makeCurrent, p2);
}
/**
* Sends an EXPORT KEY APDU. Performs derivation of the given keypath and optionally makes it the current key.
*
* @param keyPath the keypath to export
* @param makeCurrent if the key should be made current or not
* @param publicOnly the P2 parameter
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse exportKey(byte[] keyPath, int source, boolean makeCurrent, boolean publicOnly) throws IOException {
return exportKey(keyPath, source, makeCurrent, poToP2(publicOnly));
}
/**
* Sends an EXPORT KEY APDU. Performs derivation of the given keypath and optionally makes it the current key.
*
* @param keyPath the keypath to export
* @param makeCurrent if the key should be made current or not
* @param p2 the P2 parameter
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse exportKey(byte[] keyPath, int source, boolean makeCurrent, byte p2) throws IOException {
int p1 = source | (makeCurrent ? EXPORT_KEY_P1_DERIVE_AND_MAKE_CURRENT : EXPORT_KEY_P1_DERIVE);
return exportKey(p1, p2, keyPath);
}
/**
* Sends an EXPORT KEY APDU. The parameters are sent as-is.
*
* @param derivationOptions the P1 parameter
* @param publicOnly the P2 parameter
* @param keypath the data parameter
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse exportKey(int derivationOptions, boolean publicOnly, byte[] keypath) throws IOException {
return exportKey(derivationOptions, poToP2(publicOnly), keypath);
}
/**
* Sends an EXPORT KEY APDU. The parameters are sent as-is.
*
* @param derivationOptions the P1 parameter
* @param p2 the P2 parameter
* @param keypath the data parameter
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse exportKey(int derivationOptions, byte p2, byte[] keypath) throws IOException {
APDUCommand exportKey = secureChannel.protectedCommand(0x80, INS_EXPORT_KEY, derivationOptions, p2, keypath);
return secureChannel.transmit(apduChannel, exportKey);
}
/**
* Sends a GET DATA APDU.
*
* @param dataType the type of data to be stored
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse getData(byte dataType) throws IOException {
APDUCommand getData = secureChannel.protectedCommand(0x80, INS_GET_DATA, dataType, 0, new byte[0]);
return secureChannel.transmit(apduChannel, getData);
}
/**
* Sends a STORE DATA APDU for NDEF.
*
* @param ndef the data field of the APDU
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse setNDEF(byte[] ndef) throws IOException {
if((info.getAppVersion() >> 8) > 2) {
if((ndef.length - 2) != ((ndef[0] << 8) | ndef[1])) {
byte[] tmp = new byte[ndef.length + 2];
tmp[0] = (byte) (ndef.length >> 8);
tmp[1] = (byte) (ndef.length & 0xff);
System.arraycopy(ndef, 0, tmp, 2, ndef.length);
ndef = tmp;
}
return storeData(ndef, STORE_DATA_P1_NDEF);
} else {
APDUCommand setNDEF = secureChannel.protectedCommand(0x80, INS_SET_NDEF, 0, 0, ndef);
return secureChannel.transmit(apduChannel, setNDEF);
}
}
/**
* Sends a STORE DATA APDU.
*
* @param data the data field of the APDU
* @param dataType the type of data to be stored
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse storeData(byte[] data, byte dataType) throws IOException {
APDUCommand storeData = secureChannel.protectedCommand(0x80, INS_STORE_DATA, dataType, 0, data);
return secureChannel.transmit(apduChannel, storeData);
}
/**
* Sends the INIT command to the card. If either pinRetries or pukRetries is zero, neither will be sent.
*
* @param pin the PIN
* @param puk the PUK
* @param pairingPassword pairing password
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse init(String pin, String puk, String pairingPassword) throws IOException {
return this.init(pin, puk, pairingPassword, (byte) 0, (byte) 0);
}
/**
* Sends the INIT command to the card.
*
* @param pin the PIN
* @param puk the PUK
* @param pairingPassword pairing password
* @param pinRetries the number of allowed PIN retries
* @param pukRetries the number of allowed PUK retries
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse init(String pin, String puk, String pairingPassword, byte pinRetries, byte pukRetries) throws IOException {
return this.init(pin, null, puk, pairingPasswordToSecret(pairingPassword), pinRetries, pukRetries);
}
/**
* Sends the INIT command to the card.
*
* @param pin the PIN
* @param altPin the alternative PIN
* @param puk the PUK
* @param pairingPassword pairing password
* @param pinRetries the number of allowed PIN retries
* @param pukRetries the number of allowed PUK retries
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse init(String pin, String altPin, String puk, String pairingPassword, byte pinRetries, byte pukRetries) throws IOException {
return this.init(pin, altPin, puk, pairingPasswordToSecret(pairingPassword), pinRetries, pukRetries);
}
/**
* Sends the INIT command to the card.
*
* @param pin the PIN
* @param puk the PUK
* @param sharedSecret the shared secret for pairing
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse init(String pin, String puk, byte[] sharedSecret) throws IOException {
return init(pin, null, puk, sharedSecret, (byte) 0, (byte) 0);
}
/**
* Sends the INIT command to the card. If either pinRetries or pukRetries is zero, neither will be sent.
*
* @param pin the PIN
* @param pin the alternative
* @param puk the PUK
* @param sharedSecret the shared secret for pairing
* @param pinRetries the number of allowed PIN retries
* @param pukRetries the number of allowed PUK retries
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse init(String pin, String altPin, String puk, byte[] sharedSecret, byte pinRetries, byte pukRetries) throws IOException {
int baselen = pin.length() + puk.length() + sharedSecret.length;
int extlen;
if(altPin != null) {
extlen = 2 + altPin.length();
} else if((pinRetries != 0) || (pukRetries != 0)) {
extlen = 2;
} else {
extlen = 0;
}
byte[] initData = Arrays.copyOf(pin.getBytes(), baselen + extlen);
System.arraycopy(puk.getBytes(), 0, initData, pin.length(), puk.length());
System.arraycopy(sharedSecret, 0, initData, pin.length() + puk.length(), sharedSecret.length);
if(extlen > 0) {
initData[baselen] = pinRetries;
initData[baselen + 1] = pukRetries;
if(extlen > 2) {
System.arraycopy(altPin.getBytes(), 0, initData, baselen + 2, altPin.length());
}
}
APDUCommand init = new APDUCommand(0x80, INS_INIT, 0, 0, secureChannel.oneShotEncrypt(initData));
return apduChannel.send(init);
}
/**
* Sends the FACTORY RESET command to the card.
*
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse factoryReset() throws IOException {
APDUCommand factoryReset = new APDUCommand(0x80, INS_FACTORY_RESET, FACTORY_RESET_P1_MAGIC, FACTORY_RESET_P2_MAGIC, new byte[0]);
return apduChannel.send(factoryReset);
}
}
@@ -0,0 +1,74 @@
package com.sparrowwallet.sparrow.io.keycard;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.smartcardio.*;
import java.io.IOException;
import java.util.Iterator;
import java.util.List;
public class KeycardTransport implements CardChannel {
private static final Logger log = LoggerFactory.getLogger(KeycardTransport.class);
private final Card connection;
KeycardTransport(byte[] appletAid) throws CardException {
TerminalFactory tf = TerminalFactory.getDefault();
List<CardTerminal> terminals = tf.terminals().list();
if(terminals.isEmpty()) {
throw new IllegalStateException("No reader connected");
}
Card connection = null;
for(Iterator<CardTerminal> iter = terminals.iterator(); iter.hasNext(); ) {
try {
connection = getConnection(iter.next(), appletAid);
break;
} catch(CardException e) {
if(!iter.hasNext()) {
log.info(e.getMessage());
throw e;
}
}
}
this.connection = connection;
}
private Card getConnection(CardTerminal cardTerminal, byte[] appletAid) throws CardException {
Card connection = cardTerminal.connect("*");
javax.smartcardio.CardChannel cardChannel = connection.getBasicChannel();
ResponseAPDU resp = cardChannel.transmit(new CommandAPDU(0, 0xA4, 4, 0, appletAid));
if(resp.getSW() != APDUResponse.SW_OK) {
throw new CardException("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW()));
}
return connection;
}
public APDUResponse send(APDUCommand capdu) throws IOException {
javax.smartcardio.CardChannel cardChannel = this.connection.getBasicChannel();
CommandAPDU cmd = new CommandAPDU(capdu.getCla(), capdu.getIns(), capdu.getP1(), capdu.getP2(), capdu.getData());
ResponseAPDU resp;
try {
resp = cardChannel.transmit(cmd);
} catch(CardException e) {
throw new IOException(e);
}
return new APDUResponse(resp.getBytes());
}
@Override
public boolean isConnected() {
return false;
}
void disconnect() throws CardException {
connection.disconnect(true);
}
}
@@ -0,0 +1,62 @@
package com.sparrowwallet.sparrow.io.keycard;
import java.util.Arrays;
import java.util.Base64;
/**
* Stores pairing information.
*/
public class Pairing {
private byte[] pairingKey;
private byte pairingIndex;
/**
* Constructor. The pairingKey and pairingIndex are those generated at the end of a successful pairing.
*
* @param pairingKey the pairing key
* @param pairingIndex the pairing index
*/
public Pairing(byte[] pairingKey, byte pairingIndex) {
this.pairingKey = pairingKey;
this.pairingIndex = pairingIndex;
}
/**
* Constructor. Initializes from a byte array previously generated from the toByteArray method
*
* @param fromByteArray the result of a previous toByteArray invocation
*/
public Pairing(byte[] fromByteArray) {
pairingIndex = fromByteArray[0];
pairingKey = Arrays.copyOfRange(fromByteArray, 1, fromByteArray.length);
}
/**
* Constructor. Initializes from a String previously generated from the toBase64 method
*
* @param base64 the result of a previous toBase64 invocation
*/
public Pairing(String base64) {
this(Base64.getDecoder().decode(base64));
}
public byte[] getPairingKey() {
return pairingKey;
}
public byte getPairingIndex() {
return pairingIndex;
}
public byte[] toByteArray() {
byte[] res = new byte[pairingKey.length + 1];
res[0] = pairingIndex;
System.arraycopy(pairingKey, 0, res, 1, pairingKey.length);
return res;
}
public String toBase64() {
return Base64.getEncoder().encodeToString(toByteArray());
}
}
@@ -0,0 +1,142 @@
package com.sparrowwallet.sparrow.io.keycard;
import com.sparrowwallet.drongo.crypto.ECDSASignature;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import java.math.BigInteger;
import java.util.Arrays;
/**
* Signature with recoverable public key.
*/
public class RecoverableSignature {
private byte[] publicKey;
private int recId;
private byte[] r;
private byte[] s;
private boolean compressed;
public static final byte TLV_SIGNATURE_TEMPLATE = (byte) 0xA0;
public static final byte TLV_RAW_SIGNATURE = (byte) 0x80;
public static final byte TLV_ECDSA_TEMPLATE = (byte) 0x30;
/**
* Parses a signature from the card and calculates the recovery ID.
*
* @param hash the message being signed
* @param tlvData the signature as returned from the card
*/
public RecoverableSignature(byte[] hash, byte[] tlvData) {
TinyBERTLV tlv = new TinyBERTLV(tlvData);
int tag = tlv.readTag();
tlv.unreadLastTag();
if(tag == TLV_RAW_SIGNATURE) {
initFromRawSignature(hash, tlv.readPrimitive(tag));
} else if(tag == TLV_SIGNATURE_TEMPLATE) {
initFromLegacy(hash, tlv);
} else {
throw new IllegalArgumentException("invalid tlv");
}
}
private void initFromLegacy(byte[] hash, TinyBERTLV tlv) {
tlv.enterConstructed(TLV_SIGNATURE_TEMPLATE);
this.publicKey = tlv.readPrimitive(ApplicationInfo.TLV_PUB_KEY);
tlv.enterConstructed(TLV_ECDSA_TEMPLATE);
this.r = toUInt(tlv.readPrimitive(TinyBERTLV.TLV_INT));
this.s = toUInt(tlv.readPrimitive(TinyBERTLV.TLV_INT));
this.compressed = false;
calculateRecID(hash);
}
private void initFromRawSignature(byte[] hash, byte[] signature) {
this.r = Arrays.copyOfRange(signature, 0, 32);
this.s = Arrays.copyOfRange(signature, 32, 64);
this.recId = signature[64];
this.compressed = false;
this.publicKey = recoverFromSignature(this.recId, hash, this.r, this.s, this.compressed);
}
public RecoverableSignature(byte[] publicKey, boolean compressed, byte[] r, byte[] s, int recId) {
this.publicKey = publicKey;
this.r = r;
this.s = s;
this.compressed = compressed;
this.recId = recId;
}
void calculateRecID(byte[] hash) {
recId = -1;
for(int i = 0; i < 4; i++) {
byte[] candidate = recoverFromSignature(i, hash, r, s, compressed);
if(Arrays.equals(candidate, publicKey)) {
recId = i;
break;
}
}
if(recId == -1) {
throw new IllegalArgumentException("Unrecoverable signature, cannot find recId");
}
}
static byte[] toUInt(byte[] signedInt) {
if(signedInt[0] == 0) {
return Arrays.copyOfRange(signedInt, 1, signedInt.length);
} else {
return signedInt;
}
}
/**
* The public key associated to this signature.
*
* @return the public key associated to this signature
*/
public byte[] getPublicKey() {
return publicKey;
}
/**
* The recovery ID
*
* @return recovery ID
*/
public int getRecId() {
return recId;
}
/**
* The R value.
*
* @return r
*/
public byte[] getR() {
return r;
}
/**
* The S value
*
* @return s
*/
public byte[] getS() {
return s;
}
static byte[] recoverFromSignature(int recId, byte[] hash, byte[] r, byte[] s, boolean compressed) {
ECDSASignature sig = new ECDSASignature(new BigInteger(1, r), new BigInteger(1, s));
ECKey key = ECKey.recoverFromSignature(recId, sig, Sha256Hash.wrap(hash), compressed);
if(key == null) {
return null;
}
return key.getPubKey();
}
}
@@ -0,0 +1,444 @@
package com.sparrowwallet.sparrow.io.keycard;
import com.sparrowwallet.drongo.Drongo;
import com.sparrowwallet.drongo.bip47.SecretPoint;
import com.sparrowwallet.drongo.crypto.ECKey;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.security.*;
import java.util.Arrays;
/**
* Handles a SecureChannel session with the card.
*/
public class SecureChannelSession {
public static final short SC_SECRET_LENGTH = 32;
public static final short SC_BLOCK_SIZE = 16;
public static final byte INS_OPEN_SECURE_CHANNEL = 0x10;
public static final byte INS_MUTUALLY_AUTHENTICATE = 0x11;
public static final byte INS_PAIR = 0x12;
public static final byte INS_UNPAIR = 0x13;
public static final byte PAIR_P1_FIRST_STEP = 0x00;
public static final byte PAIR_P1_LAST_STEP = 0x01;
public static final int PAYLOAD_MAX_SIZE = 223;
static final byte PAIRING_MAX_CLIENT_COUNT = 5;
private byte[] secret;
private byte[] publicKey;
private byte[] iv;
private Pairing pairing;
private Cipher sessionCipher;
private Cipher sessionMac;
private SecretKeySpec sessionEncKey;
private SecretKeySpec sessionMacKey;
private SecureRandom random;
private boolean open;
/**
* Constructs a SecureChannel session on the client.
*/
public SecureChannelSession() {
random = new SecureRandom();
open = false;
}
/**
* Generates a pairing secret. This should be called before each session. The public key of the card is used as input
* for the EC-DH algorithm. The output is stored as the secret.
*
* @param keyData the public key returned by the applet as response to the SELECT command
*/
public void generateSecret(byte[] keyData) {
try {
ECKey key = new ECKey();
SecretPoint secretPoint = new SecretPoint(key.getPrivKeyBytes(), keyData);
publicKey = key.getPubKey(false);
secret = secretPoint.ECDHSecretAsBytes();
} catch(Exception e) {
throw new RuntimeException(e);
}
}
/**
* Returns the public key
*
* @return the public key
*/
public byte[] getPublicKey() {
return publicKey;
}
/**
* Returns the pairing information
*
* @return the pairing information
*/
public Pairing getPairing() {
return pairing;
}
/**
* Sets pairing information needed to open a secure channel.
*
* @param pairing the pairing information
*/
public void setPairing(Pairing pairing) {
this.pairing = pairing;
}
/**
* Establishes a Secure Channel with the card. The command parameters are the public key generated in the first step.
* Follows the specifications from the SECURE_CHANNEL.md document.
*
* @param apduChannel the apdu channel
* @throws IOException communication error
*/
public void autoOpenSecureChannel(CardChannel apduChannel) throws IOException, APDUException {
APDUResponse response = openSecureChannel(apduChannel, pairing.getPairingIndex(), publicKey);
response.checkOK("OPEN SECURE CHANNEL failed");
processOpenSecureChannelResponse(response);
response = mutuallyAuthenticate(apduChannel);
response.checkOK("MUTUALLY AUTHENTICATE failed");
verifyMutuallyAuthenticateResponse(response);
}
/**
* Processes the response from OPEN SECURE CHANNEL. This initialize the session keys, Cipher and MAC internally.
*
* @param response the card response
*/
public void processOpenSecureChannelResponse(APDUResponse response) {
try {
MessageDigest md = MessageDigest.getInstance("SHA512");
md.update(secret);
md.update(pairing.getPairingKey());
byte[] data = response.getData();
byte[] keyData = md.digest(Arrays.copyOf(data, SC_SECRET_LENGTH));
iv = Arrays.copyOfRange(data, SC_SECRET_LENGTH, data.length);
sessionEncKey = new SecretKeySpec(Arrays.copyOf(keyData, SC_SECRET_LENGTH), "AES");
sessionMacKey = new SecretKeySpec(Arrays.copyOfRange(keyData, SC_SECRET_LENGTH, keyData.length), "AES");
sessionCipher = Cipher.getInstance("AES/CBC/ISO7816-4Padding", Drongo.getProvider());
sessionMac = Cipher.getInstance("AES/CBC/NoPadding", Drongo.getProvider());
open = true;
} catch(Exception e) {
throw new RuntimeException(e);
}
}
/**
* Verify that the response from MUTUALLY AUTHENTICATE is correct.
*
* @param response the card response
* @return true if response is correct, false otherwise
*/
public void verifyMutuallyAuthenticateResponse(APDUResponse response) throws APDUException {
if(response.getData().length != SC_SECRET_LENGTH) {
throw new APDUException("Invalid authentication data from the card");
}
}
/**
* Handles the entire pairing procedure in order to be able to use the secure channel
*
* @param apduChannel the apdu channel
* @throws IOException communication error
*/
public void autoPair(CardChannel apduChannel, byte[] sharedSecret) throws IOException, APDUException {
byte[] challenge = new byte[32];
random.nextBytes(challenge);
APDUResponse resp = pair(apduChannel, PAIR_P1_FIRST_STEP, challenge).checkOK("Pairing failed on step 1");
byte[] respData = resp.getData();
byte[] cardCryptogram = Arrays.copyOf(respData, 32);
byte[] cardChallenge = Arrays.copyOfRange(respData, 32, respData.length);
byte[] checkCryptogram;
MessageDigest md;
try {
md = MessageDigest.getInstance("SHA256");
} catch(Exception e) {
throw new RuntimeException(e);
}
md.update(sharedSecret);
checkCryptogram = md.digest(challenge);
if(!Arrays.equals(checkCryptogram, cardCryptogram)) {
throw new APDUException("Invalid card cryptogram");
}
md.update(sharedSecret);
checkCryptogram = md.digest(cardChallenge);
resp = pair(apduChannel, PAIR_P1_LAST_STEP, checkCryptogram).checkOK("Pairing failed on step 2");
respData = resp.getData();
md.update(sharedSecret);
pairing = new Pairing(md.digest(Arrays.copyOfRange(respData, 1, respData.length)), respData[0]);
}
/**
* Unpairs the current paired key
*
* @param apduChannel the apdu channel
* @throws IOException communication error
*/
public void autoUnpair(CardChannel apduChannel) throws IOException, APDUException {
unpair(apduChannel, pairing.getPairingIndex()).checkOK("Unpairing failed");
}
/**
* Sends a OPEN SECURE CHANNEL APDU.
*
* @param apduChannel the apdu channel
* @param index the P1 parameter
* @param data the data
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse openSecureChannel(CardChannel apduChannel, byte index, byte[] data) throws IOException {
open = false;
APDUCommand openSecureChannel = new APDUCommand(0x80, INS_OPEN_SECURE_CHANNEL, index, 0, data);
return apduChannel.send(openSecureChannel);
}
/**
* Sends a MUTUALLY AUTHENTICATE APDU. The data is generated automatically
*
* @param apduChannel the apdu channel
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse mutuallyAuthenticate(CardChannel apduChannel) throws IOException {
byte[] data = new byte[SC_SECRET_LENGTH];
random.nextBytes(data);
return mutuallyAuthenticate(apduChannel, data);
}
/**
* Sends a MUTUALLY AUTHENTICATE APDU.
*
* @param apduChannel the apdu channel
* @param data the data
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse mutuallyAuthenticate(CardChannel apduChannel, byte[] data) throws IOException {
APDUCommand mutuallyAuthenticate = protectedCommand(0x80, INS_MUTUALLY_AUTHENTICATE, 0, 0, data);
return transmit(apduChannel, mutuallyAuthenticate);
}
/**
* Sends a PAIR APDU.
*
* @param apduChannel the apdu channel
* @param p1 the P1 parameter
* @param data the data
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse pair(CardChannel apduChannel, byte p1, byte[] data) throws IOException {
APDUCommand pair = new APDUCommand(0x80, INS_PAIR, p1, 0, data);
return transmit(apduChannel, pair);
}
/**
* Sends a UNPAIR APDU.
*
* @param apduChannel the apdu channel
* @param p1 the P1 parameter
* @return the raw card response
* @throws IOException communication error
*/
public APDUResponse unpair(CardChannel apduChannel, byte p1) throws IOException {
APDUCommand unpair = protectedCommand(0x80, INS_UNPAIR, p1, 0, new byte[0]);
return transmit(apduChannel, unpair);
}
/**
* Unpair all other clients
*
* @param apduChannel the apdu channel
* @throws IOException communication error
*/
public void unpairOthers(CardChannel apduChannel) throws IOException, APDUException {
for(int i = 0; i < PAIRING_MAX_CLIENT_COUNT; i++) {
if(i != pairing.getPairingIndex()) {
APDUCommand unpair = protectedCommand(0x80, INS_UNPAIR, i, 0, new byte[0]);
transmit(apduChannel, unpair).checkOK();
}
}
}
/**
* Encrypts the plaintext data using the session key. The maximum plaintext size is 223 bytes. The returned ciphertext
* already includes the IV and padding and can be sent as-is in the APDU payload. If the input is an empty byte array
* the returned data will still contain the IV and padding.
*
* @param data the plaintext data
* @return the encrypted data
*/
private byte[] encryptAPDU(byte[] data) {
assert data.length <= PAYLOAD_MAX_SIZE;
try {
IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
sessionCipher.init(Cipher.ENCRYPT_MODE, sessionEncKey, ivParameterSpec);
return sessionCipher.doFinal(data);
} catch(Exception e) {
throw new RuntimeException(e);
}
}
/**
* Decrypts the response from the card using the session key. The returned data is already stripped from IV and padding
* and can be potentially empty.
*
* @param data the ciphetext
* @return the plaintext
*/
private byte[] decryptAPDU(byte[] data) {
try {
IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
sessionCipher.init(Cipher.DECRYPT_MODE, sessionEncKey, ivParameterSpec);
return sessionCipher.doFinal(data);
} catch(Exception e) {
throw new RuntimeException(e);
}
}
/**
* Returns a command APDU with MAC and encrypted data.
*
* @param cla the CLA byte
* @param ins the INS byte
* @param p1 the P1 byte
* @param p2 the P2 byte
* @param data the data, can be an empty array but not null
* @return the command APDU
*/
public APDUCommand protectedCommand(int cla, int ins, int p1, int p2, byte[] data) {
byte[] finalData;
if(open) {
data = encryptAPDU(data);
byte[] meta = new byte[]{(byte) cla, (byte) ins, (byte) p1, (byte) p2, (byte) (data.length + SC_BLOCK_SIZE), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
updateIV(meta, data);
finalData = Arrays.copyOf(iv, iv.length + data.length);
System.arraycopy(data, 0, finalData, iv.length, data.length);
} else {
finalData = data;
}
return new APDUCommand(cla, ins, p1, p2, finalData);
}
/**
* Transmits a protected command APDU and unwraps the response data. The MAC is verified, the data decrypted and the
* SW read from the payload.
*
* @param apduChannel the APDU channel
* @param apdu the APDU to send
* @return the unwrapped response APDU
* @throws IOException transmission error
*/
public APDUResponse transmit(CardChannel apduChannel, APDUCommand apdu) throws IOException {
APDUResponse resp = apduChannel.send(apdu);
if(resp.getSw() == 0x6982) {
open = false;
}
if(open) {
byte[] data = resp.getData();
byte[] meta = new byte[]{(byte) data.length, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
byte[] mac = Arrays.copyOf(data, iv.length);
data = Arrays.copyOfRange(data, iv.length, data.length);
byte[] plainData = decryptAPDU(data);
updateIV(meta, data);
if(!Arrays.equals(iv, mac)) {
throw new IOException("Invalid MAC");
}
return new APDUResponse(plainData);
} else {
return resp;
}
}
/**
* Marks the SecureChannel as closed
*/
public void reset() {
open = false;
}
/**
* Encrypts the payload for the INIT command
*
* @param initData the payload for the INIT command
* @return the encrypted buffer
*/
public byte[] oneShotEncrypt(byte[] initData) {
try {
iv = new byte[SC_BLOCK_SIZE];
random.nextBytes(iv);
IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
sessionEncKey = new SecretKeySpec(secret, "AES");
sessionCipher = Cipher.getInstance("AES/CBC/ISO7816-4Padding", Drongo.getProvider());
sessionCipher.init(Cipher.ENCRYPT_MODE, sessionEncKey, ivParameterSpec);
initData = sessionCipher.doFinal(initData);
byte[] encrypted = new byte[1 + publicKey.length + iv.length + initData.length];
encrypted[0] = (byte) publicKey.length;
System.arraycopy(publicKey, 0, encrypted, 1, publicKey.length);
System.arraycopy(iv, 0, encrypted, (1 + publicKey.length), iv.length);
System.arraycopy(initData, 0, encrypted, (1 + publicKey.length + iv.length), initData.length);
return encrypted;
} catch(Exception e) {
throw new RuntimeException(e);
}
}
/**
* Marks the SecureChannel as open. Only to be used when writing tests for the SecureChannel, in normal operation this
* would only make things wrong.
*
*/
protected void setOpen() {
open = true;
}
/**
* Calculates a CMAC from the metadata and data provided and sets it as the IV for the next message.
*
* @param meta metadata
* @param data data
*/
private void updateIV(byte[] meta, byte[] data) {
try {
IvParameterSpec ivParameterSpec = new IvParameterSpec(new byte[SC_BLOCK_SIZE]);
sessionMac.init(Cipher.ENCRYPT_MODE, sessionMacKey, ivParameterSpec);
sessionMac.update(meta);
byte[] tmp = sessionMac.doFinal(data);
iv = Arrays.copyOfRange(tmp, tmp.length - SC_BLOCK_SIZE, tmp.length);
} catch(Exception e) {
throw new RuntimeException(e);
}
}
}
@@ -0,0 +1,167 @@
package com.sparrowwallet.sparrow.io.keycard;
import java.io.ByteArrayOutputStream;
import java.util.Arrays;
/**
* Tiny BER-TLV implementation. Not for general usage, but fast and easy to use for this project.
*/
public class TinyBERTLV {
public static final byte TLV_BOOL = (byte) 0x01;
public static final byte TLV_INT = (byte) 0x02;
public static final int END_OF_TLV = (int) 0xffffffff;
private byte[] buffer;
private int pos;
public static int[] readNum(byte[] buf, int off) {
int len = buf[off++] & 0xff;
int lenlen = 0;
if((len & 0x80) == 0x80) {
lenlen = len & 0x7f;
len = readVal(buf, off, lenlen);
}
return new int[]{len, off + lenlen};
}
public static int readVal(byte[] val, int off, int len) {
switch(len) {
case 1:
return val[off] & 0xff;
case 2:
return ((val[off] & 0xff) << 8) | (val[off + 1] & 0xff);
case 3:
return ((val[off] & 0xff) << 16) | ((val[off + 1] & 0xff) << 8) | (val[off + 2] & 0xff);
case 4:
return ((val[off] & 0xff) << 24) | ((val[off + 1] & 0xff) << 16) | ((val[off + 2] & 0xff) << 8) | (val[off + 3] & 0xff);
default:
throw new IllegalArgumentException("Integers of length " + len + " are unsupported");
}
}
public static void writeNum(ByteArrayOutputStream os, int len) {
if((len & 0xff000000) != 0) {
os.write(0x84);
os.write((len & 0xff000000) >> 24);
os.write((len & 0x00ff0000) >> 16);
os.write((len & 0x0000ff00) >> 8);
os.write(len & 0x000000ff);
} else if((len & 0x00ff0000) != 0) {
os.write(0x83);
os.write((len & 0x00ff0000) >> 16);
os.write((len & 0x0000ff00) >> 8);
os.write(len & 0x000000ff);
} else if((len & 0x0000ff00) != 0) {
os.write(0x82);
os.write((len & 0x0000ff00) >> 8);
os.write(len & 0x000000ff);
} else if((len & 0x00000080) != 0) {
os.write(0x81);
os.write(len & 0x000000ff);
} else {
os.write(len);
}
}
public TinyBERTLV(byte[] buffer) {
this.buffer = buffer;
this.pos = 0;
}
/**
* Enters a constructed TLV with the given tag
*
* @param tag the tag to enter
* @return the length of the TLV
* @throws IllegalArgumentException if the next tag does not match the given one
*/
public int enterConstructed(int tag) throws IllegalArgumentException {
checkTag(tag, readTag());
return readLength();
}
/**
* Reads a primitive TLV with the given tag
*
* @param tag the tag to read
* @return the body of the TLV
* @throws IllegalArgumentException if the next tag does not match the given one
*/
public byte[] readPrimitive(int tag) throws IllegalArgumentException {
checkTag(tag, readTag());
int len = readLength();
pos += len;
return Arrays.copyOfRange(buffer, (pos - len), pos);
}
/**
* Reads a boolean TLV.
*
* @return the boolean value of the TLV
* @throws IllegalArgumentException if the next tag is not a boolean
*/
public boolean readBoolean() throws IllegalArgumentException {
byte[] val = readPrimitive(TLV_BOOL);
return ((val[0] & 0xff) == 0xff);
}
/**
* Reads an integer TLV.
*
* @return the integer value of the TLV
* @throws IllegalArgumentException if the next tlv is not an integer or is of unsupported length
*/
public int readInt() throws IllegalArgumentException {
byte[] val = readPrimitive(TLV_INT);
return TinyBERTLV.readVal(val, 0, val.length);
}
/**
* Returns all unread bytes in the TLV.
*
* @return all unread bytes
*/
byte[] peekUnread() {
return Arrays.copyOfRange(buffer, pos, buffer.length);
}
/**
* Low-level method to unread the last read tag. Only valid if the previous call was readTag(). Does nothing if the
* end of the TLV has been reached.
*/
public void unreadLastTag() {
if(pos < buffer.length) {
pos--;
}
}
/**
* Reads the next tag. The current implementation only reads tags on one byte. Can be extended if needed.
*
* @return the tag
*/
public int readTag() {
return (pos < buffer.length) ? buffer[pos++] : END_OF_TLV;
}
/**
* Reads the next tag. The current implementation only reads length on one and two bytes. Can be extended if needed.
*
* @return the tag
*/
public int readLength() {
int[] len = TinyBERTLV.readNum(buffer, pos);
pos = len[1];
return len[0];
}
private void checkTag(int expected, int actual) throws IllegalArgumentException {
if(expected != actual) {
unreadLastTag();
throw new IllegalArgumentException("Expected tag: " + expected + ", received: " + actual);
}
}
}
@@ -7,6 +7,7 @@ import com.sparrowwallet.sparrow.control.TitledDescriptionPane;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.io.ckcard.Satschip;
import com.sparrowwallet.sparrow.io.ckcard.Tapsigner;
import com.sparrowwallet.sparrow.io.keycard.Keycard;
import com.sparrowwallet.sparrow.io.satochip.Satochip;
import javafx.fxml.FXML;
import javafx.scene.control.Accordion;
@@ -26,9 +27,9 @@ public class HwAirgappedController extends KeystoreImportDetailController {
public void initializeView() {
List<KeystoreFileImport> fileImporters = Collections.emptyList();
if(getMasterController().getWallet().getPolicyType().equals(PolicyType.SINGLE)) {
fileImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY(), new Krux(), new AirGapVault());
fileImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY(), new Krux(), new AirGapVault(), new KeycardShellSinglesig());
} else if(getMasterController().getWallet().getPolicyType().equals(PolicyType.MULTI)) {
fileImporters = List.of(new Bip129(), new ColdcardMultisig(), new CoboVaultMultisig(), new JadeMultisig(), new KeystoneMultisig(), new PassportMultisig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY(), new Krux());
fileImporters = List.of(new Bip129(), new ColdcardMultisig(), new CoboVaultMultisig(), new JadeMultisig(), new KeystoneMultisig(), new PassportMultisig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY(), new Krux(), new KeycardShellMultisig());
}
for(KeystoreFileImport importer : fileImporters) {
@@ -40,10 +41,10 @@ public class HwAirgappedController extends KeystoreImportDetailController {
}
}
List<KeystoreCardImport> cardImporters = List.of(new Tapsigner(), new Satochip(), new Satschip());
List<KeystoreCardImport> cardImporters = List.of(new Tapsigner(), new Satochip(), new Satschip(), new Keycard());
for(KeystoreCardImport importer : cardImporters) {
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
CardImportPane importPane = new CardImportPane(getMasterController().getWallet(), importer, getMasterController().getRequiredDerivation());
CardImportPane importPane = new CardImportPane(getMasterController().getWallet(), importer, getMasterController().getDefaultDerivation(), getMasterController().getRequiredDerivation());
if(getMasterController().getRequiredModel() == null || getMasterController().getRequiredModel() == importer.getWalletModel()) {
importAccordion.getPanes().add(importPane);
}
@@ -13,7 +13,7 @@ public class HwUsbDevicesController extends KeystoreImportDetailController {
public void initializeView(List<Device> devices) {
for(Device device : devices) {
DevicePane devicePane = new DevicePane(getMasterController().getWallet(), device, devices.size() == 1, getMasterController().getRequiredDerivation());
DevicePane devicePane = new DevicePane(getMasterController().getWallet(), device, devices.size() == 1, getMasterController().getDefaultDerivation(), getMasterController().getRequiredDerivation());
if(getMasterController().getRequiredModel() == null || getMasterController().getRequiredModel() == device.getModel()) {
deviceAccordion.getPanes().add(devicePane);
}
@@ -25,14 +25,6 @@ public class KeystoreImportDialog extends Dialog<Keystore> {
private final ScriptType scriptType;
private final String existingLabel;
public KeystoreImportDialog(Wallet wallet) {
this(wallet, KeystoreSource.HW_USB);
}
public KeystoreImportDialog(Wallet wallet, KeystoreSource initialSource) {
this(wallet, initialSource, null, null, Keystore.DEFAULT_LABEL, false);
}
public KeystoreImportDialog(Wallet wallet, KeystoreSource initialSource, KeyDerivation currentDerivation, WalletModel currentModel, String currentLabel, boolean restrictImport) {
EventManager.get().register(this);
setOnCloseRequest(event -> {
@@ -82,7 +74,7 @@ public class KeystoreImportDialog extends Dialog<Keystore> {
keystore.setLabel(existingLabel);
keystore.setSource(KeystoreSource.SW_WATCH);
keystore.setWalletModel(WalletModel.SPARROW);
keystore.setKeyDerivation(new KeyDerivation(KeystoreController.DEFAULT_WATCH_ONLY_FINGERPRINT, scriptType.getDefaultDerivationPath()));
keystore.setKeyDerivation(new KeyDerivation(KeyDerivation.DEFAULT_WATCH_ONLY_FINGERPRINT, scriptType.getDefaultDerivationPath()));
return keystore;
}
@@ -12,7 +12,7 @@ public class SwController extends KeystoreImportDetailController {
private Accordion importAccordion;
public void initializeView() {
List<KeystoreImport> importers = List.of(new Bip39(), new Bip32(), new Slip39());
List<KeystoreImport> importers = List.of(new Bip39(), new Bip32(), new Slip39(), new Bip93());
for(KeystoreImport importer : importers) {
if(importer.isDeprecated() && !Config.get().isShowDeprecatedImportExport()) {
@@ -29,6 +29,8 @@ public class SwController extends KeystoreImportDetailController {
importPane = new XprvKeystoreImportPane(getMasterController().getWallet(), (KeystoreXprvImport)importer, getMasterController().getDefaultDerivation());
} else if(importer instanceof KeystoreMnemonicShareImport) {
importPane = new MnemonicShareKeystoreImportPane(getMasterController().getWallet(), (KeystoreMnemonicShareImport)importer, getMasterController().getDefaultDerivation());
} else if (importer instanceof KeystoreCodexImport) {
importPane = new CodexKeystoreImportPane(getMasterController().getWallet(), (KeystoreCodexImport)importer, getMasterController().getDefaultDerivation());
} else {
throw new IllegalArgumentException("Could not create ImportPane for importer of type " + importer.getClass());
}
@@ -1258,6 +1258,10 @@ public class ElectrumServer {
return new ServerCapability(true, true);
}
if(server.startsWith("frigate")) {
return new ServerCapability(true, true);
}
if(server.startsWith("cormorant")) {
return new ServerCapability(true, false, true, false);
}
@@ -2132,7 +2136,7 @@ public class ElectrumServer {
}
}
public static class WalletDiscoveryService extends Service<Optional<Wallet>> {
public static class WalletDiscoveryService extends Service<Optional<List<Wallet>>> {
private final List<Wallet> wallets;
public WalletDiscoveryService(List<Wallet> wallets) {
@@ -2140,17 +2144,31 @@ public class ElectrumServer {
}
@Override
protected Task<Optional<Wallet>> createTask() {
protected Task<Optional<List<Wallet>>> createTask() {
return new Task<>() {
protected Optional<Wallet> call() throws ServerException {
protected Optional<List<Wallet>> call() throws ServerException {
ElectrumServer electrumServer = new ElectrumServer();
List<Wallet> discoveredWallets = new ArrayList<>();
for(int i = 0; i < wallets.size(); i++) {
Wallet wallet = wallets.get(i);
updateProgress(i, wallets.size() + StandardAccount.DISCOVERY_ACCOUNTS.size());
Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = new TreeMap<>();
electrumServer.getReferences(wallet, wallet.getNode(KeyPurpose.RECEIVE).getChildren(), nodeTransactionMap, 0);
if(nodeTransactionMap.values().stream().anyMatch(blockTransactionHashes -> !blockTransactionHashes.isEmpty())) {
boolean found = nodeTransactionMap.values().stream().anyMatch(blockTransactionHashes -> !blockTransactionHashes.isEmpty());
for(Iterator<Wallet> iterator = wallet.getChildWallets().iterator(); iterator.hasNext(); ) {
Wallet childWallet = iterator.next();
Map<WalletNode, Set<BlockTransactionHash>> childTransactionMap = new TreeMap<>();
electrumServer.getReferences(childWallet, childWallet.getNode(KeyPurpose.RECEIVE).getChildren(), childTransactionMap, 0);
if(childTransactionMap.values().stream().anyMatch(blockTransactionHashes -> !blockTransactionHashes.isEmpty())) {
found = true;
} else {
iterator.remove();
}
}
if(found) {
Wallet masterWalletCopy = wallet.copy();
List<StandardAccount> searchAccounts = getStandardAccounts(wallet);
Set<StandardAccount> foundAccounts = new LinkedHashSet<>();
@@ -2173,11 +2191,11 @@ public class ElectrumServer {
wallet.addChildWallet(standardAccount);
}
return Optional.of(wallet);
discoveredWallets.add(wallet);
}
}
return Optional.empty();
return discoveredWallets.isEmpty() ? Optional.empty() : Optional.of(discoveredWallets);
}
};
}
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.net;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.SparrowWallet;
import com.sparrowwallet.sparrow.event.ExchangeRatesUpdatedEvent;
import com.sparrowwallet.tern.http.client.HttpResponseException;
import javafx.concurrent.ScheduledService;
@@ -103,7 +104,7 @@ public enum ExchangeSource {
HttpClientService httpClientService = AppServices.getHttpClientService();
try {
Number[][] coinbaseData = httpClientService.requestJson(url, Number[][].class, Map.of("User-Agent", "Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1)", "Accept", "*/*"));
Number[][] coinbaseData = httpClientService.requestJson(url, Number[][].class, HTTP_HEADERS);
for(Number[] price : coinbaseData) {
Date date = new Date(price[0].longValue() * 1000);
historicalRates.put(DateUtils.truncate(date, Calendar.DAY_OF_MONTH), price[4].doubleValue());
@@ -151,7 +152,7 @@ public enum ExchangeSource {
HttpClientService httpClientService = AppServices.getHttpClientService();
try {
return httpClientService.requestJson(url, CoinGeckoRates.class, null);
return httpClientService.requestJson(url, CoinGeckoRates.class, HTTP_HEADERS);
} catch(Exception e) {
if(log.isDebugEnabled()) {
log.warn("Error retrieving currency rates", e);
@@ -181,7 +182,7 @@ public enum ExchangeSource {
Map<Date, Double> historicalRates = new TreeMap<>();
HttpClientService httpClientService = AppServices.getHttpClientService();
try {
CoinGeckoHistoricalRates coinGeckoHistoricalRates = httpClientService.requestJson(url, CoinGeckoHistoricalRates.class, null);
CoinGeckoHistoricalRates coinGeckoHistoricalRates = httpClientService.requestJson(url, CoinGeckoHistoricalRates.class, HTTP_HEADERS);
for(List<Number> historicalRate : coinGeckoHistoricalRates.prices) {
Date date = new Date(historicalRate.get(0).longValue());
historicalRates.put(DateUtils.truncate(date, Calendar.DAY_OF_MONTH), historicalRate.get(1).doubleValue());
@@ -268,6 +269,7 @@ public enum ExchangeSource {
};
private static final Logger log = LoggerFactory.getLogger(ExchangeSource.class);
private static final Map<String, String> HTTP_HEADERS = Map.of("User-Agent", "Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1)", "Accept", "*/*");
private final String name;
private final String description;
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.net;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
import com.sparrowwallet.sparrow.AppServices;
@@ -30,7 +31,7 @@ public enum FeeRatesSource {
MEMPOOL_SPACE("mempool.space", true) {
@Override
public Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates) {
String url = getApiUrl() + "v1/fees/recommended";
String url = getApiUrl() + "v1/fees/precise";
return getThreeTierFeeRates(this, defaultblockTargetFeeRates, url);
}
@@ -71,6 +72,30 @@ public enum FeeRatesSource {
return network == Network.MAINNET || network == Network.TESTNET || network == Network.TESTNET4 || network == Network.SIGNET;
}
},
BLOCK_XYZ("block.xyz", true) {
/*
https://engineering.block.xyz/blog/augur-an-open-source-bitcoin-fee-estimation-library
*/
@Override
public Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates) {
String url = "https://pricing.bitcoin.block.xyz/fees";
return getThreeTierFeeRates(this, defaultblockTargetFeeRates, url);
}
@Override
public boolean supportsNetwork(Network network) {
return network == Network.MAINNET;
}
@Override
protected ThreeTierRates getThreeTierRates(String url, HttpClientService httpClientService) throws Exception {
BlockXyzRates rates = httpClientService.requestJson(url, BlockXyzRates.class, null);
if(rates.estimates == null) {
throw new Exception("Invalid response from " + url);
}
return rates.getThreeTierRates();
}
},
BITCOINFEES_EARN_COM("bitcoinfees.earn.com", true) {
@Override
public Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates) {
@@ -343,6 +368,37 @@ public enum FeeRatesSource {
}
}
private record BlockXyzRates(Map<String, BlockXyzEstimate> estimates) {
public ThreeTierRates getThreeTierRates() {
// see https://engineering.block.xyz/blog/augur-an-open-source-bitcoin-fee-estimation-library
//
// fastestFee: 95% confidence at 3 blocks
// halfHourFee: 80% confidence at 3 blocks
// hourFee: 80% confidence at 6 blocks
// minimumFee: 80% confidence at 144 blocks
Double fastestFee = getFeeRate("3", "0.95");
Double halfHourFee = getFeeRate("3", "0.80");
Double hourFee = getFeeRate("6", "0.80");
Double minimumFee = getFeeRate("144", "0.80");
return new ThreeTierRates(fastestFee, halfHourFee, hourFee, minimumFee);
}
private Double getFeeRate(String blocks, String probability) {
BlockXyzEstimate estimate = estimates.get(blocks);
if(estimate != null && estimate.probabilities != null) {
BlockXyzFeeRate feeRate = estimate.probabilities.get(probability);
if(feeRate != null) {
return feeRate.fee_rate;
}
}
return Transaction.DEFAULT_MIN_RELAY_FEE;
}
}
private record BlockXyzEstimate(Map<String, BlockXyzFeeRate> probabilities) {}
private record BlockXyzFeeRate(Double fee_rate) {}
protected record MempoolBlock(Integer nTx, Double medianFee) {}
protected record MempoolBlockSummary(String id, Integer height, Long timestamp, Integer tx_count, Integer weight, MempoolBlockSummaryExtras extras) {
@@ -6,10 +6,7 @@ import com.sparrowwallet.drongo.protocol.Script;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionInput;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.psbt.PSBTOutput;
import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.psbt.*;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
@@ -38,7 +35,7 @@ public class Payjoin {
public Payjoin(BitcoinURI payjoinURI, Wallet wallet, PSBT psbt) {
this.payjoinURI = payjoinURI;
this.wallet = wallet;
this.psbt = psbt;
this.psbt = psbt.getForExport();
if(payjoinURI.getAddress() == null) {
throw new IllegalArgumentException("Payjoin URI must have an address");
@@ -55,7 +52,7 @@ public class Payjoin {
}
}
public PSBT requestPayjoinPSBT(boolean allowOutputSubstitution) throws PayjoinReceiverException {
public PSBT requestPayjoinPSBT(boolean allowOutputSubstitution) throws PayjoinReceiverException, PSBTProofException {
if(!payjoinURI.isPayjoinOutputSubstitutionAllowed()) {
allowOutputSubstitution = false;
}
@@ -66,6 +63,8 @@ public class Payjoin {
throw new PayjoinReceiverException("No payjoin URL provided");
}
long additionalFeeContribution = getAdditionalFeeContribution();
try {
String base64Psbt = psbt.getPublicCopy().toBase64String();
@@ -74,7 +73,7 @@ public class Payjoin {
long maxAdditionalFeeContribution = 0;
if(changeOutputIndex > -1) {
appendQuery += "&additionalfeeoutputindex=" + changeOutputIndex;
maxAdditionalFeeContribution = getAdditionalFeeContribution();
maxAdditionalFeeContribution = additionalFeeContribution;
appendQuery += "&maxadditionalfeecontribution=" + maxAdditionalFeeContribution;
}
@@ -117,15 +116,16 @@ public class Payjoin {
}
}
private void checkProposal(PSBT original, PSBT proposal, int changeOutputIndex, long maxAdditionalFeeContribution, boolean allowOutputSubstitution) throws PayjoinReceiverException {
private void checkProposal(PSBT original, PSBT proposal, int changeOutputIndex, long maxAdditionalFeeContribution, boolean allowOutputSubstitution) throws PayjoinReceiverException, PSBTProofException {
Transaction originalTx = original.getTransaction();
Queue<Map.Entry<TransactionInput, PSBTInput>> originalInputs = new ArrayDeque<>();
for(int i = 0; i < original.getPsbtInputs().size(); i++) {
originalInputs.add(Map.entry(original.getTransaction().getInputs().get(i), original.getPsbtInputs().get(i)));
originalInputs.add(Map.entry(originalTx.getInputs().get(i), original.getPsbtInputs().get(i)));
}
Queue<Map.Entry<TransactionOutput, PSBTOutput>> originalOutputs = new ArrayDeque<>();
for(int i = 0; i < original.getPsbtOutputs().size(); i++) {
originalOutputs.add(Map.entry(original.getTransaction().getOutputs().get(i), original.getPsbtOutputs().get(i)));
originalOutputs.add(Map.entry(originalTx.getOutputs().get(i), original.getPsbtOutputs().get(i)));
}
// Checking that the PSBT of the receiver is clean
@@ -133,7 +133,6 @@ public class Payjoin {
throw new PayjoinReceiverException("Global xpubs should not be included in the receiver's PSBT");
}
Transaction originalTx = original.getTransaction();
Transaction proposalTx = proposal.getTransaction();
// Verify that the transaction version, and nLockTime are unchanged.
if(proposalTx.getVersion() != originalTx.getVersion()) {
@@ -154,7 +153,7 @@ public class Payjoin {
}
TransactionInput proposedTxIn = proposedPSBTInput.getInput();
boolean isOriginalInput = originalInputs.size() > 0 && originalInputs.peek().getKey().getOutpoint().equals(proposedTxIn.getOutpoint());
boolean isOriginalInput = !originalInputs.isEmpty() && originalInputs.peek().getKey().getOutpoint().equals(proposedTxIn.getOutpoint());
if(isOriginalInput) {
Map.Entry<TransactionInput, PSBTInput> originalInput = originalInputs.remove();
TransactionInput originalTxIn = originalInput.getKey();
@@ -223,11 +222,11 @@ public class Payjoin {
}
TransactionOutput proposedTxOut = proposalTx.getOutputs().get(i);
boolean isOriginalOutput = originalOutputs.size() > 0 && originalOutputs.peek().getKey().getScript().equals(proposedTxOut.getScript());
boolean isOriginalOutput = !originalOutputs.isEmpty() && originalOutputs.peek().getKey().getScript().equals(proposedTxOut.getScript());
if(isOriginalOutput) {
Map.Entry<TransactionOutput, PSBTOutput> originalOutput = originalOutputs.remove();
if(originalOutput.getKey() == changeOutput) {
var actualContribution = changeOutput.getValue() - proposedTxOut.getValue();
var actualContribution = originalOutput.getKey().getValue() - proposedTxOut.getValue();
// The amount that was subtracted from the output's value is less than or equal to maxadditionalfeecontribution
if(actualContribution > maxAdditionalFeeContribution) {
throw new PayjoinReceiverException("The actual contribution is more than maxadditionalfeecontribution");
@@ -245,7 +244,7 @@ public class Payjoin {
// That's the payment output, the receiver may have changed it.
} else {
if(originalOutput.getKey().getValue() > proposedTxOut.getValue()) {
throw new PayjoinReceiverException("The receiver decreased the value of one of the outputs");
throw new PayjoinReceiverException("The receiver decreased the value of one of the outputs from " + originalOutput.getKey().getValue() + " sats to " + proposedTxOut.getValue() + " sats");
}
}
@@ -282,17 +281,17 @@ public class Payjoin {
return -1;
}
private long getAdditionalFeeContribution() {
private long getAdditionalFeeContribution() throws PSBTProofException {
return getSingleInputFee();
}
private long getSingleInputFee() {
private long getSingleInputFee() throws PSBTProofException {
Transaction transaction = psbt.extractTransaction();
double feeRate = psbt.getFee().doubleValue() / transaction.getVirtualSize();
int vSize = 68;
if(transaction.getInputs().size() > 0) {
TransactionInput input = transaction.getInputs().get(0);
if(!transaction.getInputs().isEmpty()) {
TransactionInput input = transaction.getInputs().getFirst();
vSize = input.getLength() * Transaction.WITNESS_SCALE_FACTOR;
vSize += input.getWitness() != null ? input.getWitness().getLength() : 0;
vSize = (int)Math.ceil((double)vSize / Transaction.WITNESS_SCALE_FACTOR);
@@ -338,7 +337,7 @@ public class Payjoin {
@Override
protected Task<PSBT> createTask() {
return new Task<>() {
protected PSBT call() throws PayjoinReceiverException {
protected PSBT call() throws PayjoinReceiverException, PSBTProofException {
return payjoin.requestPayjoinPSBT(allowOutputSubstitution);
}
};
@@ -88,8 +88,8 @@ public abstract class NewWalletDialog extends DialogWindow {
Platform.runLater(() -> {
ElectrumServer.WalletDiscoveryService walletDiscoveryService = new ElectrumServer.WalletDiscoveryService(wallets);
walletDiscoveryService.setOnSucceeded(successEvent -> {
Optional<Wallet> optWallet = walletDiscoveryService.getValue();
wallet = optWallet.orElseGet(() -> wallets.get(0));
Optional<List<Wallet>> optWallets = walletDiscoveryService.getValue();
wallet = optWallets.orElseGet(() -> wallets).getFirst();
SparrowTerminal.get().getGuiThread().invokeLater(() -> {
SparrowTerminal.get().getGui().removeWindow(discoveringDialog);
saveWallet(wallet);
@@ -20,8 +20,6 @@ import org.slf4j.LoggerFactory;
import java.util.*;
import static com.sparrowwallet.sparrow.wallet.KeystoreController.DEFAULT_WATCH_ONLY_FINGERPRINT;
public class WatchOnlyDialog extends NewWalletDialog {
private static final Logger log = LoggerFactory.getLogger(WatchOnlyDialog.class);
@@ -126,7 +124,7 @@ public class WatchOnlyDialog extends NewWalletDialog {
Keystore keystore = new Keystore();
keystore.setSource(KeystoreSource.SW_WATCH);
keystore.setWalletModel(WalletModel.SPARROW);
keystore.setKeyDerivation(new KeyDerivation(DEFAULT_WATCH_ONLY_FINGERPRINT, scriptType.getDefaultDerivationPath()));
keystore.setKeyDerivation(new KeyDerivation(KeyDerivation.DEFAULT_WATCH_ONLY_FINGERPRINT, scriptType.getDefaultDerivationPath()));
keystore.setExtendedPublicKey(xpub);
wallet.makeLabelsUnique(keystore);
wallet.getKeystores().add(keystore);
@@ -6,8 +6,7 @@ import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.psbt.*;
import com.sparrowwallet.drongo.silentpayments.SilentPayment;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.drongo.uri.BitcoinURI;
@@ -87,6 +86,8 @@ public class HeadersController extends TransactionFormController implements Init
private static final Pattern RBF_INSUFFICIENT_FEE = Pattern.compile("insufficient fee, rejecting replacement.*?(\\d+\\.?\\d*) < (\\d+\\.?\\d*)");
private static final Pattern RBF_INSUFFICIENT_FEE_RATE = Pattern.compile("insufficient fee, rejecting replacement.*new feerate (\\d+\\.?\\d*)[^\\d]*(\\d+\\.?\\d*)[^\\d]*");
private static final double FEE_MULTIPLE_LIMIT = 100d;
private HeadersForm headersForm;
@FXML
@@ -981,7 +982,7 @@ public class HeadersController extends TransactionFormController implements Init
//TODO: Remove once Cobo Vault support has been removed
boolean addLegacyEncodingOption = headersForm.getSigningWallet().getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().showLegacyQR());
boolean addBbqrOption = headersForm.getSigningWallet().getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().showBbqr());
boolean selectBbqrOption = headersForm.getSigningWallet().getKeystores().stream().allMatch(keystore -> keystore.getWalletModel().selectBbqr());
QREncoding encoding = headersForm.getSigningWallet().getKeystores().stream().allMatch(keystore -> keystore.getWalletModel().selectBbqr()) ? QREncoding.BBQR : QREncoding.UR;
//Don't include non witness utxo fields for segwit wallets when displaying the PSBT as a QR - it can add greatly to the time required for scanning
boolean includeNonWitnessUtxos = !Arrays.asList(ScriptType.WITNESS_TYPES).contains(headersForm.getSigningWallet().getScriptType());
@@ -989,7 +990,7 @@ public class HeadersController extends TransactionFormController implements Init
CryptoPSBT cryptoPSBT = new CryptoPSBT(psbtBytes);
BBQR bbqr = addBbqrOption ? new BBQR(BBQRType.PSBT, psbtBytes) : null;
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR(), bbqr, addLegacyEncodingOption, true, selectBbqrOption);
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR(), bbqr, addLegacyEncodingOption, true, encoding);
qrDisplayDialog.initOwner(toggleButton.getScene().getWindow());
Optional<ButtonType> optButtonType = qrDisplayDialog.showAndWait();
if(optButtonType.isPresent() && optButtonType.get().getButtonData() == ButtonBar.ButtonData.OK_DONE) {
@@ -1160,8 +1161,13 @@ public class HeadersController extends TransactionFormController implements Init
Optional<PSBT> optionalSignedPsbt = dlg.showAndWait();
if(optionalSignedPsbt.isPresent()) {
PSBT signedPsbt = optionalSignedPsbt.get();
headersForm.getPsbt().combine(signedPsbt);
EventManager.get().post(new PSBTCombinedEvent(headersForm.getPsbt()));
try {
headersForm.getPsbt().verifyCombinedSignatures(signedPsbt);
headersForm.getPsbt().combine(signedPsbt);
EventManager.get().post(new PSBTCombinedEvent(headersForm.getPsbt()));
} catch(PSBTSignatureException e) {
AppServices.showErrorDialog("Invalid PSBT", e.getMessage());
}
}
}
@@ -1187,22 +1193,36 @@ public class HeadersController extends TransactionFormController implements Init
}
public void extractTransaction(ActionEvent event) {
extractTransaction();
}
public boolean extractTransaction() {
viewFinalButton.setDisable(true);
Transaction finalTx = headersForm.getPsbt().extractTransaction();
headersForm.setFinalTransaction(finalTx);
EventManager.get().post(new TransactionExtractedEvent(headersForm.getPsbt(), finalTx));
try {
Transaction finalTx = headersForm.getPsbt().extractTransaction();
headersForm.setFinalTransaction(finalTx);
EventManager.get().post(new TransactionExtractedEvent(headersForm.getPsbt(), finalTx));
return true;
} catch(PSBTProofException e) {
AppServices.showErrorDialog("Invalid Silent Payments Transaction", e.getMessage());
viewFinalButton.setDisable(false);
return false;
}
}
public void broadcastTransaction(ActionEvent event) {
broadcastButton.setDisable(true);
if(headersForm.getPsbt() != null) {
extractTransaction(event);
if(!extractTransaction()) {
broadcastButton.setDisable(false);
return;
}
}
if(fee.getValue() > 0) {
double feeRateAmt = fee.getValue() / headersForm.getTransaction().getVirtualSize();
if(feeRateAmt > AppServices.getLongFeeRatesRange().getLast()) {
if(feeRateAmt > AppServices.getLongFeeRatesRange().getLast() || (AppServices.getTargetBlockFeeRates() != null && feeRateAmt > AppServices.getDefaultFeeRate() * FEE_MULTIPLE_LIMIT)) {
Optional<ButtonType> optType = AppServices.showWarningDialog("Very high fee rate!",
"This transaction pays a very high fee rate of " + String.format("%.0f", feeRateAmt) + " sats/vB.\n\nBroadcast this transaction?", ButtonType.YES, ButtonType.NO);
if(optType.isPresent() && optType.get() == ButtonType.NO) {
@@ -1340,7 +1360,8 @@ public class HeadersController extends TransactionFormController implements Init
byte[] txBytes = transaction.bitcoinSerialize();
UR ur = UR.fromBytes(txBytes);
BBQR bbqr = new BBQR(BBQRType.TXN, txBytes);
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, false, false);
String raw = Utils.bytesToHex(txBytes);
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(ur, bbqr, raw, false, false, QREncoding.UR);
qrDisplayDialog.initOwner(showTransactionButton.getScene().getWindow());
qrDisplayDialog.showAndWait();
} catch (Exception exception) {
@@ -1363,10 +1384,12 @@ public class HeadersController extends TransactionFormController implements Init
File file = fileChooser.showSaveDialog(window);
if(file != null) {
try {
Transaction finalTx = headersForm.getPsbt().extractTransaction();
try(PrintWriter writer = new PrintWriter(file, StandardCharsets.UTF_8)) {
Transaction finalTx = headersForm.getPsbt().extractTransaction();
writer.print(Utils.bytesToHex(finalTx.bitcoinSerialize()));
}
} catch(PSBTProofException e) {
AppServices.showErrorDialog("Invalid Silent Payments Transaction", e.getMessage());
} catch(IOException e) {
log.error("Error saving transaction", e);
AppServices.showErrorDialog("Error saving transaction", "Cannot write to " + file.getAbsolutePath());
@@ -1387,7 +1410,8 @@ public class HeadersController extends TransactionFormController implements Init
EventManager.get().post(new ViewPSBTEvent(payjoinButton.getScene().getWindow(), headersForm.getName() + " Payjoin", null, proposalPsbt));
});
requestPayjoinPSBTService.setOnFailed(failedEvent -> {
AppServices.showErrorDialog("Error Requesting Payjoin Transaction", failedEvent.getSource().getException().getMessage());
Throwable exception = failedEvent.getSource().getException();
AppServices.showErrorDialog(exception instanceof PSBTProofException ? "Invalid Silent Payments Transaction" : "Error Requesting Payjoin Transaction", exception.getMessage());
});
requestPayjoinPSBTService.start();
}
@@ -1505,6 +1529,7 @@ public class HeadersController extends TransactionFormController implements Init
@Subscribe
public void unitFormatChanged(UnitFormatChangedEvent event) {
transactionDiagram.update(transactionDiagram.getWalletTransaction());
fee.refresh(event.getUnitFormat(), event.getBitcoinUnit());
}
@@ -1604,6 +1629,7 @@ public class HeadersController extends TransactionFormController implements Init
broadcastButtonBox.setVisible(true);
} else {
signButtonBox.setVisible(true);
event.getPsbt().addKeyPathInformation(event.getSigningWallet());
}
}
}
@@ -6,16 +6,19 @@ import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.NonStandardScriptException;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.BaseController;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.TransactionTabData;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.control.AddressLabelSkin;
import com.sparrowwallet.sparrow.event.TransactionTabsClosedEvent;
import com.sparrowwallet.sparrow.io.Config;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.chart.PieChart;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
import javafx.scene.control.Tooltip;
import javafx.scene.input.Clipboard;
@@ -93,6 +96,19 @@ public abstract class TransactionFormController extends BaseController {
Tooltip.install(data.getNode(), tooltip);
data.pieValueProperty().addListener((observable, oldValue, newValue) -> tooltip.setText(newValue + "%"));
});
Platform.runLater(() -> applyAddressLabelSkinToLegend(pie));
}
private void applyAddressLabelSkinToLegend(PieChart pie) {
pie.lookupAll(".chart-legend-item").forEach(node -> {
if(node instanceof Label label) {
String text = label.getText();
label.setSkin(new AddressLabelSkin(label));
label.setText("");
label.setText(text);
}
});
}
public void close() {
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.wallet;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.*;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
@@ -44,8 +45,6 @@ import static com.sparrowwallet.sparrow.io.CardApi.isReaderAvailable;
public class KeystoreController extends WalletFormController implements Initializable {
private static final Logger log = LoggerFactory.getLogger(KeystoreController.class);
public static final String DEFAULT_WATCH_ONLY_FINGERPRINT = "00000000";
private Keystore keystore;
@FXML
@@ -196,6 +195,14 @@ public class KeystoreController extends WalletFormController implements Initiali
if(!extendedKey.equals(keystore.getExtendedPublicKey()) && extendedKey.getKey().isPubKeyOnly()) {
keystore.setExtendedPublicKey(extendedKey);
EventManager.get().post(new SettingsChangedEvent(walletForm.getWallet(), SettingsChangedEvent.Type.KEYSTORE_XPUB));
ExtendedKey.Header header = ExtendedKey.Header.fromExtendedKey(newValue);
ExtendedKey.Header defaultHeader = Network.get().getXpubHeader();
ScriptType scriptType = walletForm.getWallet().getScriptType();
if(keystore.getSource() == KeystoreSource.SW_WATCH && header.getDefaultScriptType() != defaultHeader.getDefaultScriptType() && scriptType != header.getDefaultScriptType()) {
AppServices.showWarningDialog("Script type mismatch", "You have entered a " + header.getDisplayName() + " into a " + scriptType.getDescription() + " wallet. " +
"Consider changing the script type to " + header.getDefaultScriptType().getDescription() + " to match the default value for a " + header.getDisplayName() + ".");
}
}
} else {
xpub.setContextMenu(null);
@@ -252,7 +259,7 @@ public class KeystoreController extends WalletFormController implements Initiali
if(keystoreSource != KeystoreSource.SW_WATCH) {
launchImportDialog(keystoreSource);
} else {
fingerprint.setText(DEFAULT_WATCH_ONLY_FINGERPRINT);
fingerprint.setText(KeyDerivation.DEFAULT_WATCH_ONLY_FINGERPRINT);
derivation.setText(getWalletForm().getWallet().getScriptType().getDefaultDerivationPath());
selectSourcePane.setVisible(false);
}
@@ -387,6 +387,7 @@ public class PaymentController extends WalletFormController implements Initializ
address.textProperty().addListener(addressListener);
address.setContextMenu(address.getCustomContextMenu(Collections.emptyList()));
address.setSkin(new AddressTextFieldSkin(address));
label.textProperty().addListener((observable, oldValue, newValue) -> {
maxButton.setDisable(!isMaxButtonEnabled());
@@ -467,7 +468,7 @@ public class PaymentController extends WalletFormController implements Initializ
private void setSilentPaymentAddress(SilentPaymentAddress silentPaymentAddress) {
if(!sendController.getWalletForm().getWallet().canSendSilentPayments()) {
Platform.runLater(() -> AppServices.showErrorDialog("Silent Payments Unsupported", "This wallet does not support sending silent payments. Use a single signature software wallet."));
Platform.runLater(() -> AppServices.showErrorDialog("Silent Payments Unsupported", "This wallet does not support sending silent payments. Use a single signature wallet."));
return;
}
@@ -79,6 +79,7 @@ public class ReceiveController extends WalletFormController implements Initializ
@Override
public void initializeView() {
address.setSkin(new AddressTextFieldSkin(address));
initializeScriptField(scriptPubKeyArea);
displayAddress.managedProperty().bind(displayAddress.visibleProperty());
@@ -164,7 +165,7 @@ public class ReceiveController extends WalletFormController implements Initializ
displayAddress.setUserData(addressDevices);
return;
} else if(currentEntry != null && wallet.getKeystores().stream().anyMatch(keystore -> keystore.getSource().equals(KeystoreSource.HW_USB))) {
} else if(currentEntry != null && wallet.getKeystores().stream().anyMatch(keystore -> keystore.getSource().equals(KeystoreSource.HW_USB) || keystore.getSource().equals(KeystoreSource.SW_WATCH))) {
displayAddress.setVisible(true);
displayAddress.setUserData(null);
return;
@@ -1523,6 +1523,7 @@ public class SendController extends WalletFormController implements Initializabl
@Subscribe
public void unitFormatChanged(UnitFormatChangedEvent event) {
transactionDiagram.update(transactionDiagram.getWalletTransaction());
setEffectiveFeeRate(getWalletTransaction());
setFeeRate(getFeeRate());
if(fee.getTextFormatter() instanceof CoinTextFormatter coinTextFormatter && coinTextFormatter.getUnitFormat() != event.getUnitFormat()) {
@@ -383,11 +383,11 @@ public class SettingsController extends WalletFormController implements Initiali
}
boolean addBbqrOption = walletForm.getWallet().getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().showBbqr());
boolean selectBbqrOption = walletForm.getWallet().getKeystores().stream().allMatch(keystore -> keystore.getWalletModel().selectBbqr());
QREncoding encoding = walletForm.getWallet().getKeystores().stream().allMatch(keystore -> keystore.getWalletModel().selectBbqr()) ? QREncoding.BBQR : QREncoding.UR;
UR cryptoOutputUR = cryptoOutput.toUR();
BBQR bbqr = addBbqrOption ? new BBQR(BBQRType.UNICODE, outputDescriptor.toString(true).getBytes(StandardCharsets.UTF_8)) : null;
QRDisplayDialog qrDisplayDialog = new DescriptorQRDisplayDialog(walletForm.getWallet().getFullDisplayName(), outputDescriptor.toString(true), cryptoOutputUR, bbqr, selectBbqrOption);
QRDisplayDialog qrDisplayDialog = new DescriptorQRDisplayDialog(walletForm.getWallet().getFullDisplayName(), outputDescriptor.toString(true), cryptoOutputUR, bbqr, encoding);
qrDisplayDialog.initOwner(showDescriptorQR.getScene().getWindow());
qrDisplayDialog.showAndWait();
}
@@ -11,7 +11,7 @@
<?import com.sparrowwallet.sparrow.Theme?>
<?import impl.org.controlsfx.skin.DecorationPane?>
<VBox maxHeight="-Infinity" maxWidth="-Infinity" minHeight="200" minWidth="350" prefHeight="770.0" prefWidth="1070.0" fx:controller="com.sparrowwallet.sparrow.AppController" xmlns="http://javafx.com/javafx/10.0.2-internal" xmlns:fx="http://javafx.com/fxml/1">
<VBox fx:id="rootBox" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="200" minWidth="350" prefHeight="770.0" prefWidth="1070.0" fx:controller="com.sparrowwallet.sparrow.AppController" xmlns="http://javafx.com/javafx/10.0.2-internal" xmlns:fx="http://javafx.com/fxml/1">
<children>
<MenuBar useSystemMenuBar="true">
<menus>
@@ -99,6 +99,12 @@
</RadioMenuItem>
</items>
</Menu>
<Menu mnemonicParsing="false" text="Bitcoin Addresses">
<items>
<CheckMenuItem fx:id="chunkAddresses" mnemonicParsing="false" text="Chunk Addresses" onAction="#chunkAddresses"/>
<CheckMenuItem fx:id="hideEmptyUsedAddresses" mnemonicParsing="false" text="Hide Empty Used Addresses" onAction="#hideEmptyUsedAddresses"/>
</items>
</Menu>
<Menu mnemonicParsing="false" text="Theme">
<items>
<RadioMenuItem mnemonicParsing="false" text="Light" toggleGroup="$theme" onAction="#setTheme">
@@ -119,7 +125,6 @@
</Menu>
<SeparatorMenuItem />
<CheckMenuItem fx:id="openWalletsInNewWindows" mnemonicParsing="false" text="Open Wallets In New Windows" onAction="#openWalletsInNewWindows"/>
<CheckMenuItem fx:id="hideEmptyUsedAddresses" mnemonicParsing="false" text="Hide Empty Used Addresses" onAction="#hideEmptyUsedAddresses"/>
<CheckMenuItem fx:id="hideAmounts" mnemonicParsing="false" text="Hide Amounts" accelerator="Shortcut+Shift+H" onAction="#hideAmounts"/>
<CheckMenuItem fx:id="showLoadingLog" mnemonicParsing="false" text="Show Wallet Loading Log" onAction="#showLoadingLog" />
<CheckMenuItem fx:id="showTxHex" mnemonicParsing="false" text="Show Transaction Hex" onAction="#showTxHex"/>
@@ -38,7 +38,7 @@
-fx-base: -fx-default-button;
}
.split-menu-button.default-button > .label {
.split-menu-button.default-button > .label, .menu-item.default-button > .label {
-fx-text-fill: white;
}
@@ -376,4 +376,24 @@ HorizontalHeaderColumn > TableColumnHeader.column-header.table-column{
.root .block-unconfirmed .block-left {
-fx-fill: #4e4846;
}
}
.root .label .address-chunk {
-fx-fill: lightgray;
}
.root .tree-table-view .address-chunk,
.root .text-field .address-chunk,
.root .tooltip .address-chunk {
-fx-fill: white;
}
.root.chunk-addresses .text-field .address-chunk.alternate,
.root.chunk-addresses .label .address-chunk.alternate,
.root.chunk-addresses .tree-table-view:focused .address-chunk.alternate {
-fx-fill: derive(-fx-text-inner-color, -30%);
}
.root.chunk-addresses .tree-table-view .tree-table-row-cell:selected .address-chunk.alternate {
-fx-fill: derive(-fx-selection-bar-text, -20%);
}

Some files were not shown because too many files have changed in this diff Show More