Compare commits

..
90 Commits
Author SHA1 Message Date
Craig Raw 53d1f196e4 suggest configuring a custom wallets directory when opening a wallet from a non-default location 2026-02-17 09:07:09 +02:00
Craig Raw 64b8da14a6 recognise renamed linux packages for file verify drag and drop 2026-02-16 12:57:28 +02:00
ottoschandGitHub 529cc3d6e7 handle import of samourai wallet backup file with extraneous appended data 2026-02-12 09:20:40 +02:00
Craig Raw e13fe897e1 add keepkey passphrase support 2026-02-12 09:17:02 +02:00
Craig Raw dac1a61601 check wallet file names on open 2026-02-12 09:12:13 +02:00
Craig Raw 227c7ae637 avoid triggering repeated layout passes in address chunking skins 2026-02-11 14:34:21 +02:00
Craig Raw e11675b9a8 bump to v2.4.1 2026-02-10 09:59:35 +02:00
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
Craig Raw 16e73ebc32 implement hide amounts feature 2025-11-06 10:00:07 +02:00
Craig Raw 3a5fa69fb6 fix occasional issue with cell reuse when avoiding updating cells during table size estimation 2025-11-05 12:20:02 +02:00
Craig Raw 4774830ce4 add yu12 to supported pixel formats on linux 2025-11-05 07:51:26 +02:00
Craig Raw 2f62a9e9c8 show signing keystores in transaction blockchain form for spends from multisig wallets 2025-11-04 10:45:31 +02:00
doblon8andGitHub 75bcfe2253 update jzbar dependency to 0.2.1 2025-10-24 09:38:11 +02:00
Craig Raw bedf1399ea request display of path when retreiving xpubs on ledger devices for any non-standard path 2025-10-24 08:38:48 +02:00
Craig Raw 58575793ea update openpnp-capture to 0.0.30-1 2025-10-21 15:55:32 +02:00
Craig Raw 6c9b580d4f refactor to use transaction parameters record object when creating a wallet transaction 2025-10-21 12:06:00 +02:00
doblon8andGitHub 31909b7a15 use language-independent sid for windows users group permission 2025-10-21 09:35:18 +02:00
Craig Raw 092267339a adapt to use declarative style to for consolidation payments 2025-10-17 10:27:20 +02:00
Craig Raw 0974918cff hide confirmations in tooltip when showing inputs and outputs on the transactions table 2025-10-16 08:42:55 +02:00
Craig Raw 0f4c36b3c2 add ctrl+shift+left/right keyboard shortcuts for moving tabs left and right 2025-10-13 14:26:37 +02:00
Craig Raw e1fe35fb74 update nsmenufx to avoid npe 2025-10-06 14:52:24 +02:00
Craig Raw d37fd00c4b avoid using deprecated camera device type constants on recent macos versions 2025-10-06 13:27:12 +02:00
Craig Raw 5f54f86df7 bump to v2.3.1 2025-10-03 11:53:27 +02:00
141 changed files with 6049 additions and 528 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
+31 -34
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.0'
version = '2.4.1'
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'
@@ -73,13 +73,15 @@ dependencies {
implementation('com.fasterxml.jackson.core:jackson-databind:2.17.2')
implementation('com.sparrowwallet:hummingbird:1.7.4')
implementation('co.nstant.in:cbor:0.9')
implementation('org.openpnp:openpnp-capture-java:0.0.28-6')
implementation("io.matthewnelson.kmp-tor:runtime:2.2.1")
implementation("io.matthewnelson.kmp-tor:resource-exec-tor-gpl:408.16.3")
implementation('org.openpnp:openpnp-capture-java:0.0.30-1')
implementation("io.matthewnelson.kmp-tor:runtime:2.5.0")
implementation("io.matthewnelson.kmp-tor:resource-exec-tor-gpl:408.21.0")
implementation('org.jetbrains.kotlinx:kotlinx-coroutines-javafx:1.10.1') {
exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-common'
}
implementation('de.codecentric.centerdevice:centerdevice-nsmenufx:2.1.7')
implementation('de.jangassen:nsmenufx:3.1.0') {
exclude group: 'net.java.dev.jna', module: 'jna'
}
implementation('org.controlsfx:controlsfx:11.1.0' ) {
exclude group: 'org.openjfx', module: 'javafx-base'
exclude group: 'org.openjfx', module: 'javafx-graphics'
@@ -108,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.0.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')
@@ -133,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 {
@@ -156,11 +158,6 @@ application {
"--add-opens=javafx.controls/javafx.scene.control.cell=com.sparrowwallet.sparrow",
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=com.sparrowwallet.sparrow",
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=javafx.fxml",
"--add-opens=javafx.graphics/com.sun.javafx.tk=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.javafx.tk.quantum=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui=centerdevice.nsmenufx",
"--add-opens=javafx.controls/com.sun.javafx.scene.control=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.javafx.menu=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/javafx.scene.input=com.sparrowwallet.sparrow",
@@ -171,8 +168,7 @@ application {
"--add-reads=org.flywaydb.core=java.desktop"]
if(os.macOsX) {
applicationDefaultJvmArgs += ["-Dprism.lcdtext=false", "-Xdock:name=Sparrow",
"--add-opens=javafx.graphics/com.sun.glass.ui.mac=centerdevice.nsmenufx"]
applicationDefaultJvmArgs += ["-Dprism.lcdtext=false", "-Xdock:name=Sparrow"]
}
if(headless) {
applicationDefaultJvmArgs += ["-Dglass.platform=Monocle", "-Dmonocle.platform=Headless", "-Dprism.order=sw"]
@@ -189,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'
}
@@ -211,11 +208,6 @@ jlink {
"--add-opens=javafx.controls/javafx.scene.control.cell=com.sparrowwallet.sparrow",
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=com.sparrowwallet.sparrow",
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=javafx.fxml",
"--add-opens=javafx.graphics/com.sun.javafx.tk=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.javafx.tk.quantum=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui=centerdevice.nsmenufx",
"--add-opens=javafx.controls/com.sun.javafx.scene.control=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.javafx.menu=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/javafx.scene.input=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
@@ -242,7 +234,7 @@ jlink {
jvmArgs += ["-Djavax.accessibility.assistive_technologies", "-Djavax.accessibility.screen_magnifier_present=false"]
}
if(os.macOsX) {
jvmArgs += ["-Dprism.lcdtext=false", "--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module", "--add-opens=javafx.graphics/com.sun.glass.ui.mac=centerdevice.nsmenufx"]
jvmArgs += ["-Dprism.lcdtext=false", "--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module"]
}
if(headless) {
jvmArgs += ["-Dglass.platform=Monocle", "-Dmonocle.platform=Headless", "-Dprism.order=sw"]
@@ -255,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']
@@ -274,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"
}
}
@@ -288,13 +280,17 @@ jlink {
if(os.linux) {
tasks.jlink.finalizedBy('addUserWritePermission', 'copyUdevRules')
tasks.jpackageImage.finalizedBy('prepareResourceDir')
if(!headless) {
tasks.jpackage.dependsOn('copyMimeInfo')
}
} else {
tasks.jlink.finalizedBy('addUserWritePermission')
}
tasks.register('addUserWritePermission', Exec) {
if(os.windows) {
commandLine 'icacls', "$buildDir\\image\\legal", '/grant', 'Users:(OI)(CI)F', '/T'
def usersGroup = '*S-1-5-32-545' // Windows "Users" group SID (language-independent)
commandLine 'icacls', "$buildDir\\image\\legal", '/grant', "${usersGroup}:(OI)(CI)F", '/T'
} else {
commandLine 'chmod', '-R', 'u+w', "$buildDir/image/legal"
}
@@ -322,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()) {
@@ -394,12 +397,6 @@ extraJavaModuleInfo {
requires('java.desktop')
requires('com.sun.jna')
}
module('de.codecentric.centerdevice:centerdevice-nsmenufx', 'centerdevice.nsmenufx') {
exports('de.codecentric.centerdevice')
requires('javafx.base')
requires('javafx.controls')
requires('javafx.graphics')
}
module('net.sourceforge.javacsv:javacsv', 'net.sourceforge.javacsv') {
exports('com.csvreader')
}
+20 -47
View File
@@ -12,63 +12,36 @@ Work on resolving both of these issues is ongoing.
### Install Java
Because Sparrow bundles a Java runtime in the release binaries, it is essential to have the same version of Java installed when creating the release.
For v1.6.6 to v1.9.1, this was Eclipse Temurin 18.0.1+10. For v2.0.0 and later, Eclipse Temurin 22.0.2+9 is used.
For v1.6.6 to v1.9.1, this was Eclipse Temurin 18.0.1+10. For v2.0.0 to v2.3.1, this was Eclipse Temurin 22.0.2+9. For v2.4.0 and later, Eclipse Temurin 25.0.2+10 is used.
Note: Do not install Java using a system package manager (e.g. apt, dnf, rpm).
Linux packages replace the JDK's bundled `cacerts` file with a symlink to the system CA certificates, which differ from those in the release tarballs and will produce a non-reproducible build.
#### Java from Adoptium github repo
It is available for all supported platforms from [Eclipse Temurin 22.0.2+9](https://github.com/adoptium/temurin22-binaries/releases/tag/jdk-22.0.2%2B9).
It is available for all supported platforms from [Eclipse Temurin 25.0.2+10](https://github.com/adoptium/temurin25-binaries/releases/tag/jdk-25.0.2%2B10).
For reference, the downloads are as follows:
- [Linux x64](https://github.com/adoptium/temurin22-binaries/releases/download/jdk-22.0.2%2B9/OpenJDK22U-jdk_x64_linux_hotspot_22.0.2_9.tar.gz)
- [Linux aarch64](https://github.com/adoptium/temurin22-binaries/releases/download/jdk-22.0.2%2B9/OpenJDK22U-jdk_aarch64_linux_hotspot_22.0.2_9.tar.gz)
- [MacOS x64](https://github.com/adoptium/temurin22-binaries/releases/download/jdk-22.0.2%2B9/OpenJDK22U-jdk_x64_mac_hotspot_22.0.2_9.tar.gz)
- [MacOS aarch64](https://github.com/adoptium/temurin22-binaries/releases/download/jdk-22.0.2%2B9/OpenJDK22U-jdk_aarch64_mac_hotspot_22.0.2_9.tar.gz)
- [Windows x64](https://github.com/adoptium/temurin22-binaries/releases/download/jdk-22.0.2%2B9/OpenJDK22U-jdk_x64_windows_hotspot_22.0.2_9.zip)
- [Linux x64](https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.2%2B10/OpenJDK25U-jdk_x64_linux_hotspot_25.0.2_10.tar.gz)
- [Linux aarch64](https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.2%2B10/OpenJDK25U-jdk_aarch64_linux_hotspot_25.0.2_10.tar.gz)
- [MacOS x64](https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.2%2B10/OpenJDK25U-jdk_x64_mac_hotspot_25.0.2_10.tar.gz)
- [MacOS aarch64](https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.2%2B10/OpenJDK25U-jdk_aarch64_mac_hotspot_25.0.2_10.tar.gz)
- [Windows x64](https://github.com/adoptium/temurin25-binaries/releases/download/jdk-25.0.2%2B10/OpenJDK25U-jdk_x64_windows_hotspot_25.0.2_10.zip)
#### Java from Adoptium deb repo
It is also possible to install via a package manager on *nix systems. For example, on Debian/Ubuntu systems:
- Install dependencies:
```sh
sudo apt-get install -y wget curl apt-transport-https gnupg
On Linux, extract the tarball and set `JAVA_HOME` to use it for the build:
```shell
tar -xzf OpenJDK25U-jdk_x64_linux_hotspot_25.0.2_10.tar.gz
export JAVA_HOME=$PWD/jdk-25.0.2+10
export PATH=$JAVA_HOME/bin:$PATH
```
Download Adoptium public PGP key:
```sh
curl --tlsv1.2 --proto =https --location -o adoptium.asc https://packages.adoptium.net/artifactory/api/gpg/key/public
```
#### Java from SDKMAN
Check if key fingerprint matches: `3B04D753C9050D9A5D343F39843C48A565F8F04B`:
```
gpg --import --import-options show-only adoptium.asc
```
If key doesn't match, do not proceed.
Add Adoptium PGP key to a the keyring shared folder:
```sh
sudo cp adoptium.asc /usr/share/keyrings/
```
Add Adoptium debian repository:
```sh
echo "deb [signed-by=/usr/share/keyrings/adoptium.asc] https://packages.adoptium.net/artifactory/deb $(awk -F= '/^VERSION_CODENAME/{print$2}' /etc/os-release) main" | sudo tee /etc/apt/sources.list.d/adoptium.list
```
Update cache, install the desired temurin version and configure java to be linked to this same version:
```
sudo apt update -y
sudo apt-get install -y temurin-22-jdk=22.0.2+9
sudo update-alternatives --config java
```
#### Java from SDK
A alternative option for all platforms is to use the [sdkman.io](https://sdkman.io/) package manager ([Git Bash for Windows](https://git-scm.com/download/win) is a good choice on that platform).
An alternative option for all platforms is to use the [sdkman.io](https://sdkman.io/) package manager ([Git Bash for Windows](https://git-scm.com/download/win) is a good choice on that platform).
See the installation [instructions here](https://sdkman.io/install).
Once installed, run
```shell
sdk install java 22.0.2-tem
sdk install java 25.0.2-tem
```
### Other requirements
@@ -83,7 +56,7 @@ sudo apt install -y rpm fakeroot binutils
First, assign a temporary variable in your shell for the specific release you want to build. For the current one specify:
```shell
GIT_TAG="2.2.3"
GIT_TAG="2.4.0"
```
The project can then be initially cloned as follows:
@@ -101,7 +74,7 @@ git checkout "${GIT_TAG}"
```
Note - there is an additional step if you updated rather than initially cloned your repo at `GIT_TAG`.
This is due to the [drongo submodule](https://github.com/sparrowwallet/drongo/tree/master) which needs to be checked out to the commit state it had at the time of the release.
This is due to the Git submodules which need to be checked out to the commit state they had at the time of the release.
Only then your build will be comparable to the provided one in the release section of Github.
To checkout the submodule to the correct commit for `GIT_TAG`, additionally run:
+1 -1
Submodule drongo updated: 2ced4c1996...0a500ea002
+1 -1
Submodule lark updated: c16389fdea...b074099a38
-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.0</string>
<string>2.4.1</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

@@ -31,7 +31,7 @@ import com.sparrowwallet.sparrow.transaction.TransactionView;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.WalletController;
import com.sparrowwallet.sparrow.wallet.WalletForm;
import de.codecentric.centerdevice.MenuToolkit;
import de.jangassen.MenuToolkit;
import javafx.animation.*;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
@@ -50,12 +50,14 @@ import javafx.geometry.Side;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.control.Label;
import javafx.scene.control.Menu;
import javafx.scene.control.MenuItem;
import javafx.scene.input.*;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.stage.*;
import javafx.stage.Window;
import javafx.util.Duration;
import org.controlsfx.control.Notifications;
import org.controlsfx.control.StatusBar;
@@ -70,6 +72,7 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.text.ParseException;
import java.util.*;
import java.util.List;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.AppServices.*;
@@ -87,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;
@@ -135,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;
@@ -143,8 +156,7 @@ public class AppController implements Initializable {
private static final BooleanProperty openWalletsInNewWindowsProperty = new SimpleBooleanProperty();
@FXML
private CheckMenuItem hideEmptyUsedAddresses;
private static final BooleanProperty hideEmptyUsedAddressesProperty = new SimpleBooleanProperty();
private CheckMenuItem hideAmounts;
@FXML
private CheckMenuItem useHdCameraResolution;
@@ -367,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;
@@ -379,8 +398,7 @@ 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);
mirrorCameraImageProperty.set(Config.get().isMirrorCapture());
@@ -463,7 +481,7 @@ public class AppController implements Initializable {
settings, new SeparatorMenuItem(),
tk.createHideMenuItem(SparrowWallet.APP_NAME), tk.createHideOthersMenuItem(), tk.createUnhideAllMenuItem(), new SeparatorMenuItem(),
tk.createQuitMenuItem(SparrowWallet.APP_NAME));
Platform.runLater(() -> tk.setApplicationMenu(defaultApplicationMenu));
tk.setApplicationMenu(defaultApplicationMenu);
fileMenu.getItems().removeIf(item -> item.getStyleClass().contains("osxHide"));
toolsMenu.getItems().removeIf(item -> item.getStyleClass().contains("osxHide"));
@@ -576,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);
@@ -764,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) {
@@ -866,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();
}
@@ -938,12 +957,28 @@ 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());
EventManager.get().post(new HideEmptyUsedAddressesStatusEvent(item.isSelected()));
}
public void hideAmounts(ActionEvent event) {
CheckMenuItem item = (CheckMenuItem)event.getSource();
Config.get().setHideAmounts(item.isSelected());
EventManager.get().post(new HideAmountsStatusEvent(item.isSelected()));
}
public void useHdCameraResolution(ActionEvent event) {
CheckMenuItem item = (CheckMenuItem)event.getSource();
if(Config.get().getWebcamResolution().isStandardAspect() && item.isSelected()) {
@@ -1105,12 +1140,37 @@ public class AppController implements Initializable {
AppServices.moveToActiveWindowScreen(window, 800, 450);
List<File> files = fileChooser.showOpenMultipleDialog(window);
if(files != null) {
configureWalletsDir(files);
for(File file : files) {
openWalletFile(file, forceSameWindow);
}
}
}
private static void configureWalletsDir(List<File> files) {
List<File> parentDirs = files.stream().map(File::getParentFile).distinct().collect(Collectors.toList());
if(parentDirs.size() == 1 && !Boolean.FALSE.equals(Config.get().getSuggestChangeWalletsDir())) {
File selectedDir = parentDirs.getFirst();
boolean sameDir;
try {
sameDir = Files.isSameFile(selectedDir.toPath(), Storage.getWalletsDir().toPath());
} catch(IOException e) {
sameDir = selectedDir.toPath().normalize().equals(Storage.getWalletsDir().toPath().normalize());
}
if(!sameDir) {
ConfirmationAlert alert = new ConfirmationAlert("Change wallets directory?",
"Do you want to configure Sparrow to use " + selectedDir + " as the default wallets directory?", ButtonType.NO, ButtonType.YES);
Optional<ButtonType> optType = alert.showAndWait();
if(optType.isPresent() && optType.get() == ButtonType.YES) {
Config.get().setWalletsDir(selectedDir);
Config.get().setSuggestChangeWalletsDir(null);
} else if(alert.isDontAskAgain()) {
Config.get().setSuggestChangeWalletsDir(Boolean.FALSE);
}
}
}
}
public void openWalletFile(File file, boolean forceSameWindow) {
try {
Storage storage = new Storage(file);
@@ -1203,22 +1263,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);
}
}
}
}
@@ -1898,11 +1960,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) {
@@ -1978,39 +2035,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;
}
}
@@ -2077,6 +2108,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();
@@ -2093,23 +2187,33 @@ public class AppController implements Initializable {
}
MenuItem moveRight = new MenuItem("Move Right");
moveRight.setAccelerator(new KeyCodeCombination(KeyCode.RIGHT, KeyCombination.CONTROL_DOWN, KeyCombination.SHIFT_DOWN));
moveRight.setOnAction(event -> {
int index = tabs.getTabs().indexOf(tab);
int currentIndex = tabs.getSelectionModel().getSelectedIndex();
if(currentIndex + 1 >= tabs.getTabs().size()) {
return;
}
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
tabs.getTabs().removeListener(tabsChangeListener);
tabs.getTabs().remove(tab);
tabs.getTabs().add(index + 1, tab);
tabs.getTabs().remove(selectedTab);
tabs.getTabs().add(currentIndex + 1, selectedTab);
tabs.getTabs().addListener(tabsChangeListener);
tabs.getSelectionModel().select(tab);
tabs.getSelectionModel().select(selectedTab);
EventManager.get().post(new RequestOpenWalletsEvent()); //Rearrange recent files list
});
MenuItem moveLeft = new MenuItem("Move Left");
moveLeft.setAccelerator(new KeyCodeCombination(KeyCode.LEFT, KeyCombination.CONTROL_DOWN, KeyCombination.SHIFT_DOWN));
moveLeft.setOnAction(event -> {
int index = tabs.getTabs().indexOf(tab);
int currentIndex = tabs.getSelectionModel().getSelectedIndex();
if(currentIndex == 0) {
return;
}
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
tabs.getTabs().removeListener(tabsChangeListener);
tabs.getTabs().remove(tab);
tabs.getTabs().add(index - 1, tab);
tabs.getTabs().remove(selectedTab);
tabs.getTabs().add(currentIndex - 1, selectedTab);
tabs.getTabs().addListener(tabsChangeListener);
tabs.getSelectionModel().select(tab);
tabs.getSelectionModel().select(selectedTab);
EventManager.get().post(new RequestOpenWalletsEvent()); //Rearrange recent files list
});
contextMenu.getItems().addAll(moveRight, moveLeft);
@@ -2542,7 +2646,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 {
@@ -3111,6 +3215,11 @@ public class AppController implements Initializable {
hideEmptyUsedAddresses.setSelected(event.isHideEmptyUsedAddresses());
}
@Subscribe
public void hideAmountsStatusChanged(HideAmountsStatusEvent event) {
hideAmounts.setSelected(event.isHideAmounts());
}
@Subscribe
public void requestOpenWallets(RequestOpenWalletsEvent event) {
EventManager.get().post(new OpenWalletsEvent(tabs.getScene().getWindow(), getOpenWalletTabData()));
@@ -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.0";
public static final String APP_VERSION = "2.4.1";
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,148 @@
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.setManaged(false);
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,274 @@
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.setManaged(false);
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,128 @@
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.setManaged(false);
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();
}
}
@@ -128,4 +128,15 @@ public class BalanceChart extends LineChart<Number, Number> {
NumberAxis yaxis = (NumberAxis)getYAxis();
yaxis.setTickLabelFormatter(new CoinAxisFormatter(yaxis, format, unit));
}
public void refreshAxisLabels() {
NumberAxis yaxis = (NumberAxis)getYAxis();
// Force the axis to redraw by invalidating the upper and lower bounds
yaxis.setAutoRanging(false);
double lower = yaxis.getLowerBound();
double upper = yaxis.getUpperBound();
yaxis.setLowerBound(lower);
yaxis.setUpperBound(upper);
yaxis.setAutoRanging(true);
}
}
@@ -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;
}
}
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.io.Config;
import javafx.scene.chart.NumberAxis;
import javafx.util.StringConverter;
@@ -18,6 +19,10 @@ final class CoinAxisFormatter extends StringConverter<Number> {
@Override
public String toString(Number object) {
if(Config.get().isHideAmounts()) {
return "";
}
Double value = bitcoinUnit.getValue(object.longValue());
return new CoinTextFormatter(unitFormat).getCoinFormat().format(value);
}
@@ -58,16 +58,22 @@ class CoinCell extends TreeTableCell<Entry, Number> implements ConfirmationsList
DecimalFormat decimalFormat = (amount.longValue() == 0L ? format.getBtcFormat() : format.getTableBtcFormat());
final String btcValue = decimalFormat.format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
if(unit.equals(BitcoinUnit.BTC)) {
tooltip.setValue(satsValue + " " + BitcoinUnit.SATOSHIS.getLabel());
setText(btcValue);
if(Config.get().isHideAmounts()) {
setText(CoinLabel.HIDDEN_AMOUNT_TEXT);
setTooltip(null);
setContextMenu(null);
} else {
tooltip.setValue(btcValue + " " + BitcoinUnit.BTC.getLabel());
setText(satsValue);
if(unit.equals(BitcoinUnit.BTC)) {
tooltip.setValue(satsValue + " " + BitcoinUnit.SATOSHIS.getLabel());
setText(btcValue);
} else {
tooltip.setValue(btcValue + " " + BitcoinUnit.BTC.getLabel());
setText(satsValue);
}
setTooltip(tooltip);
contextMenu.updateAmount(amount);
setContextMenu(contextMenu);
}
setTooltip(tooltip);
contextMenu.updateAmount(amount);
setContextMenu(contextMenu);
if(entry instanceof TransactionEntry transactionEntry) {
tooltip.showConfirmations(transactionEntry.confirmationsProperty(), transactionEntry.isCoinbase());
@@ -86,14 +92,16 @@ class CoinCell extends TreeTableCell<Entry, Number> implements ConfirmationsList
}
} else if(entry instanceof UtxoEntry) {
setGraphic(null);
} else if(entry instanceof HashIndexEntry) {
} else if(entry instanceof HashIndexEntry hashIndexEntry) {
tooltip.hideConfirmations();
Region node = new Region();
node.setPrefWidth(10);
setGraphic(node);
setContentDisplay(ContentDisplay.RIGHT);
if(((HashIndexEntry) entry).getType() == HashIndexEntry.Type.INPUT) {
satsValue = "-" + satsValue;
if(hashIndexEntry.getType() == HashIndexEntry.Type.INPUT && !Config.get().isHideAmounts()) {
setText("-" + getText());
}
} else {
setGraphic(null);
@@ -148,6 +156,14 @@ class CoinCell extends TreeTableCell<Entry, Number> implements ConfirmationsList
setTooltipText();
}
public void hideConfirmations() {
showConfirmations = false;
isCoinbase = false;
confirmationsProperty.unbind();
setTooltipText();
}
private void setTooltipText() {
setText(value + (showConfirmations ? " (" + getConfirmationsDescription() + ")" : ""));
}
@@ -13,6 +13,8 @@ import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
public class CoinLabel extends Label {
public static final String HIDDEN_AMOUNT_TEXT = "\u2022\u2022\u2022\u2022\u2022";
private final LongProperty valueProperty = new SimpleLongProperty(-1);
private final Tooltip tooltip;
private final CoinContextMenu contextMenu;
@@ -49,6 +51,13 @@ public class CoinLabel extends Label {
}
private void setValueAsText(Long value, BitcoinUnit bitcoinUnit) {
if(Config.get().isHideAmounts()) {
setText(HIDDEN_AMOUNT_TEXT);
setTooltip(null);
setContextMenu(null);
return;
}
setTooltip(tooltip);
setContextMenu(contextMenu);
@@ -10,9 +10,7 @@ import javafx.scene.control.MenuItem;
import javafx.scene.control.Tooltip;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.event.EventHandler;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
public class CopyableCoinLabel extends CopyableLabel {
private final LongProperty valueProperty = new SimpleLongProperty(-1);
@@ -72,6 +70,13 @@ public class CopyableCoinLabel extends CopyableLabel {
}
private void setValueAsText(Long value, UnitFormat unitFormat, BitcoinUnit bitcoinUnit) {
if(Config.get().isHideAmounts()) {
setText(CoinLabel.HIDDEN_AMOUNT_TEXT);
setTooltip(null);
setContextMenu(null);
return;
}
setTooltip(tooltip);
setContextMenu(contextMenu);
@@ -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();
@@ -62,7 +62,7 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
private static final List<String> ARCHIVE_EXTENSIONS = List.of("zip", "tar.gz", "tar.bz2", "tar.xz", "rar", "7z");
private static final String SPARROW_RELEASE_PREFIX = "sparrow-";
private static final String SPARROW_RELEASE_ALT_PREFIX = "sparrow_";
private static final String[] SPARROW_RELEASE_ALT_PREFIXES = { "sparrowwallet-", "sparrowwallet_", "sparrowserver-", "sparrowserver_" };
private static final String SPARROW_MANIFEST_SUFFIX = "-manifest.txt";
private static final String SPARROW_SIGNATURE_SUFFIX = SPARROW_MANIFEST_SUFFIX + ".asc";
private static final Pattern SPARROW_RELEASE_VERSION = Pattern.compile("[0-9]+(\\.[0-9]+)*");
@@ -465,7 +465,7 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
}
String providedName = providedFile.getName().toLowerCase(Locale.ROOT);
if(providedName.startsWith(SPARROW_RELEASE_PREFIX) || providedName.startsWith(SPARROW_RELEASE_ALT_PREFIX)) {
if(providedName.startsWith(SPARROW_RELEASE_PREFIX) || Arrays.stream(SPARROW_RELEASE_ALT_PREFIXES).anyMatch(providedName::startsWith)) {
Matcher matcher = SPARROW_RELEASE_VERSION.matcher(providedFile.getName());
if(matcher.find()) {
String version = matcher.group();
@@ -591,7 +591,8 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
}
}
if((name.startsWith(SPARROW_RELEASE_PREFIX) || name.startsWith(SPARROW_RELEASE_ALT_PREFIX)) && file.length() >= MIN_VALID_SPARROW_RELEASE_SIZE) {
if((name.startsWith(SPARROW_RELEASE_PREFIX) || Arrays.stream(SPARROW_RELEASE_ALT_PREFIXES).anyMatch(name::startsWith))
&& file.length() >= MIN_VALID_SPARROW_RELEASE_SIZE) {
Matcher matcher = SPARROW_RELEASE_VERSION.matcher(name);
return matcher.find();
}
@@ -57,7 +57,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
super.updateItem(entry, empty);
//Return immediately to avoid CPU usage when updating the same invisible cell to determine tableview size (see https://bugs.openjdk.org/browse/JDK-8280442)
if(this == lastCell && !getTableRow().isVisible()) {
if(this == lastCell && !getTableRow().isVisible() && isTableSizeRecalculation()) {
return;
}
lastCell = this;
@@ -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");
@@ -856,4 +856,11 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
}
}
}
private boolean isTableSizeRecalculation() {
//As per https://bugs.openjdk.org/browse/JDK-8265669 we check for cell visibility to avoid unnecessary recalculation, but this can result in false positives
//The method releaseCell in VirtualFlow is responsible for setting accumCell visibility to false after use, so check this method is calling updateItem
return StackWalker.getInstance().walk(frames -> frames.anyMatch(frame -> frame.getClassName().equals("javafx.scene.control.skin.VirtualFlow")
&& frame.getMethodName().equals("releaseCell")));
}
}
@@ -4,6 +4,7 @@ import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.CurrencyRate;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.Entry;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
@@ -47,20 +48,27 @@ public class FiatCell extends TreeTableCell<Entry, Number> {
CurrencyRate currencyRate = coinTreeTable.getCurrencyRate();
if(currencyRate != null && currencyRate.isAvailable()) {
Currency currency = currencyRate.getCurrency();
double btcRate = currencyRate.getBtcRate();
if(Config.get().isHideAmounts()) {
setText(CoinLabel.HIDDEN_AMOUNT_TEXT);
setGraphic(null);
setTooltip(null);
setContextMenu(null);
} else {
Currency currency = currencyRate.getCurrency();
double btcRate = currencyRate.getBtcRate();
BigDecimal satsBalance = BigDecimal.valueOf(amount.longValue());
BigDecimal btcBalance = satsBalance.divide(BigDecimal.valueOf(Transaction.SATOSHIS_PER_BITCOIN));
BigDecimal fiatBalance = btcBalance.multiply(BigDecimal.valueOf(btcRate));
BigDecimal satsBalance = BigDecimal.valueOf(amount.longValue());
BigDecimal btcBalance = satsBalance.divide(BigDecimal.valueOf(Transaction.SATOSHIS_PER_BITCOIN));
BigDecimal fiatBalance = btcBalance.multiply(BigDecimal.valueOf(btcRate));
String label = format.formatCurrencyValue(fiatBalance.doubleValue());
tooltip.setText("1 BTC = " + currency.getSymbol() + " " + format.formatCurrencyValue(btcRate));
String label = format.formatCurrencyValue(fiatBalance.doubleValue());
tooltip.setText("1 BTC = " + currency.getSymbol() + " " + format.formatCurrencyValue(btcRate));
setText(label);
setGraphic(null);
setTooltip(tooltip);
setContextMenu(contextMenu);
setText(label);
setGraphic(null);
setTooltip(tooltip);
setContextMenu(contextMenu);
}
} else {
setText(null);
setGraphic(null);
@@ -90,6 +90,13 @@ public class FiatLabel extends CopyableLabel {
private void setValueAsText(long balance, UnitFormat unitFormat) {
if(getCurrency() != null && getBtcRate() > 0.0) {
if(Config.get().isHideAmounts()) {
setText(CoinLabel.HIDDEN_AMOUNT_TEXT);
setTooltip(null);
setContextMenu(null);
return;
}
BigDecimal satsBalance = BigDecimal.valueOf(balance);
BigDecimal btcBalance = satsBalance.divide(BigDecimal.valueOf(Transaction.SATOSHIS_PER_BITCOIN));
BigDecimal fiatBalance = btcBalance.multiply(BigDecimal.valueOf(getBtcRate()));
@@ -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,9 +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) {
@@ -723,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 = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getAddressNodeMap().get(payment.getAddress()) : null;
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());
@@ -778,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);
@@ -837,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);
@@ -90,20 +90,20 @@ public class TransactionDiagramLabel extends HBox {
outputLabels.add(mixOutputLabel);
}
} else if(walletTx.getPayments().size() >= 5 && walletTx.getPayments().stream().mapToLong(Payment::getAmount).distinct().count() <= 1 && walletTx.getWallet() != null
&& walletTx.getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_POSTMIX && walletTx.getPayments().stream().anyMatch(walletTx::isConsolidationSend)) {
&& walletTx.getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_POSTMIX && !walletTx.getWalletNodePayments().isEmpty()) {
OutputLabel remixOutputLabel = getRemixOutputLabel(transactionDiagram, walletTx.getPayments());
if(remixOutputLabel != null) {
outputLabels.add(remixOutputLabel);
}
} else {
List<Payment> payments = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT && !walletTx.isConsolidationSend(payment)).collect(Collectors.toList());
List<Payment> payments = walletTx.getExternalPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT).collect(Collectors.toList());
List<OutputLabel> paymentLabels = payments.stream().map(payment -> getOutputLabel(transactionDiagram, payment)).collect(Collectors.toList());
if(walletTx.getSelectedUtxos().values().stream().allMatch(Objects::isNull)) {
paymentLabels.sort(Comparator.comparingInt(paymentLabel -> (paymentLabel.text.startsWith("Receive") ? 0 : 1)));
}
outputLabels.addAll(paymentLabels);
List<Payment> consolidations = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT && walletTx.isConsolidationSend(payment)).collect(Collectors.toList());
List<Payment> consolidations = walletTx.getWalletNodePayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT).collect(Collectors.toList());
outputLabels.addAll(consolidations.stream().map(consolidation -> getOutputLabel(transactionDiagram, consolidation)).collect(Collectors.toList()));
List<Payment> mixes = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.MIX || payment.getType() == Payment.Type.FAKE_MIX).collect(Collectors.toList());
@@ -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);
}
@@ -203,10 +203,10 @@ public class TransactionDiagramLabel extends HBox {
private OutputLabel getOutputLabel(TransactionDiagram transactionDiagram, Payment payment) {
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
Wallet toWallet = walletTx.getToWallet(AppServices.get().getOpenWallets().keySet(), payment);
WalletNode toNode = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getAddressNodeMap().get(payment.getAddress()) : null;
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")));
@@ -90,6 +90,10 @@ public class UtxosChart extends BarChart<String, Number> {
private void installTooltip(XYChart.Data<String, Number> item) {
Tooltip.uninstall(item.getNode(), null);
if(Config.get().isHideAmounts()) {
return;
}
String satsValue = String.format(Locale.ENGLISH, "%,d", item.getYValue());
Tooltip tooltip = new Tooltip(item.getXValue() + "\n" + satsValue + " sats");
tooltip.setShowDelay(Duration.millis(TOOLTIP_SHOW_DELAY));
@@ -129,4 +133,21 @@ public class UtxosChart extends BarChart<String, Number> {
NumberAxis yaxis = (NumberAxis)getYAxis();
yaxis.setTickLabelFormatter(new CoinAxisFormatter(yaxis, format, unit));
}
public void refreshAxisLabels() {
NumberAxis yaxis = (NumberAxis)getYAxis();
// Force the axis to redraw by invalidating the upper and lower bounds
yaxis.setAutoRanging(false);
double lower = yaxis.getLowerBound();
double upper = yaxis.getUpperBound();
yaxis.setLowerBound(lower);
yaxis.setUpperBound(upper);
yaxis.setAutoRanging(true);
}
public void refreshTooltips() {
for(XYChart.Data<String, Number> data : utxoSeries.getData()) {
installTooltip(data);
}
}
}
@@ -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)));
@@ -73,7 +73,7 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
name = (CustomTextField)TextFields.createClearableTextField();
name.setText(initialName);
name.setTextFormatter(new TextFormatter<>((change) -> {
change.setText(change.getText().replaceAll("[\\\\/:*?\"<>|]", "_"));
change.setText(change.getText().replaceAll("[\\\\/:*?\"<>|;]", "_"));
return change;
}));
content.getChildren().add(name);
@@ -7,6 +7,7 @@ public enum WebcamPixelFormat {
PIX_FMT_RGB24("RGB3", true),
PIX_FMT_YUYV("YUYV", true),
PIX_FMT_NV12("NV12", true),
PIX_FMT_YU12("YU12", true),
PIX_FMT_MJPG("MJPG", true);
private final String name;
@@ -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);
@@ -0,0 +1,13 @@
package com.sparrowwallet.sparrow.event;
public class HideAmountsStatusEvent {
private final boolean hideAmounts;
public HideAmountsStatusEvent(boolean hideAmounts) {
this.hideAmounts = hideAmounts;
}
public boolean isHideAmounts() {
return hideAmounts;
}
}
@@ -4,6 +4,7 @@ import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.control.CoinLabel;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
@@ -48,6 +49,10 @@ public class NewWalletTransactionsEvent {
}
public String getValueAsText(long value) {
if(Config.get().isHideAmounts()) {
return CoinLabel.HIDDEN_AMOUNT_TEXT;
}
UnitFormat format = Config.get().getUnitFormat();
if(format == null) {
format = UnitFormat.DOT;
@@ -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;
}
}
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.glyphfont;
import com.sparrowwallet.drongo.wallet.Payment;
import com.sparrowwallet.drongo.wallet.WalletNodePayment;
import com.sparrowwallet.drongo.wallet.WalletTransaction;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.control.TransactionDiagram;
@@ -15,7 +16,7 @@ public class GlyphUtils {
return getFakeMixGlyph();
} else if(payment.getType().equals(Payment.Type.ANCHOR)) {
return getAnchorGlyph();
} else if(walletTx.isConsolidationSend(payment)) {
} else if(payment instanceof WalletNodePayment) {
return getConsolidationGlyph();
} else if(walletTx.isPremixSend(payment)) {
return getPremixGlyph();
@@ -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,7 +47,9 @@ 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;
private boolean showLoadingLog = true;
private boolean showAddressTransactionCount = false;
@@ -56,11 +59,14 @@ public class Config {
private Boolean connectToBroadcast;
private Boolean connectToResolve;
private Boolean suggestSendToMany;
private Boolean suggestChangeWalletsDir;
private File walletsDir;
private List<File> recentWalletFiles;
private Integer keyDerivationPeriod;
private long dustAttackThreshold = DUST_ATTACK_THRESHOLD_SATS;
private int enumerateHwPeriod = ENUMERATE_HW_PERIOD_SECS;
private QRDensity qrDensity;
private QREncoding qrEncoding;
private WebcamResolution webcamResolution;
private boolean mirrorCapture = true;
private boolean useZbar = true;
@@ -294,6 +300,15 @@ public class Config {
flush();
}
public boolean isChunkAddresses() {
return chunkAddresses;
}
public void setChunkAddresses(boolean chunkAddresses) {
this.chunkAddresses = chunkAddresses;
flush();
}
public boolean isHideEmptyUsedAddresses() {
return hideEmptyUsedAddresses;
}
@@ -303,6 +318,15 @@ public class Config {
flush();
}
public boolean isHideAmounts() {
return hideAmounts;
}
public void setHideAmounts(boolean hideAmounts) {
this.hideAmounts = hideAmounts;
flush();
}
public boolean isShowTransactionHex() {
return showTransactionHex;
}
@@ -384,6 +408,24 @@ public class Config {
flush();
}
public Boolean getSuggestChangeWalletsDir() {
return suggestChangeWalletsDir;
}
public void setSuggestChangeWalletsDir(Boolean suggestChangeWalletsDir) {
this.suggestChangeWalletsDir = suggestChangeWalletsDir;
flush();
}
public File getWalletsDir() {
return walletsDir;
}
public void setWalletsDir(File walletsDir) {
this.walletsDir = walletsDir;
flush();
}
public List<File> getRecentWalletFiles() {
return recentWalletFiles;
}
@@ -419,6 +461,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;
}
@@ -29,10 +29,7 @@ public class Samourai implements KeystoreFileImport {
try {
String input = CharStreams.toString(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
Gson gson = new Gson();
Type stringStringMap = new TypeToken<Map<String, JsonElement>>() {
}.getType();
Map<String, JsonElement> map = gson.fromJson(input, stringStringMap);
Map<String, JsonElement> map = this.parseJsonInput(input);
String payload = input;
if(map.containsKey("payload")) {
@@ -53,7 +50,7 @@ public class Samourai implements KeystoreFileImport {
throw new ImportException("Unsupported backup version: " + version);
}
SamouraiBackup backup = gson.fromJson(decrypted, SamouraiBackup.class);
SamouraiBackup backup = new Gson().fromJson(decrypted, SamouraiBackup.class);
DeterministicSeed seed = new DeterministicSeed(Utils.hexToBytes(backup.wallet.seed), password, 0);
Keystore keystore = Keystore.fromSeed(seed, scriptType.getDefaultDerivation());
keystore.setLabel(getWalletModel().toDisplayString());
@@ -67,6 +64,24 @@ public class Samourai implements KeystoreFileImport {
}
}
private Map<String, JsonElement> parseJsonInput(String input) {
Gson gson = new Gson();
Type stringStringMap = new TypeToken<Map<String, JsonElement>>() {
}.getType();
try {
return gson.fromJson(input, stringStringMap);
} catch (JsonParseException e) {
int closingBracket = input.indexOf('}');
if (closingBracket < 0) {
throw e;
}
String fixedInput = input.substring(0, closingBracket + 1);
return gson.fromJson(fixedInput, stringStringMap);
}
}
@Override
public boolean isKeystoreImportScannable() {
return false;
@@ -485,7 +485,16 @@ public class Storage {
}
public static File getWalletsDir() {
File walletsDir = new File(getSparrowDir(), WALLETS_DIR);
File walletsDir = Config.get().getWalletsDir();
if(walletsDir != null) {
if(!walletsDir.exists() && (walletsDir.getParentFile() == null || !walletsDir.getParentFile().exists() || !walletsDir.getParentFile().canWrite())) {
log.info("Configured wallets directory " + walletsDir.getAbsolutePath() + " is not reachable, reverting to default");
walletsDir = null;
}
}
if(walletsDir == null) {
walletsDir = new File(getSparrowDir(), WALLETS_DIR);
}
if(!walletsDir.exists()) {
createOwnerOnlyDirectory(walletsDir);
}
@@ -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();
@@ -36,6 +36,7 @@ import java.nio.file.StandardCopyOption;
import java.security.SecureRandom;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.regex.Pattern;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@@ -54,6 +55,7 @@ public class DbPersistence implements Persistence {
private static final String H2_USER = "sa";
private static final String H2_PASSWORD = "";
public static final String MIGRATION_RESOURCES_DIR = "com/sparrowwallet/sparrow/sql/";
private static final Pattern JDBC_URL_INJECTION_PATTERN = Pattern.compile(";\\w+=");
private HikariDataSource dataSource;
private AsymmetricKeyDeriver keyDeriver;
@@ -695,7 +697,10 @@ public class DbPersistence implements Persistence {
}
}
private String getUrl(File walletFile, String password) {
private String getUrl(File walletFile, String password) throws StorageException {
if(JDBC_URL_INJECTION_PATTERN.matcher(walletFile.getName()).find()) {
throw new StorageException("Wallet file name contains invalid characters");
}
return "jdbc:h2:" + walletFile.getAbsolutePath().replace("." + getType().getExtension(), "") + ";INIT=SET TRACE_LEVEL_FILE=4;TRACE_LEVEL_FILE=4;DEFRAG_ALWAYS=true;MAX_COMPACT_TIME=5000;DATABASE_TO_UPPER=false" + (password == null ? "" : ";CIPHER=AES");
}
@@ -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);
}

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